Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8

This commit is contained in:
2026-10-08 12:25:23 +09:00
18 changed files with 434 additions and 98 deletions
@@ -32,6 +32,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 구조 · 두께(cm · 새 상자 30) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
items: [
{
id: "structure",
@@ -42,6 +43,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
{ value: "돌붙임", label: "돌붙임" },
],
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
],
},
],
+15 -11
View File
@@ -110,6 +110,20 @@ export const SOIL_ADD: SpecContainer = {
items: guardItems(),
};
/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 물넘이 포장(`ford_chute`)도 같은 칸. */
export const CHUTE_ITEMS: SpecItem[] = [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
export const CHUTE_ADD: SpecContainer = {
id: "chute_add",
@@ -118,15 +132,5 @@ export const CHUTE_ADD: SpecContainer = {
prefix: "chute_add",
link: "gchuteadd",
max: 1,
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
],
items: CHUTE_ITEMS,
};
+9 -4
View File
@@ -220,10 +220,12 @@ export interface CulvertSideSpec {
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
apron_thickness_m?: number;
}
/** 도수로 · 돌붙임(90-2) — 유출 성토사면을 따라 사면 안쪽으로 두께만큼 띠 · 안에 돌(그림만 · 면적 무관). */
export interface ChuteSpec {
structure: string;
thickness_m: number;
}
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
@@ -249,6 +251,7 @@ export interface CulvertSet {
foundation?: string | null;
/** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */
body?: "box" | "ford";
chute?: ChuteSpec;
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
@@ -280,6 +283,7 @@ export interface FordSet {
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
chute?: ChuteSpec;
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -300,6 +304,7 @@ export interface BoxSet {
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
chute?: ChuteSpec;
}
export interface CrossSection extends SectionStation {
+35 -18
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import json
import logging
import math
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -63,12 +64,8 @@ _SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
MIN_PIPE_COVER_M = 0.5
# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수.
# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택.
APRON_LENGTH_FACTOR = 2.0
# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합).
# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다.
APRON_THICKNESS_M = 0.45
# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 30 cm(10-8 · 90-2).
CHUTE_THICKNESS_CM = 30.0
# 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
@@ -226,15 +223,36 @@ def _side_spec(
"face_slope": REVET_FACE_SLOPE,
}
)
# 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
def _chute(values: dict[str, Any]) -> dict[str, Any] | None:
"""도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) ·
구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`."""
head = re.compile(r"^chute_add_(\d+)_structure$")
picked = sorted(
(int(hit.group(1)), str(value).strip())
for key, value in values.items()
if (hit := head.match(key)) and str(value or "").strip()
)
if not picked:
return None
n, structure = picked[0]
cm = _number(values.get(f"chute_add_{n}_thickness"), None)
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2)."""
chute = _chute(values)
if chute:
spec["chute"] = chute
return spec
def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공)."""
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임)."""
defaults = _registry_pipe_defaults()
values = dict(options or {})
diameter_mm = _number(
@@ -242,7 +260,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
)
kind = values.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
spec = {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": diameter_m,
@@ -253,6 +271,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
return _with_chute(spec, values)
def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
@@ -286,9 +305,6 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
@@ -334,7 +350,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None)
if not any(side["structure"] for side in sides.values()):
return None
return {
guard = {
"type": "pipe",
"hidden_pipe": True,
"body": body,
@@ -346,6 +362,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
"min_cover_m": 0.0,
**sides,
}
return _with_chute(guard, values) # 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2)
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
@@ -459,7 +476,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
guard = _guard_set(values, "ford", spec["diameter_m"])
if guard:
spec["guard"] = guard
return spec
return _with_chute(spec, values)
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
@@ -536,7 +553,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
guard = _guard_set(values, "box", box_body_height(spec))
if guard:
spec["guard"] = guard
return spec
return _with_chute(spec, values)
def box_body_height(box: dict[str, Any]) -> float:
+12
View File
@@ -11,6 +11,7 @@
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
export {
computeBoxLayout,
@@ -127,6 +128,17 @@ export function appendBoxOverlay(
polyline.append(title);
layer.append(polyline);
}
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
if (box.chute)
appendChuteBand(
layer,
layout.sides
.filter((side) => side.wingRole === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
box.chute,
x,
toDisplayY,
);
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
for (const side of layout.sides) {
const mark = side.role === "left" ? "좌" : "우";
+11 -1
View File
@@ -22,7 +22,7 @@ import type {
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
@@ -101,6 +101,16 @@ export function appendCulvertOverlay(
layer.append(line);
};
const drawOutletFill = (): void => {
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
// (B07 은 `linked` 없이 관 숨김으로만 알림 — 관 숨긴 배관 = 연동 카드)
if (culvert.chute && !linked && (!hidePipe || culvert.body))
appendChuteBand(
layer,
layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points),
culvert.chute,
x,
toDisplayY,
);
for (const segment of layout.outletFill.segments) {
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
@@ -20,9 +20,6 @@ export const REVET_THICKNESS_M = 0.45;
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
export const PITCHING_THICKNESS_M = 0.45;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
+12
View File
@@ -11,6 +11,7 @@
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
export {
computeFordLayout,
@@ -127,6 +128,17 @@ export function appendFordOverlay(
layer.append(pipeOuter, pipeInner);
const bodyShapes = [slab, pipeOuter, pipeInner];
// 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
if (ford.chute)
appendChuteBand(
layer,
layout.sides
.filter((side) => side.role === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
ford.chute,
x,
toDisplayY,
);
// ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
for (const side of layout.sides) {
const role = side.role === "inlet" ? "유입" : "유출";
+97 -5
View File
@@ -44,7 +44,7 @@ export function hatchOfForm(form: string | null | undefined): WallHatch {
}
/** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */
interface BandFrame {
export interface BandFrame {
point: (u: number, v: number) => [number, number];
/** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */
leanDegrees: number;
@@ -64,7 +64,7 @@ function line(a: [number, number], b: [number, number]): SVGLineElement {
}
/** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */
function cell(
export function cell(
frame: BandFrame,
u: number,
v: number,
@@ -87,14 +87,20 @@ function cell(
}
/** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */
function courses(spanM: number, targetM: number): number {
export function courses(spanM: number, targetM: number): number {
return Math.max(2, Math.round(spanM / targetM));
}
function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void {
/** 돌 한 줄 — `thicknessM` = 띠 두께(벽 = 흙막이 띠 · 도수로/돌붙임 = 그 두께 · 90-2). */
export function drawStones(
group: SVGElement,
frame: BandFrame,
wet: boolean,
thicknessM = REVET_THICKNESS_M,
): void {
const count = courses(frame.spanM, 0.45);
const courseM = frame.spanM / count;
const widthM = REVET_THICKNESS_M * (wet ? 0.76 : 0.9);
const widthM = thicknessM * (wet ? 0.76 : 0.9);
const heightM = courseM * (wet ? 0.7 : 0.86);
for (let i = 0; i < count; i += 1) {
group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3));
@@ -308,3 +314,89 @@ export function appendWallHatch(
// 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2).
if (wall.retaining) drawDrains(group, wall, x, toDisplayY);
}
/**
* 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
* 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴).
*/
export function appendChuteBand(
layer: SVGElement,
lines: OffsetPoint[][],
chute: { structure: string; thickness_m: number },
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const thickness = chute.thickness_m;
if (!(thickness > 0)) return;
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
for (const raw of lines) {
const top = raw.filter(
(p, i) =>
i === 0 ||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
);
if (top.length < 2) continue;
// 변마다 아래쪽(사면 안쪽) 단위 법선
const normals = top.slice(1).map((p, i) => {
const dx = p.offset - top[i].offset;
const dz = p.elevation - top[i].elevation;
const length = Math.hypot(dx, dz);
const sign = dx / length > 0 ? -1 : 1;
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
});
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
const bottom = top.map((p, i) => {
const a = normals[Math.max(i - 1, 0)];
const b = normals[Math.min(i, normals.length - 1)];
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
});
const outline = [...top, ...bottom.reverse()];
const band = document.createElementNS(SVG_NS, "polygon");
band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" "));
band.setAttribute("class", "b06-chart__culvert-pitching");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`;
band.append(title);
clipSeq += 1;
const clipId = `b06-chute-clip-${clipSeq}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
const clipShape = document.createElementNS(SVG_NS, "polygon");
clipShape.setAttribute("points", band.getAttribute("points") ?? "");
clip.append(clipShape);
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("clip-path", `url(#${clipId})`);
top.slice(1).forEach((end, i) => {
const start = top[i];
const normal = normals[i];
const [ax, ay] = px(start);
const [bx, by] = px(end);
drawStones(
group,
{
spanM: Math.hypot(end.offset - start.offset, end.elevation - start.elevation),
pixelsPerMeter,
point: (u, v) =>
px({
offset:
start.offset +
(end.offset - start.offset) * u +
normal.offset * thickness * (v + 0.5),
elevation:
start.elevation +
(end.elevation - start.elevation) * u +
normal.elevation * thickness * (v + 0.5),
}),
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
},
false,
thickness,
);
});
layer.append(band, clip, group);
}
}
@@ -360,7 +360,7 @@
stroke-width: 2.2;
}
/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */
/* 유출부 도수로 · 돌붙임 띠(90-2) — 성토사면 안쪽으로 두께만큼 · 안에 돌(`appendChuteBand`) */
.b06-chart__culvert-pitching {
fill: color-mix(in srgb, #3fa34d 18%, transparent);
stroke: #3fa34d;
+61 -39
View File
@@ -37,8 +37,7 @@ const SECTION_KEYS: Record<string, string> = {
const SPAN_LINKED_TYPES = new Set(["ford_pavement"]);
export const MIN_PIPE_COVER_M = 0.5;
export const APRON_LENGTH_FACTOR = 2.0;
export const APRON_THICKNESS_M = 0.45;
export const CHUTE_THICKNESS_CM = 30.0;
export const REVET_FACE_SLOPE = 0.3;
export const INLET_STRUCTURE_BASIN = "집수정";
export const FORD_SLAB_THICKNESS_M = 0.3;
@@ -124,15 +123,35 @@ function sideSpec(
spec.revet_before_m = before;
spec.revet_after_m = after;
spec.face_slope = REVET_FACE_SLOPE;
// 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if (height !== null && height > 0) {
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
spec.apron_thickness_m = APRON_THICKNESS_M;
}
return spec;
}
/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공). */
/** 도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안
* 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */
export function chute(
values: Record<string, unknown>,
): { structure: string; thickness_m: number } | null {
const picked = Object.entries(values)
.flatMap(([key, value]) => {
const hit = /^chute_add_(\d+)_structure$/.exec(key);
const structure = String(value ?? "").trim();
return hit && structure ? [[Number(hit[1]), structure] as const] : [];
})
.sort((a, b) => a[0] - b[0]);
if (!picked.length) return null;
const [n, structure] = picked[0];
const cm = num(values[`chute_add_${n}_thickness`], null);
return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) };
}
/** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */
function withChute(spec: CulvertSetSpec, values: Record<string, unknown>): CulvertSetSpec {
const found = chute(values);
if (found) spec.chute = found;
return spec;
}
/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임). */
function culvertSet(
options: Record<string, unknown>,
registry: CulvertRegistryDefaults,
@@ -140,18 +159,21 @@ function culvertSet(
const defaults = registry.pipe ?? {};
const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
const diameterM = pipeDiameterM(diameterMm);
return {
type: "pipe",
pipe_kind: text(options.pipe_kind),
diameter_m: diameterM,
min_cover_m: MIN_PIPE_COVER_M,
// 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로
// 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이
// 확인되면 그때 나눌 것.
foundation: text(options.revet_foundation),
inlet: sideSpec(options, defaults, "inlet", diameterM),
outlet: sideSpec(options, defaults, "outlet", diameterM),
};
return withChute(
{
type: "pipe",
pipe_kind: text(options.pipe_kind),
diameter_m: diameterM,
min_cover_m: MIN_PIPE_COVER_M,
// 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로
// 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이
// 확인되면 그때 나눌 것.
foundation: text(options.revet_foundation),
inlet: sideSpec(options, defaults, "inlet", diameterM),
outlet: sideSpec(options, defaults, "outlet", diameterM),
},
options,
);
}
/**
@@ -175,10 +197,6 @@ function revetSide(values: Record<string, unknown>, role: string): CulvertSetSpe
face_role: values.face_role ?? null,
face_slope_ratio: values.face_slope_ratio ?? null,
};
if (height !== null && height > 0) {
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
spec.apron_thickness_m = APRON_THICKNESS_M;
}
return spec;
}
@@ -222,19 +240,23 @@ export function guardSet(
if (inlet.structure) inlet.revet_berm_m = num(values.inlet_revet_berm_m, null);
const outlet = side("outlet", outletKey);
if (!inlet.structure && !outlet.structure) return null;
return {
type: "pipe",
hidden_pipe: true,
body,
side: "양쪽",
tiers: 1,
foundation: null,
pipe_kind: null,
diameter_m: diameterM,
min_cover_m: 0.0,
inlet,
outlet,
};
// 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2)
return withChute(
{
type: "pipe",
hidden_pipe: true,
body,
side: "양쪽",
tiers: 1,
foundation: null,
pipe_kind: null,
diameter_m: diameterM,
min_cover_m: 0.0,
inlet,
outlet,
},
values,
);
}
/** 박스 본체 전체 높이(상판 + 내공 + 저판) — 흙막이 세트의 관경 자리(파이썬 `wall_body_height`). */
@@ -373,7 +395,7 @@ function fordSet(
...wingDefaulted(values, wingOut, "out"),
],
};
return withGuard(spec, guardSet(values, "ford", Number(spec.diameter_m)));
return withChute(withGuard(spec, guardSet(values, "ford", Number(spec.diameter_m))), values);
}
/** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */
@@ -441,7 +463,7 @@ function boxSet(
...wingDefaulted(values, wingOut, "out"),
],
};
return withGuard(spec, guardSet(values, "box", boxBodyHeight(spec)));
return withChute(withGuard(spec, guardSet(values, "box", boxBodyHeight(spec))), values);
}
/** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */
@@ -3360,7 +3360,8 @@
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
"chute_add.structure=돌붙임": "돌붙임",
"chute_add.thickness": "두께 (cm)"
}
},
"box_culvert": {
@@ -3422,7 +3423,8 @@
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
"chute_add.structure=돌붙임": "돌붙임",
"chute_add.thickness": "두께 (cm)"
}
},
"ford_pavement": {
@@ -3492,7 +3494,8 @@
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
"chute_add.structure=돌붙임": "돌붙임",
"chute_add.thickness": "두께 (cm)"
}
},
"revetment": {
+84
View File
@@ -0,0 +1,84 @@
/* 90-2 도수로 · 돌붙임 띠 헬퍼 — `appendChuteBand`(B06_Section_UI_Cross_Wall_Hatch.ts)를 그 자리에서
* 트랜스파일해 흉내 DOM 에 그림 · 결과 = 띠 꼭짓점(m) · 돌 수(표준출력 JSON). 짝 = `test_90_chute_band.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => loadTs(path.join(path.dirname(full), `${request}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
class Node {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
append(...kids) {
this.children.push(...kids);
}
set textContent(v) {
this.text = v;
}
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
const { appendChuteBand } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"),
);
const SCALE = 100;
const run = (line) => {
const layer = new Node("g");
appendChuteBand(
layer,
[line],
{ structure: "돌붙임", thickness_m: 0.3 },
(o) => o * SCALE,
(e) => -e * SCALE,
);
const band = layer.children.find((k) => k.tag === "polygon");
const group = layer.children.find((k) => k.tag === "g");
const points = band.attrs.points.split(" ").map((p) => {
const [px, py] = p.split(",").map(Number);
return { offset: px / SCALE, elevation: -py / SCALE };
});
return {
points,
stones: group.children.filter((k) => k.tag === "rect").length,
clipped: group.attrs["clip-path"]?.startsWith("url(#") ?? false,
title: band.children[0]?.text ?? "",
};
};
const out = {
// 좌측 유출(+ 바깥) · 성토 1:1.2 → 단 사이 수평 조각 → 다시 성토(흙막이 단 사이 포함)
left: run([
{ offset: 5, elevation: 10 },
{ offset: 7.4, elevation: 8 },
{ offset: 8.4, elevation: 8 },
{ offset: 10.8, elevation: 6 },
]),
// 우측 유출(− 바깥) · 곧은 성토 한 줄
right: run([
{ offset: -5, elevation: 10 },
{ offset: -8.6, elevation: 7 },
]),
};
process.stdout.write(JSON.stringify(out));
@@ -75,7 +75,8 @@ def test_저장_값은_도_와_인스턴스_키(out):
"width": "6",
"height": "0.3",
} # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5)
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임"}]}
# 두께(cm · 90-2) — 빈 칸은 빈 값(새 상자만 기본 30)
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임", "thickness": ""}]}
assert out["written"] == {
"ford_slope_deg": 15,
"ford_width_m": 6,
+2 -1
View File
@@ -64,7 +64,8 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
"before",
"after",
]
assert out[spec]["chute_add"] == [["structure", None, None]]
# 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2)
assert out[spec]["chute_add"] == [["structure", None, None], ["thickness", None, "30"]]
keys = out[f"{spec}Keys"]
assert keys["prefixes"] == ["soil_add_", "chute_add_"]
# 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음
+59
View File
@@ -0,0 +1,59 @@
"""90-2 도수로 · 돌붙임 띠 — 윗면 = 유출 성토사면 선 · 사면 안쪽(아래)으로 두께만큼 · 안에 돌.
화면 TS(`appendChuteBand`)를 그 자리에서 돌린 값(`helper_90_chute_band.cjs`).
"""
import json
import math
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_90_chute_band.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _distance(p: dict, a: dict, b: dict) -> float:
dx, dz = b["offset"] - a["offset"], b["elevation"] - a["elevation"]
cross = (p["offset"] - a["offset"]) * dz - (p["elevation"] - a["elevation"]) * dx
return abs(cross) / math.hypot(dx, dz)
@pytest.mark.parametrize("case", ["left", "right"])
def test_띠_두께는_사면에서_직각으로_0_30m_아래(out: dict, case: str) -> None:
points = out[case]["points"]
n = len(points) // 2
top, bottom = points[:n], points[n:][::-1]
for i, low in enumerate(bottom):
# 꼭짓점마다 붙은 변(앞 · 뒤)까지 직각 거리 = 두께
for j in {max(i - 1, 0), min(i, n - 2)}:
assert _distance(low, top[j], top[j + 1]) == pytest.approx(0.3, abs=1e-6)
assert low["elevation"] < top[i]["elevation"] # 사면 안쪽 = 아래
def test_안에_돌_윗면은_사면선(out: dict) -> None:
for case in out.values():
assert case["stones"] >= 2 and case["clipped"]
assert "돌붙임" in case["title"] and "0.30m" in case["title"]
assert out["left"]["points"][:4] == [
{"offset": 5, "elevation": 10},
{"offset": 7.4, "elevation": 8},
{"offset": 8.4, "elevation": 8},
{"offset": 10.8, "elevation": 6},
]
+15 -11
View File
@@ -13,8 +13,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
APRON_LENGTH_FACTOR,
APRON_THICKNESS_M,
CHUTE_THICKNESS_CM,
MIN_PIPE_COVER_M,
REVET_FACE_SLOPE,
_culvert_set,
@@ -24,10 +23,9 @@ from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
def test_cross_reference_constants_match_plan():
"""계획서 3-0 표 값 — 토피 0.5m·보호공 길이 2배·두께 0.45m(사용자 확정)·전면 1:0.3."""
"""계획서 3-0 표 값 — 토피 0.5m·전면 1:0.3 · 도수로/돌붙임 빈 두께 30 cm(90-2 사용자 확정)."""
assert MIN_PIPE_COVER_M == 0.5
assert APRON_LENGTH_FACTOR == 2.0
assert APRON_THICKNESS_M == 0.45
assert CHUTE_THICKNESS_CM == 30.0
assert REVET_FACE_SLOPE == 0.3
# 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정).
assert "min_slope" not in _culvert_set(None)
@@ -54,9 +52,8 @@ def test_saved_values_drive_members():
assert part["revet_form"] is None
assert part["revet_height_m"] == 2.0
assert part["face_slope"] == REVET_FACE_SLOPE
# 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 4.0
assert part["apron_thickness_m"] == 0.45
# 옛 보호공(apron) 칸은 걷음 — 유출 보호는 도수로/돌붙임 띠(90-2)
assert "apron_length_m" not in part
def test_basin_inlet_skips_revet_and_apron():
@@ -64,10 +61,9 @@ def test_basin_inlet_skips_revet_and_apron():
spec = _culvert_set({"inlet_type": "집수정", "outlet_type": "기슭막이"})
assert spec["inlet"]["structure"] == "집수정"
assert "revet_height_m" not in spec["inlet"]
assert "apron_length_m" not in spec["inlet"]
# 유출측은 그대로 세트를 갖춘다.
assert spec["outlet"]["structure"] == "기슭막이"
assert spec["outlet"]["apron_length_m"] == 4.0 # 빈 높이 = 관경 기준 최소 2.0 × 2
assert spec["outlet"]["revet_height_m"] == 2.0 # 빈 높이 = 관경 기준 최소
def test_string_diameter_and_custom_height():
@@ -77,7 +73,15 @@ def test_string_diameter_and_custom_height():
)
assert spec["diameter_m"] == 0.8
assert spec["outlet"]["revet_height_m"] == 1.5
assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2
def test_chute_thickness_cm_to_m():
"""도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음."""
assert "chute" not in _culvert_set({"chute_add_1_thickness": 40})
spec = _culvert_set({"chute_add_1_structure": "돌붙임", "chute_add_1_thickness": "45"})
assert spec["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
blank = _culvert_set({"chute_add_1_structure": "도수로", "chute_add_1_thickness": ""})
assert blank["chute"] == {"structure": "도수로", "thickness_m": 0.3}
def test_attach_only_pipe_facilities(tmp_path):
@@ -57,6 +57,10 @@ POINTS = [
"inlet_type": "흙막이",
"inlet_revet_form": "콘크리트",
"inlet_revet_height_m": 1.5,
"inlet_revet_berm_m": 2,
"outlet_revet_berm_m": 3, # 유출 수평공간 칸 없음(89-4) — 세트에 안 실림
# 도수로/돌붙임(90-2) — 빈 두께 = 30 cm
"chute_add_1_structure": "도수로",
},
},
{
@@ -72,6 +76,8 @@ POINTS = [
"outlet_revet_form": "돌쌓기(찰)",
"outlet_revet_height_m": 3.5,
"outlet_revet_length_m": 10,
"chute_add_2_structure": "돌붙임",
"chute_add_2_thickness": 45,
},
},
{
@@ -199,3 +205,8 @@ def test_ford_box_guard_attached_as_culvert() -> None:
assert box["diameter_m"] == round(b["top_thickness_m"] + 2.5 + b["slab_thickness_m"], 3)
# 흙막이 없는 시설엔 안 얹음
assert "culvert" not in sections[100.0]
# 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2)
assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"]
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
assert "chute" not in sections[40.0]["culvert"]