From 297910d28c521f346df68c535281d47ef9082bd2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:47:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(B06=20=ED=9A=A1=EB=8B=A8):=20=EC=82=AC?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20=EC=A0=88=20=E2=86=94=20?= =?UTF-8?q?=EC=84=B1=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=98=AE=EA=B8=B4=20?= =?UTF-8?q?=EB=85=B8=EA=B2=AC=EC=97=90=EC=84=9C=20=E2=80=94=20=EB=A7=88?= =?UTF-8?q?=EB=A3=A8=20=EB=84=88=EB=A8=B8=20=3D=20=EC=84=B1=ED=86=A0=201:n?= =?UTF-8?q?=20(370-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 짝 common_util_cross_design_geometry.ts ↔ B06_Section_Engine_Design_Geometry.py — 노견 확장을 tilt 바로 뒤로 · 단면유형 · 자동 측구 판정은 옮긴 뒤 - 거울 사례 「마루 너머 = 성토 1:n」 · 「성 → 절 넘어감」 · 옛 사례 이름 「판정은 옮긴 노견」 - 서버 묶음 셋 다시 지음(이 파일 안 실음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B06_Section_Engine_Design_Geometry.py | 20 ++--- .../common_util_cross_design_geometry.ts | 19 ++--- .../tester/test_b06_cross_design_mirror.py | 74 ++++++++++++++++++- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index e19dffc10..abd91a5f2 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -103,6 +103,16 @@ class _SectionGeometry: low_left = ground_at(self.left_extent) < ground_at(-self.right_extent) self.slope_per_offset = -slope if low_left else slope + # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이 + # 같이 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 · + # 370-1 — 마루를 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ TS 짝 `SectionGeometry`. + self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0) + self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0) + self.half_road_left += self.shoulder_extend_left + self.half_road_right += self.shoulder_extend_right + self.left_extent += self.shoulder_extend_left + self.right_extent += self.shoulder_extend_right + # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 @@ -156,16 +166,6 @@ class _SectionGeometry: if tilted: self.has_ditch = False - # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** - # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. - # ⚠ TS 짝 `SectionGeometry`. - self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0) - self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0) - self.half_road_left += self.shoulder_extend_left - self.half_road_right += self.shoulder_extend_right - self.left_extent += self.shoulder_extend_left - self.right_extent += self.shoulder_extend_right - # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). self.ditch_points: list[tuple[float, float]] = [] edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 75358fc4b..59aa1fa94 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -180,6 +180,16 @@ export class SectionGeometry { this.slopePerOffset = lowLeft ? -slope : slope; } + // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이 같이 + // 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 · 370-1 — 마루를 + // 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ 파이썬 짝 `_SectionGeometry`. + this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0); + this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0); + this.halfRoadLeft += this.shoulderExtendLeft; + this.halfRoadRight += this.shoulderExtendRight; + this.leftExtent += this.shoulderExtendLeft; + this.rightExtent += this.shoulderExtendRight; + // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. const groundAt = params.groundAt; if (groundAt !== null) { @@ -226,15 +236,6 @@ export class SectionGeometry { // 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝) if (params.tilted) this.hasDitch = false; - // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** - // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. - this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0); - this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0); - this.halfRoadLeft += this.shoulderExtendLeft; - this.halfRoadRight += this.shoulderExtendRight; - this.leftExtent += this.shoulderExtendLeft; - this.rightExtent += this.shoulderExtendRight; - // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; const outward = params.ditchSide === "left" ? 1 : -1; diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index c3789c971..f0554ff98 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -61,6 +61,22 @@ def _dip_ground() -> list[dict]: return samples +def _ridge_ground() -> list[dict]: + """우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1).""" + return [ + {"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True} + for o in (-12.0 + i * 0.5 for i in range(49)) + ] + + +def _rise_ground() -> list[dict]: + """좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1).""" + return [ + {"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True} + for o in (-12.0 + i * 0.5 for i in range(49)) + ] + + # 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교 # 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간. CASES = [ @@ -214,8 +230,8 @@ CASES = [ }, }, { - # 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서 - "name": "노견 확장 — 판정은 옮기기 전 노견", + # 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1) + "name": "노견 확장 — 판정은 옮긴 노견", "samples": _ground(-0.05), "design_elevation_m": 100.0, "options": { @@ -226,6 +242,30 @@ CASES = [ "shoulder_extend_left_m": 2.5, }, }, + { + # 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님) + "name": "마루 너머 = 성토 1:n", + "samples": _ridge_ground(), + "design_elevation_m": 100.0, + "options": { + "ground_type": "soil", + "section_mode": "right_cut", + "ditch_side": "right", + "shoulder_extend_right_m": 3.0, + }, + }, + { + # 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름) + "name": "성 → 절 넘어감", + "samples": _rise_ground(), + "design_elevation_m": 100.0, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "shoulder_extend_left_m": 2.5, + }, + }, { # 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임) "name": "확폭 좌 + 사면 이동 좌 0.4", @@ -490,3 +530,33 @@ def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> No both = by_name["확폭 좌 + 사면 이동 좌 0.4"] assert both["widening_left_m"] == curve["widening_left_m"] > 0 assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9 + + +def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float: + """노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리).""" + sign = 1.0 if side == "left" else -1.0 + start_offset = result["road_edges"][side]["offset_m"] + sign * skip + line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"]) + index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6) + start, nxt = line[index], line[index + 1] + return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"]) + + +def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None: + """370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동.""" + by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True)) + soil = STANDARD_CROSS_SECTION["soil"] + ridge = by_name["마루 너머 = 성토 1:n"] + assert ridge["section_mode"] == "left_cut" # 우 = 성토 + assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐 + assert ridge["fill_area_m2"] > 0 + assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3 + rise = by_name["성 → 절 넘어감"] + assert rise["section_mode"] == "left_cut" # 좌 = 절토 + assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left" + assert ( + abs( + _outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"] + ) + < 1e-3 + ) From 6cac1b7cd4d0c1e03238578006b7ea465ac34435 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:55:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B06=20=ED=9A=A1=EB=8B=A8):=20=EC=82=AC?= =?UTF-8?q?=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20=EB=A9=88=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=EA=B5=90=EC=B0=A8=EC=A0=90=20=EC=86=90=EC=9E=A1=EC=9D=B4=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A6=84=20=EC=9D=B4=EC=96=B4=EB=B0=9B?= =?UTF-8?q?=EA=B8=B0=20(370-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/B06_Section_UI_Cross_Fit.ts — extendStop(노견 끝 = 원지반 자리 보간 · 0.001 m) · toePoints(교차점 자리) - Design_Pick — ◀▶ 한 걸음이 그 자리를 지나면 한 번 섬(↺ 그대로) · 쪽마다 교차점 원(키 toe:left|right · 늘 보임 · 고르면 ◀▶) · 사면 키가 절 ↔ 성으로 바뀌거나 사라지면 같은 쪽 손잡이가 이어받음 - 손잡이 CSS(Areas.css) · Node 시험 test_370_2_toe_stop.py + helper - 서버 묶음 셋 다시 지음(b07-extent 가 Fit 을 실음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B06_Section_UI_Cross_Design_Pick.ts | 96 ++++-- .../B06_Section_UI_Style_Cross_Areas.css | 12 + B06_Section/logic/B06_Section_UI_Cross_Fit.ts | 56 ++++ resources/tester/helper_370_2_toe_stop.cjs | 300 ++++++++++++++++++ resources/tester/test_370_2_toe_stop.py | 79 +++++ 5 files changed, 519 insertions(+), 24 deletions(-) create mode 100644 resources/tester/helper_370_2_toe_stop.cjs create mode 100644 resources/tester/test_370_2_toe_stop.py diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 355e83579..b486ff4e9 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -12,6 +12,8 @@ * 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고). * 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬. * 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2). + * 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 = + * 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음. * ========================================================================== */ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; @@ -27,10 +29,13 @@ import { makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center"; +import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; const HIT_CLASS = "b06-chart__design-hit"; +/** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */ +const TOE_CLASS = "b06-chart__toe-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ @@ -282,7 +287,10 @@ const keysByStation = new Map(); export function pickedLineAt(stationId: string): number | null { if (picked?.stationId !== stationId) return null; const keys = keysByStation.get(stationId); - const index = keys ? keys.indexOf(picked.key) : Number(picked.key); + let index = keys ? keys.indexOf(picked.key) : Number(picked.key); + // 사면이 절 ↔ 성으로 넘어가 키가 바뀌거나 길이 0 으로 사라지면 같은 쪽 교차점 손잡이(370-2) + const slope = /^(?:cut|fill)(?:\+s)?:(left|right):/.exec(picked.key); + if (index < 0 && keys && slope) index = keys.indexOf(`toe:${slope[1]}`); return index >= 0 ? index : null; } export function setPickedLine(stationId: string, index: number | null): void { @@ -312,7 +320,12 @@ const pointsOf = (element: Element): XY[] => { export interface ShoulderShift { valueOf: (side: CrossSide) => number; set: (side: CrossSide, valueM: number) => void; + /** 한 걸음 now → next 사이 노견 끝 = 원지반 자리가 있으면 그 값(370-2 · 없으면 next). */ + stopAt?: (side: CrossSide, now: number, next: number) => number; } +/** 이동값 글 — 0.1 m 눈금이면 한 자리 · 멈춤 자리(370-2)면 두 자리. */ +const extendText = (valueM: number): string => + valueM.toFixed(Math.abs(valueM * 10 - Math.round(valueM * 10)) < 1e-6 ? 1 : 2); /** 쉬프트 걸음(m). */ export const SHIFT_STEP_M = 0.1; @@ -345,9 +358,15 @@ function tellBermHost(chainageM: number | null): void { /** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */ export function designLineMoves( - section: Pick, + section: Pick, onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, -): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } { +): { + stationId: string; + shift?: ShoulderShift; + berm?: BermDepthControl; + center?: number; + toes?: ReturnType; +} { const design = section.design; if (!onDesignChange || !design) return { stationId: section.station_id }; const send = (change: Partial) => @@ -361,7 +380,9 @@ export function designLineMoves( shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), + stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next), }, + toes: toePoints(section), // 교차점 손잡이(370-2) berm: { set: (side, valueM) => send({ berm_depth: { side, valueM } }), // 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움 @@ -400,6 +421,8 @@ export function attachDesignLinePick(input: { stationId?: string; /** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */ center?: number; + /** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */ + toes?: ReturnType; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -429,7 +452,12 @@ export function attachDesignLinePick(input: { : edges.right.elevation_m - ((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) / (edges.left.offset_m - edges.right.offset_m); - const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])]; + const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]); + const keys = [ + ...pieceKeys(design, pieces), + ...(centerZ === null ? [] : ["center"]), + ...toeSides.map((at) => `toe:${at}`), + ]; if (input.stationId) keysByStation.set(input.stationId, keys); // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). @@ -448,6 +476,14 @@ export function attachDesignLinePick(input: { // 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`) const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ)); if (center) hits.push(center); + // 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례) + for (const at of toeSides) { + const toe = input.toes?.[at] as { offset_m: number; elevation_m: number }; + const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m)); + hit.classList.add(TOE_CLASS); + hit.setAttribute("r", "5"); + hits.push(hit); + } hits.forEach((hit, index) => { hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 @@ -486,7 +522,11 @@ export function attachDesignLinePick(input: { : null; const step = (dir: -1 | 1) => () => { if (axis === "center") return centerShift?.step(dir); - if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); + if (!side || !shift) return; + // 노견 끝 = 원지반(절도 성도 아닌 점)을 지나면 그 자리에 한 번 섬(370-2) + const now = shift.valueOf(side); + const next = shiftedExtend(now, side, dir, kx); + shift.set(side, shift.stopAt?.(side, now, next) ?? next); }; // 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선) const stepTitle = (screen: "왼쪽" | "오른쪽"): string => @@ -546,22 +586,29 @@ export function attachDesignLinePick(input: { label, panel: shell.root, setActive: (index) => { - const onCenter = !!centerShift && index === pieces.length; - const piece = index === null ? undefined : pieces[index]; - hits.forEach((hit, i) => - hit.classList.toggle("is-active", (!!piece || onCenter) && i === index), - ); - label.hidden = !piece && !onCenter; - active = piece || onCenter ? index : null; - side = piece ? pieceZone(design, piece).side : null; + const key = index === null ? undefined : keys[index]; + const onCenter = !!centerShift && key === "center"; + const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null; + const piece = index === null || index >= pieces.length ? undefined : pieces[index]; + const chosen = !!piece || onCenter || !!toeSide; + hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index)); + label.hidden = !chosen; + active = chosen ? index : null; + side = piece ? pieceZone(design, piece).side : toeSide; axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; + const sideName = side === "left" ? "(좌)" : "(우)"; + const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : ""; + const toe = toeSide ? input.toes?.[toeSide] : null; label.textContent = onCenter ? "중심점" - : piece - ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") - : ""; - const open = onCenter || !!(piece && (axis === "depth" ? berm : shift)); + : toe + ? `교차점 ${sideName} · 노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}` + : piece + ? pieceLabel(design, piece) + moved + : ""; + const open = + onCenter || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) // 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3) @@ -573,15 +620,16 @@ export function attachDesignLinePick(input: { [toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")]; lift.row.classList.toggle("is-hidden", axis !== "depth"); lift.controls.classList.toggle("is-hidden", axis !== "depth"); - const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = axis === "center" ? "중심점" - : axis === "y" - ? "노면 높이" - : axis === "depth" - ? `소단 높이 ${sideName}` - : `사면 이동 ${sideName}`; + : toeSide + ? `교차점 이동 ${sideName}` + : axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = axis === "center" ? centerPreviewText(0) @@ -589,7 +637,7 @@ export function attachDesignLinePick(input: { ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : axis === "depth" && side ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` - : `이동 ${extend.toFixed(1)}m`; + : `이동 ${extendText(extend)}m`; berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index b3fff596b..5c3207fd4 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -193,6 +193,18 @@ cursor: ew-resize; } +/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움. */ +.b06-chart__toe-hit { + fill: var(--color-surface); + stroke: var(--color-royal-amethyst); + stroke-width: 1.5; +} + +.b06-chart__toe-hit.is-active { + fill: var(--color-royal-amethyst); + cursor: pointer; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/B06_Section/logic/B06_Section_UI_Cross_Fit.ts b/B06_Section/logic/B06_Section_UI_Cross_Fit.ts index 305ab66ec..0b4250e69 100644 --- a/B06_Section/logic/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/logic/B06_Section_UI_Cross_Fit.ts @@ -10,6 +10,8 @@ * * 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면 * 기울기와 지반 기울기 차이로 만나는 자리를 추정한다. + * + * 교차점 손잡이 자리(`toePoints`) · 사면 이동 멈춤(`extendStop`)도 여기(370-2). * ========================================================================== */ import type { CrossDesign, CrossSection } from "../B06_Section_Api_Fetch"; @@ -56,6 +58,60 @@ export function toeOffsets(section: CrossSection): { left: number | null; right: return out; } +/** 교차점 손잡이 자리(370-2) — `toeOffsets` 자리 · 표고 = 원지반. 못 만난 쪽은 null. */ +export function toePoints( + section: Pick, +): Record<"left" | "right", { offset_m: number; elevation_m: number } | null> { + if (!section.samples?.length) return { left: null, right: null }; + const toes = toeOffsets(section as CrossSection); + const groundAt = groundInterpolator(section.samples); + const at = (offset: number | null) => + offset === null || !groundAt ? null : { offset_m: offset, elevation_m: groundAt(offset) }; + return { left: at(toes.left), right: at(toes.right) }; +} + +/** 멈춤 탐색 간격(m) — 사면 이동 걸음(0.1 m)보다 촘촘하게. */ +const STOP_SCAN_M = 0.01; + +/** + * 사면 이동 멈춤(370-2) — 그 쪽 노견 확장 `now` → `next` 사이에서 옮긴 노견 끝이 원지반과 딱 만나는 + * (절토도 성토도 아닌) 확장값. 원지반 − 노면 평면(`road_edges` 두 점을 이은 선) 부호가 바뀌는 자리를 + * 보간 · 못 찾거나 지금 그 자리면 `next` 그대로. 0.001 m 반올림. + */ +export function extendStop( + samples: CrossSection["samples"], + edges: CrossDesign["road_edges"] | undefined, + side: "left" | "right", + now: number, + next: number, +): number { + const groundAt = groundInterpolator(samples ?? []); + if (!groundAt || !edges || edges.left.offset_m === edges.right.offset_m) return next; + const { left, right } = edges; + const plane = (o: number) => + right.elevation_m + + ((left.elevation_m - right.elevation_m) * (o - right.offset_m)) / + (left.offset_m - right.offset_m); + const sign = side === "left" ? 1 : -1; + const base = Math.abs(edges[side].offset_m) - now; // 확장 0 노견 끝 거리 + const gapAt = (extend: number) => { + const o = sign * (base + extend); + return groundAt(o) - plane(o); + }; + let previous = gapAt(now); + if (Math.abs(previous) < 1e-4) return next; + const steps = Math.max(1, Math.ceil(Math.abs(next - now) / STOP_SCAN_M)); + for (let i = 1; i <= steps; i += 1) { + const a = now + ((next - now) * (i - 1)) / steps; + const b = now + ((next - now) * i) / steps; + const gap = gapAt(b); + if (gap === 0 || Math.sign(gap) !== Math.sign(previous)) + return Math.round((a + ((b - a) * previous) / (previous - gap)) * 1000) / 1000; + previous = gap; + } + return next; +} + /** * 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시). * diff --git a/resources/tester/helper_370_2_toe_stop.cjs b/resources/tester/helper_370_2_toe_stop.cjs new file mode 100644 index 000000000..88bafda41 --- /dev/null +++ b/resources/tester/helper_370_2_toe_stop.cjs @@ -0,0 +1,300 @@ +/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.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 }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +class FakeNode { + constructor(tag) { + this.tagName = String(tag); + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this.title = ""; + 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(""); + } + get childElementCount() { + return this.children.length; + } + get isConnected() { + return true; + } + _detach(kid) { + if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid); + kid.parent = this; + } + append(...kids) { + kids.forEach((k) => { + if (typeof k === "string") return; + this._detach(k); + this.children.push(k); + }); + } + prepend(...kids) { + kids.reverse().forEach((k) => { + this._detach(k); + this.children.unshift(k); + }); + } + appendChild(kid) { + this.append(kid); + return kid; + } + insertBefore(kid, ref) { + this._detach(kid); + const at = ref ? this.children.indexOf(ref) : -1; + if (at < 0) this.children.push(kid); + else this.children.splice(at, 0, kid); + return kid; + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this); + } + querySelector() { + return null; + } + 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); + } + hasAttribute(name) { + return name in this.attrs; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + click() { + const event = { stopPropagation: () => {}, preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), + addEventListener: () => {}, + removeEventListener: () => {}, +}; +globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} }; + +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]; + if (request.endsWith(".css")) return {}; + 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 pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick")); +const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit")); +const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design")); +const { computeCrossDesign } = loadTs( + path.join(ROOT, "common_util", "common_util_cross_design.ts"), +); +const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = {}; + +// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막 +const samples = Array.from({ length: 49 }, (_, i) => { + const o = -12 + i * 0.5; + return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true }; +}); +const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4); +const designOf = (extendRight) => { + const result = computeCrossDesign(samples, 100, { + standard, + groundType: "soil", + sectionMode: "right_cut", + ditchSide: "right", + shoulderExtendRightM: extendRight, + }); + // 화면 design 칸 이름(저장 · 응답과 같음) + return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 }; +}; +const section = (extendRight) => ({ + station_id: "S1", + chainage_m: 20, + samples, + design: designOf(extendRight), +}); + +// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리 +const gapAt = (design, e) => { + const { left, right } = design.road_edges; + const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e); + const plane = + right.elevation_m + + ((left.elevation_m - right.elevation_m) * (o - right.offset_m)) / + (left.offset_m - right.offset_m); + return groundAt(o) - plane; +}; +const base = designOf(0); +let crossing = null; +for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) { + if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) { + crossing = [e, Math.round((e + 0.1) * 10) / 10]; + break; + } +} +const stop = fit.extendStop( + samples, + designOf(crossing[0]).road_edges, + "right", + crossing[0], + crossing[1], +); +out.stop = { + crossing, + value: stop, + gap: Math.abs(gapAt(base, stop)), + noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1), + // 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬) + fromStop: fit.extendStop( + samples, + designOf(stop).road_edges, + "right", + stop, + Math.round((stop + 0.1) * 10) / 10, + ), + leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1), +}; + +// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄 +const x = (o) => 300 - o * 20; +const y = (e) => 400 - (e - 100) * 20; +const attach = (sec, changes) => { + const layer = new FakeNode("g"); + const from = layer.childElementCount; + appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined); + layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, "")); + const picks = []; + const handle = pick.attachDesignLinePick({ + layer, + design: sec.design, + x, + toDisplayY: y, + onPick: (i) => picks.push(i), + ...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)), + }); + return { layer, handle, picks }; +}; +const changes = []; +const near = section(crossing[0]); +const { layer, handle, picks } = attach(near, changes); +const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit")); +const keys = pick.designLineMoves(near, () => undefined).toes; +out.toe = { + count: toes.length, + centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")), + sides: ["left", "right"].filter((s) => keys[s]), + rightOnGround: + keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9, + rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null, + rightX: keys.right ? x(keys.right.offset_m) : null, +}; +toes[1].click(); +handle.setActive(picks[0]); +const right = handle.panel.children.find((k) => + k.classList?.contains("b06-structure-panel__struct"), +); +const buttons = right.children[1].children; +const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset) +rightButton.click(); +out.toePick = { + picked: picks, + active: toes[1].classList.contains("is-active"), + label: handle.label.textContent, + title: handle.panel.children[0].children[0].textContent, + panelOpen: !handle.panel.classList.contains("is-hidden"), + sent: changes.map((c) => c.shoulder_extend), +}; + +// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이 +const cutIndex = handle.pieces.findIndex( + (p) => + pick.pieceZone(near.design, p).zone === "cut" && + pick.pieceZone(near.design, p).side === "right", +); +pick.setPickedLine("S1", null); +attach(near, []); // 키 적음 +pick.setPickedLine("S1", cutIndex); +const before = pick.pickedLineAt("S1"); +const far = section(crossing[1] + 1.0); +const after = attach(far, []); +const afterIndex = pick.pickedLineAt("S1"); +out.inherit = { + cutIndex, + before, + farMode: far.design.section_mode, + afterIndex, +}; +after.handle.setActive(afterIndex); +out.inherit.label = after.handle.label.textContent; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_370_2_toe_stop.py b/resources/tester/test_370_2_toe_stop.py new file mode 100644 index 000000000..46d6d2689 --- /dev/null +++ b/resources/tester/test_370_2_toe_stop.py @@ -0,0 +1,79 @@ +"""PLAN 370-2 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기(요구 240-14). + +짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints`) · +`B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(손잡이 · 키 이어받기). 실제 TS 를 +`helper_370_2_toe_stop.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out(tmp_path_factory) -> dict: + standard = tmp_path_factory.mktemp("std") / "standard.json" + standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_370_2_toe_stop.cjs"), str(standard)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노견_끝이_원지반을_지나면_그_자리에_한_번_섬(out) -> None: + stop = out["stop"] + low, high = stop["crossing"] + assert low < stop["value"] < high + assert stop["gap"] < 2e-3 # 노견 끝 = 원지반 + assert stop["noCross"] == 0.1 # 안 지나면 걸음 그대로 + assert abs(stop["fromStop"] - round(stop["value"] + 0.1, 1)) < 1e-9 # 멈춘 뒤엔 다시 안 섬 + assert stop["leftNoGround"] == 0.1 # 지반 없으면 그대로 + + +def test_교차점_손잡이_쪽마다_늘_보임(out) -> None: + toe = out["toe"] + assert toe["count"] == len(toe["sides"]) == 2 + assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례 + assert toe["rightOnGround"] is True + assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9 + + +def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None: + got = out["toePick"] + assert got["active"] is True and got["panelOpen"] is True + assert got["label"].startswith("교차점 (우) · 노선에서 ") + assert got["title"] == "교차점 이동 (우)" + assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right" + low, high = out["stop"]["crossing"] + assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리 + assert low < got["sent"][0]["valueM"] < high + + +def test_절에서_성으로_넘어가도_고름_유지(out) -> None: + inherit = out["inherit"] + assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"] + assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감 + assert inherit["afterIndex"] is not None + assert inherit["label"].startswith("교차점 (우)")