From e1b1c79249d0121557b40139df176dcb777ce5c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:40:01 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(=ED=8F=89=EB=A9=B4):=20353-1=20[?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=EA=B5=AD]=20=E2=86=92=20[=EC=A0=88/=EC=84=B1?= =?UTF-8?q?=ED=86=A0=EC=84=A0]=20=C2=B7=20=EC=84=A0=20=EB=A7=A4=EB=81=84?= =?UTF-8?q?=EB=9F=BD=EA=B2=8C=20=E2=80=94=20=EB=8B=A8=EC=A1=B0=203?= =?UTF-8?q?=EC=B0=A8=20=EB=B3=B4=EA=B0=84=20=C2=B7=20=C2=B11=20m=20?= =?UTF-8?q?=EB=B2=95=EC=84=A0=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EC=B4=98?= =?UTF-8?q?=EC=B4=98=20=C2=B7=20=EC=A0=91=ED=9E=98=20=EB=B2=84=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EB=B0=94=EB=80=9C=20=EC=9E=90=EB=A6=AC=20=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EB=81=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 오프셋 = 측점 값 그대로 지나는 단조 3차(PCHIP) · 비탈은 폭으로 보간 - 사이 점 법선 = 측점 횡단 틀과 같은 앞뒤 ±1 m 식(route_normal · routeNormal 새 함수 · route_frame 그대로) - 네 선 중 하나라도 가운데서 2° 넘게 꺾이는 사이를 반씩 나눔(깊이 4 · 2 m → 12.5 cm) - 진행 거스르는 점 버림(안쪽 비탈 끝 접힘) · 그 자리 눈금 뺌 · 눈금은 측점 · 2 m 자리만 - 깎기↔쌓기 바뀜 자리 = 폭 0(비탈 끝이 도로 끝으로 모임) · 바뀜 점은 앞뒤 조각이 나눠 가짐 - TS ↔ 파이썬(B07 계획평면도) 짝 · 거울 시험 test_353_1 · 옛 계산은 꺾임 · 접힘 · 바뀜 자리 시험 셋 모두 떨어짐 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 2 +- .../logic/B05_Profile_PlanFootprint.ts | 298 +++++++++++++++--- .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 261 ++++++++++++--- .../tester/test_346_2_footprint_stage.py | 12 +- .../tester/test_353_1_cut_fill_line_smooth.py | 199 ++++++++++++ 5 files changed, 676 insertions(+), 96 deletions(-) create mode 100644 resources/tester/test_353_1_cut_fill_line_smooth.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index cf375a5ed..c2d51167e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -81,7 +81,7 @@ export function createRouteEditChrome( aria-pressed="false" title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅 + title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선 diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 62ca60b39..93d9c06c9 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -3,7 +3,8 @@ * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` - * (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면 + * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 + * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length]; } -function interp(records: Array>, chainage: number, key: string): number { - const chainages = records.map((record) => record.chainage_m); - const index = bisectLeft(chainages, chainage); - if (index <= 0) return records[0][key]; - if (index >= records.length) return records[records.length - 1][key]; - const before = records[index - 1]; - const after = records[index]; - const span = after.chainage_m - before.chainage_m; - const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0; - return before[key] + (after[key] - before[key]) * ratio; -} - function groundOf(samples: FootprintPoint[]): Array<[number, number]> { return samples .filter( @@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; } +/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */ +export const SPLIT_TURN_DEG = 2.0; +/** 나눔 깊이 상한(2 m → 12.5 cm). */ +export const SPLIT_DEPTH = 4; +/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */ +export const NORMAL_PROBE_M = 1.0; + +type Frame = [number, number, number, number]; + +/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */ +export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame { + const [x, y, nx, ny] = routeFrame(route, chainage); + const first = route[0][0]; + const last = route[route.length - 1][0]; + let before = Math.max(first, chainage - NORMAL_PROBE_M); + let after = Math.min(last, chainage + NORMAL_PROBE_M); + if (after - before <= 1e-9) { + before = Math.max(first, chainage - 1e-3); + after = Math.min(last, chainage + 1e-3); + } + const [x0, y0] = routeFrame(route, before); + const [x1, y1] = routeFrame(route, after); + const length = Math.hypot(x1 - x0, y1 - y0); + if (length <= 1e-9) return [x, y, nx, ny]; + return [x, y, -(y1 - y0) / length, (x1 - x0) / length]; +} + +function bisectRight(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >> 1; + if (target < values[middle]) high = middle; + else low = middle + 1; + } + return low; +} + +type Knots = [number[], number[], number[]]; + +/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */ +function pchipSlopes(xs: number[], ys: number[]): number[] { + const n = xs.length; + if (n < 2) return xs.map(() => 0); + const h = xs.slice(1).map((x, i) => x - xs[i]); + const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span); + const slopes = [delta[0], ...new Array(n - 2).fill(0), delta[n - 2]]; + for (let k = 1; k < n - 1; k += 1) { + if (delta[k - 1] * delta[k] > 0) { + const w1 = 2 * h[k] + h[k - 1]; + const w2 = h[k] + 2 * h[k - 1]; + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]); + } + } + return slopes; +} + +/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */ +function pchip([xs, ys, slopes]: Knots, x: number): number { + if (x <= xs[0]) return ys[0]; + if (x >= xs[xs.length - 1]) return ys[ys.length - 1]; + const i = bisectRight(xs, x) - 1; + const h = xs[i + 1] - xs[i]; + const t = (x - xs[i]) / h; + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ); +} + +const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)]; + +interface SideCurves { + chainages: number[]; + flags: boolean[]; + switches: number[]; + edge: Knots; + width: Knots; +} + +/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */ +function sideCurves(records: FootprintRecord[], side: Side): SideCurves { + const chainages = records.map((record) => record.chainage_m); + const edges = records.map((record) => record[`${side}_edge_m`]); + const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]); + const flags = records.map((record) => Boolean(record[`${side}_cut`])); + const switches: number[] = []; + const widthX = [chainages[0]]; + const widthY = [widths[0]]; + for (let i = 1; i < records.length; i += 1) { + const c0 = chainages[i - 1]; + const c1 = chainages[i]; + const w0 = Math.abs(widths[i - 1]); + const w1 = Math.abs(widths[i]); + const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5); + switches.push(at); + if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) { + widthX.push(at); + widthY.push(0.0); + } + widthX.push(c1); + widthY.push(widths[i]); + } + return { + chainages, + flags, + switches, + edge: knots(chainages, edges), + width: knots(widthX, widthY), + }; +} + +/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */ +function cutAt(curves: SideCurves, chainage: number): boolean { + const { flags } = curves; + const i = bisectRight(curves.chainages, chainage); + if (i === 0) return flags[0]; + if (i === flags.length) return flags[flags.length - 1]; + if (flags[i - 1] === flags[i]) return flags[i]; + return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i]; +} + +/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */ +function forwardOnly(points: Line, frames: Frame[]): number[] { + const kept = points.length ? [0] : []; + for (let i = 1; i < points.length; i += 1) { + const last = points[kept[kept.length - 1]]; + const [, , nx, ny] = frames[i]; + if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i); + } + return kept; +} + +function turnDeg(a: number[], m: number[], b: number[]): number { + const u = [m[0] - a[0], m[1] - a[1]]; + const v = [b[0] - m[0], b[1] - m[1]]; + return Math.abs( + (Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI, + ); +} + +/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */ +function densify( + chainages: number[], + frameOf: (chainage: number) => Frame, + place: (chainage: number, frame: Frame) => Line, +): [number[], Frame[]] { + const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => { + if (depth >= SPLIT_DEPTH) return []; + const middle = (c0 + c1) / 2.0; + const pm = place(middle, frameOf(middle)); + // 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + const bends = pm.some((m, k) => { + const turn = turnDeg(p0[k], m, p1[k]); + return SPLIT_TURN_DEG < turn && turn < 90.0; + }); + if (!bends) return []; + return [ + ...split(c0, p0, middle, pm, depth + 1), + middle, + ...split(middle, pm, c1, p1, depth + 1), + ]; + }; + const points = chainages.map((chainage) => place(chainage, frameOf(chainage))); + const out = chainages.slice(0, 1); + for (let i = 1; i < chainages.length; i += 1) { + out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)); + out.push(chainages[i]); + } + return [out, out.map(frameOf)]; +} + interface Sampled { lines: Record<(typeof KEYS)[number], Line>; cut: Record; ticks: Line[]; } -/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ +/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ function sampleLines( route: Array<[number, number, number]>, input: FootprintRecord[], @@ -230,49 +393,94 @@ function sampleLines( endM: number, stepM: number, ): Sampled | null { - const records = input + const sorted = input .map((record) => ({ ...record, - left_cut: Number(Boolean(record.left_cut)), - right_cut: Number(Boolean(record.right_cut)), + left_cut: Boolean(record.left_cut), + right_cut: Boolean(record.right_cut), })) - .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; - if (route.length < 2 || !records.length) return null; - const byChainage = new Map>(); + .sort((a, b) => a.chainage_m - b.chainage_m); + if (route.length < 2 || !sorted.length) return null; + // 같은 누가거리 측점은 뒤 것 하나만 + const records: FootprintRecord[] = []; + for (const record of sorted) { + const last = records[records.length - 1]; + if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record; + else records.push(record); + } + const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") }; + const byChainage = new Map(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); - const samples = new Set(); + const base = new Set(); for (const record of records) { if (startM <= record.chainage_m && record.chainage_m <= endM) - samples.add(roundTo(record.chainage_m, 3)); + base.add(roundTo(record.chainage_m, 3)); } - for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); - const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; - const cut: Record = { left: [], right: [] }; + for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3)); + const all = new Set(base); + for (const side of SIDES) { + const { flags, switches } = curves[side]; + switches.forEach((at, i) => { + if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at); + }); + } + const frameOf = (chainage: number): Frame => { + const record = byChainage.get(roundTo(chainage, 3)); + const { x, y, nx, ny } = record ?? {}; + if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined) + return [x, y, nx, ny]; + return routeNormal(route, chainage); + }; + const valuesAt = (chainage: number): Record => { + const values: Record = {}; + for (const side of SIDES) { + const edge = pchip(curves[side].edge, chainage); + values[`${side}_edge_m`] = edge; + values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage); + } + return values; + }; + const place = (chainage: number, [x, y, nx, ny]: Frame): Line => { + const values = valuesAt(chainage); + return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]); + }; + const [chainages, frames] = densify( + [...all].sort((a, b) => a - b), + frameOf, + place, + ); + const offsets = chainages.map(valuesAt); + const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; + frames.forEach(([x, y, nx, ny], i) => { + for (const key of KEYS) + raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]); + }); + const kept = Object.fromEntries( + KEYS.map((key) => [key, forwardOnly(raw[key], frames)]), + ) as unknown as Record<(typeof KEYS)[number], number[]>; + const lines = Object.fromEntries( + KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]), + ) as unknown as Sampled["lines"]; + const cut: Record = { + left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])), + right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])), + }; + // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 + // 도면 관례) · 접혀 버린 자리는 뺌. const ticks: Line[] = []; - [...samples] + const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) }; + const position = new Map(chainages.map((chainage, i) => [chainage, i])); + [...base] .sort((a, b) => a - b) .forEach((chainage, index) => { - const record = byChainage.get(chainage); - const [x, y, nx, ny] = - record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined) - ? [record.x, record.y, record.nx, record.ny] - : routeFrame(route, chainage); - const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)])); - const points = Object.fromEntries( - KEYS.map((key) => [ - key, - [roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)], - ]), - ); - for (const key of KEYS) lines[key].push(points[key]); - for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5); - // 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례). + const i = position.get(chainage) ?? 0; + const [x, y, nx, ny] = frames[i]; for (const side of SIDES) { - const edge = offsets[`${side}_edge_m`]; - const end = offsets[`${side}_end_m`]; - if (Math.abs(end - edge) < 0.05) continue; + const edge = offsets[i][`${side}_edge_m`]; + const end = offsets[i][`${side}_end_m`]; + if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue; const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0; - ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); + ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]); } }); return { lines, cut, ticks }; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index d5c327e7f..ddf0b615a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -2,13 +2,14 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. -⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end · -slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · +slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면 +발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math -from bisect import bisect_left +from bisect import bisect_left, bisect_right from typing import Any from config.config_system import DRAWING_SCALE_PLAN @@ -39,19 +40,6 @@ def route_frame( return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) -def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: - chainages = [record["chainage_m"] for record in records] - index = bisect_left(chainages, chainage) - if index <= 0: - return records[0][key] - if index >= len(records): - return records[-1][key] - before, after = records[index - 1], records[index] - span = after["chainage_m"] - before["chainage_m"] - ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 - return before[key] + (after[key] - before[key]) * ratio - - def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: return sorted( (float(item["offset_m"]), float(item["elevation_m"])) @@ -120,6 +108,149 @@ def _split_runs( ) +#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°) +SPLIT_TURN_DEG = 2.0 +#: 나눔 깊이 상한(2 m → 12.5 cm) +SPLIT_DEPTH = 4 +#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값 +NORMAL_PROBE_M = 1.0 + + +def route_normal( + route: list[tuple[float, float, float]], chainage: float +) -> tuple[float, float, float, float]: + """노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식).""" + x, y, nx, ny = route_frame(route, chainage) + first, last = route[0][0], route[-1][0] + before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M) + if after - before <= 1e-9: + before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3) + x0, y0, _nx, _ny = route_frame(route, before) + x1, y1, _nx, _ny = route_frame(route, after) + length = math.hypot(x1 - x0, y1 - y0) + if length <= 1e-9: + return x, y, nx, ny + return x, y, -(y1 - y0) / length, (x1 - x0) / length + + +def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]: + """단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다.""" + n = len(xs) + if n < 2: + return [0.0] * n + h = [xs[i + 1] - xs[i] for i in range(n - 1)] + delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)] + slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]] + for k in range(1, n - 1): + if delta[k - 1] * delta[k] > 0: + w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1] + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]) + return slopes + + +def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float: + """꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값.""" + xs, ys, slopes = knots + if x <= xs[0]: + return ys[0] + if x >= xs[-1]: + return ys[-1] + i = bisect_right(xs, x) - 1 + h = xs[i + 1] - xs[i] + t = (x - xs[i]) / h + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ) + + +def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]: + return xs, ys, _pchip_slopes(xs, ys) + + +def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]: + """한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임).""" + chainages = [record["chainage_m"] for record in records] + edges = [record[f"{side}_edge_m"] for record in records] + widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records] + flags = [bool(record[f"{side}_cut"]) for record in records] + switches: list[float] = [] + width_x, width_y = [chainages[0]], [widths[0]] + for i in range(1, len(records)): + c0, c1 = chainages[i - 1], chainages[i] + w0, w1 = abs(widths[i - 1]), abs(widths[i]) + switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5) + switches.append(switch) + if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9: + width_x.append(switch) + width_y.append(0.0) + width_x.append(c1) + width_y.append(widths[i]) + return { + "chainages": chainages, + "flags": flags, + "switches": switches, + "edge": _knots(chainages, edges), + "width": _knots(width_x, width_y), + } + + +def _cut_at(curves: dict[str, Any], chainage: float) -> bool: + """그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐).""" + flags = curves["flags"] + i = bisect_right(curves["chainages"], chainage) + if i == 0: + return flags[0] + if i == len(flags): + return flags[-1] + if flags[i - 1] == flags[i]: + return flags[i] + return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i] + + +def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]: + """진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호.""" + kept = [0] if points else [] + for i in range(1, len(points)): + last = points[kept[-1]] + _x, _y, nx, ny = frames[i] + if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6: + kept.append(i) + return kept + + +def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: + u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1]) + return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + + +def _densify( + chainages: list[float], frame_of: Any, place: Any +) -> tuple[list[float], list[tuple[float, float, float, float]]]: + """곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔.""" + + def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]: + if depth >= SPLIT_DEPTH: + return [] + middle = (c0 + c1) / 2.0 + pm = place(middle, frame_of(middle)) + # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)): + return [] + return ( + split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) + ) + + points = [place(chainage, frame_of(chainage)) for chainage in chainages] + out = chainages[:1] + for i in range(1, len(chainages)): + out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0) + out.append(chainages[i]) + return out, [frame_of(chainage) for chainage in out] + + def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], @@ -131,60 +262,94 @@ def slope_pieces( `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 - 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 - 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 + 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 + 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). + 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted( + sorted_records = sorted( ( - { - **record, - **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, - } + {**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}} for record in records ), key=lambda record: record["chainage_m"], ) - if len(route) < 2 or not records: + if len(route) < 2 or not sorted_records: return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} + # 같은 누가거리 측점은 뒤 것 하나만 + records = [] + for record in sorted_records: + if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9: + records[-1] = record + else: + records.append(record) + sides = ("left", "right") keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") + curves = {side: _side_curves(records, side) for side in sides} by_chainage = {round(record["chainage_m"], 3): record for record in records} - samples = { + base = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: - samples.add(round(step, 3)) + base.add(round(step, 3)) step += step_m - lines: dict[str, list[list[float]]] = {key: [] for key in keys} - cut: dict[str, list[bool]] = {"left": [], "right": []} - ticks: list[list[list[float]]] = [] - for index, chainage in enumerate(sorted(samples)): - record = by_chainage.get(chainage) + switches = { + at + for side in sides + for i, at in enumerate(curves[side]["switches"]) + if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m + } + + def frame_of(chainage: float) -> tuple[float, float, float, float]: + record = by_chainage.get(round(chainage, 3)) if record and all(k in record for k in ("x", "y", "nx", "ny")): - x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] - else: - x, y, nx, ny = route_frame(route, chainage) - offsets = {key: _interp(records, chainage, key) for key in keys} - points = { - key: [round(x + nx * value, 4), round(y + ny * value, 4)] - for key, value in offsets.items() - } + return record["x"], record["y"], record["nx"], record["ny"] + return route_normal(route, chainage) + + def values_at(chainage: float) -> dict[str, float]: + values: dict[str, float] = {} + for side in sides: + edge = _pchip(curves[side]["edge"], chainage) + values[f"{side}_edge_m"] = edge + values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage) + return values + + def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]: + x, y, nx, ny = frame + values = values_at(chainage) + return [[x + nx * values[key], y + ny * values[key]] for key in keys] + + chainages, frames = _densify(sorted(base | switches), frame_of, place) + offsets = [values_at(chainage) for chainage in chainages] + raw: dict[str, list[list[float]]] = {key: [] for key in keys} + for (x, y, nx, ny), values in zip(frames, offsets): for key in keys: - lines[key].append(points[key]) - for side in cut: - cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) - # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). - for side in ("left", "right"): - edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] - if abs(end - edge) < 0.05: + raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)]) + kept = {key: _forward_only(raw[key], frames) for key in keys} + lines = {key: [raw[key][i] for i in kept[key]] for key in keys} + cut = { + side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides + } + # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 + # 선다(납품 도면 관례) · 접혀 버린 자리는 뺌. + ticks: list[list[list[float]]] = [] + kept_sets = {key: set(kept[key]) for key in keys} + position = {chainage: i for i, chainage in enumerate(chainages)} + for index, chainage in enumerate(sorted(base)): + i = position[chainage] + x, y, nx, ny = frames[i] + for side in sides: + edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"] + if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 - ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) + ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] - for side in cut: + for side in sides: cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) cut_lines += cut_runs fill_lines += fill_runs diff --git a/resources/tester/test_346_2_footprint_stage.py b/resources/tester/test_346_2_footprint_stage.py index 4691b4804..e2aad6a0a 100644 --- a/resources/tester/test_346_2_footprint_stage.py +++ b/resources/tester/test_346_2_footprint_stage.py @@ -165,11 +165,19 @@ def test_계약_꼴_선(result) -> None: first = result["first"] for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"): line = first[name] - assert len(line) == 51 # 0 ~ 100 m · 2 m + # 0 ~ 100 m · 2 m + 깎기↔쌓기 바뀜 자리(353-1 · 비탈 끝이 도로 끝으로 모임) + assert {round(p[0], 3) for p in line} >= {float(2 * i) for i in range(51)} assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점 assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"]) assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"]) - assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51 + assert len(first["slopeLeftCut"]) == len(first["slopeLeft"]) + assert len(first["slopeRightCut"]) == len(first["slopeRight"]) + for side in ("slopeLeft", "slopeRight"): # 바뀜 자리 = 도로 끝 위 + flags, points = first[f"{side}Cut"], first[side] + edge = 2.5 if side == "slopeLeft" else -2.5 + for i in range(1, len(flags)): + if flags[i] != flags[i - 1]: + assert points[i][1] == pytest.approx(edge, abs=1e-3) assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임 diff --git a/resources/tester/test_353_1_cut_fill_line_smooth.py b/resources/tester/test_353_1_cut_fill_line_smooth.py new file mode 100644 index 000000000..2be252fc6 --- /dev/null +++ b/resources/tester/test_353_1_cut_fill_line_smooth.py @@ -0,0 +1,199 @@ +"""353-1 절/성토선 매끄럽게 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 +(`B07_DesignDetail_Engine_Cad_PlanSlope.py`) 거울 + 측점 폭 = 횡단 값 · 꺾임 각 상한 · 접힘 버림 · +깎기↔쌓기 바뀜 자리 = 도로 끝. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_1_cut_fill_line_smooth.py -q +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + route_normal, + slope_pieces, +) + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TOL = 2e-4 +RADIUS = 20.0 +#: 꺾임 각 상한(°) — 이웃 선분 사이 방향 차 +MAX_TURN_DEG = 10.0 + + +def _route() -> list[tuple[float, float, float]]: + """+x 직선 50 m → 좌회전 90° 원호(R 20 · 5° 편) → +y 직선 40 m.""" + points = [(0.0, 0.0)] + points += [ + (50.0 + RADIUS * math.sin(math.radians(a)), RADIUS - RADIUS * math.cos(math.radians(a))) + for a in range(0, 91, 5) + ] + points.append((70.0, 60.0)) + route, chain = [], 0.0 + for index, (x, y) in enumerate(points): + if index: + step = math.hypot(x - points[index - 1][0], y - points[index - 1][1]) + if step < 1e-9: + continue + chain += step + route.append((chain, x, y)) + return route + + +def _records() -> list[dict]: + """20 m 측점 — 좌(곡선 안쪽) 비탈 폭이 곡선에서 R 보다 커져 접힘 · 우는 깎기↔쌓기 번갈아.""" + route = _route() + total = route[-1][0] + records = [] + for i in range(int(total // 10) + 1): + chainage = 10.0 * i + x, y, nx, ny = route_normal(route, chainage) + inside_curve = 50.0 <= chainage <= 50.0 + RADIUS * math.pi / 2 + records.append( + { + "chainage_m": chainage, + "left_edge_m": 2.5, + "left_end_m": 2.5 + (22.0 if inside_curve else 3.0 + 2.0 * math.sin(chainage / 7)), + "right_edge_m": -2.5 - 0.3 * (i % 2), + "right_end_m": -6.0 - 2.0 * math.cos(chainage / 11), + "left_cut": True, + "right_cut": (i // 2) % 2 == 0, + "x": x, + "y": y, + "nx": nx, + "ny": ny, + } + ) + return records + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { slopePieces } from "./B05_Profile_PlanFootprint.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, c.start, c.end))); +""" + + +@pytest.fixture(scope="module") +def pieces() -> dict: + route = _route() + edge, fill, cut, ticks = slope_pieces(route, _records(), 0.0, route[-1][0]) + return { + "edge": edge["lines"], + "fill": fill["lines"], + "cut": cut["lines"], + "ticks": ticks["ticks"], + } + + +@pytest.fixture(scope="module") +def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_353_1") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + route = _route() + payload = {"route": route, "records": _records(), "start": 0.0, "end": route[-1][0]} + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_두_쪽이_같다(pieces, ts_pieces) -> None: + for name in ("edge", "fill", "cut", "ticks"): + want, got = pieces[name], ts_pieces[name] + assert len(want) == len(got), name + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), name + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, (name, pw, pg) + + +def _nearest(lines: list, point: tuple[float, float]) -> float: + return min(math.hypot(p[0] - point[0], p[1] - point[1]) for line in lines for p in line) + + +def test_측점_폭은_횡단_값_그대로(pieces) -> None: + left_edge, right_edge = pieces["edge"] + ends = pieces["fill"] + pieces["cut"] + for record in _records(): + x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] + for key, lines in (("left_edge_m", [left_edge]), ("right_edge_m", [right_edge]), + ("right_end_m", ends)): # fmt: skip + value = record[key] + assert _nearest(lines, (x + nx * value, y + ny * value)) < 1e-3, (record, key) + if not 50.0 <= record["chainage_m"] <= 50.0 + RADIUS * math.pi / 2: # 접히지 않는 자리 + value = record["left_end_m"] + assert _nearest(ends, (x + nx * value, y + ny * value)) < 1e-3, record + + +def _turns(line: list) -> list[float]: + turns = [] + for a, b, c in zip(line, line[1:], line[2:]): + u = (b[0] - a[0], b[1] - a[1]) + v = (c[0] - b[0], c[1] - b[1]) + turns.append( + abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + ) + return turns + + +def _right_runs(pieces: dict) -> list: + """우측 비탈 끝 조각 — 좌측은 늘 깎기라 깎기 첫 조각이 좌측 한 줄.""" + return pieces["fill"] + pieces["cut"][1:] + + +def test_꺾임_각_상한(pieces) -> None: + # 도로 끝 · 곡선 바깥(우) 비탈 끝 — 원호 5° 편 위에서도 톱니 없이 고름 + for line in [*pieces["edge"], *_right_runs(pieces)]: + assert max(_turns(line), default=0.0) < MAX_TURN_DEG + + +def _crosses(p1, p2, p3, p4) -> bool: + def side(a, b, c) -> float: + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + + return side(p1, p2, p3) * side(p1, p2, p4) < 0 and side(p3, p4, p1) * side(p3, p4, p2) < 0 + + +def test_안쪽_접힘은_버림(pieces) -> None: + left_end = pieces["cut"][0] + segments = list(zip(left_end, left_end[1:])) + for i, (a, b) in enumerate(segments): # 스스로 꼬이지 않음 + for c, d in segments[i + 2 :]: + assert not _crosses(a, b, c, d), (a, b, c, d) + + +def test_깎기_쌓기_바뀜_자리는_도로_끝(pieces) -> None: + right_edge = pieces["edge"][1] + runs = _right_runs(pieces) + assert len(runs) >= 4 + ends = sorted((p for line in runs for p in (line[0], line[-1])), key=lambda p: p[0] + p[1]) + for point in ends[1:-1]: # 시점 · 종점 빼고 조각 끝 = 바뀜 자리 + assert _nearest([right_edge], point) < 0.05, point + + +def test_단추_이름은_절성토선() -> None: + chrome = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8") + at = chrome.index('data-act="footprint"') + assert ">절/성토선" in chrome[at : at + 200] and ">발자국<" not in chrome From 0d1d54242106c83661d9f6bfd9ad5d30398fa9e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:42:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(3D):=20353-3=203D=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=EC=A3=BC=EA=B3=A1=EC=84=A0=205=20m=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=C2=B7=201=20m=20=EC=98=85=EA=B2=8C=20=E2=80=94=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EC=83=89=20=ED=86=A0=ED=81=B0=20=C2=B7=20?= =?UTF-8?q?B05=20=C2=B7=20B04=20=EA=B0=99=EC=9D=80=20=EC=9E=AC=EC=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 공용 재질 ui_template_contour3d(isMajorContour · contourMaterials) — 주곡선 = 5 m 배수(간격 무관) - 색 = --map-contour-major(진하게 0.95) · --map-contour-minor(토큰 알파 0.45 · 깊이 안 씀) - B05 3D 뷰어 · B04 3D 지형 뷰어 둘 다 옛 주황 두 색 · 간격 × 5 갈래 걷음 · B04 높이 글도 5 m 선에만 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- ...04_PreProcess_UI_TerrainViewer_Contours.ts | 18 +++---- B05_Profile/B05_Profile_UI_Viewer.ts | 16 +++--- .../tester/test_353_3_contour3d_style.py | 30 ++++++++++++ ui_template/ui_template_contour3d.ts | 49 +++++++++++++++++++ 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_353_3_contour3d_style.py create mode 100644 ui_template/ui_template_contour3d.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts index febb8b805..32c90eea2 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -10,6 +10,7 @@ import * as THREE from "three"; import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ const MAX_CONTOUR_LABELS = 40; @@ -93,7 +94,7 @@ export async function loadContourLinesInto( const points = transform(c.coordinates); if (points.length < 2) return; - const isMajor = c.level % (interval * 5) === 0; + const isMajor = isMajorContour(c.level); const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); @@ -156,18 +157,15 @@ export async function loadContourLinesInto( ctx.setLabelsDirty(true); } - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다 — 주곡선 = 5 m 고정 · 1 m 는 + // 옅게(평면 그림과 같은 색 토큰 · 353-3). + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); }); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 619242cc5..00eb84fa7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { createTerrainCompass } from "@ui/ui_template_compass"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; @@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer { ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), ); if (points.length < 2) return; - const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints; for (let index = 0; index < points.length - 1; index += 1) { bucket.push(points[index], points[index + 1]); } }); + // 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3) + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; contours.add( - new THREE.LineSegments( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), - ), + new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material), ); }); } diff --git a/resources/tester/test_353_3_contour3d_style.py b/resources/tester/test_353_3_contour3d_style.py new file mode 100644 index 000000000..6164ac994 --- /dev/null +++ b/resources/tester/test_353_3_contour3d_style.py @@ -0,0 +1,30 @@ +"""353-3 3D 등고선 — 주곡선 5 m 고정 · 1 m 옅게 · 평면 색 토큰(B05 · B04 3D 같은 재질). + +./venv/Scripts/python.exe -m pytest resources/tester/test_353_3_contour3d_style.py -q +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STYLE = ROOT / "ui_template" / "ui_template_contour3d.ts" +VIEWERS = [ + ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts", + ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer_Contours.ts", +] + + +def test_재질은_평면_색_토큰과_5m_고정() -> None: + src = STYLE.read_text("utf-8") + assert '"--map-contour-major"' in src and '"--map-contour-minor"' in src + assert "CONTOUR_MAJOR_STEP_M = 5;" in src + # 1 m 선 불투명 = 토큰 알파(0.45) · 주곡선은 진하게 + assert "opacity: minor.alpha" in src and "opacity: 0.95 * major.alpha" in src + + +def test_두_3D_뷰어가_같은_재질을_씀() -> None: + for path in VIEWERS: + src = path.read_text("utf-8") + assert 'from "@ui/ui_template_contour3d"' in src, path.name + assert "isMajorContour(" in src and "contourMaterials()" in src, path.name + # 옛 갈래(간격 × 5 · 주황 두 색) 없음 + assert "interval * 5" not in src and "0xf59e0b" not in src, path.name diff --git a/ui_template/ui_template_contour3d.ts b/ui_template/ui_template_contour3d.ts new file mode 100644 index 000000000..d8411d452 --- /dev/null +++ b/ui_template/ui_template_contour3d.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * ui_template_contour3d.ts + * 3D 등고선 선 재질 — 평면 그림(노선 편집)과 같은 갈래 · 같은 색 토큰(353-3). + * + * 주곡선 = 5 m 배수 고정(간격 무관 · 평면 `B05_Profile_UI_RouteEdit_Render` 와 같음) · + * 색 `--map-contour-major` 진하게 · 나머지(1 m) = `--map-contour-minor` 옅게 · 낮은 불투명. + * B05 3D(`B05_Profile_UI_Viewer`) · B04 3D(`B04_PreProcess_UI_TerrainViewer_Contours`) 가 함께 씀. + * ========================================================================== */ + +import * as THREE from "three"; +import { themeColor } from "./ui_template_palette"; + +/** 주곡선 간격(m) — 평면 그림과 같은 5 m. */ +export const CONTOUR_MAJOR_STEP_M = 5; + +/** 5 m 배수 높이면 주곡선. */ +export function isMajorContour(level: number): boolean { + return Math.abs(level - Math.round(level / CONTOUR_MAJOR_STEP_M) * CONTOUR_MAJOR_STEP_M) < 1e-6; +} + +/** CSS 색(#rrggbb · rgba(...)) → 색 + 불투명(알파 없으면 1). */ +function parseColor(value: string): { color: THREE.Color; alpha: number } { + const rgba = value.match(/rgba?\(([^)]+)\)/); + if (!rgba) return { color: new THREE.Color(value), alpha: 1 }; + const [r, g, b, a] = rgba[1].split(",").map((part) => Number(part.trim())); + return { color: new THREE.Color(r / 255, g / 255, b / 255), alpha: Number.isFinite(a) ? a : 1 }; +} + +/** 주곡선 · 1 m 선 재질 — 1 m 는 주곡선보다 옅게. */ +export function contourMaterials(): { + major: THREE.LineBasicMaterial; + minor: THREE.LineBasicMaterial; +} { + const major = parseColor(themeColor("--map-contour-major", "#818cf8")); + const minor = parseColor(themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)")); + return { + major: new THREE.LineBasicMaterial({ + color: major.color, + transparent: true, + opacity: 0.95 * major.alpha, + }), + minor: new THREE.LineBasicMaterial({ + color: minor.color, + transparent: true, + opacity: minor.alpha, + depthWrite: false, + }), + }; +} From fb94f3d6866e97fc0bf100fddd3ed08e0026e9f0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 20:50:13 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(=ED=8F=89=EB=A9=B4):=20353-2=20?= =?UTF-8?q?=EB=93=B1=EA=B3=A0=EC=84=A0=20=ED=95=98=EC=9D=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=9E=90=EB=AC=BC=EC=87=A0=20=E2=80=94=20?= =?UTF-8?q?=ED=98=B8=EB=B2=84=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=88=84=EB=A5=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EB=86=92=EC=9D=B4=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EB=88=84=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=ED=92=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등고선 위를 지나거나 고른 직후 커서 옆에 자물쇠 단추(캔버스 위 DOM · aria-pressed · 호버 판정 = 집기와 같은 hitPreparedLayer) - 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것 그대로 · 잠긴 높이 위에서만 눌린 단추가 떠 풂 - 높이값으로 쥠 — 분석 지형을 바꿔도 같은 높이 가닥을 다시 짚음 · 페이지 동안만(저장 안 함) - 새 파일 logic/B05_Profile_ContourLock(규칙) · UI_RouteEdit_ContourLock(단추) · 모양 css · RouteEdit.ts 는 고르기 · 지형 바꿈 두 줄 + 세우기만(700줄) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 11 +- .../B05_Profile_UI_RouteEdit_ContourLock.ts | 151 ++++++++++++++++++ ...Profile_UI_Style_RouteEdit_ContourLock.css | 28 ++++ B05_Profile/logic/B05_Profile_ContourLock.ts | 39 +++++ resources/tester/test_353_2_contour_lock.py | 81 ++++++++++ 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts create mode 100644 B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css create mode 100644 B05_Profile/logic/B05_Profile_ContourLock.ts create mode 100644 resources/tester/test_353_2_contour_lock.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..50fb2d7fc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ import { loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; +import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { analysisTerrain, @@ -189,6 +190,12 @@ export async function mountRouteEditor( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ + const contourLock = createContourLock( + canvas, + () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + (index) => ((pickedContour = index), draw()), + ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, @@ -521,7 +528,7 @@ export async function mountRouteEditor( picked = index; }, setPickedContour: (index) => { - pickedContour = index; + if (contourLock.canPick()) pickedContour = index; }, toScreen, toMetric, @@ -652,7 +659,7 @@ export async function mountRouteEditor( const useBackground = (background: Awaited>): void => { otherSheets = background.otherSheets; contours = background.contours; - pickedContour = -1; + pickedContour = contourLock.reattach(contours?.layer ?? null); // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. minorContourButton.disabled = !contours?.minor; minorContourButton.title = contours?.minor diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts new file mode 100644 index 000000000..e8e52b173 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_ContourLock.ts + * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). + * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { + hitPreparedLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + canPickContour, + featureOfLevel, + lockButtonState, + toggleLock, +} from "./logic/B05_Profile_ContourLock"; +import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; + +/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ +const HOVER_HIT_PX = 6; +/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIDE_DELAY_MS = 700; +/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ +const OFFSET_PX = 12; + +const LOCK_SVG = ``; + +/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +export interface ContourLockScene { + layer: PreparedLayer; + view: ViewState; + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + stepM: number; + rotation: { unrotate: (px: number, py: number) => [number, number] }; +} + +export interface ContourLock { + /** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */ + canPick: () => boolean; + /** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */ + reattach: (layer: PreparedLayer | null) => number; +} + +/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */ +export function createContourLock( + canvas: HTMLCanvasElement, + scene: () => ContourLockScene | null, + pick: (index: number) => void, +): ContourLock { + const wrap = canvas.parentElement ?? document.body; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--glass b05-contourlock"; + button.dataset.act = "contour-lock"; + button.hidden = true; + button.innerHTML = LOCK_SVG; + wrap.append(button); + + let lockedLevel: number | null = null; + /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ + let target = -1; + let hideTimer = 0; + let frame = 0; + let last: PointerEvent | null = null; + + const levelOf = (index: number): number | null => + index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; + + const paint = (): void => { + const pressed = lockedLevel !== null; + button.setAttribute("aria-pressed", String(pressed)); + const label = pressed + ? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음` + : `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`; + button.setAttribute("aria-label", label); + button.title = label; + }; + + const hideLater = (): void => { + if (hideTimer) return; + hideTimer = window.setTimeout(() => { + hideTimer = 0; + button.hidden = true; + }, HIDE_DELAY_MS); + }; + const keep = (): void => { + window.clearTimeout(hideTimer); + hideTimer = 0; + }; + + const hover = (event: PointerEvent): void => { + const now = scene(); + // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 + if (!now || event.buttons !== 0) return hideLater(); + const rect = canvas.getBoundingClientRect(); + const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); + const hitPx = pickPx(HOVER_HIT_PX); + const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); + const state = lockButtonState(levelOf(index), lockedLevel); + if (!state.visible) return hideLater(); + keep(); + target = index; + const origin = wrap.getBoundingClientRect(); + button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; + button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`; + button.hidden = false; + paint(); + }; + + // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. + canvas.addEventListener("pointermove", (event) => { + last = event; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (last) hover(last); + }); + }); + // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. + canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerenter", keep); + button.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + button.addEventListener("click", (event) => { + event.stopPropagation(); + lockedLevel = toggleLock(lockedLevel, levelOf(target)); + if (lockedLevel !== null) pick(target); + paint(); + }); + + return { + canPick: () => canPickContour(lockedLevel), + reattach: (layer) => { + target = -1; + button.hidden = true; + return featureOfLevel( + layer?.features.map((feature) => feature.labelValue) ?? [], + lockedLevel, + ); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css new file mode 100644 index 000000000..6a2f493b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css @@ -0,0 +1,28 @@ +/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */ +.b05-contourlock { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; +} + +.b05-contourlock[hidden] { + display: none; +} + +/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */ +.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle { + transform: translate(2.5px, -1px) rotate(18deg); + transform-origin: 11px 7px; +} + +.b05-contourlock[aria-pressed="true"] { + border-color: var(--map-flow-arrow); + background-color: var(--map-flow-arrow); + color: #fff; +} diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts new file mode 100644 index 000000000..85fffd545 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_ContourLock.ts + * 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙. + * + * 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함). + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`. + * ========================================================================== */ + +/** 가닥마다 높이(모르면 null). */ +export type ContourLevels = ReadonlyArray; + +/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */ +export interface LockButtonState { + visible: boolean; + pressed: boolean; +} + +/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */ +export function featureOfLevel(levels: ContourLevels, level: number | null): number { + if (level === null) return -1; + return levels.findIndex((value) => value === level); +} + +/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +export function lockButtonState( + hoverLevel: number | null, + lockedLevel: number | null, +): LockButtonState { + if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; + return { visible: hoverLevel !== null, pressed: false }; +} + +/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ +export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null { + return lockedLevel !== null ? null : targetLevel; +} + +/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */ +export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null; diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py new file mode 100644 index 000000000..9dc0a4c58 --- /dev/null +++ b/resources/tester/test_353_2_contour_lock.py @@ -0,0 +1,81 @@ +"""353-2 평면 등고선 자물쇠 — 규칙(`logic/B05_Profile_ContourLock.ts`)을 Node 로 돌려 판정 + +편집기 이음(고르기 막음 · 지형 바꿔도 같은 높이 · 호버 = 집기와 같은 hitPreparedLayer). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_2_contour_lock.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +LOGIC = B05 / "logic" / "B05_Profile_ContourLock.ts" +TS = ROOT / "config" / "node_modules" / "typescript" + +DRIVER = r""" +const fs = require("fs"); +const ts = require(process.argv[1]); +const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const m = { exports: {} }; +new Function("exports", "module", js)(m.exports, m); +const L = m.exports; +const levels = [101, 102, 105, 102, null]; +console.log(JSON.stringify({ + hoverFree: L.lockButtonState(102, null), + hoverNone: L.lockButtonState(null, null), + hoverLocked: L.lockButtonState(102, 102), + hoverOther: L.lockButtonState(105, 102), + lock: L.toggleLock(null, 102), + unlock: L.toggleLock(102, 105), + pickFree: L.canPickContour(null), + pickLocked: L.canPickContour(102), + again: L.featureOfLevel(levels, 102), + gone: L.featureOfLevel(levels, 110), + none: L.featureOfLevel(levels, null), +})); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(TS), str(LOGIC)], capture_output=True, text=True, timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None: + assert got["hoverFree"] == {"visible": True, "pressed": False} + assert got["hoverNone"]["visible"] is False + + +def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None: + assert got["hoverLocked"] == {"visible": True, "pressed": True} + assert got["hoverOther"]["visible"] is False + + +def test_누르면_잠금_다시_누르면_풂(got) -> None: + assert got["lock"] == 102 and got["unlock"] is None + + +def test_잠긴_동안_고르기_막음(got) -> None: + assert got["pickFree"] is True and got["pickLocked"] is False + + +def test_지형_바꿔도_같은_높이를_다시_짚음(got) -> None: + assert got["again"] == 1 and got["gone"] == -1 and got["none"] == -1 + + +def test_편집기_이음() -> None: + edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "if (contourLock.canPick()) pickedContour = index;" in edit + assert "pickedContour = contourLock.reattach(contours?.layer ?? null);" in edit + lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8") + assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock + assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만