diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 83c18eafd..00de70541 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -48,6 +48,7 @@ import { import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute"; +import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -588,8 +589,8 @@ export function buildCorridorStructures( wall.role, ); } - // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 유출 벽 길이(135-3). - chuteBands(layout.outletFill.segments, section.culvert?.chute).forEach((band) => + // 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3). + chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) => pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"), ); // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts index fdf929128..20cbd37d8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Chute.ts @@ -19,14 +19,16 @@ interface FillSegment { points: OffsetPoint[]; } -/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. */ +/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. + * `keep` = 띠가 서는 조각 — 기본 성토만(박스 · 세월교 본체 끝) · 관 · 흙막이는 `isChuteSegment`(152-3). */ export function chuteBands( segments: ReadonlyArray, chute: ChuteSpec | null | undefined, + keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut", ): OffsetPoint[][] { if (!chute) return []; return segments.flatMap((segment) => { - if (segment.kind === "cut") return []; + if (!keep(segment)) return []; const band = chuteBandOutline(segment.points, chute.thickness_m); return band ? [band.outline] : []; }); diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 3815cd3c4..e194f2270 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -47,9 +47,17 @@ const EXTRA_ROWS = [ ".b06-structure-panel__struct--span", ].join(", "); +/** 도수로 · 돌붙임 상자 — 배관 · 박스 · 세월교 chute_add · 물넘이 chute(152-4). */ +const CHUTE_BOX = + 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]'; + +/** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */ +const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height"; + /** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · * 단 수 · 옵션 · 집수정 내공(24-4a). */ const SIDE_ROWS = [ + OWN_HEIGHT_ROW, ".b06-structure-panel__struct--readout", ".b06-structure-panel__struct--tier", ".b06-structure-panel__struct--options", @@ -59,15 +67,16 @@ const SIDE_ROWS = [ /** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29). * 유출 단 수는 폼 「추가 흙막이(단)」 칸(54-27) · 읽기 · 옵션 줄은 안 받음. */ const PIPE_INLET_ROWS = [ + OWN_HEIGHT_ROW, ".b06-structure-panel__struct--tier", ".b06-structure-panel__struct--basin-size", ].join(", "); -const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size"; +const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`; /** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 * 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */ function rowsFor(slot: HTMLElement): string | null { - if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null; + if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW; if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS; return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS; } @@ -155,14 +164,20 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { const side = panelRoot.dataset.side; // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) - if (side === "body" || side === "pipe") { - const body = (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) - ?.closest(".b05-structure__spec") - ?.querySelector( - side === "pipe" - ? 'fieldset[data-spec-container="pipe"]' - : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', - ); + // 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4) + if (side === "body" || side === "pipe" || side === "chute") { + const body = + side === "chute" + ? [...document.querySelectorAll(CHUTE_BOX)].find( + (box) => !box.closest("[hidden]"), + ) + : (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) + ?.closest(".b05-structure__spec") + ?.querySelector( + side === "pipe" + ? 'fieldset[data-spec-container="pipe"]' + : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', + ); if (!body || body.closest("[hidden]")) return; setGroupLabel(body, ""); body.classList.add("is-active"); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index fd5820f08..d9e4ff93a 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -162,7 +162,7 @@ export function appendBoxOverlay( layer, layout.sides .filter((side) => side.wingRole === "outlet") - .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")), box.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 45a79ad0c..6f74b9983 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -22,7 +22,11 @@ import type { WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; -import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { + appendChuteBand, + appendWallHatch, + isChuteSegment, +} from "./B06_Section_UI_Cross_Wall_Hatch"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. @@ -56,8 +60,8 @@ function polygon( // "own" = 배관과 무관한 **독립 기슭막이**(구조물 정본 D군). 조정창·조작값 저장은 // 배관 벽과 같은 체계를 그대로 쓴다(2026-08-28 사용자: 배관측점처럼 이동). export type RevetKey = "inlet" | "outlet" | "own" | `extra${number}` | `bextra${number}`; -/** 고름 전용 키 — 벽 키 + 관(118-1 · 조정창 없음 · 강조만). 조정 함수에는 `RevetKey` 만. */ -export type RevetPick = RevetKey | "pipe"; +/** 고름 전용 키 — 벽 키 + 관(118-1) · 도수로 띠(152-4) — 조정창 없음 · 강조만. 조정 함수에는 `RevetKey` 만. */ +export type RevetPick = RevetKey | "pipe" | "chute"; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ export type RevetHighlightSetter = (key: RevetPick | null) => void; @@ -110,11 +114,12 @@ export function appendCulvertOverlay( : `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`; }; const drawOutletFill = (): void => { - // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림 + // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) + // · 연동 카드엔 안 그림 if (culvert.chute && !linked) appendChuteBand( layer, - layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points), + layout.outletFill.segments.filter(isChuteSegment), culvert.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6aceb2af5..5bbd5364a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -680,6 +680,7 @@ export function computeCulvertLayout( }, bodyX: inletBody || outletBody ? { inlet: inletBody?.x ?? 0, outlet: outletBody?.x ?? 0 } : null, + pureHeights: { inlet: inletWallSpec.pureHeight, outlet: outletWallSpec.pureHeight }, designTrim: body && ownTrim ? mergeGuardTrim(ownTrim, body.trim, walls) : ownTrim, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 98bd07199..56c2499c6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -221,6 +221,8 @@ export interface CulvertLayout { inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean }; /** 세월교 · 박스 흙막이(73-4) — 본체 끝 자리의 배관 4축 x(적용 x 의 0점). 본체 없으면 없음. */ bodyX?: { inlet: number; outlet: number } | null; + /** 유입 · 유출 벽의 순수 높이(m) — 높이 빈 칸인 연동 옆 카드가 물려받음(152-2). */ + pureHeights?: { inlet: number; outlet: number }; } /** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */ export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 3796e0126..ab8212da6 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -606,7 +606,16 @@ export function ownerAppliedAdjusts( owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner), inletStructure?.adjustFor(owner), ); - return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; + if (!ownerLayout) return ownerInput; + // 높이 빈 칸 = 소유 측점이 그린 순수 높이 — 옆 측점엔 본체가 없어 근입 0.5 만 서던 것(152-2). + const shift = bodyAbsolute(ownerLayout); + const heights = ownerLayout.pureHeights; + if (!heights) return shift; + return { + ...shift, + inlet: { ...shift.inlet, h: shift.inlet.h ?? heights.inlet }, + outlet: { ...shift.outlet, h: shift.outlet.h ?? heights.outlet }, + }; } function adjustsInput( diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index fc9f3324c..f5577ca81 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -136,7 +136,7 @@ export function appendFordOverlay( layer, layout.sides .filter((side) => side.role === "outlet") - .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")), ford.chute, x, toDisplayY, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 6dc7ab614..f833f5624 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -182,7 +182,7 @@ export function appendFordPavementOverlay( "b06-chart__ford-deck", ), ); - if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y); + if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y); return true; } diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 89460db24..b5549919b 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -248,6 +248,7 @@ export function buildStructurePanel( const heightRow = heightParts.row; // 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4). heightRow.classList.add("b06-structure-panel__struct--height"); + const heightLabel = heightRow.querySelector?.(".b06-structure-panel__label"); const heightValue = document.createElement("span"); heightValue.className = "b06-structure-panel__hval"; const heightMinus = makeButton( @@ -495,11 +496,11 @@ export function buildStructurePanel( return { root, show: (pick) => { - // 관 = 조정창 없음 · 좌측 「관」 상자만 강조(118-1 · 세월교 본체 꼴) - const key = pick === "pipe" ? null : pick; + // 관 · 도수로 띠 = 조정창 없음 · 좌측 「관」 · 「도수로/돌붙임」 상자만 강조(118-1 · 152-4) + const key = pick === "pipe" || pick === "chute" ? null : pick; current = key; root.classList.toggle("is-hidden", key === null); - root.dataset.side = pick === "pipe" ? "pipe" : ""; + root.dataset.side = pick === "pipe" || pick === "chute" ? pick : ""; document.removeEventListener("keydown", onArrowKey); drag.detach(); if (!key) return; @@ -550,11 +551,14 @@ export function buildStructurePanel( // 형태 · 높이는 좌측 폼 칸이 정본이다(유입구 · 유출구 · 독립 좌우 · 구조물 옵션) — 폼에 // 자기 칸이 없는 **다단 벽**만 이 줄을 「추가 기슭막이」 칸에 세운다(24-4a). materialRow.classList.toggle("is-hidden", !isExtra); - heightRow.classList.toggle("is-hidden", !isExtra); - if (isExtra) { - heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; - materialSelect.value = deps.formFor(key); - } + // 연동을 푼 옆 카드의 흙막이 = 「이 측점 높이」 줄(폼 칸은 본 흙막이 · 152-2) + const ownHeight = deps.linkState()?.linked === false && guardKey && !isBasin; + heightRow.classList.toggle("is-hidden", !isExtra && !ownHeight); + heightRow.classList.toggle("b06-structure-panel__struct--own-height", ownHeight); + if (heightLabel) + heightLabel.textContent = ownHeight ? "이 측점 높이" : L("B06_Cross_Height_Label"); + if (isExtra || ownHeight) heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; + if (isExtra) materialSelect.value = deps.formFor(key); // 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다. // 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다. // 유출 벽과 독립 기슭막이 유입 벽에서는 **항상** 보인다(2026-08-22 사용자 ⑤ — diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 0729de1e4..18ff82f52 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -22,6 +22,7 @@ import { } from "./B06_Section_UI_Cross_Ford_Pavement"; import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; +import { attachChuteBandPick } 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 { @@ -572,6 +573,19 @@ export function createCrossSectionCard( if (highlightRevet) setRevetActive(highlightRevet); } raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5) + // 도수로 · 돌붙임 띠 고름 — 관 고름 꼴(152-4) + const setChuteActive = attachChuteBandPick( + plotLayer, + revetOffset && (() => toggleRevet("chute")), + ); + if (setChuteActive) { + const baseSetActive = setRevetActive; + setRevetActive = (key) => { + baseSetActive(key); + setChuteActive(key === "chute"); + }; + setChuteActive(highlightRevet === "chute"); + } // 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2). const lineDesign = section.design; linePick = attachDesignLinePick({ diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 2be9fbf51..ab62c86d3 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -349,14 +349,23 @@ export function chuteBandOutline( return { top, normals, outline: [...top, ...bottom.reverse()] }; } +/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작). + * 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */ +export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean { + if (segment.kind !== "cut") return true; + const { points } = segment; + return points.length > 1 && points[points.length - 1].elevation <= points[0].elevation + 1e-6; +} + /** * 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한 * 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관). * 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴). + * 절토 조각(`kind: "cut"` · 152-3)은 3도 절토선 따라. */ export function appendChuteBand( layer: SVGElement, - lines: OffsetPoint[][], + lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>, chute: { structure: string; thickness_m: number }, x: (offset: number) => number, toDisplayY: (elevation: number) => number, @@ -365,15 +374,16 @@ export function appendChuteBand( 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 shape = chuteBandOutline(raw, thickness); + for (const line of lines) { + const shape = chuteBandOutline(line.points, thickness); if (!shape) continue; const { top, normals, outline } = shape; 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 · 성토사면 따라 원지반까지`; + const along = line.kind === "cut" ? "3도 절토선" : "성토사면"; + title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`; band.append(title); clipSeq += 1; const clipId = `b06-chute-clip-${clipSeq}`; @@ -414,3 +424,31 @@ export function appendChuteBand( layer.append(band, clip, group); } } + +/** + * 도수로 · 돌붙임 띠 고름(152-4 · 118-1 관 고름 꼴) — 그린 띠(관 · 박스 · 세월교 · 물넘이)마다 투명 판정면. + * 벽 판정면 아래 · 보라 선 판정 위(판정면 클래스를 같이 씀). 띠가 없으면 null · 강조 setter 반환. + */ +export function attachChuteBandPick( + layer: SVGElement, + onPick?: () => void, +): ((active: boolean) => void) | null { + const kids = [...layer.children]; + const bands = kids.filter((el) => el.classList.contains("b06-chart__culvert-pitching")); + if (!bands.length) return null; + const firstHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit")); + for (const band of onPick ? bands : []) { + const hit = document.createElementNS(SVG_NS, "polygon"); + hit.setAttribute("points", band.getAttribute("points") ?? ""); + hit.setAttribute("class", "b06-chart__culvert-revet-hit b06-chart__chute-hit"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "도수로 · 돌붙임 선택"; + hit.append(title); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onPick?.(); + }); + layer.insertBefore(hit, firstHit ?? null); + } + return (active) => bands.forEach((band) => band.classList.toggle("is-active", active)); +} diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 97698499b..4ec8b2bb4 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -23,8 +23,8 @@ import { type SelectableStructure, } from "../A00_Common/design/design_selection"; -/** 벽 고름이 받는 부재키 — 관은 강조만(118-1) · 집수정·본체는 여기서 빠진다. */ -const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe)$/; +/** 벽 고름이 받는 부재키 — 관 · 도수로 띠는 강조만(118-1 · 152-4) · 집수정·본체는 여기서 빠진다. */ +const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe|chute)$/; interface PickTargets { focusStation: (stationId: string) => void; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index bfdb42d82..bdd0f2c51 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -73,10 +73,12 @@ export function wireStructureSelection( // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) if (key) markWallSelecting(); origRevet(chainageM, key); - if (key) remember?.(ownerChainageOf(chainageM), key); - if (key) panel.showAtChainage(ownerChainageOf(chainageM)); + // 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4) + const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM); + if (key) remember?.(owner, key); + if (key) panel.showAtChainage(owner); else if (!nested) { - remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + remember?.(owner, ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 panel.releasePipe(); } if (key) syncInletStructure(); @@ -88,14 +90,20 @@ export function wireStructureSelection( const keyOf = (box: HTMLElement): RevetPick | null => { const id = box.dataset.specContainer; if (id === "inlet" || id === "outlet" || id === "pipe") return id; + if (id === "chute_add" || id === "chute") return "chute"; // 도수로 띠(152-4) return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null; }; const pickBox = (box: HTMLElement): void => { const chainageM = panel.currentChainage(); const target = chainageM === null ? undefined : sectionAt(chainageM); const key = keyOf(box); - if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key) - return; + const chute = + target?.culvert?.chute ?? + target?.box?.chute ?? + target?.ford?.chute ?? + target?.ford_pavement?.chute; + if (!key || !target || !(key === "chute" ? chute : target.culvert)) return; + if (stationControls.revetOffset.highlightFor(target) === key) return; stationControls.revetOffset.select(target.chainage_m, key); refreshCard?.(target.chainage_m); }; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index de92d2214..62088f15c 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -391,6 +391,18 @@ stroke-linejoin: round; } +/* 도수로 · 돌붙임 띠 고름(152-4) — 관 고름과 같은 강조 · 판정면은 굵은 투명 테두리로 여유 */ +.b06-chart__culvert-pitching.is-active { + fill: color-mix(in srgb, var(--color-danger) 30%, transparent); + stroke: var(--color-danger); + stroke-width: 2.2; + stroke-dasharray: none; +} +.b06-chart__culvert-revet-hit.b06-chart__chute-hit { + stroke: transparent; + stroke-width: 8; +} + /* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */ .b06-design__rockb-btn { width: 22px; diff --git a/resources/tester/helper_135_3_chute_solid.cjs b/resources/tester/helper_135_3_chute_solid.cjs index d55aba449..befdea96a 100644 --- a/resources/tester/helper_135_3_chute_solid.cjs +++ b/resources/tester/helper_135_3_chute_solid.cjs @@ -49,7 +49,7 @@ const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); const { structureHashParts } = loadTs(B05("B05_Profile_UI_Corridor_Structures_Hash")); const { structureTopLoops } = loadTs(B05("B05_Profile_UI_Corridor_Tin_Design")); -const { chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { chuteBandOutline, isChuteSegment } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom")); const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford")); const { outletFillLine } = loadTs(B06("B06_Section_UI_Cross_Ford_Pavement")); @@ -181,7 +181,7 @@ function check(name, section, expectedBands, expectedLength, strip) { const { culvertLayoutOf } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); const layout = culvertLayoutOf(s); const bands = (layout?.outletFill.segments ?? []) - .filter((g) => g.kind !== "cut") + .filter(isChuteSegment) // 묻힌 끝 3도 절토 조각도(152-3) .map((g) => chuteBandOutline(g.points, 0.3)?.outline) .filter(Boolean); const spec = s.culvert.outlet; diff --git a/resources/tester/helper_152_2_link_height.cjs b/resources/tester/helper_152_2_link_height.cjs new file mode 100644 index 000000000..8fa879473 --- /dev/null +++ b/resources/tester/helper_152_2_link_height.cjs @@ -0,0 +1,98 @@ +/* 152-2 연동 흙막이 높이 헬퍼 — 높이 칸 빈 박스 흙막이(73-4 픽스처 352.14)의 연동 옆 측점(340 · 360)이 + * 소유 측점이 그린 순수 높이를 물려받는지 · 연동을 푼 카드만 자기 높이로 서는지. 결과 = 표준출력 JSON. + * 짝 = `test_152_2_link_height.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(); +// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임 +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +// 높이 칸 비움(= 자동 · 세월교 · 박스 흙막이 기본) — 원본은 3.5 를 넣은 상태 +const sections = fixture.cross_sections.map((s) => { + if (!s.culvert?.body) return s; + const copy = JSON.parse(JSON.stringify(s)); + copy.culvert.inlet.revet_height_m = null; + copy.culvert.outlet.revet_height_m = null; + return copy; +}); +const at = (list, c) => list.find((s) => Math.abs(s.chainage_m - c) < 0.6); +const round = (v) => Math.round(v * 1000) / 1000; +const heightsOf = (list, c) => { + const culvert = computeStoredLayouts(at(list, c), list)?.culvert; + return { + pure: culvert?.pureHeights + ? { inlet: round(culvert.pureHeights.inlet), outlet: round(culvert.pureHeights.outlet) } + : null, + walls: (culvert?.walls ?? []).map((w) => ({ role: w.role, height: round(w.height) })), + shift: culvert?.revetShift ?? null, + }; +}; + +const out = { owner: heightsOf(sections, 352.14), linked: {} }; +for (const c of [340, 360]) out.linked[c] = heightsOf(sections, c); + +// 340 연동 풂 + 유출 흙막이만 +0.1 m(좌측 「이 측점 높이」 줄이 그 측점 값을 고친 것) +const shift = out.linked[340].shift; +const detached = sections.map((s) => { + if (Math.abs(s.chainage_m - 340) >= 0.6) return s; + const copy = JSON.parse(JSON.stringify(s)); + copy.design.revet_link_detached = true; + copy.design.revet_adjust = { + ...(copy.design.revet_adjust ?? {}), + inlet: { ...shift.inlet }, + outlet: { ...shift.outlet, h: round(shift.outlet.h + 0.1) }, + }; + return copy; +}); +out.detached = { + 340: heightsOf(detached, 340), + 360: heightsOf(detached, 360), + owner: heightsOf(detached, 352.14), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_152_3_chute_cut.cjs b/resources/tester/helper_152_3_chute_cut.cjs new file mode 100644 index 000000000..9bbaf3331 --- /dev/null +++ b/resources/tester/helper_152_3_chute_cut.cjs @@ -0,0 +1,112 @@ +/* 152-3 도수로 띠 절토 조각 헬퍼 — 묻힌 유출 끝 3도 절토선에도 띠 · 오르막 복합경사 절토엔 띠 없음. + * 화면 TS(`_Cross_Wall_Hatch` 거름 · 단면 · B05 3D `chuteBands`)를 그 자리에서 돌림 · 짝 = `test_152_3_chute_cut.py`. + * 입력 = 139-4 와 같은 픽스처(박스 352.14 · 배관 85.052 원지반 고친 사본). + */ +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(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +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, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { isChuteSegment, chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { chuteBands } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Chute.ts"), +); +const fixture = (name) => + JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections; +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; +const CHUTE = { structure: "돌붙임", thickness_m: 0.3 }; + +/** 유출 조각마다 — 종류 · 오르막 · 띠 섬 · 띠 윗면이 그 선 · 아랫면이 선 아래(두께). */ +function study(section, list) { + const culvert = computeStoredLayouts(section, list).culvert; + const segments = culvert.outletFill.segments; + const kept = segments.filter(isChuteSegment); + return { + segments: segments.map((g) => { + const [a, b] = [g.points[0], g.points[g.points.length - 1]]; + const band = isChuteSegment(g) ? chuteBandOutline(g.points, CHUTE.thickness_m) : null; + const top = band?.top ?? []; + const bottom = band ? band.outline.slice(top.length) : []; + return { + kind: g.kind ?? "fill", + rises: b.elevation > a.elevation + 1e-6, + band: !!band, + topIsLine: !!band && top.length === g.points.length, + below: band + ? r3( + Math.min(...top.map((p, i) => p.elevation - bottom[bottom.length - 1 - i].elevation)), + ) + : null, + }; + }), + // 3D 띠(관 · 흙막이 = isChuteSegment) · 기본 거름(박스 · 세월교 본체 끝 = 성토만) + bands3d: chuteBands(segments, CHUTE, isChuteSegment).length, + bands3dFillOnly: chuteBands(segments, CHUTE).length, + kept: kept.length, + }; +} + +const out = {}; +const bodies = fixture("b06_73_4_body_guard_sections.json"); +out.box = study( + bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6), + bodies, +); + +const pipes = fixture("b07_cross_overlay_sections.json"); +const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe); +const base = computeStoredLayouts(pipe, pipes).culvert; +const tail = base.outletFill.segments[base.outletFill.segments.length - 1]; +const start = tail.points[0]; +const outward = Math.sign(tail.points[1].offset - start.offset) || 1; +function reshaped(rise, slope) { + const s = clone(pipe); + for (const p of s.samples) { + const t = ((p.offset_m ?? 0) - start.offset) * outward; + if (t >= 0 && typeof p.elevation_m === "number") + p.elevation_m = start.elevation + rise + slope * t; + } + return study( + s, + pipes.map((x) => (x === pipe ? s : x)), + ); +} +out.pipeBase = study(pipe, pipes); +out.pipeDown = reshaped(0.3, -0.15); // 3도 선이 원지반을 만남 +out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 복합경사 절토 +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_152_4_chute_pick.cjs b/resources/tester/helper_152_4_chute_pick.cjs new file mode 100644 index 000000000..d18e1a16d --- /dev/null +++ b/resources/tester/helper_152_4_chute_pick.cjs @@ -0,0 +1,278 @@ +/* PLAN 152-4 단위검증 헬퍼 — B06 도수로 · 돌붙임 띠 고름 · 강조(118-1 관 고름 꼴) · 조정창 숨김 · 3D chute 키. + * 화면 TS 를 그 자리에서 트랜스파일해 흉내 DOM 에서 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점(도수로 상자를 붙인 사본). 짝 = `test_152_4_chute_pick.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(); +let STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +// ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ───────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; + this.attrs = {}; + this.dataset = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + this.style = { setProperty: () => {} }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + insertBefore(kid, ref) { + const at = ref ? this.children.indexOf(ref) : -1; + at < 0 ? this.children.push(kid) : this.children.splice(at, 0, kid); + return kid; + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + replaceChildren(...kids) { + this.children = kids; + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + removeEventListener() {} + click() { + let stopped = false; + const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + return stopped; + } + querySelectorAll(selector) { + const found = []; + const walk = (node) => + node.children.forEach((kid) => { + if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid); + walk(kid); + }); + walk(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + getClientRects() { + return []; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), + createComment: () => new FakeNode("#comment"), + addEventListener: () => {}, + removeEventListener: () => {}, +}; +globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); +const { attachChuteBandPick } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch")); +const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const plain = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const pipeSection = JSON.parse(JSON.stringify(plain)); +pipeSection.culvert.chute = { structure: "돌붙임", thickness_m: 0.3 }; +const listWith = sections.map((s) => (s === plain ? pipeSection : s)); +const out = {}; +const cls = (n) => n.className; + +// ① 오버레이 + 띠 고름 — 판정면 차례 · 누름 · 강조 +function draw(section, list, onPick) { + const layer = new FakeNode("g"); + const layout = computeStoredLayouts(section, list).culvert; + const setter = appendCulvertOverlay( + layer, + layout, + (o) => -o * 10, + (e) => -e * 10, + () => {}, + false, + ); + const picked = []; + const setChute = attachChuteBandPick(layer, onPick ? () => picked.push("chute") : undefined); + return { layer, setter, setChute, picked }; +} +const drawn = draw(pipeSection, listWith, true); +const kids = drawn.layer.children; +const bands = kids.filter((n) => n.classList.contains("b06-chart__culvert-pitching")); +const hits = kids.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")); +const chuteHits = hits.filter((n) => n.classList.contains("b06-chart__chute-hit")); +const stopped = chuteHits[0].click(); +drawn.setChute(true); +const on = bands.map((b) => b.classList.contains("is-active")); +drawn.setChute(false); +const off = bands.map((b) => b.classList.contains("is-active")); +out.overlay = { + bands: bands.length, + chuteHits: chuteHits.length, + // 판정면 차례 — 띠 판정면이 관 · 벽 판정면보다 먼저(아래) + hitOrder: hits.map((n) => (n.classList.contains("b06-chart__chute-hit") ? "chute" : "other")), + sameShape: chuteHits.every((h, i) => h.attrs.points === bands[i].attrs.points), + picked: drawn.picked, + stopped, + on, + off, +}; +// 고름 배선 없음(조작 없는 카드) — 판정면 없이 강조만 · 띠 없으면 null +const bare = draw(pipeSection, listWith, false); +out.bare = { + hits: bare.layer.children.filter((n) => n.classList.contains("b06-chart__chute-hit")).length, + setter: typeof bare.setChute, +}; +out.noChute = draw(plain, sections, true).setChute === null; + +// ② B05 3D 띠 고름(chute 키) → B06 고름 길로 +const calls = []; +applyStructurePick( + { at: pipeSection.chainage_m, key: "chute" }, + { cross_sections: [pipeSection] }, + { + focusStation: () => {}, + refreshCard: () => {}, + revetSelect: (c, k) => calls.push(["revet", k]), + fordSelect: (c, k) => calls.push(["ford", k]), + boxSelect: (c, k) => calls.push(["box", k]), + }, +); +out.pick = calls; + +// ③ 조정창 — show("chute") = 숨김 · side "chute" +const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base")); +STUBS = { + ...STUBS, + "./B06_Section_UI_Section_Common": { L: (key) => key }, + "./B06_Section_UI_Cross_Panel_Base": { + ...base, + bindDragHandle: () => ({ attach: () => {}, detach: () => {} }), + }, +}; +const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel")); +const touched = []; +const spy = (name, value) => () => { + touched.push(name); + return value; +}; +const panel = buildStructurePanel({ + scrollKey: "t152", + adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }), + nudge: spy("nudge"), + nudgeSlope: spy("nudgeSlope"), + nudgeHeight: spy("nudgeHeight"), + heightFor: spy("heightFor", 2), + formFor: spy("formFor", "돌쌓기(메)"), + setForm: spy("setForm"), + reset: spy("reset"), + close: () => {}, + structureFor: () => "revet", + basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }), + updateBasin: () => {}, + nudgeBasinLateral: () => {}, + resetBasin: () => {}, + canNudge: spy("canNudge", true), + fineMove: spy("fineMove", false), + dragAxes: spy("dragAxes", null), + extraState: () => ({ canAdd: false, count: 0 }), + isLinkedCard: () => false, + basinExtraState: () => ({ canAdd: false, count: 0 }), + hiddenPipe: () => false, + bodyGuard: () => false, + setExtraCount: () => {}, + setBasinExtraCount: () => {}, + equalizeExtras: () => {}, + spanFor: spy("spanFor", null), + setSpan: () => {}, + linkState: () => null, + setLinked: () => {}, + followGrade: () => true, + setFollowGrade: () => {}, +}); +panel.show("chute"); +out.panel = { + hidden: panel.root.classList.contains("is-hidden"), + side: panel.root.dataset.side, + touched, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_90_chute_band.cjs b/resources/tester/helper_90_chute_band.cjs index 13708c8f2..d61db73a6 100644 --- a/resources/tester/helper_90_chute_band.cjs +++ b/resources/tester/helper_90_chute_band.cjs @@ -49,7 +49,7 @@ const run = (line) => { const layer = new Node("g"); appendChuteBand( layer, - [line], + [{ points: line }], { structure: "돌붙임", thickness_m: 0.3 }, (o) => o * SCALE, (e) => -e * SCALE, diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py index 0bf896f9a..42c96a478 100644 --- a/resources/tester/test_118_pipe_pick.py +++ b/resources/tester/test_118_pipe_pick.py @@ -92,7 +92,7 @@ def test_관은_조정창_없음(out) -> None: def test_좌측_관_상자_강조_스크롤() -> None: dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") - assert 'if (side === "body" || side === "pipe") {' in dock + assert 'if (side === "body" || side === "pipe" || side === "chute") {' in dock # 152-4 도수로 assert "'fieldset[data-spec-container=\"pipe\"]'" in dock assert 'body.classList.add("is-active");' in dock assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock diff --git a/resources/tester/test_152_2_link_height.py b/resources/tester/test_152_2_link_height.py new file mode 100644 index 000000000..d0945ba5f --- /dev/null +++ b/resources/tester/test_152_2_link_height.py @@ -0,0 +1,55 @@ +"""152-2 연동 흙막이 높이 — 본 흙막이가 그린 순수 높이를 옆 카드가 물려받음 · 풀면 그 측점만 따로. + +화면 TS 를 그 자리에서 돌린 값(`helper_152_2_link_height.cjs`) · 픽스처 = 73-4(박스 352.14 · 높이 칸 비움). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_2_link_height.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def screen() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_연동_옆_카드_높이_본_흙막이와_같음(screen: dict) -> None: + owner = screen["owner"]["pure"] + assert owner == {"inlet": 2.0, "outlet": 2.0} # 본체 끝 높이(근입 0.5 아님) + for chainage in ("340", "360"): + assert screen["linked"][chainage]["pure"] == owner + assert screen["linked"][chainage]["walls"] == screen["owner"]["walls"] + + +def test_연동_풀린_카드만_따로(screen: dict) -> None: + detached = screen["detached"] + assert detached["340"]["pure"] == {"inlet": 2.0, "outlet": 2.1} + assert detached["360"]["pure"] == {"inlet": 2.0, "outlet": 2.0} + assert detached["owner"]["pure"] == {"inlet": 2.0, "outlet": 2.0} + + +def test_풀린_카드_이_측점_높이_줄() -> None: + panel = _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts") + assert "deps.linkState()?.linked === false && guardKey && !isBasin" in panel + assert '"이 측점 높이"' in panel + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert 'const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height";' in dock + assert "return OWN_HEIGHT_ROW;" in dock # 세월교 · 박스 날개벽 칸도 받음 + wire = _src("B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts") + assert "h: shift.inlet.h ?? heights.inlet" in wire diff --git a/resources/tester/test_152_3_chute_cut.py b/resources/tester/test_152_3_chute_cut.py new file mode 100644 index 000000000..01421f9d3 --- /dev/null +++ b/resources/tester/test_152_3_chute_cut.py @@ -0,0 +1,65 @@ +"""152-3 도수로 · 돌붙임 띠 — 묻힌 유출 끝 3도 절토선(내림 · 수평)에도 띠 · 오르막 복합경사 절토엔 없음. + +화면 TS 를 그 자리에서 돌린 값(`helper_152_3_chute_cut.cjs`) · B06 관 · 흙막이 띠와 3D 띠가 같은 거름. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_3_chute_cut.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_묻힌_흙막이_유출_3도_선에_띠(out: dict) -> None: + (cut,) = out["box"]["segments"] + assert cut["kind"] == "cut" and not cut["rises"] + assert cut["band"] and cut["topIsLine"] # 윗면 = 3도 선 + assert cut["below"] == pytest.approx(0.3, abs=0.01) # 선 아래 두께 + assert out["box"]["bands3d"] == 1 and out["box"]["bands3dFillOnly"] == 0 + + +def test_배관_묻힌_끝_내림이면_띠_오르막이면_없음(out: dict) -> None: + down = out["pipeDown"]["segments"] + assert [s["kind"] for s in down] == ["fill", "cut"] + assert all(s["band"] for s in down) + up = out["pipeUp"]["segments"] + assert up[-1]["kind"] == "cut" and up[-1]["rises"] and not up[-1]["band"] + assert out["pipeDown"]["bands3d"] == 2 and out["pipeUp"]["bands3d"] == 1 + + +def test_성토만인_측점은_그대로(out: dict) -> None: + base = out["pipeBase"] + assert base["bands3d"] == base["bands3dFillOnly"] == 2 + + +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 + ) + hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts") + assert '"3도 절토선"' in hatch + # 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘) + for name in ("B06_Section_UI_Cross_Box.ts", "B06_Section_UI_Cross_Ford.ts"): + assert 'side.fillSegments.filter((s) => s.kind !== "cut")' in _src(f"B06_Section/{name}") diff --git a/resources/tester/test_152_4_chute_pick.py b/resources/tester/test_152_4_chute_pick.py new file mode 100644 index 000000000..36bf47d36 --- /dev/null +++ b/resources/tester/test_152_4_chute_pick.py @@ -0,0 +1,66 @@ +"""152-4 도수로 · 돌붙임 띠 고름 · 강조 — 118-1 관 고름 꼴(판정면 · setter · 좌측 상자 · 3D 키). + +화면 TS 를 흉내 DOM 에서 돌린 값(`helper_152_4_chute_pick.cjs`) + 배선 문자열. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_152_4_chute_pick.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _src(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_띠마다_판정면_벽_관_아래(out: dict) -> None: + overlay = out["overlay"] + assert overlay["bands"] == overlay["chuteHits"] == 2 + assert overlay["sameShape"] + n = overlay["chuteHits"] + assert overlay["hitOrder"][:n] == ["chute"] * n # 벽 · 관 판정면이 위(겹치면 이김) + assert "chute" not in overlay["hitOrder"][n:] + + +def test_띠_누름_고름_강조(out: dict) -> None: + overlay = out["overlay"] + assert overlay["picked"] == ["chute"] and overlay["stopped"] + assert overlay["on"] == [True, True] and overlay["off"] == [False, False] + assert out["bare"] == {"hits": 0, "setter": "function"} + assert out["noChute"] + + +def test_조정창_없음_3D_키(out: dict) -> None: + assert out["panel"] == {"hidden": True, "side": "chute", "touched": []} + assert out["pick"] == [["revet", "chute"]] + + +def test_좌측_상자_강조_배선() -> None: + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "attachChuteBandPick(" in view and 'toggleRevet("chute")' in view + assert 'setChuteActive(key === "chute");' in view + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert ( + 'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]' in dock + ) + selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts") + assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection + assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection + css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__culvert-pitching.is-active {" in css diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 5a2ed80d3..51b4cb53f 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -88,5 +88,8 @@ def test_자동_자리_읽기_글이_없음() -> None: def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None: dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") - # 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음) - assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock + # 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음) · 152-2 풀린 카드 높이 줄 + assert ( + "const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;" + in dock + ) diff --git a/resources/tester/test_54_29_pick_same_panel.py b/resources/tester/test_54_29_pick_same_panel.py index 7622d62af..4ee156b77 100644 --- a/resources/tester/test_54_29_pick_same_panel.py +++ b/resources/tester/test_54_29_pick_same_panel.py @@ -25,7 +25,11 @@ def test_배관_칸은_옵션_읽기_줄을_안_받음() -> None: assert inlet assert "struct--options" not in inlet.group(1) assert "struct--readout" not in inlet.group(1) - assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock + # 152-2 — 풀린 연동 카드 「이 측점 높이」 줄만 더 받음 + assert ( + "const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;" + in dock + ) def test_배관_칸_이름표는_그대로() -> None: diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 6a8f5eb85..5d327f526 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -168,8 +168,11 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼(): def test_B06_세월교_박스_칸은_받는_줄_없음(): - """107-3 — 측벽 높이 줄 지움.""" + """107-3 — 측벽 높이 줄 지움 · 152-2 연동 풀린 카드 「이 측점 높이」 줄만 받음.""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock + assert ( + "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW" + in dock + ) lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8") assert "return isNone(c, state[c.id][0])" in lists diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 1720f43fd..3ae13bcf1 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -44,7 +44,7 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None: """107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출).""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "sideSlots?.wingOutlet) return null" in dock + assert "sideSlots?.wingOutlet) return OWN_HEIGHT_ROW" in dock # 152-2 풀린 카드 높이 줄만 assert "wall-height" not in dock