From 209383ed1784f8a0fc1c11fee7d99260ba8b08d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:58:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(b05,b06,b07):=20=EB=8F=84=EC=88=98?= =?UTF-8?q?=EB=A1=9C/=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=9D=A0=20=3D=20?= =?UTF-8?q?=EB=82=AE=EC=9D=80=20=EC=AA=BD=20=EB=81=9D=EB=8B=A8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=EC=9B=90=EC=A7=80=EB=B0=98=20=EB=8B=BF?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EB=81=9D=20=C2=B7=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=3D=20=EA=B7=B8=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=EC=8B=9C=EC=84=A4=20=EA=B0=99=EC=9D=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=EA=B3=BC=20=EA=B8=B8=EC=9D=B4=20=EC=B9=B8=20(294-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 chuteRun(Wall_Hatch) — 길이 비움 = 원지반 닿으면 끝 · 넣음 = 사면 따라 그 길이 · 사면보다 길면 원지반 따라 이어 그 길이 · 끝단이 이미 원지반이면 안 그림 - 물넘이 — 띠 시작 = 늘인 노면 끝(294-3) · 아니면 유출 쪽 노견 끝 · 원지반 닿으면 끝(양측 절토 = 띠 없음) - 배관 · 박스 · 세월교 — 띠 계산이 원지반(layout.ground)을 받아 같은 규칙 · 3D 띠도 같음 · 코리도 해시에 길이 - 명세 CHUTE_ITEMS 맨 아래 「길이 (m)」(chute_add_{n}_length_m · 그림 전용) · 물넘이는 공용 칸 그대로 - 면적 자동 = 물넘이 · 세월교만 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 손 입력 그대로 · 브레인 정함) - 길이는 집계 열 · B08 수량(면적만)에 안 실림 · 마스터 정의 이름 표는 안 건드림(이름 표 대조 시험에서 이 칸만 밖) - 시험 test_294_3 · 286_1 · 52 · 65 · 152_3 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- .../design/design_structure_ford_calc.ts | 19 ++-- .../design/design_structure_ford_pavement.ts | 4 +- A00_Common/design/design_structure_guard.ts | 2 + .../B05_Profile_UI_Corridor_Structures.ts | 9 +- .../B05_Profile_UI_Corridor_Structures_Box.ts | 2 + ...05_Profile_UI_Corridor_Structures_Chute.ts | 21 ++++- ...B05_Profile_UI_Corridor_Structures_Hash.ts | 5 +- .../B05_Profile_UI_Drainage_Facility.ts | 5 +- B06_Section/B06_Section_UI_Cross_Box.ts | 1 + B06_Section/B06_Section_UI_Cross_Box_Geom.ts | 4 + B06_Section/B06_Section_UI_Cross_Culvert.ts | 1 + .../B06_Section_UI_Cross_Culvert_Geom.ts | 2 + .../B06_Section_UI_Cross_Culvert_Types.ts | 2 + B06_Section/B06_Section_UI_Cross_Ford.ts | 1 + B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 4 + .../B06_Section_UI_Cross_Ford_Pavement.ts | 76 ++++++++++------ B06_Section/B06_Section_UI_Cross_View.ts | 3 +- .../B06_Section_UI_Cross_Wall_Hatch.ts | 81 ++++++++++++++++- .../B07_DesignDetail_Cross_Overlay.ts | 4 +- .../tester/helper_286_1_ford_pavement.cjs | 5 +- .../tester/helper_294_3_ford_extend_chute.cjs | 32 +++++++ resources/tester/test_152_3_chute_cut.py | 5 +- .../tester/test_286_1_ford_pavement_design.py | 3 +- .../tester/test_294_3_ford_extend_chute.py | 91 ++++++++++++++++++- .../tester/test_52_4_structure_spec_ford.py | 5 +- .../tester/test_52_structure_spec_box.py | 3 +- .../tester/test_52_structure_spec_pipe.py | 3 +- resources/tester/test_65_ford_defaults.py | 1 + 28 files changed, 331 insertions(+), 63 deletions(-) diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index 1f7fee6bb..474f37a92 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -88,28 +88,31 @@ export function chuteAreaM2(widthM: number, lengthM: number): number { } /** - * 물넘이 포장 도수로/돌붙임 길이 ↔ 면적(287-1) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 도로 방향으로 - * 걸치는 월류 폭 · 비면 물넘이 기본 폭) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 + * 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 + * 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 * 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀. */ export function syncChuteArea( form: Pick, c: SpecContainer, item: SpecItem, + /** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */ + chuteBox = "chute", + widthBox = "slope", ): void { - const byLength = c.id === "chute" && item.id === "length_m"; - if (!byLength && !(c.id === "slope" && item.id === "width")) return; - const chute = form.state().chute?.[0]; + const byLength = c.id === chuteBox && item.id === "length_m"; + if (!byLength && !(c.id === widthBox && item.id === "width")) return; + const chute = form.state()[chuteBox]?.[0]; if (!chute) return; const length = Number(chute.length_m ?? ""); if (!(chute.length_m ?? "").trim() || !(length > 0)) { if (byLength) - form.setValue("chute", "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? ""); + form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? ""); return; } - const width = Number(form.state().slope?.[0]?.width ?? ""); + const width = Number(form.state()[widthBox]?.[0]?.width ?? ""); form.setValue( - "chute", + chuteBox, "area", String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)), ); diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index 8db636fa2..2f0f602f1 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -35,8 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { prefix: "ford_chute", link: "gfordpavechute", max: 1, - // 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 90-2) + 맨 아래 길이(그림 전용 · 286-3) - items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }], + // 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4) + items: CHUTE_ITEMS, }, ], // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index c5d18b956..818acb556 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [ }, { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" }, { id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" }, + // 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀) + { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }, ]; /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index c39573839..2a6b0c3fc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -487,6 +487,8 @@ export function buildCorridorStructures( chuteBands( fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments), section.ford?.chute, + undefined, + fordLayout.ground, ).forEach((band) => pushSwept("chute", band, half, half, null, "chute")); // 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면** // (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은 @@ -595,7 +597,12 @@ export function buildCorridorStructures( ); } // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3). - chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) => + chuteBands( + layout.outletFill.segments, + section.culvert?.chute, + isChuteSegment, + layout.ground, + ).forEach((band) => pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"), ); // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index 65f01341d..01a49e1aa 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -182,6 +182,8 @@ export function boxSolids( const chutes: CorridorStructure[] = chuteBands( layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments), box.chute, + undefined, + layout.ground, ).map((band) => ({ chainage_m: chainage, kind: "chute", diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts index 1cbe71281..cedbba41f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts @@ -10,8 +10,12 @@ import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; -import { fordChuteLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; +import { + chuteBandOutline, + chuteRun, + groundLine, +} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; +import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; /** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */ interface FillSegment { @@ -25,10 +29,11 @@ export function chuteBands( segments: ReadonlyArray, chute: ChuteSpec | null | undefined, keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut", + /** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */ + ground?: readonly OffsetPoint[], ): OffsetPoint[][] { if (!chute) return []; - return segments.flatMap((segment) => { - if (!keep(segment)) return []; + return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => { const band = chuteBandOutline(segment.points, chute.thickness_m); return band ? [band.outline] : []; }); @@ -51,6 +56,12 @@ export function fordPavementChute( ); if (owner !== section) return null; } - const bands = chuteBands([{ points: fordChuteLine(section.design, pave.chute) }], pave.chute); + const ground = groundLine(section.samples ?? []); + const bands = chuteBands( + [{ points: outletFillLine(section.design, ground) }], + pave.chute, + undefined, + ground, + ); return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index bd4e7aa1d..6cbfcd72f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -16,7 +16,10 @@ import type { /** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */ function chuteParts(chute: ChuteSpec | null | undefined): Array { - return chute ? ["ch", chute.structure, chute.thickness_m] : []; + if (!chute) return []; + // 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로 + const base = ["ch", chute.structure, chute.thickness_m]; + return chute.length_m ? [...base, chute.length_m] : base; } /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 18825e7b9..a9a65e47b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -384,7 +384,10 @@ export function createFacilityOptionsForm( ); // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { - onChange: fordFlowBox.onChange, + onChange: (c, item) => ( + syncChuteArea(fordForm, c, item, "chute_add", "ford"), + fordFlowBox.onChange(c, item) + ), fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)], count: guardSoilCount( () => chainage, diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index d9e4ff93a..e044cebbb 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -166,6 +166,7 @@ export function appendBoxOverlay( box.chute, x, toDisplayY, + layout.ground, ); // ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠. for (const side of layout.sides) { diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index fecab8916..c7014e1b5 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -16,6 +16,7 @@ * 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import type { BasinLayout, @@ -74,6 +75,8 @@ export interface BoxSideLayout { export interface BoxLayout { box: BoxSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; sides: BoxSideLayout[]; /** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */ topSlab: OffsetPoint[]; @@ -222,6 +225,7 @@ export function computeBoxLayout( const apronLengthOf = (side: BoxSideLayout): number => side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0; return { + ground: groundLine(groundSamples), box, sides: [left, right], topSlab: quad(0, barrelTopDrop), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 6413b563e..226e9014b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -125,6 +125,7 @@ export function appendCulvertOverlay( culvert.chute, x, toDisplayY, + layout.ground, ); for (const segment of layout.outletFill.segments) { // 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 3e46b469e..0b66c0e2e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -6,6 +6,7 @@ * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { revetWallSpec, @@ -575,6 +576,7 @@ export function computeCulvertLayout( const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; return { + ground: groundLine(groundSamples), inletOptions, culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 8ffe3bed8..1b21ed7cc 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -201,6 +201,8 @@ export interface OutletFillSegment { export interface CulvertLayout { culvert: CulvertSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number }; /** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */ pipeAxis: { axis: OffsetPoint; normal: OffsetPoint }; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index fde0f514b..62082530c 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -144,6 +144,7 @@ export function appendFordOverlay( ford.chute, x, toDisplayY, + layout.ground, ); // ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계). for (const side of layout.sides) { diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 5c4bfb605..b3ba4642f 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -19,6 +19,7 @@ * 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다. * ========================================================================== */ +import { groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const"; import type { @@ -108,6 +109,8 @@ export interface FordSideLayout { export interface FordLayout { ford: FordSet; + /** 원지반 — 도수로 · 돌붙임 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: OffsetPoint[]; sides: FordSideLayout[]; /** 두 측벽 사이를 잇는 바닥판. */ slabBridge: OffsetPoint[]; @@ -329,6 +332,7 @@ export function computeFordLayout( const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : ""; return { + ground: groundLine(groundSamples), ford, sides, slabBridge, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 37d5d4320..f6e241919 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -13,7 +13,7 @@ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; -import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch"; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { @@ -102,6 +102,8 @@ export function appendFordPavementOverlay( design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, + /** 원지반 — 도수로 띠가 원지반에 닿으면 끝 · 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: readonly OffsetPoint[], ): boolean { const edges = fordEdges(design); if (!spec || !edges) return false; @@ -130,7 +132,7 @@ export function appendFordPavementOverlay( ), ); if (spec.chute) - appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute) }], spec.chute, x, y); + appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute, ground) }], spec.chute, x, y); return true; } @@ -143,18 +145,38 @@ export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | unde return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end }; } +/** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */ +function groundZ(ground: readonly OffsetPoint[], offset: number): number | null { + if (!ground.length) return null; + if (offset <= ground[0].offset) return ground[0].elevation; + for (let i = 1; i < ground.length; i += 1) { + const [a, b] = [ground[i - 1], ground[i]]; + if (offset > b.offset) continue; + const span = b.offset - a.offset; + return span > 1e-9 + ? a.elevation + ((b.elevation - a.elevation) * (offset - a.offset)) / span + : b.elevation; + } + return ground[ground.length - 1].elevation; +} + /** - * 유출측(`inflowLeft` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 - * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). + * 유출측(낮은 쪽) 성토사면 선(136-2 · 294-4) — 낮은 쪽 끝단(늘인 노면 끝 · 아니면 유출 쪽 노견 끝)에서 + * 설계선을 바깥으로 따라 내려가는 동안만(오르면 절토라 멈춤) · 원지반(`ground`)에 닿으면 끝. + * 성토가 없으면(끝단이 이미 원지반 · 양측 절토 물넘이) 점 하나 = 띠 없음. */ -export function outletFillLine(design: CrossDesign): OffsetPoint[] { - const edges = design.road_edges; +export function outletFillLine( + design: CrossDesign, + ground?: readonly OffsetPoint[], +): OffsetPoint[] { + const edges = fordEdges(design); if (!edges || !design.design_line?.length) return []; - const outward = inflowLeft(design) ? -1 : 1; // +offset = 좌측 · 유입 반대 - const edge = - outward > 0 - ? Math.max(edges.left.offset_m, edges.right.offset_m) - : Math.min(edges.left.offset_m, edges.right.offset_m); + const reach = design.tilt_reach; + const outLeft = reach ? reach.side === "left" : !inflowLeft(design); // +offset = 좌측 + const outward = outLeft ? 1 : -1; + const edge = outLeft + ? Math.max(edges.left.offset_m, edges.right.offset_m) + : Math.min(edges.left.offset_m, edges.right.offset_m); const points = design.design_line .filter((p) => (p.offset_m - edge) * outward >= -1e-6) .sort((a, b) => (a.offset_m - b.offset_m) * outward); @@ -163,31 +185,25 @@ export function outletFillLine(design: CrossDesign): OffsetPoint[] { const last = line[line.length - 1]; if (last && p.elevation_m > last.elevation + 1e-6) break; line.push({ offset: p.offset_m, elevation: p.elevation_m }); + const z = ground ? groundZ(ground, p.offset_m) : null; + if (z !== null && Math.abs(z - p.elevation_m) < 1e-3) break; // 원지반에 닿음 = 끝 } return line; } /** - * 물넘이 도수로 · 돌붙임 선(286-3) — 유출 성토사면 선을 노견에서 사면 따라 `length_m` 까지 자름 · - * 비거나 사면보다 길면 원지반까지. 횡단 띠 · 3D 띠가 같이 씀(그림 전용). + * 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 · + * 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어) · 양측 절토(늘인 노면 끝이 원지반) = 길이 없으면 안 그림. + * 횡단 띠 · 3D 띠가 같이 씀(그림 전용). */ -export function fordChuteLine(design: CrossDesign, chute?: { length_m?: number }): OffsetPoint[] { - const line = outletFillLine(design); - let left = chute?.length_m ?? 0; - if (!(left > 0)) return line; - const out = line.slice(0, 1); - for (let i = 1; i < line.length && left > 1e-9; i += 1) { - const [a, b] = [line[i - 1], line[i]]; - const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation); - if (step < 1e-9) continue; - const t = Math.min(left / step, 1); - out.push({ - offset: a.offset + (b.offset - a.offset) * t, - elevation: a.elevation + (b.elevation - a.elevation) * t, - }); - left -= step; - } - return out; +export function fordChuteLine( + design: CrossDesign, + chute?: { length_m?: number }, + ground?: readonly OffsetPoint[], +): OffsetPoint[] { + return ( + chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground)[0]?.points ?? [] + ); } /** diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7f797e240..d45a76c81 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -23,7 +23,7 @@ import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick"; import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; -import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { @@ -452,6 +452,7 @@ export function createCrossSectionCard( section.design, x, toDisplayY, + groundLine(section.samples ?? []), ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index ab62c86d3..2b402938b 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -349,6 +349,77 @@ export function chuteBandOutline( return { top, normals, outline: [...top, ...bottom.reverse()] }; } +/** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */ +function trimPolyline( + points: readonly OffsetPoint[], + lengthM: number, +): { points: OffsetPoint[]; used: number } { + const out = points.slice(0, 1); + let used = 0; + for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) { + const [a, b] = [points[i - 1], points[i]]; + const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation); + if (step < 1e-9) continue; + const t = Math.min((lengthM - used) / step, 1); + out.push({ + offset: a.offset + (b.offset - a.offset) * t, + elevation: a.elevation + (b.elevation - a.elevation) * t, + }); + used += step * t; + } + return { points: out, used }; +} + +/** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */ +export function groundLine( + samples: ReadonlyArray<{ + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; + }>, +): OffsetPoint[] { + return samples + .filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null) + .map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); +} + +/** + * 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서 + * 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선 + * (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관). + */ +export function chuteRun( + lines: readonly L[], + lengthM?: number | null, + ground?: readonly OffsetPoint[], +): L[] { + const drawn = (list: L[]) => list.filter((line) => line.points.length > 1); + if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]); + let left = lengthM; + const out: L[] = []; + for (const line of lines) { + if (left <= 1e-9) break; + const cut = trimPolyline(line.points, left); + out.push({ ...line, points: cut.points }); + left -= cut.used; + } + const last = out[out.length - 1]; + if (left > 1e-9 && ground?.length && last) { + const first = lines[0].points[0]; + const end = last.points[last.points.length - 1]; + const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1; + const along = [ + end, + ...ground + .filter((p) => (p.offset - end.offset) * outward > 1e-6) + .sort((a, b) => (a.offset - b.offset) * outward), + ]; + out.push({ ...last, points: trimPolyline(along, left).points }); + } + return drawn(out); +} + /** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작). * 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean { @@ -365,13 +436,16 @@ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] } */ export function appendChuteBand( layer: SVGElement, - lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, - chute: { structure: string; thickness_m: number }, + allLines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, + chute: { structure: string; thickness_m: number; length_m?: number }, x: (offset: number) => number, toDisplayY: (elevation: number) => number, + /** 원지반 — 길이가 사면보다 길면 이어 그림(294-4). */ + ground?: readonly OffsetPoint[], ): void { const thickness = chute.thickness_m; if (!(thickness > 0)) return; + const lines = chuteRun(allLines, chute.length_m, ground); const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)]; for (const line of lines) { @@ -383,7 +457,8 @@ export function appendChuteBand( band.setAttribute("class", "b06-chart__culvert-pitching"); const title = document.createElementNS(SVG_NS, "title"); const along = line.kind === "cut" ? "3도 절토선" : "성토사면"; - title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`; + const reach = chute.length_m ? `길이 ${chute.length_m.toFixed(2)}m` : "원지반까지"; + title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 ${reach}`; band.append(title); clipSeq += 1; const clipId = `b06-chute-clip-${clipSeq}`; diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 6af40280b..dbce9f48d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -18,6 +18,7 @@ import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culver import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import { appendUnderdrainOverlay } from "../B06_Section/B06_Section_UI_Cross_Underdrain"; +import { groundLine } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; import { appendCrossDesignOverlay, appendPavementOverlay, @@ -486,7 +487,8 @@ function drawDesign( y: (elevation: number) => number, ): void { const { design, culvert, box, ford, own } = layouts; - const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); + const ground = groundLine(section.samples ?? []); // 물넘이 도수로 띠 끝 · 길이 이음(294-4) + const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y, ground); if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement); appendUnderdrainOverlay(svg, section.underdrain, design, x, y); // 맹암거(288-1) appendCrossDesignOverlay( diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index dc98731df..0d0b018cc 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -24,7 +24,10 @@ const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts")); const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts"), { - "./B06_Section_UI_Cross_Wall_Hatch": { appendChuteBand() {} }, + "./B06_Section_UI_Cross_Wall_Hatch": loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"), + { "./B06_Section_UI_Cross_Culvert_Const": {} }, + ), "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, }); // 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) diff --git a/resources/tester/helper_294_3_ford_extend_chute.cjs b/resources/tester/helper_294_3_ford_extend_chute.cjs index 646a89802..0a3dc1bf9 100644 --- a/resources/tester/helper_294_3_ford_extend_chute.cjs +++ b/resources/tester/helper_294_3_ford_extend_chute.cjs @@ -55,8 +55,40 @@ pave.appendFordPavementOverlay( ); const paveLayer = svg.children.find((k) => (k.attrs.class ?? "").includes("ford-pavement")); +// 294-4 도수로 · 돌붙임 끝단 · 길이 +const hatch = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts")); +const P = (offset, elevation) => ({ offset, elevation }); +const slope = [{ points: [P(0, 0), P(3, -4)], kind: "fill" }]; // 사면 5 m +const flat = [P(3, -4), P(5, -4), P(8, -4)]; // 사면 끝 바깥 원지반 +const pts = (lines) => + lines.map((l) => l.points.map((p) => [+p.offset.toFixed(4), +p.elevation.toFixed(4)])); +const dipGround = hatch.groundLine(input.dipSamples); +const fillGround = hatch.groundLine(input.fillSamples); +const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); +const formOf = (state) => ({ state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }); +const fordState = { ford: [{ width: "8" }], chute_add: [{ length_m: "2.5", area: "30" }] }; +calc.syncChuteArea(formOf(fordState), { id: "chute_add" }, { id: "length_m" }, "chute_add", "ford"); +const guard = load(path.join(ROOT, "A00_Common", "design", "design_structure_guard.ts")); +const fordPave = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_pavement.ts")); + const out = { edges: pave.fordEdges(design), pavePoints: paveLayer?.attrs.points ?? null, + runNone: pts(hatch.chuteRun(slope)), + runShort: pts(hatch.chuteRun(slope, 2)), + runLong: pts(hatch.chuteRun(slope, 7, flat)), + runPointOnly: pts(hatch.chuteRun([{ points: [P(3, -4)] }])), + runPointLength: pts(hatch.chuteRun([{ points: [P(3, -4)] }], 2, flat)), + dipLine: pave.outletFillLine(design, dipGround), + dipChute: pave.fordChuteLine(design, {}, dipGround), + dipChuteLength: pave.fordChuteLine(design, { length_m: 1.5 }, dipGround), + fillLine: pave.outletFillLine(input.fillDesign, fillGround), + fillLineOld: pave.outletFillLine(input.fillDesign), + fillChuteLength: pave.fordChuteLine(input.fillDesign, { length_m: 1 }, fillGround), + fordArea: fordState.chute_add[0].area, + chuteItems: guard.CHUTE_ITEMS.map((i) => i.id), + fordPaveChuteSame: + fordPave.FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "chute").items === + guard.CHUTE_ITEMS, }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_152_3_chute_cut.py b/resources/tester/test_152_3_chute_cut.py index 01421f9d3..e3870b7c6 100644 --- a/resources/tester/test_152_3_chute_cut.py +++ b/resources/tester/test_152_3_chute_cut.py @@ -55,9 +55,8 @@ def test_거름_한_벌_배선() -> None: culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts") assert "layout.outletFill.segments.filter(isChuteSegment)," in culvert corridor = _src("B05_Profile/B05_Profile_UI_Corridor_Structures.ts") - assert ( - "chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment)" in corridor - ) + flat = " ".join(corridor.split()) + assert "chuteBands( layout.outletFill.segments, section.culvert?.chute, isChuteSegment," in flat hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts") assert '"3도 절토선"' in hatch # 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘) diff --git a/resources/tester/test_286_1_ford_pavement_design.py b/resources/tester/test_286_1_ford_pavement_design.py index da373f8af..a880eedad 100644 --- a/resources/tester/test_286_1_ford_pavement_design.py +++ b/resources/tester/test_286_1_ford_pavement_design.py @@ -174,8 +174,7 @@ def test_도수로_길이_없음_원지반까지_있음_그_길이(ts): def test_컨테이너_같은_칸_맨_아래_길이(): spec = _read("A00_Common", "design", "design_structure_ford_pavement.ts") assert ( - 'items: [...CHUTE_ITEMS, { id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 }],' - in spec + "items: CHUTE_ITEMS," in spec # 공용 칸 맨 아래 길이(294-4 · CHUTE_ITEMS 로 옮김) ) diff --git a/resources/tester/test_294_3_ford_extend_chute.py b/resources/tester/test_294_3_ford_extend_chute.py index 90056560a..e20bb8560 100644 --- a/resources/tester/test_294_3_ford_extend_chute.py +++ b/resources/tester/test_294_3_ford_extend_chute.py @@ -5,6 +5,10 @@ 처음 만나는 자리까지(그 쪽 절토 사면 없음 · `tilt_reach`) · 성토 쪽 그대로 · 반폭 안에서 못 만나면 옛 길 · 서버 정본 ↔ TS 짝 같은 값 = 횡단 설계 거울(`test_b06_cross_design_mirror` 「물넘이포장 낮은 쪽 노면 연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지. + - 294-4 도수로/돌붙임 띠 = 낮은 쪽 끝단(늘인 끝 · 아니면 유출 쪽 노견 끝)에서 성토 사면 따라 · 길이 비움 = + 원지반 닿으면 끝 · 양측 절토 물넘이 = 띠 없음 · 길이 = 그 길이(사면보다 길면 원지반 따라) · 다른 구조물 + (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 세월교만 + 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 자동 안 함) · 길이는 집계 · B08 수량에 안 실림. 진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인. """ @@ -60,7 +64,15 @@ def dip() -> dict: @pytest.fixture(scope="module") def ts(dip, tmp_path_factory) -> dict: source = tmp_path_factory.mktemp("s294") / "in.json" - source.write_text(json.dumps({"design": dip}), encoding="utf-8") + fill_samples = _line(lambda o: 100.0 + 0.35 * o) + fill = compute_cross_design(fill_samples, 100.4, **KW) + payload = { + "design": dip, + "dipSamples": _dip_ground(), + "fillSamples": fill_samples, + "fillDesign": fill, + } + source.write_text(json.dumps(payload), encoding="utf-8") proc = subprocess.run( ["node", str(HERE / "helper_294_3_ford_extend_chute.cjs"), str(source)], capture_output=True, @@ -110,3 +122,80 @@ def test_콘크리트는_늘인_끝까지(dip, ts): } first = ts["pavePoints"].split()[1] # 포장층 윗면 우 끝 assert float(first.split(",")[0]) == pytest.approx(-4.3333, abs=1e-4) + + +def test_띠_길이_맞춤_공용(ts): + assert ts["runNone"] == [[[0, 0], [3, -4]]] # 길이 없음 = 원지반 닿으면 끝(그대로) + assert ts["runShort"] == [[[0, 0], [1.2, -1.6]]] # 길이 2 = 사면 따라 2 m + assert ts["runLong"] == [[[0, 0], [3, -4]], [[3, -4], [5, -4]]] # 사면 5 + 원지반 2 + assert ts["runPointOnly"] == [] # 끝단이 이미 원지반 · 길이 없음 = 안 그림 + assert ts["runPointLength"] == [[[3, -4], [5, -4]]] + + +def test_양측_절토_물넘이_띠_없음(ts): + assert len(ts["dipLine"]) == 1 # 늘인 끝이 원지반 + assert ts["dipLine"][0]["offset"] == pytest.approx(-4.3333, abs=1e-4) + assert ts["dipChute"] == [] + tail = ts["dipChuteLength"] # 길이를 넣으면 늘인 끝에서 원지반 따라 그 길이 + assert tail[0]["offset"] == pytest.approx(-4.3333, abs=1e-4) and len(tail) >= 2 + + +def test_성토_쪽_띠는_원지반_닿으면_끝(ts): + line = ts["fillLine"] + end = line[-1] + assert end["elevation"] == pytest.approx(100.0 + 0.35 * end["offset"], abs=1e-3) # 원지반 위 + assert len(ts["fillLineOld"]) > len(line) # 원지반을 모르면 내려가는 원지반까지 따라갔음 + chute = ts["fillChuteLength"] + start, stop = chute[0], chute[-1] + assert (stop["offset"] - start["offset"]) ** 2 + ( + stop["elevation"] - start["elevation"] + ) ** 2 == pytest.approx(1.0, abs=1e-6) + + +def test_다른_구조물_같은_규칙과_길이_칸(ts): + assert ts["chuteItems"][-1] == "length_m" + assert ts["fordPaveChuteSame"] is True + assert ts["fordArea"] == "20" # 세월교 월류 폭 8 × 길이 2.5 + hatch = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Wall_Hatch.ts").read_text( + encoding="utf-8" + ) + assert "const lines = chuteRun(allLines, chute.length_m, ground);" in hatch + for name in ("Culvert", "Box", "Ford"): + overlay = (ROOT / "B06_Section" / f"B06_Section_UI_Cross_{name}.ts").read_text( + encoding="utf-8" + ) + assert "layout.ground," in overlay + form = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert form.count("syncChuteArea(") == 2 # 물넘이 · 세월교만(배관 · 박스 = 폭 정의 없음) + + +def test_길이는_집계_수량에_안_실림(): + from B06_Section.B06_Section_Engine_Culvert import _chute + from B08_Quantity.B08_Quantity_Router_Summary import _chutes + from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows + + options = { + "chute_add_1_structure": "돌붙임", + "chute_add_1_bond": "찰붙임", + "chute_add_1_area": 12, + } + longer = {**options, "chute_add_1_length_m": 7} + assert _chute(longer)["length_m"] == 7 and "length_m" not in _chute(options) + + class Sheet: + def __init__(self) -> None: + self.rows: list[tuple] = [] + + def add(self, at, key, sort, group, label, value, combos) -> None: + self.rows.append((key, label, value)) + + sheet = Sheet() + _chutes(sheet, 50.0, longer, {"gchuteadd1": "SS1"}, {}) + assert sheet.rows == [("chute|찰붙임", "㎡", 12.0)] + item = {"type_id": "pipe", "options": options, "복사본": "pipe@50.0", "흙막이단": 0} + plain = container_rows(item, {"gchuteadd1": "SS1"}) + long = container_rows({**item, "options": longer}, {"gchuteadd1": "SS1"}) + for key in ("length_m", "inlet_revet_length_m", "outlet_revet_length_m", "area_m"): + assert plain[0]["options"].get(key) == long[0]["options"].get(key) diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py index 218a7f2b2..a7a4dd1ca 100644 --- a/resources/tester/test_52_4_structure_spec_ford.py +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -113,4 +113,7 @@ def test_정의_파일에_세월교_이름_표가_올라_있음(): os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" ) as f: doc = json.load(f) - assert doc["종류"]["ford_bridge"]["이름"] == names + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert doc["종류"]["ford_bridge"]["이름"] == { + k: v for k, v in names.items() if k != "chute_add.length_m" + } diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py index a9b7bf0ca..5bc8c469d 100644 --- a/resources/tester/test_52_structure_spec_box.py +++ b/resources/tester/test_52_structure_spec_box.py @@ -102,7 +102,8 @@ def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" ) as f: doc = json.load(f) - assert set(names) == set(doc["종류"]["box_culvert"]["이름"]) + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert set(names) - {"chute_add.length_m"} == set(doc["종류"]["box_culvert"]["이름"]) assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"] box = next(t for t in doc["types"] if t["type_id"] == "box_culvert") wing = {o["key"]: o for o in box["options"]} diff --git a/resources/tester/test_52_structure_spec_pipe.py b/resources/tester/test_52_structure_spec_pipe.py index 88277e3d1..64b33b570 100644 --- a/resources/tester/test_52_structure_spec_pipe.py +++ b/resources/tester/test_52_structure_spec_pipe.py @@ -107,5 +107,6 @@ def test_정의_파일에_이름_표가_올라_있음(): ) as f: doc = json.load(f) on_file = doc["종류"]["pipe"]["이름"] - assert set(names) == set(on_file) + # 그림 전용 길이 칸(294-4)은 마스터 정의 이름 표에 안 올림(마스터 손대지 않음) — 대조 밖 + assert set(names) - {"chute_add.length_m"} == set(on_file) assert on_file["inlet.structure=기슭막이"] == "흙막이" diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 3fc31aa01..e856893c9 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -73,6 +73,7 @@ def test_유입_유출은_구조와_흙막이_칸만(out): ["bond", None, "메붙임"], ["thickness", None, "45"], ["area", None, "30"], + ["length_m", None, None], # 그림 전용 길이(294-4) ] keys = out[f"{spec}Keys"] assert keys["prefixes"] == ["soil_add_", "chute_add_"]