diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 571b89ddc..2e685d5d0 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -109,6 +109,8 @@ FORD_DEFAULT_WIDTH_M = 10.0 # 물넘이포장 월류 폭 기본값(m) — 같은 폼이 물넘이에 채우는 값(2026-08-18 사용자 확정, # 지식DB 폭 수치 근거 없음). FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0 +# 물넘이포장 노면 경사각 기본값(°) — 좌측 패널 「경사각도」 기본 3°(PLAN 65-5 · 66-2). +FORD_PAVEMENT_DEFAULT_SLOPE_DEG = 3.0 # BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이다 # (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기). @@ -434,7 +436,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: values = dict(options or {}) width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None)) depth = _number(values.get("ford_height_m"), None) - slope = _number(values.get("ford_slope_pct"), None) + # 노면 경사 = 좌측 패널 경사각(°) → % (66-2) · 없으면 옛 「바닥 경사(%)」 · 둘 다 없으면 기본 3° + degree = _number(values.get("ford_slope_deg"), None) + legacy = _number(values.get("ford_slope_pct"), None) + fallback = None + if degree is None and legacy is None: + fallback = _number(defaults.get("ford_slope_deg"), FORD_PAVEMENT_DEFAULT_SLOPE_DEG) + used = degree if degree is not None else fallback + slope = round(math.tan(math.radians(used)) * 100.0, 4) if used is not None else legacy span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M return { "type": "ford_pavement", @@ -442,7 +451,9 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: # 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). "depth_m": depth if depth and depth > 0 else None, "slope_pct": slope, - "defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]), + "defaulted": _defaulted( + values, [("ford_width_m", "월류 폭", span), ("ford_slope_deg", "경사각", fallback)] + ), } diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index dc884d86b..d1979183c 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -154,6 +154,19 @@ function moveRows(panelRoot: HTMLElement, selector: string | null, target: HTMLE function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { clearSideSlots(); const side = panelRoot.dataset.side; + // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 + if (side === "body") { + const body = sideSlots?.wingInlet + .closest(".b05-structure__spec") + ?.querySelector( + 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', + ); + if (!body || body.closest("[hidden]")) return; + setGroupLabel(body, ""); + body.classList.add("is-active"); + if (scroll) body.scrollIntoView({ block: "nearest" }); + return; + } if (!sideSlots || (side !== "inlet" && side !== "outlet" && side !== "extra")) return; const target = side === "extra" ? sideSlots.extra : slotOf(side); // 추가 기슭막이 칸은 평소 숨어 있다 — 고른 벽이 다단이면 **그 단이 딸린 칸 바로 diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index 1fccfe0b2..5e6e46382 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -27,8 +27,11 @@ export type { const SVG_NS = "http://www.w3.org/2000/svg"; +/** 고르는 부재 — 좌 · 우 끝 또는 본체(구체 가운데 1/3 · 좌측 「본체」 상자 · 66-1). */ +export type BoxPick = BoxSideRole | "body"; + /** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */ -export type BoxHighlightSetter = (role: BoxSideRole | null) => void; +export type BoxHighlightSetter = (role: BoxPick | null) => void; /** * BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. @@ -39,7 +42,7 @@ export function appendBoxOverlay( layout: BoxLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, - onSelectSide?: (role: BoxSideRole) => void, + onSelectSide?: (role: BoxPick) => void, ): BoxHighlightSetter { const { box } = layout; const toXY = (point: OffsetPoint): [number, number] => [ @@ -133,21 +136,30 @@ export function appendBoxOverlay( label.textContent = layout.label.text; layer.append(label); - // 좌·우 절반을 덮는 투명 겹면 — 어느 끝을 조정할지 클릭으로 고른다. - const hits = new Map(); + // 투명 겹면 — 구체 가운데 1/3 = 본체, 그 바깥 양쪽 = 그 끝(66-1). 어느 것을 조정할지 클릭으로 고른다. + const hits = new Map(); if (onSelectSide) { const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2; + const third = (layout.sides[0].offset - layout.sides[1].offset) / 6; const top = Math.max(...layout.topSlab.map((point) => point.elevation)); const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation)); - for (const side of layout.sides) { - const outer = side.apron.length ? side.apron[1].offset : side.offset; + const band = (from: number, to: number): OffsetPoint[] => [ + { offset: from, elevation: top }, + { offset: to, elevation: top }, + { offset: to, elevation: bottom }, + { offset: from, elevation: bottom }, + ]; + const parts: Array<[BoxPick, OffsetPoint[]]> = [ + ["body", band(midOffset - third, midOffset + third)], + ...layout.sides.map((side): [BoxPick, OffsetPoint[]] => { + const outer = side.apron.length ? side.apron[1].offset : side.offset; + const inner = midOffset + Math.sign(side.offset - midOffset) * Math.abs(third); + return [side.role, band(inner, outer)]; + }), + ]; + for (const [role, points] of parts) { const hit = polygon( - [ - { offset: midOffset, elevation: top }, - { offset: outer, elevation: top }, - { offset: outer, elevation: bottom }, - { offset: midOffset, elevation: bottom }, - ], + points, // 강조용 클래스를 하나 더 단다 — 고른 끝을 옅게 칠한다(2026-08-30 사용자: // BOX암거는 선택해도 아무 표시가 없었다). 구체 부재는 좌·우가 한 몸이라 // 그쪽을 칠하면 어느 끝을 고른 것인지 되레 흐려진다. @@ -156,14 +168,14 @@ export function appendBoxOverlay( ); hit.addEventListener("click", (event) => { event.stopPropagation(); - onSelectSide(side.role); + onSelectSide(role); }); - hits.set(side.role, hit); + hits.set(role, hit); layer.append(hit); } } - return (role: BoxSideRole | null): void => { + return (role: BoxPick | null): void => { for (const [side, hit] of hits) hit.classList.toggle("is-active", side === role); }; } diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index 4aa0acff8..3c77b6975 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import type { BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; +import type { BoxPick } from "./B06_Section_UI_Cross_Box"; import { bindArrowKeys, bindDragHandle, @@ -41,7 +42,8 @@ export interface BoxPanelDeps { export interface BoxPanelHandle { root: HTMLElement; /** null이면 숨긴다. 값이 오면 그 측 기준으로 다시 그린다. */ - show: (role: BoxSideRole | null) => void; + /** 본체는 창 없이 좌측 「본체」 상자만(66-1). */ + show: (role: BoxPick | null) => void; } /** 한 걸음(m) — 사용자 지정 0.1m. */ @@ -133,15 +135,16 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo return { root, show(role) { - current = role; - root.classList.toggle("is-hidden", role === null); + current = role === "body" ? null : role; + root.classList.toggle("is-hidden", current === null); // 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 날개벽(유입)/(유출) 칸이다 // (세월교와 같은 규약, 2026-08-30 사용자). - root.dataset.side = role === null ? "" : (deps.wingRoleFor?.(role) ?? ""); + root.dataset.side = + role === null || role === "body" ? (role ?? "") : (deps.wingRoleFor?.(role) ?? ""); root.dataset.panelTitle = role === null ? "" : "BOX암거"; arrows.detach(); drag.detach(); - if (role !== null) { + if (current !== null) { arrows.attach(); drag.attach(); } @@ -163,6 +166,6 @@ export interface BoxControl { role: "inlet" | "outlet", patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>, ) => void; - selectedFor: (chainageM: number) => BoxSideRole | null; - select: (chainageM: number, role: BoxSideRole | null) => void; + selectedFor: (chainageM: number) => BoxPick | null; + select: (chainageM: number, role: BoxPick | null) => void; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 6ed3171b1..81879e0b5 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -27,8 +27,11 @@ export type { const SVG_NS = "http://www.w3.org/2000/svg"; +/** 고르는 부재 — 측벽(유입 · 유출) 또는 본체(바닥판 · 관 · 좌측 「세월교」 상자 · 66-1). */ +export type FordPick = FordWallRole | "body"; + /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ -export type FordHighlightSetter = (key: FordWallRole | null) => void; +export type FordHighlightSetter = (key: FordPick | null) => void; /** * 세월교 구체 오버레이 — computeFordLayout 결과를 그린다. @@ -41,7 +44,7 @@ export function appendFordOverlay( layout: FordLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, - onSelectWall?: (key: FordWallRole) => void, + onSelectWall?: (key: FordPick) => void, ): FordHighlightSetter { const { ford } = layout; const toXY = (point: OffsetPoint): [number, number] => [ @@ -80,7 +83,7 @@ export function appendFordOverlay( }): string => (wing.installed ? `${wing.length_m ?? 0}m·${wing.angle_deg ?? 0}°` : "없음"); // ① 바닥판 — 두 측벽 사이를 잇는 판. 각 측 바깥 연장은 측벽 부재로 함께 그린다. - layer.append( + const slab = layer.appendChild( polygon( layout.slabBridge, "b06-chart__ford-slab", @@ -115,14 +118,14 @@ export function appendFordOverlay( } // ③ 관 — 바닥판 상면에 안힌다. 단면엔 1개만, 련수는 라벨로 알린다. - layer.append( - polygon( - layout.pipe.outer, - "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer", - `${layout.label.text} — 관 밑이 바닥판 상면`, - ), - polygon(layout.pipe.inner, "b06-chart__culvert-pipe", layout.label.text), + const pipeOuter = polygon( + layout.pipe.outer, + "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer", + `${layout.label.text} — 관 밑이 바닥판 상면`, ); + const pipeInner = polygon(layout.pipe.inner, "b06-chart__culvert-pipe", layout.label.text); + layer.append(pipeOuter, pipeInner); + const bodyShapes = [slab, pipeOuter, pipeInner]; // ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계). for (const side of layout.sides) { @@ -142,6 +145,15 @@ export function appendFordOverlay( // 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다 // (배수관 기슭막이 `culvert-revet-hit`와 같은 수법). if (onSelectWall) { + // 본체(바닥판 · 관) 판정면 — 벽 판정면보다 먼저 깔아 겹친 자리는 벽이 먹는다(66-1). + for (const points of [layout.slabBridge, layout.pipe.outer]) { + const hit = polygon(points, "b06-chart__culvert-revet-hit", "세월교 본체 선택"); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectWall("body"); + }); + layer.append(hit); + } for (const side of layout.sides) { const wall = side.parts.find((part) => part.kind === "wall"); if (!wall) continue; @@ -163,9 +175,10 @@ export function appendFordOverlay( label.textContent = layout.label.text; layer.append(label); - return (key: FordWallRole | null): void => { + return (key: FordPick | null): void => { for (const [role, shapes] of walls) { for (const shape of shapes) shape.classList.toggle("is-active", role === key); } + for (const shape of bodyShapes) shape.classList.toggle("is-active", key === "body"); }; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index dda551ef5..09e1b9a52 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; +import type { FordPick } from "./B06_Section_UI_Cross_Ford"; import { bindArrowKeys, bindDragHandle, @@ -44,8 +45,8 @@ export interface FordPanelDeps { export interface FordPanelHandle { root: HTMLElement; - /** null이면 숨긴다. 값이 오면 그 측벽 기준으로 다시 그린다. */ - show: (role: FordWallRole | null) => void; + /** null이면 숨긴다. 값이 오면 그 측벽 기준으로 다시 그린다 — 본체는 창 없이 좌측 「세월교」 상자만(66-1). */ + show: (role: FordPick | null) => void; } /** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */ @@ -157,8 +158,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): return { root, show(role) { - current = role; - root.classList.toggle("is-hidden", role === null); + current = role === "body" ? null : role; + root.classList.toggle("is-hidden", current === null); // 좌측 폼에 어느 칸을 세울지 알린다 — 세월교는 그 측 날개벽 칸이다 // (배관 조정창이 유입구·유출구 칸을 가리키는 것과 같은 규약, 2026-08-30 지시 1). root.dataset.side = role ?? ""; @@ -166,7 +167,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): root.dataset.panelTitle = role === null ? "" : "세월교"; arrows.detach(); drag.detach(); - if (role !== null) { + if (current !== null) { arrows.attach(); drag.attach(); } @@ -189,6 +190,7 @@ export interface FordControl { pipe_count?: number; pipe_kind?: string; ford_width_m?: number; + ford_height_m?: number; }, ) => void; /** 날개벽 제원 저장 — 좌측 폼이 보낸다(계획서 24-4a). 같은 정본으로 간다. */ @@ -198,6 +200,6 @@ export interface FordControl { patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>, ) => void; /** 지금 선택된 측벽(측점별). */ - selectedFor: (chainageM: number) => FordWallRole | null; - select: (chainageM: number, role: FordWallRole | null) => void; + selectedFor: (chainageM: number) => FordPick | null; + select: (chainageM: number, role: FordPick | null) => void; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 74ce333b8..6f5e86a39 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -22,6 +22,8 @@ let hatchSeq = 0; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ export interface FordPavementSpec { + /** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */ + chainage_m?: number; span_m: number; /** 노선 중심에서 잰 파임 깊이(m). 없으면 그리지 않는다 — 수치를 지어내지 않는다. */ depth_m: number | null; @@ -85,9 +87,13 @@ export function appendFordSurfaceDropPlan( design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, - ford?: { sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }> } | null, + ford?: { + sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }>; + ford?: { overflow_depth_m?: number }; + } | null, ): void { - const drop = design.surface_drop_m ?? 0; + // 점선 = 노면 + 월류 높이(66-3) — 패널 월류 높이(세트 제원)가 먼저 · 저장 뒤 내린 양과 같은 값 + const drop = ford?.ford?.overflow_depth_m ?? design.surface_drop_m ?? 0; if (!(drop > 0) || !design.road_edges) return; const { left, right } = design.road_edges; // +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순. @@ -175,3 +181,79 @@ export function appendFordPavementOverlay( ); return true; } + +/** + * 물넘이 자리 노면 · 측구 설계선을 걷는다(66-2 · 66-3) — 노면은 파인 바닥(실선)과 옛 계획고(점선)가 + * 대신하고, 측구는 서지 않음. `from` = 설계선 그리기 앞의 자식 수(그 뒤에 붙은 선만 본다). + * 측구 자리는 바닥 끝에서 절토 비탈 발까지 실선으로 잇는다. 면적 · 서버 설계선은 그대로(그림만). + */ +export function hideFordPavedRoad( + layer: SVGElement, + from: number, + design: CrossDesign, + spec: FordPavementSpec, + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const edges = design.road_edges; + const designLine = design.design_line; + if (!edges || !designLine?.length) return; + const ditch = design.ditch; + const ditchWidth = + design.ditch_enabled === false || ditch.type === "none" + ? 0 + : ditch.type === "standard" + ? ditch.top_width_m + : ditch.width_m; + // +offset = 좌측 · 측구는 노견 바깥 그 쪽에 붙는다 + const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m); + const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m); + const hi = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); + const lo = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); + const [xa, xb] = [x(lo), x(hi)]; + const inside = (px: number): boolean => + px >= Math.min(xa, xb) - 0.5 && px <= Math.max(xa, xb) + 0.5; + for (const node of [...layer.children].slice(from)) { + if (node.tagName !== "line" || !node.classList.contains("b06-chart__design-cross")) continue; + if (inside(Number(node.getAttribute("x1"))) && inside(Number(node.getAttribute("x2")))) + node.remove(); + } + if (!(ditchWidth > 0) || !design.ditch_side) return; + const edge = design.ditch_side === "left" ? leftEdge : rightEdge; + const foot = design.ditch_side === "left" ? hi : lo; + const sorted = [...designLine].sort((a, b) => a.offset_m - b.offset_m); + const at = sorted.find((point) => Math.abs(point.offset_m - foot) < 1e-3); + if (!at) return; + const edgeElevation = design.ditch_side === "left" ? edges.left : edges.right; + layer.append( + line( + [ + `${x(edge)},${y(fordDeckElevationAt(design, spec, edge, edgeElevation.elevation_m))}`, + `${x(foot)},${y(at.elevation_m)}`, + ], + "b06-chart__ford-deck", + ), + ); +} + +/** + * 좌측 패널의 물넘이 칸을 캐시 제원에 얹는다(66-2 · 66-3) — 월류 높이 = 파임 깊이 · 경사각(°) → %. + * 서버 `_ford_pavement_set` 과 같은 환산(저장 뒤 서버가 정본으로 다시 냄). 바꿨으면 true. + */ +export function applyFordPavementOptions( + spec: FordPavementSpec, + patch: Record, +): boolean { + const num = (key: string): number | undefined => { + const value = patch[key] === "" ? NaN : Number(patch[key]); + return Number.isFinite(value) ? value : undefined; + }; + const height = num("ford_height_m"); + const degree = num("ford_slope_deg"); + if (height !== undefined) spec.depth_m = height > 0 ? height : null; + if (degree !== undefined) { + spec.slope_pct = Number((Math.tan((degree * Math.PI) / 180) * 100).toFixed(4)); + spec.defaulted = spec.defaulted?.filter((item) => !item.startsWith("경사각 ")); + } + return height !== undefined || degree !== undefined; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9db21d919..85343d197 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -16,9 +16,11 @@ import { } from "./B06_Section_UI_Cross_Areas"; import { appendFordPavementOverlay, + hideFordPavedRoad, appendFordSurfaceDropPlan, } 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 { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { @@ -467,11 +469,22 @@ export function createCrossSectionCard( // (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이 // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. applyStructureAreas(section, designTrim); + const designFrom = plotLayer.childElementCount; appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, { layer: svg, x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2, y: heightPx - CROSS_PAD.bottom - 4, }); + // 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남) + if (fordPaved && section.ford_pavement) + hideFordPavedRoad( + plotLayer, + designFrom, + section.design, + section.ford_pavement, + x, + toDisplayY, + ); // 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서 // 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다. appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY); @@ -516,6 +529,7 @@ export function createCrossSectionCard( ); if (highlightRevet) setRevetActive(highlightRevet); } + raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5) } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한). diff --git a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts index d8ab44c67..86d662ab2 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts @@ -10,17 +10,13 @@ import type { FordAdjust, FordHighlightSetter, FordLayout, + FordPick, FordWallRole, } from "./B06_Section_UI_Cross_Ford"; import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel"; import type { FordControl, FordPanelHandle } from "./B06_Section_UI_Cross_Ford_Panel"; import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford"; -import type { - BoxAdjust, - BoxHighlightSetter, - BoxLayout, - BoxSideRole, -} from "./B06_Section_UI_Cross_Box"; +import type { BoxAdjust, BoxHighlightSetter, BoxLayout, BoxPick } from "./B06_Section_UI_Cross_Box"; import { buildBoxPanel } from "./B06_Section_UI_Cross_Box_Panel"; import type { BoxControl, BoxPanelHandle } from "./B06_Section_UI_Cross_Box_Panel"; import { boxPanelDeps, structureToggle } from "./B06_Section_UI_Cross_View_Box"; @@ -40,8 +36,9 @@ export interface BodyWiringDeps { export interface BodyWiring { fordAdjust: () => FordAdjust | undefined; boxAdjust: () => BoxAdjust | undefined; - toggleFord: (role: FordWallRole) => void; - toggleBox: (role: BoxSideRole) => void; + /** 본체(`body`)는 조정창 없이 좌측 본체 상자만 고름(66-1). */ + toggleFord: (role: FordPick) => void; + toggleBox: (role: BoxPick) => void; /** 오버레이가 만들어 준 강조 setter를 등록하고 현재 선택을 다시 칠한다. */ attachFord: (setter: FordHighlightSetter, layout: FordLayout) => void; attachBox: (setter: BoxHighlightSetter, layout: BoxLayout) => void; @@ -93,15 +90,15 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { const boxDock = boxDeps ? buildBoxPanel(boxDeps, { dock: true }) : null; const syncDock = ( dock: FordPanelHandle | BoxPanelHandle | null, - role: FordWallRole | BoxSideRole | null, + role: FordPick | BoxPick | null, ): void => { if (!dock) return; - (dock as { show: (role: FordWallRole | BoxSideRole | null) => void }).show(role); + (dock as { show: (role: FordPick | BoxPick | null) => void }).show(role); if (role) adoptAdjustPanel(dock.root); else releaseAdjustPanel(dock.root); }; - const toggleFord = structureToggle({ + const toggleFord = structureToggle({ current: () => fordRole, setCurrent: (value) => (fordRole = value), setActive: (value) => setFordActive(value), @@ -114,7 +111,7 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { clearArea: deps.clearArea, selectCard: deps.selectCard, }); - const toggleBox = structureToggle({ + const toggleBox = structureToggle({ current: () => boxRole, setCurrent: (value) => (boxRole = value), setActive: (value) => setBoxActive(value), diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 5c7670e0b..4702ee779 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -160,3 +160,38 @@ export function appendPlanLine( } layer.append(line); } + +/** + * 벽 안으로 지나가는 다른 선(원지반 · 설계선 · 비탈 · 다른 외곽)을 가린다(66-5) — 흙막이 · 옹벽을 + * 바탕색 덮개와 함께 맨 위로 올림. 벽 자기 외곽 · 무늬는 그대로. 관 · 클릭 판정면 · 글은 그 위로 + * 다시 올려 지금 차례(관이 벽을 지나감 · 판정면 맨 위)를 지킨다. + */ +export function raiseWalls(layer: SVGElement): void { + const kids = [...layer.children]; + const groups = kids + .filter((el) => el.classList.contains("b06-chart__culvert-revet")) + .map((wall) => { + // 무늬 = 벽 바로 뒤의 clipPath + 잘린 묶음(`appendWallHatch`) + const hatch: Element[] = []; + let next = wall.nextElementSibling; + while ( + next && + (next.tagName === "clipPath" || + next.getAttribute("clip-path")?.startsWith("url(#b06-revet-clip-")) + ) { + hatch.push(next); + next = next.nextElementSibling; + } + const cover = document.createElementNS(SVG_NS, "polygon"); + cover.setAttribute("points", wall.getAttribute("points") ?? ""); + cover.setAttribute("class", "b06-chart__wall-cover"); + return [cover, wall, ...hatch]; + }); + if (!groups.length) return; + for (const group of groups) layer.append(...group); + layer.append( + ...kids.filter((el) => + el.matches(".b06-chart__culvert-pipe, .b06-chart__culvert-revet-hit, text"), + ), + ); +} diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index a94fd89b5..3f3a037ec 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -16,10 +16,10 @@ import { } from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; -import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; +import type { FordAdjust, FordPick, FordWallAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; -import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box"; +import type { BoxAdjust, BoxPick, BoxSideAdjust } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ @@ -49,6 +49,7 @@ const DEFAULTED_LABELS: Record = { pipe_kind: "관종", pipe_diameter_mm: "관경", ford_width_m: "월류 폭", + ford_height_m: "월류 높이", pipe_count: "배관 수량", body_width_m: "본체 폭", body_height_m: "본체 높이", @@ -125,7 +126,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { * 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라 * 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */ const fordAdjustments = new Map(); - const fordSelections = new Map(); + const fordSelections = new Map(); const fordSessionKey = (): string | null => deps.sessionKey(); function loadFordAdjustments(): void { @@ -199,6 +200,9 @@ export function createFordControls(deps: FordControlDeps): FordControls { if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind; // 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리. if (patch.ford_width_m) spec.span_m = patch.ford_width_m; + // 월류 높이 — 횡단 점선(노면 + 월류 높이 · 66-3)이 바로 따라옴 · 노면 내림은 저장 뒤 서버 + if (patch.ford_height_m !== undefined) + spec.overflow_depth_m = Math.max(patch.ford_height_m, 0); } dropDefaulted(spec, patch); deps.queuePipeOptions(chainageM, patch); @@ -265,6 +269,8 @@ export function applyFordFormOptions( if (typeof patch.pipe_kind === "string") pipe.pipe_kind = patch.pipe_kind; const widthM = num("ford_width_m"); if (widthM !== undefined) pipe.ford_width_m = widthM; + const heightM = num("ford_height_m"); + if (heightM !== undefined) pipe.ford_height_m = heightM; if (Object.keys(pipe).length) control.setPipe(chainageM, pipe); applyWingFormOptions(control, chainageM, patch); @@ -322,7 +328,7 @@ export function createBoxControls(deps: FordControlDeps): { } { const { round1, clampMove, sectionAt, patchCachedDesign } = deps; const adjustments = new Map(); - const selections = new Map(); + const selections = new Map(); function load(): void { adjustments.clear(); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index d443de3c4..ca64a19e5 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -11,6 +11,7 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; +import { applyFordPavementOptions } from "./B06_Section_UI_Cross_Ford_Pavement"; import { applySpanPatch, revetWallSpec, @@ -50,6 +51,18 @@ export function applyPipeOptionsToCache( patch: Record, whole = true, ): void { + // 물넘이포장 — 월류 높이 · 경사각을 캐시 제원에 얹음(66-2 · 66-3) · 폭 절반 걸친 옆 카드도 + const paved = (ctx.detail()?.cross_sections ?? []).filter( + (section) => + section.ford_pavement && + Math.abs((section.ford_pavement.chainage_m ?? section.chainage_m) - chainageM) < PIPE_MATCH_M, + ); + if (paved.length) { + for (const section of paved) + if (applyFordPavementOptions(section.ford_pavement!, patch)) + ctx.refreshCard(section.chainage_m); + return; + } const owner = ctx .detail() ?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M); diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index bbeef09bd..79037c928 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -6,7 +6,8 @@ * 부재키는 **횡단도가 그 부재를 부르는 이름 그대로**다. 이름이 겹쳐도(배수관 유입 ↔ * 세월교 유입) 한 측점에는 시설이 하나뿐이라 헷갈리지 않는다 — 그 측점이 세월교면 * 세월교 조정창, BOX암거면 BOX 조정창, 아니면 기슭막이 조정창으로 보낸다. - * 본체(`body` — 세월교 월류부 상판·BOX 구체)는 고르는 부재가 아니라 카드까지만 연다. + * 본체(`body` — 세월교 월류부 상판·BOX 구체)는 세월교 · BOX 에서 좌측 본체 상자를 고른다(66-1) · + * 그 밖 시설은 카드까지만 연다. * * 세월교·BOX는 조정창 하나가 그 측 부재 여럿(벽·바닥·날개벽·에이프런)을 함께 다루므로 * 부재키가 `측이름:부재`(예 `inlet:wall`) 꼴이다 — **앞칸이 조정창 이름**이다. 3D 강조는 @@ -29,8 +30,8 @@ interface PickTargets { focusStation: (stationId: string) => void; refreshCard: (chainageM: number) => void; revetSelect: (chainageM: number, key: RevetKey) => void; - fordSelect: (chainageM: number, role: "inlet" | "outlet") => void; - boxSelect: (chainageM: number, role: "left" | "right") => void; + fordSelect: (chainageM: number, role: "inlet" | "outlet" | "body") => void; + boxSelect: (chainageM: number, role: "left" | "right" | "body") => void; } /** @@ -63,12 +64,12 @@ export function applyStructurePick( const key = handoff.key?.split(":")[0]; requestAnimationFrame(() => { targets.focusStation(target.station_id); - if (!key || key === "body") return; + if (!key) return; if (target.ford) { - if (key !== "inlet" && key !== "outlet") return; + if (key !== "inlet" && key !== "outlet" && key !== "body") return; targets.fordSelect(target.chainage_m, key); } else if (target.box) { - if (key !== "left" && key !== "right") return; + if (key !== "left" && key !== "right" && key !== "body") return; targets.boxSelect(target.chainage_m, key); } else { if (!REVET_KEYS.test(key)) return; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index baca7195d..4e6925180 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -163,7 +163,7 @@ /* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */ .b06-chart__carriageway-label { fill: var(--color-royal-amethyst); - font-size: 9px; + font-size: 11px; text-anchor: middle; pointer-events: none; } @@ -254,6 +254,13 @@ stroke-width: 2.2; } +/* 흙막이 · 옹벽 덮개(66-5) — 벽 안으로 지나가는 다른 선을 바탕색으로 가림. */ +.b06-chart__wall-cover { + fill: var(--color-surface-raised); + stroke: none; + pointer-events: none; +} + /* 클릭 판정용 투명 겹면 — 관·보호공보다 위에 얹혀 벽 가운데도 눌린다. */ .b06-chart__culvert-revet-hit { fill: transparent; @@ -367,6 +374,14 @@ stroke-linejoin: round; } +/* 세월교 본체(바닥판 · 관) 선택 강조(66-1) — 측벽 강조와 같은 색. */ +.b06-chart__ford-slab.is-active, +.b06-chart__culvert-pipe.is-active { + fill: color-mix(in srgb, var(--color-danger) 30%, transparent); + stroke: var(--color-danger); + stroke-width: 2.2; +} + /* BOX암거 내공(유로) — 면은 비우고 윤곽만. 물이 지나는 빈 공간이라 채우지 않는다. */ .b06-chart__box-barrel { fill: none; diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 27e8d5574..0781ddd5e 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -45,6 +45,7 @@ export const FORD_SLAB_THICKNESS_M = 0.3; export const FORD_WALL_THICKNESS_M = 0.2; export const FORD_DEFAULT_WIDTH_M = 10.0; export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0; +export const FORD_PAVEMENT_DEFAULT_SLOPE_DEG = 3.0; export const BOX_COVER_M = 0.5; /** 관 벽 최소 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조). * 높이를 안 적은 관 벽은 이 값으로 섬(2026-09-14 브레인 판정 · 옛 등록부 기본 2.5 걷음). */ @@ -330,14 +331,25 @@ function fordPavementSet( const defaults = registry.ford_pavement ?? {}; const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); const depth = num(values.ford_height_m, null); + // 노면 경사 = 경사각(°) → % (66-2) · 없으면 옛 바닥 경사(%) · 둘 다 없으면 기본 3° + const degree = num(values.ford_slope_deg, null); + const legacy = num(values.ford_slope_pct, null); + const fallback = + degree === null && legacy === null + ? num(defaults.ford_slope_deg, FORD_PAVEMENT_DEFAULT_SLOPE_DEG) + : null; + const used = degree ?? fallback; const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M; return { type: "ford_pavement", span_m: span, // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). depth_m: depth && depth > 0 ? depth : null, - slope_pct: num(values.ford_slope_pct, null), - defaulted: defaulted(values, [["ford_width_m", "월류 폭", span]]), + slope_pct: used !== null ? round(Math.tan((used * Math.PI) / 180) * 100, 4) : legacy, + defaulted: defaulted(values, [ + ["ford_width_m", "월류 폭", span], + ["ford_slope_deg", "경사각", fallback], + ]), }; } diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index de989165a..d8ac02cd8 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -82,10 +82,17 @@ POINTS = [ "form": "메쌓기", }, }, + # 물넘이포장 경사각(°) → %(66-2) · 경사 칸이 없으면 기본 3° + { + "chainage_m": 140.0, + "facility": "ford_pavement", + "options": {"ford_height_m": 0.3, "ford_slope_deg": 5}, + }, + {"chainage_m": 160.0, "facility": "ford_pavement", "options": {"ford_height_m": 0.2}}, ] # 물넘이포장(폭 9m)이 ±4.5m 까지 걸치는지 보려고 95·105 측점을 함께 둔다. -CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 95.0, 100.0, 105.0, 120.0, 140.0] +CHAINAGES = [0.0, 20.0, 40.0, 60.0, 80.0, 95.0, 100.0, 105.0, 120.0, 140.0, 160.0] _TS_RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; diff --git a/resources/tester/test_b06_ford_pavement_slope.py b/resources/tester/test_b06_ford_pavement_slope.py new file mode 100644 index 000000000..2b4dfd5fc --- /dev/null +++ b/resources/tester/test_b06_ford_pavement_slope.py @@ -0,0 +1,25 @@ +"""물넘이포장 노면 경사 = 좌측 패널 경사각(°) → %(PLAN 66-2). + +경사각이 있으면 그 값 · 없으면 옛 「바닥 경사(%)」 · 둘 다 없으면 기본 3°. +""" + +import math + +from B06_Section.B06_Section_Engine_Culvert import _ford_pavement_set + + +def test_slope_degree_becomes_percent() -> None: + spec = _ford_pavement_set({"ford_height_m": 0.3, "ford_slope_deg": 5}) + assert spec["slope_pct"] == round(math.tan(math.radians(5)) * 100, 4) + assert not any("경사각" in item for item in spec["defaulted"]) + + +def test_slope_defaults_to_three_degrees() -> None: + spec = _ford_pavement_set({"ford_height_m": 0.3}) + assert spec["slope_pct"] == round(math.tan(math.radians(3)) * 100, 4) + assert "경사각 3" in spec["defaulted"] + + +def test_legacy_percent_kept_without_degree() -> None: + spec = _ford_pavement_set({"ford_height_m": 0.3, "ford_slope_pct": 2.0}) + assert spec["slope_pct"] == 2.0