From 43f0f6276f829822563659c97b57337de6cfa21b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:22:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=8F=84=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=91=90=EA=BB=98=20?= =?UTF-8?q?=EC=B9=B8(cm=20=C2=B7=20=EA=B8=B0=EB=B3=B8=2030)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=EC=B6=9C=20=EC=84=B1=ED=86=A0=EC=82=AC=EB=A9=B4=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=9D=A0=20+=20=EB=8F=8C=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EC=A7=9D=20=C2=B7=20=EC=98=9B=20apron=20?= =?UTF-8?q?=EC=9E=94=EC=9E=AC=20=EC=A7=80=EC=9B=80(90-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../design/design_structure_ford_pavement.ts | 2 + A00_Common/design/design_structure_guard.ts | 26 +++-- B06_Section/B06_Section_Api_Types.ts | 13 ++- B06_Section/B06_Section_Engine_Culvert.py | 53 +++++---- B06_Section/B06_Section_UI_Cross_Box.ts | 12 +++ B06_Section/B06_Section_UI_Cross_Culvert.ts | 12 ++- .../B06_Section_UI_Cross_Culvert_Const.ts | 3 - B06_Section/B06_Section_UI_Cross_Ford.ts | 12 +++ .../B06_Section_UI_Cross_Wall_Hatch.ts | 102 +++++++++++++++++- .../B06_Section_UI_Style_Cross_Areas.css | 2 +- common_util/common_util_culvert_sets.ts | 100 ++++++++++------- resources/master_template/구조물정의.json | 9 +- resources/tester/helper_90_chute_band.cjs | 84 +++++++++++++++ .../test_52_3_structure_spec_ford_pavement.py | 3 +- resources/tester/test_65_ford_defaults.py | 3 +- resources/tester/test_90_chute_band.py | 59 ++++++++++ resources/tester/test_b06_culvert_set.py | 26 +++-- .../tester/test_b06_culvert_sets_mirror.py | 11 ++ 18 files changed, 434 insertions(+), 98 deletions(-) create mode 100644 resources/tester/helper_90_chute_band.cjs create mode 100644 resources/tester/test_90_chute_band.py diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index 582b1bd33..d9ccc19d2 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -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" }, ], }, ], diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index 3d4ad1c45..721bbce37 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -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, }; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ae320539e..7ec2811bf 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -219,10 +219,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; } /** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */ @@ -248,6 +250,7 @@ export interface CulvertSet { foundation?: string | null; /** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */ body?: "box" | "ford"; + chute?: ChuteSpec; } /** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */ @@ -279,6 +282,7 @@ export interface FordSet { wing_out: FordWingSpec; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + chute?: ChuteSpec; } /** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */ @@ -299,6 +303,7 @@ export interface BoxSet { wing_out: FordWingSpec; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + chute?: ChuteSpec; } export interface CrossSection extends SectionStation { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ab28ec192..788e9b5e8 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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: diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index c83a1a607..0eed5732a 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -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" ? "좌" : "우"; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index e8e02a3c4..8e905c8c5 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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 사용자) — 벽이 // 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e4bad48ea..4baa279d9 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index c3c4217a3..e01993074 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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" ? "유입" : "유출"; diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 0449d5ff9..9f35159bf 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -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); + } +} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d240917ba..bd2757250 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -344,7 +344,7 @@ stroke-width: 2.2; } -/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */ +/* 유출부 도수로 · 돌붙임 띠(90-2) — 성토사면 안쪽으로 두께만큼 · 안에 돌(`appendChuteBand`) */ .b06-chart__culvert-pitching { fill: color-mix(in srgb, #3fa34d 18%, transparent); stroke: #3fa34d; diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 490e7d4c6..d65b778de 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -37,8 +37,7 @@ const SECTION_KEYS: Record = { 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, +): { 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): CulvertSetSpec { + const found = chute(values); + if (found) spec.chute = found; + return spec; +} + +/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임). */ function culvertSet( options: Record, 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, 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자리)별 세트 제원. */ diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 308e34b20..66f923635 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3371,7 +3371,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "box_culvert": { @@ -3433,7 +3434,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "ford_pavement": { @@ -3503,7 +3505,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "revetment": { diff --git a/resources/tester/helper_90_chute_band.cjs b/resources/tester/helper_90_chute_band.cjs new file mode 100644 index 000000000..13708c8f2 --- /dev/null +++ b/resources/tester/helper_90_chute_band.cjs @@ -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)); diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py index 04e92025a..e183dd48c 100644 --- a/resources/tester/test_52_3_structure_spec_ford_pavement.py +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -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, diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 95d751985..09323289b 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -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_"] # 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음 diff --git a/resources/tester/test_90_chute_band.py b/resources/tester/test_90_chute_band.py new file mode 100644 index 000000000..21e4c98f3 --- /dev/null +++ b/resources/tester/test_90_chute_band.py @@ -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}, + ] diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index 1970ecbbe..22a6bd6f2 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -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): diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index 8ddc842e5..22e87c786 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -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"]