diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index d74e1ce27..f19214b97 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -501,6 +501,8 @@ export interface CrossDesign { /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(`tilt_pct`). */ tilted?: boolean; tilt_pct?: number; + /** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면 · 콘크리트가 원지반까지. */ + tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number }; cut_area_m2: number; /** * 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다. diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 3f9e27daa..a83fe08e0 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -314,6 +314,7 @@ def compute_cross_design( shoulder_extend_left_m=shoulder_extend_left_m, shoulder_extend_right_m=shoulder_extend_right_m, tilted=bool(tilted), + ground_span=(valid[0][0], valid[-1][0]), ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -546,6 +547,13 @@ def compute_cross_design( } if tilted: result["tilted"] = True + for side, reach in geometry.reach.items(): # 낮은 쪽 늘인 노면 끝(294-3) + signed = reach if side == "left" else -reach + result["tilt_reach"] = { + "side": side, + "offset_m": round(signed, 4), + "elevation_m": round(geometry.road_z(signed), 4), + } if tilt_pct is not None: result["tilt_pct"] = round(float(tilt_pct), 4) if drop > 0: diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 6131e19f7..e19dffc10 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -63,6 +63,7 @@ class _SectionGeometry: shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, tilted: bool = False, + ground_span: tuple[float, float] | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). @@ -195,6 +196,36 @@ class _SectionGeometry: (edge_offset + outward * top, edge_z), ] + # 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토) + # 노면을 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭 + # (`ground_span`) 안에서 못 만나면 옛 길(절토 사면). ⚠ TS 짝 `SectionGeometry.reach`. + self.reach: dict[str, float] = {} + if tilted and ground_at is not None and abs(self.slope_per_offset) > 1e-12: + self._extend_low_side(ground_at, ground_span) + + def _extend_low_side( + self, ground_at: Callable[[float], float], span: tuple[float, float] | None + ) -> None: + side = "left" if self.slope_per_offset < 0 else "right" + sign = 1.0 if side == "left" else -1.0 + extent = self.left_extent if side == "left" else self.right_extent + limit = (span[1] if side == "left" else -span[0]) if span else extent + 50.0 + + def gap(dist: float) -> float: # > 0 = 원지반이 늘인 노면 위(절토) + return ground_at(sign * dist) - self.road_z(sign * dist) + + if gap(extent) <= 1e-3: + return + step = 0.05 + dist = extent + while dist < limit - 1e-9: + nxt = min(dist + step, limit) + g0, g1 = gap(dist), gap(nxt) + if g1 <= 0: + self.reach[side] = dist + (nxt - dist) * g0 / (g0 - g1) + return + dist = nxt + def road_z(self, offset_m: float) -> float: """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" return self.z_center + self.slope_per_offset * offset_m @@ -430,6 +461,9 @@ class _SectionGeometry: return points[-1][1] role = self.left_role if side == "left" else self.right_role dist = abs(offset_m) + reach = self.reach.get(side) + if reach is not None: # 늘인 노면 → 그 바깥은 원지반(294-3) + return self.road_z(offset_m) if dist <= reach + 1e-9 else ground_m if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) @@ -468,6 +502,9 @@ class _SectionGeometry: if cross is not None and offset > cross + 1e-9: break points.append(offset if side == "left" else -offset) + # 늘인 노면 끝(294-3) + for side, reach in self.reach.items(): + points.append(reach if side == "left" else -reach) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts new file mode 100644 index 000000000..36c23aa89 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Ford_Linked.ts + * 물넘이포장 연결 표기(294-6) — 월류 폭으로 옆 측점 카드에 걸쳐 그린 포장층 · 월류 높이선을 + * 흙막이 연동 형상(79-6 `.is-linked`)과 같은 꼴로 · 호버 글에 주인 측점. 주인 측점 카드는 그대로 · + * 고름 표식(`data-structure-id`)은 주인 구조물 그대로. + * ========================================================================== */ + +import { stationLabel } from "../common_util/common_util_station"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 연결 꼴을 입힐 물넘이 그림 — 포장층 · 월류 높이선 */ +const LINKED_CLASSES = ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"]; + +/** `from` 번째 자식부터(방금 그린 물넘이포장) 옆 카드면 연결 꼴 · 주인 측점 글. 붙였으면 true */ +export function markLinkedFordPavement( + svg: SVGElement, + from: number, + section: Pick, + stationInterval: number, +): boolean { + const owner = section.ford_pavement?.chainage_m; + if (typeof owner !== "number" || Math.abs(owner - section.chainage_m) < 1e-3) return false; + const name = `물넘이포장 · ${stationLabel(owner, stationInterval)} 연결`; + for (const el of [...svg.children].slice(from)) { + if (!LINKED_CLASSES.some((className) => el.classList.contains(className))) continue; + el.classList.add("is-linked"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = name; + el.append(title); + } + return true; +} diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 38c948fb4..37d5d4320 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -103,7 +103,7 @@ export function appendFordPavementOverlay( x: (offset: number) => number, y: (elevation: number) => number, ): boolean { - const edges = design.road_edges; + const edges = fordEdges(design); if (!spec || !edges) return false; const from = svg.children.length; // 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임) @@ -134,6 +134,15 @@ export function appendFordPavementOverlay( return true; } +/** 콘크리트 · 노면 양 끝 — 낮은 쪽을 늘였으면(294-3 `tilt_reach`) 그 쪽은 원지반과 만난 자리까지. */ +export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | undefined { + const edges = design.road_edges; + const reach = design.tilt_reach; + if (!edges || !reach) return edges; + const end = { offset_m: reach.offset_m, elevation_m: reach.elevation_m }; + return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end }; +} + /** * 유출측(`inflowLeft` 반대) 노견 바깥 성토사면 선(136-2) — 설계선을 노견에서 바깥으로 따라 * 내려가는 동안만(오르면 절토라 멈춤). 성토가 없으면 빈 선(띠 안 그림). diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7f797e240..fd4fb2cee 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard"; +import { markLinkedFordPavement } from "./B06_Section_UI_Cross_Ford_Linked"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, @@ -446,6 +447,7 @@ export function createCrossSectionCard( // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. // 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반 // 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다). + const fordFrom = plotLayer.children.length; const fordPaved = appendFordPavementOverlay( plotLayer, section.ford_pavement, @@ -453,6 +455,8 @@ export function createCrossSectionCard( x, toDisplayY, ); + // 월류 폭으로 걸친 옆 카드 = 흙막이 연동 꼴 · 주인 측점 글(294-6) + markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); // 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상 diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d875d8a78..434e10a85 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -316,6 +316,17 @@ stroke-dasharray: 4 2; } +/* 월류 폭으로 옆 측점에 걸친 물넘이포장(294-6) — 연동 형상과 같은 꼴 · 고름 강조(is-active)가 먼저. */ +.b06-chart__ford-pavement.is-linked:not(.is-active) { + fill: color-mix(in srgb, #6f8696 30%, transparent); + stroke: #6f8696; + stroke-dasharray: 4 2; +} + +.b06-chart__ford-deck-plan.is-linked { + stroke: #6f8696; +} + .b06-chart__culvert-revet.is-active { fill: color-mix(in srgb, var(--color-danger) 45%, transparent); stroke: var(--color-danger); diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index f57a47453..2c6b95a76 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -194,6 +194,8 @@ export interface CrossDesignResult { /** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 지대 낮은 쪽 한 경사. */ tilted?: boolean; tilt_pct?: number; + /** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면이 원지반까지. */ + tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number }; pavement_thickness_m?: number; rock_boundary_offset_m?: number; } @@ -377,6 +379,7 @@ export function computeCrossDesign( shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0, shoulderExtendRightM: options.shoulderExtendRightM ?? 0, tilted: Boolean(options.tilted), + groundSpan: [valid[0][0], valid[valid.length - 1][0]], }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -592,6 +595,17 @@ export function computeCrossDesign( cut_slope_segments: geometry.cutSlopeSegments(), }; if (options.tilted) result.tilted = true; + // 낮은 쪽 늘인 노면 끝(294-3 · 파이썬 짝) + for (const side of ["left", "right"] as const) { + const reach = geometry.reach[side]; + if (!options.tilted || reach === undefined) continue; + const signed = side === "left" ? reach : -reach; + result.tilt_reach = { + side, + offset_m: round4(signed), + elevation_m: round4(geometry.roadZ(signed)), + }; + } if (options.tilted && options.tiltPct != null) result.tilt_pct = round4(options.tiltPct); if (drop > 0) result.surface_drop_m = round4(drop); if (options.berm) { diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index c32f80cfb..75358fc4b 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -147,6 +147,8 @@ export class SectionGeometry { shoulderExtendRightM?: number; /** 물넘이포장 · 맹암거 자리 — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(286-2 · 287-4 · 파이썬 짝 `tilted`). */ tilted?: boolean; + /** 원지반 샘플 범위(오프셋 최소 · 최대) — 낮은 쪽 연장 한계(294-3 · 파이썬 짝 `ground_span`). */ + groundSpan?: [number, number] | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -261,6 +263,36 @@ export class SectionGeometry { ]; } } + + // 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토) 노면을 + // 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭 안에서 못 만나면 + // 옛 길(절토 사면). ⚠ 파이썬 짝 `_extend_low_side`. + if (params.tilted && params.groundAt && Math.abs(this.slopePerOffset) > 1e-12) + this.extendLowSide(params.groundAt, params.groundSpan ?? null); + } + + /** 낮은 쪽 늘인 노면 끝(오프셋 절대값) — 쪽마다(294-3). */ + reach: { left?: number; right?: number } = {}; + + private extendLowSide(groundAt: (offsetM: number) => number, span: [number, number] | null) { + const side = this.slopePerOffset < 0 ? "left" : "right"; + const sign = side === "left" ? 1 : -1; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + const limit = span ? (side === "left" ? span[1] : -span[0]) : extent + 50; + const gap = (dist: number) => groundAt(sign * dist) - this.roadZ(sign * dist); // > 0 = 절토 + if (gap(extent) <= 1e-3) return; + const step = 0.05; + let dist = extent; + while (dist < limit - 1e-9) { + const next = Math.min(dist + step, limit); + const g0 = gap(dist); + const g1 = gap(next); + if (g1 <= 0) { + this.reach[side] = dist + ((next - dist) * g0) / (g0 - g1); + return; + } + dist = next; + } } /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ @@ -422,6 +454,8 @@ export class SectionGeometry { } const role = side === "left" ? this.leftRole : this.rightRole; const dist = Math.abs(offsetM); + const reach = this.reach[side]; + if (reach !== undefined) return dist <= reach + 1e-9 ? this.roadZ(offsetM) : groundM; // 294-3 if (role === "cut") { const cross = this.cutCrossDist(side); if (cross !== null && dist >= cross) return groundM; @@ -512,6 +546,9 @@ export class SectionGeometry { } } } + // 늘인 노면 끝(294-3) + if (this.reach.left !== undefined) points.push(this.reach.left); + if (this.reach.right !== undefined) points.push(-this.reach.right); for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); diff --git a/resources/tester/helper_294_3_ford_extend_chute.cjs b/resources/tester/helper_294_3_ford_extend_chute.cjs new file mode 100644 index 000000000..646a89802 --- /dev/null +++ b/resources/tester/helper_294_3_ford_extend_chute.cjs @@ -0,0 +1,62 @@ +/* 294 물넘이 낮은 쪽 연장 · 도수로 끝단 · 띠 ↔ 사면 고름 헬퍼 — 진짜 TS 를 Node 로 돌려 JSON 한 벌로. */ +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 load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts")); +const design = input.design; // 서버가 낸 양측 절토 · 낮은 쪽 연장 설계 +const svg = new Node("g"); +pave.appendFordPavementOverlay( + svg, + { span_m: 8, depth_m: 0.3, slope_pct: 5 }, + design, + (o) => o, + (e) => e, +); +const paveLayer = svg.children.find((k) => (k.attrs.class ?? "").includes("ford-pavement")); + +const out = { + edges: pave.fordEdges(design), + pavePoints: paveLayer?.attrs.points ?? null, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_294_6_ford_linked_mark.cjs b/resources/tester/helper_294_6_ford_linked_mark.cjs new file mode 100644 index 000000000..98dc452df --- /dev/null +++ b/resources/tester/helper_294_6_ford_linked_mark.cjs @@ -0,0 +1,142 @@ +/* 294-6 물넘이 연결 표기 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 옆 카드(물넘이 측점 ≠ 카드 측점) · 주인 카드에 물넘이포장을 그리고 연결 꼴 · 호버 글 · 고름 표식을 잼. + */ +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 load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +// DOM 흉내 — 속성 · 자식 · classList · 글만 +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k in this.attrs; + } + append(...kids) { + this.children.push(...kids); + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + const set = (list) => (this.attrs.class = list.join(" ")); + return { + contains: (c) => names().includes(c), + add: (c) => names().includes(c) || set([...names(), c]), + toggle: (c, on) => + set(on ? [...names().filter((n) => n !== c), c] : names().filter((n) => n !== c)), + }; + } + querySelectorAll(selector) { + const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1]; + const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]); + return all.filter((k) => attr && k.hasAttribute(attr)); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const pave = load(B06("B06_Section_UI_Cross_Ford_Pavement.ts")); +const linked = load(B06("B06_Section_UI_Cross_Ford_Linked.ts")); +const shapePick = load(B06("B06_Section_UI_Cross_Shape_Pick.ts")); + +const design = { + section_mode: "left_cut", + tilted: true, + cross_slope_pct: 5, + design_elevation_m: 100, + pavement_thickness_m: 0.2, + paved: true, + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: 100.125 }, + right: { offset_m: -2.5, elevation_m: 99.875 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 103 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +// 검증 프로젝트 꼴 — 4+10(물넘이 · 폭 10) → 4+15 카드에도 포장 · 측점 간격 20 +const spec = { chainage_m: 90, span_m: 10, depth_m: 0.4, slope_pct: 5 }; + +const draw = (chainage) => { + const svg = new Node("g"); + const before = new Node("polyline"); // 앞서 그린 것(세월교 점선 등) — 연결 꼴 밖 + before.setAttribute("class", "b06-chart__ford-deck-plan"); + svg.append(before); + const from = svg.children.length; + pave.appendFordPavementOverlay( + svg, + spec, + design, + (o) => o, + (e) => e, + ); + const marked = linked.markLinkedFordPavement( + svg, + from, + { ford_pavement: spec, chainage_m: chainage }, + 20, + ); + const shapes = svg.children + .slice(from) + .filter((k) => + ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"].some((c) => + k.classList.contains(c), + ), + ); + shapePick.highlightShapes(svg, "pipe-90.00"); + return { + marked, + classes: shapes.map((k) => k.attrs.class), + titles: shapes.map((k) => + k.children.filter((c) => c.tag === "title").map((c) => c.textContent), + ), + pick: shapes + .filter((k) => k.hasAttribute("data-structure-id")) + .map((k) => k.attrs["data-structure-id"]), + beforeClass: before.attrs.class, + }; +}; + +process.stdout.write( + JSON.stringify({ + neighbor: draw(95), + owner: draw(90), + noSpec: linked.markLinkedFordPavement(new Node("g"), 0, { chainage_m: 95 }, 20), + }), +); diff --git a/resources/tester/test_294_3_ford_extend_chute.py b/resources/tester/test_294_3_ford_extend_chute.py new file mode 100644 index 000000000..90056560a --- /dev/null +++ b/resources/tester/test_294_3_ford_extend_chute.py @@ -0,0 +1,112 @@ +"""294 물넘이 낮은 쪽 연장 · 도수로/돌붙임 끝단 · 띠 ↔ 성토 사면 고름(요구 188-2 · 188-3 · 189 · 그림_188). + +못박는 것 + - 294-3 물넘이 측점 낮은 쪽 노면 끝 원지반이 노면보다 높으면(절토) 노면을 같은 기울기로 늘여 원지반과 + 처음 만나는 자리까지(그 쪽 절토 사면 없음 · `tilt_reach`) · 성토 쪽 그대로 · 반폭 안에서 못 만나면 옛 길 · + 서버 정본 ↔ TS 짝 같은 값 = 횡단 설계 거울(`test_b06_cross_design_mirror` 「물넘이포장 낮은 쪽 노면 + 연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지. + +진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from test_b06_cross_design_mirror import CASES, _dip_ground # noqa: E402 + +KW = { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + "standard": STANDARD_CROSS_SECTION, +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _line(samples_fn) -> list[dict]: + return [ + {"offset_m": o / 2, "elevation_m": round(samples_fn(o / 2), 4), "valid": True} + for o in range(-24, 25) + ] + + +@pytest.fixture(scope="module") +def dip() -> dict: + return compute_cross_design(_dip_ground(), 100.4, **KW) + + +@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") + proc = subprocess.run( + ["node", str(HERE / "helper_294_3_ford_extend_chute.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_양측_절토_낮은_쪽_늘인_끝은_원지반_교점(dip): + reach = dip["tilt_reach"] + # 원지반 101.05 + 0.2·o(−4.5 까지) · 노면 100.4 + 0.05·o → −4.3333 에서 만남 + assert reach["side"] == "right" + assert reach["offset_m"] == pytest.approx(-4.3333, abs=1e-4) + assert reach["elevation_m"] == pytest.approx(100.4 - 0.05 * 4.3333, abs=1e-4) + edge = dip["road_edges"]["right"]["offset_m"] + for p in dip["design_line"]: # 노견 끝 ~ 늘인 끝 = 노면 기울기 그대로(절토 사면 없음) + if reach["offset_m"] - 1e-6 <= p["offset_m"] <= edge: + assert p["elevation_m"] == pytest.approx(100.4 + 0.05 * p["offset_m"], abs=1e-4) + assert dip["ditch_enabled"] is False + + +def test_성토_쪽은_그대로(): + got = compute_cross_design(_line(lambda o: 100.0 + 0.35 * o), 100.4, **KW) + assert "tilt_reach" not in got # 낮은 쪽(우) 노면 끝 원지반이 노면 아래 = 성토 사면 + + +def test_반폭_안에서_못_만나면_옛_길(): + got = compute_cross_design(_line(lambda o: 101.0 + 0.02 * o), 100.4, **KW) + assert "tilt_reach" not in got # 늘인 노면이 끝까지 원지반 아래 = 절토 사면 그대로 + right = [ + p for p in got["design_line"] if p["offset_m"] < got["road_edges"]["right"]["offset_m"] + ] + assert any(p["elevation_m"] > got["road_edges"]["right"]["elevation_m"] for p in right) + + +def test_서버_TS_같은_값은_거울_시험(): + assert any(case["name"] == "물넘이포장 낮은 쪽 노면 연장" for case in CASES) + + +def test_콘크리트는_늘인_끝까지(dip, ts): + assert ts["edges"]["right"] == { + "offset_m": dip["tilt_reach"]["offset_m"], + "elevation_m": dip["tilt_reach"]["elevation_m"], + } + first = ts["pavePoints"].split()[1] # 포장층 윗면 우 끝 + assert float(first.split(",")[0]) == pytest.approx(-4.3333, abs=1e-4) diff --git a/resources/tester/test_294_6_ford_linked_mark.py b/resources/tester/test_294_6_ford_linked_mark.py new file mode 100644 index 000000000..f825fa3fb --- /dev/null +++ b/resources/tester/test_294_6_ford_linked_mark.py @@ -0,0 +1,81 @@ +"""294-6 물넘이 월류 폭으로 옆 측점 카드에 그려진 포장 = 연결 표기(요구 190-3). + +못박는 것 + - 옆 카드(물넘이 측점 ≠ 카드 측점) = 포장층 · 월류 높이선에 `is-linked`(흙막이 연동 꼴) · + 호버 글 「물넘이포장 · 4+10 연결」 · 앞서 그린 것(세월교 점선)은 그대로. + - 주인 카드 = 옛 꼴(연결 표식 · 글 없음). + - 고름 = 주인 구조물 그대로(`data-structure-id` pipe-90.00 · 강조 켜짐) · CSS 연결 꼴이 강조를 안 덮음. + - 카드 그리기(`Cross_View`)가 물넘이포장 바로 뒤에 표기를 부름. + +진짜 TS 는 `helper_294_6_ford_linked_mark.cjs` 가 Node 로 돌림. 화면은 브레인 확인. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_294_6_ford_linked_mark.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_옆_카드_연결_꼴_호버_글(out): + neighbor = out["neighbor"] + assert neighbor["marked"] is True + assert len(neighbor["classes"]) == 2 + assert all("is-linked" in c.split() for c in neighbor["classes"]) + assert neighbor["titles"] == [["물넘이포장 · 4+10 연결"]] * 2 + assert neighbor["beforeClass"] == "b06-chart__ford-deck-plan" + + +def test_주인_카드_옛_꼴(out): + owner = out["owner"] + assert owner["marked"] is False + assert not any("is-linked" in c.split() for c in owner["classes"]) + assert owner["titles"] == [[], []] + assert out["noSpec"] is False + + +def test_고름은_주인_구조물(out): + for card in (out["neighbor"], out["owner"]): + assert card["pick"] == ["pipe-90.00"] + assert "is-active" in card["classes"][0].split() + + +def test_CSS_연결_꼴_강조가_먼저(): + css = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text( + encoding="utf-8" + ) + assert ".b06-chart__ford-pavement.is-linked:not(.is-active)" in css + assert ".b06-chart__ford-deck-plan.is-linked" in css + + +def test_카드가_물넘이포장_뒤에_표기_부름(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + start = view.index("const fordFrom = plotLayer.children.length;") + call = view.index("appendFordPavementOverlay(", start) + mark = view.index( + "markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval);", call + ) + assert start < call < mark diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 229e8406b..22f84dad6 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -51,6 +51,16 @@ def _ground(slope: float, *, gap: tuple[float, float] | None = None) -> list[dic return samples +def _dip_ground() -> list[dict]: + """양측 절토 · 우측 노면 끝 너머(−4.5 m 밖)에서 원지반이 급히 내려가는 지반(294-3).""" + samples = [] + for index in range(49): + offset = -12.0 + index * 0.5 + z = 101.05 + 0.2 * offset if offset >= -4.5 else 100.15 + 1.0 * (offset + 4.5) + samples.append({"offset_m": offset, "elevation_m": round(z, 4), "valid": True}) + return samples + + # 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교 # 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간. CASES = [ @@ -245,6 +255,20 @@ CASES = [ "tilt_pct": 5.2408, }, }, + { + # 294-3 — 양측 절토 · 낮은 쪽(우) 노면을 늘여 원지반이 내려와 만나는 자리까지 + "name": "물넘이포장 낮은 쪽 노면 연장", + "samples": _dip_ground(), + "design_elevation_m": 100.4, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "paved": True, + "tilted": True, + "tilt_pct": 5.0, + }, + }, ] _TS_RUNNER = """