diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 2c4204455..b53a16ec9 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -8,13 +8,15 @@ * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. * 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 · - * 노폭 안쪽 금지 = 0 에서 멈춤). + * 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 · + * 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고). * ========================================================================== */ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import { clipSegment, type XY } from "../A00_Common/b_polygon_clip"; import { sideRole } from "@util/common_util_cross_design_geometry"; import { + arrowsBelongToProfile, bindArrowKeys, buildPanelShell, dpadButton, @@ -100,6 +102,11 @@ export function crossZoneAt( export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure); +/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) · + * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ +export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" => + pieceZone(design, piece).zone === "road" ? "y" : "x"; + const gap = (a: CrossPoint, b: CrossPoint): number => Math.hypot(a.offset - b.offset, a.elevation - b.elevation); @@ -330,6 +337,7 @@ export function attachDesignLinePick(input: { label.className = "b06-cross-card__line-dims"; label.hidden = true; let side: CrossSide | null = null; + let axis: "x" | "y" = "x"; let active: number | null = null; const { shift } = input; @@ -364,6 +372,20 @@ export function attachDesignLinePick(input: { ); shell.root.append(readout, move.row); const arrows = bindArrowKeys(shell.root, () => [move.controls]); + // 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감. + // 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김. + const blockOffAxis = (event: KeyboardEvent): void => { + if (!shell.root.isConnected) { + document.removeEventListener("keydown", blockOffAxis); + return; + } + if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return; + const focus = event.target as HTMLElement | null; + if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; + const vertical = event.key === "ArrowUp" || event.key === "ArrowDown"; + const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; + if (axis === "x" ? vertical : horizontal) event.preventDefault(); + }; return { pieces, @@ -375,16 +397,25 @@ export function attachDesignLinePick(input: { label.hidden = !piece; active = piece ? index : null; side = piece ? pieceZone(design, piece).side : null; - const extend = side && shift ? shift.valueOf(side) : 0; + axis = piece ? pieceMoveAxis(design, piece) : "x"; + const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; label.textContent = piece ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") : ""; const open = !!(piece && shift); shell.root.classList.toggle("is-hidden", !open); - shell.title.textContent = side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)"; - readout.textContent = `이동 ${extend.toFixed(1)}m`; + move.row.classList.toggle("is-hidden", axis === "y"); + move.controls.classList.toggle("is-hidden", axis === "y"); + shell.title.textContent = + axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)"; + readout.textContent = + axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`; arrows.detach(); - if (open) arrows.attach(); + document.removeEventListener("keydown", blockOffAxis); + if (open) { + arrows.attach(); + document.addEventListener("keydown", blockOffAxis); + } }, }; } diff --git a/resources/tester/helper_193_1_line_axis.cjs b/resources/tester/helper_193_1_line_axis.cjs new file mode 100644 index 000000000..e2e9d71eb --- /dev/null +++ b/resources/tester/helper_193_1_line_axis.cjs @@ -0,0 +1,264 @@ +/* 139-6 고른 설계선 사면 이동 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 + * 트랜스파일해 흉내 DOM 으로 돌림 · 151-3 이동 패널(◀ ↺ ▶ · 방향키 ←→). 짝 = `test_139_6_line_shift.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(); + +class Node { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.attrs = {}; + this.listeners = {}; + this.hidden = false; + this.isConnected = true; + this._text = ""; + this._cls = new Set(); + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._cls.add(c)), + toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)), + contains: (c) => self._cls.has(c), + }; + } + get descendants() { + return this.children.flatMap((c) => [c, ...c.descendants]); + } + /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ + querySelector(sel) { + return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; + } + getClientRects() { + return this._cls.has("is-hidden") ? [] : [{}]; + } + set className(v) { + this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + this.children.push(...kids); + } + insertBefore(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this.attrs[n] = String(v); + if (n === "class") this.className = v; + } + getAttribute(n) { + return this.attrs[n] ?? null; + } + hasAttribute(n) { + return n in this.attrs; + } + addEventListener(t, fn) { + (this.listeners[t] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); + } +} +const docListeners = {}; +globalThis.document = { + createElement: (t) => new Node(t), + createElementNS: (_n, t) => new Node(t), + addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); + }, +}; +const key = (name) => + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => undefined }), + ); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const req = (r) => + r.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)) + : loadTs(path.join(path.dirname(full), `${r}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} +globalThis.Element = Node; + +const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")); +const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽 +const y = (e) => 900 - (e - 100) * 12; +const line = (pts, structure = false) => { + const el = new Node("polyline"); + el.setAttribute("class", "b06-chart__design-cross"); + el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" ")); + if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, ""); + return el; +}; +/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */ +const press = (name) => { + let prevented = false; + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => (prevented = true) }), + ); + return prevented; +}; +const pointer = (inProfile) => + [...(docListeners.pointerdown ?? [])].forEach((fn) => + fn({ target: Object.assign(new Node("g"), { closest: () => (inProfile ? {} : null) }) }), + ); + +/** 단면 하나 — 좌 사면 · 노면 · 우 사면(+ 좌 구조물 트림 선). */ +function card(sectionMode, ditch) { + for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = []; + const design = { + road_edges: { + left: { offset_m: 2, elevation_m: 100 }, + right: { offset_m: -2, elevation_m: 100 }, + }, + ditch: ditch ? { type: "standard", top_width_m: 1 } : null, + ditch_side: "left", + ditch_enabled: !!ditch, + section_mode: sectionMode, + }; + const layer = new Node("g"); + layer.append( + line([ + [6, sectionMode === "left_cut" ? 103 : 97], + [2, 100], + ]), + line([ + [2, 100], + [-2, 100], + ]), + line([ + [-2, 100], + [-6, 97], + ]), + line( + [ + [7, 96], + [7, 94], + ], + true, + ), + ); + const values = { left: 0, right: 0 }; + const handle = pick.attachDesignLinePick({ + layer, + design, + x, + toDisplayY: y, + onPick: () => undefined, + shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) }, + }); + const at = (offset, structure = false) => + handle.pieces.findIndex( + (p) => + p.structure === structure && + Math.min(p.from.offset, p.to.offset) <= offset && + offset <= Math.max(p.from.offset, p.to.offset), + ); + const slot = (name) => handle.panel.querySelector(`.b06-structure-panel__btn--${name}`); + const title = () => + handle.panel.descendants.find((n) => n._cls.has("b06-structure-panel__title")).textContent; + const rowHidden = () => + handle.panel.descendants + .find((n) => n._cls.has("b06-structure-panel__struct")) + ._cls.has("is-hidden"); + return { design, handle, values, at, slot, title, rowHidden }; +} + +const out = {}; + +// ① 성토측(both_fill) — 좌 사면: 가로만 · 바깥만 · 중심 못 넘음 · ▲▼ 막힘 +{ + const c = card("both_fill", false); + out.axis = { + left: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]), + road: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(0)]), + right: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(-4)]), + structure: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(7, true)]), + }; + c.handle.setActive(c.at(4)); + const upBlocked = press("ArrowUp"); + const downBlocked = press("ArrowDown"); + const afterUpDown = { ...c.values }; + press("ArrowLeft"); // 화면 왼쪽 = 좌 바깥 + press("ArrowLeft"); + const out2 = { ...c.values }; + for (let i = 0; i < 30; i += 1) press("ArrowRight"); // 안쪽 끝까지 — 0 에서 멈춤 + const inward = { ...c.values }; + out.fill = { + upBlocked, + downBlocked, + afterUpDown, + out2, + inward, + title: c.title(), + rowHidden: c.rowHidden(), + }; + // 종단도를 마지막에 눌렀으면 방향키는 종단도 몫 — 막지 않음 + pointer(true); + out.fill.profileUp = press("ArrowUp"); + pointer(false); + // ② 구조물 트림 선(성토측) = 같은 쪽 노견째 · 반대측 고정 + c.handle.setActive(c.at(7, true)); + c.slot("left").click(); + out.structure = { values: { ...c.values }, title: c.title(), upBlocked: press("ArrowUp") }; + // 우 사면 = 우 바깥(화면 오른쪽)만 · 좌 값 그대로 + c.handle.setActive(c.at(-4)); + press("ArrowLeft"); // 안쪽 — 0 + press("ArrowRight"); + out.right = { values: { ...c.values }, title: c.title() }; + // ④ 도로측 — 높이만: ▲▼ 는 종단도로 넘김(안 막음) · ◀▶ 막힘 · 값 그대로 · 이동 줄 숨김 + const before = { ...c.values }; + c.handle.setActive(c.at(0)); + out.road = { + upPassed: !press("ArrowUp"), + downPassed: !press("ArrowDown"), + leftBlocked: press("ArrowLeft"), + rightBlocked: press("ArrowRight"), + same: JSON.stringify(before) === JSON.stringify(c.values), + title: c.title(), + rowHidden: c.rowHidden(), + label: c.handle.label.textContent, + }; + // 고름 풀면 아무 키도 안 막음 + c.handle.setActive(null); + out.released = [press("ArrowUp"), press("ArrowLeft")]; +} + +// ③ 절토측(left_cut · 좌 = 절토 + 측구) — 같은 규칙 반대로 +{ + const c = card("left_cut", true); + out.cutZone = pick.pieceZone(c.design, c.handle.pieces[c.at(4)]).zone; + c.handle.setActive(c.at(4)); + const upBlocked = press("ArrowUp"); + c.slot("left").click(); // 좌 바깥 + const outward = { ...c.values }; + c.slot("right").click(); + c.slot("right").click(); // 안쪽 — 0 에서 멈춤 + out.cut = { + upBlocked, + outward, + inward: { ...c.values }, + axis: pick.pieceMoveAxis(c.design, c.handle.pieces[c.at(4)]), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_193_1_line_axis.py b/resources/tester/test_193_1_line_axis.py new file mode 100644 index 000000000..ba53cb1fa --- /dev/null +++ b/resources/tester/test_193_1_line_axis.py @@ -0,0 +1,111 @@ +"""193-1 B06 횡단 보라 계획선 이동 제약 — 갈래(도로 · 절토 · 성토)마다 축 · 방향. + +화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_193_1_line_axis.cjs`) + +서버 재계산(노견 확장 = 그 쪽만 · 반대측 고정). +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_193_1_line_axis.cjs" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 + +node_only = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +@node_only +def test_갈래_판정(out: dict) -> None: + assert out["axis"] == {"left": "x", "road": "y", "right": "x", "structure": "x"} + assert out["cutZone"] == "cut" and out["cut"]["axis"] == "x" + + +@node_only +def test_성토측_바깥_가로만_중심_못_넘음(out: dict) -> None: + fill = out["fill"] + assert fill["upBlocked"] and fill["downBlocked"] # ▲▼ 가 종단 계획고로 새지 않음 + assert fill["afterUpDown"] == {"left": 0, "right": 0} + assert fill["out2"] == {"left": 0.2, "right": 0} # ← ← = 좌 바깥 0.2 · 우 그대로 + assert fill["inward"] == {"left": 0, "right": 0} # 안쪽 30 걸음 = 0 에서 멈춤 + assert fill["title"] == "사면 이동 (좌)" and fill["rowHidden"] is False + assert fill["profileUp"] is False # 종단도를 눌렀으면 방향키는 종단도 몫 + assert out["right"]["values"] == {"left": 0.1, "right": 0.1} # 우 사면 = 우 바깥만 + + +@node_only +def test_구조물_선_반대측_고정_한_벌(out: dict) -> None: + """성토측 구조물 트림 선 = 그 쪽 노견째(벽은 노견 끝에 섬) · 반대측 0 그대로.""" + assert out["structure"]["values"] == {"left": 0.1, "right": 0} + assert out["structure"]["title"] == "사면 이동 (좌)" and out["structure"]["upBlocked"] + + +@node_only +def test_절토측_같은_규칙(out: dict) -> None: + cut = out["cut"] + assert cut["upBlocked"] + assert cut["outward"] == {"left": 0.1, "right": 0} + assert cut["inward"] == {"left": 0, "right": 0} + + +@node_only +def test_도로측_높이만_종단_함께(out: dict) -> None: + road = out["road"] + assert road["upPassed"] and road["downPassed"] # ▲▼ = 종단도 계획고(지금 길 그대로) + assert road["leftBlocked"] and road["rightBlocked"] and road["same"] + assert road["title"] == "노면 높이" and road["rowHidden"] is True + assert "이동" not in road["label"] + assert out["released"] == [False, False] # 고름 풀면 막지 않음 + + +def test_종단도는_막힌_방향키를_건너뜀() -> None: + src = (ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel_Tools.ts").read_text( + encoding="utf-8" + ) + assert "if (event.defaultPrevented) return;" in src + assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src + + +def _ground(level: float) -> list[dict]: + return [{"offset_m": o / 2, "elevation_m": level, "valid": True} for o in range(-30, 31)] + + +def test_서버_한쪽_이동_반대측_고정_바깥으로() -> None: + samples = _ground(97.0) + base = compute_cross_design(samples, 100.0, ground_type="soil", section_mode="both_fill") + moved = compute_cross_design( + samples, 100.0, ground_type="soil", section_mode="both_fill", shoulder_extend_left_m=0.7 + ) + right = lambda d: [p for p in d["design_line"] if p["offset_m"] < 0] # noqa: E731 + assert right(moved) == right(base) # 반대측 고정 + assert moved["road_edges"]["right"] == base["road_edges"]["right"] + assert moved["road_edges"]["left"]["offset_m"] == pytest.approx( + base["road_edges"]["left"]["offset_m"] + 0.7 + ) + toe = lambda d: max(p["offset_m"] for p in d["design_line"] if p["elevation_m"] > 97.0) # noqa: E731 + assert toe(moved) > toe(base) # 사면째 바깥으로 + + +def test_구조물은_노견_끝에_섬() -> None: + """관 기슭막이 자리 = 설계 노견 끝(`road_edges` · 노견 확장 포함) — 사면과 한 벌로 감.""" + src = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( + encoding="utf-8" + ) + assert "const edges = section.design?.road_edges;" in src