From 192fa6cf7cfebaf393a1c4d891bbd182c68b0ccf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:32:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=B0=BD):=20=EA=B3=A1=EC=84=A0=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EA=B5=90=EA=B0=81=EC=A0=90(=EC=82=BC=EA=B0=81)?= =?UTF-8?q?=EB=8F=84=20=ED=95=98=EB=8B=A8=EC=97=90=20=E3=80=8C=EB=82=B4?= =?UTF-8?q?=EA=B0=81=20N=C2=B0=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=20=ED=91=9C=EA=B8=B0=20(403-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 24 +++++----- .../tester/helper_403_triangle_angle.cjs | 34 ++++++++++++++ resources/tester/test_403_triangle_angle.py | 47 +++++++++++++++++++ 3 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 resources/tester/helper_403_triangle_angle.cjs create mode 100644 resources/tester/test_403_triangle_angle.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index de2c38274..f292f93cd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -54,6 +54,16 @@ export function centerDirectionOf( return length <= 1e-9 ? null : [sx / length, sy / length]; } +/** 패널 하단 글 — **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) · 고정 여부는 단추 색, 하한은 + * 칸이 막음. 155° 이상은 L 하한을 안 거는 까닭이 보이게. 곡선 없는 교각점(삼각)도 내각(403-1). */ +export function curveInfoText(curveOn: boolean, inner: number | null, optional: boolean): string { + const angle = inner ? `내각 ${Math.round(inner)}°` : ""; + if (!curveOn) return [angle, "곡선 없음 — 직선이 그대로 꺾입니다"].filter(Boolean).join(" · "); + return angle && optional + ? `${angle} · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` + : angle; +} + /** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ const GAP_PX = 40; /** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ @@ -342,19 +352,7 @@ export function createCurveLabel( state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); arc.value = state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); - const inner = state.innerAngleDeg; - // 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로, - // 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다. - // 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이 - // 여기서 보여야 한다(2026-09-12 사용자 확정). - info.textContent = state.curveOn - ? inner - ? `내각 ${Math.round(inner)}°` + - (state.curveOptional - ? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` - : "") - : "" - : "곡선 없음 — 직선이 그대로 꺾입니다"; + info.textContent = curveInfoText(state.curveOn, state.innerAngleDeg, state.curveOptional); place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. requestAnimationFrame(() => { diff --git a/resources/tester/helper_403_triangle_angle.cjs b/resources/tester/helper_403_triangle_angle.cjs new file mode 100644 index 000000000..72e54682e --- /dev/null +++ b/resources/tester/helper_403_triangle_angle.cjs @@ -0,0 +1,34 @@ +/* 403 곡선 창 하단 글 헬퍼 — `_Label.curveInfoText` 를 트랜스파일해 경우별 글을 JSON 으로 낸다. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +const STUBS = { + "@ui/ui_template_elements": { createButton: () => null }, + "./B05_Profile_UI_Panel_Fields": { numberField: () => null, toggleButton: () => null }, +}; +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const { curveInfoText } = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Label.ts")); +process.stdout.write( + JSON.stringify({ + curve: curveInfoText(true, 132.4, false), + curveOptional: curveInfoText(true, 160.2, true), + triangle: curveInfoText(false, 132.6, false), + triangleOptional: curveInfoText(false, 168.4, true), + triangleUnknown: curveInfoText(false, null, false), + }), +); diff --git a/resources/tester/test_403_triangle_angle.py b/resources/tester/test_403_triangle_angle.py new file mode 100644 index 000000000..21bcd1003 --- /dev/null +++ b/resources/tester/test_403_triangle_angle.py @@ -0,0 +1,47 @@ +"""403 곡선 없는 교각점(삼각) 각도 글(요구 250-5) — 곡선 창 하단에 곡선 있는 자리와 같은 「내각 N°」. + +Node 헬퍼(`helper_403_triangle_angle.cjs`)가 `_Label.curveInfoText` 를 트랜스파일해 돌림. +./venv/Scripts/python.exe -m pytest resources/tester/test_403_triangle_angle.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def texts(): + proc = subprocess.run( + ["node", str(HERE / "helper_403_triangle_angle.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_곡선_있는_자리_그대로(texts): + assert texts["curve"] == "내각 132°" + assert texts["curveOptional"] == "내각 160° · 155° 이상 — 곡선 생략 가능" + + +def test_삼각_자리도_내각(texts): + assert texts["triangle"] == "내각 133° · 곡선 없음 — 직선이 그대로 꺾입니다" + assert texts["triangleOptional"].startswith("내각 168° · ") + + +def test_내각_모르면_곡선_없음만(texts): + assert texts["triangleUnknown"] == "곡선 없음 — 직선이 그대로 꺾입니다" + + +def test_화면이_같은_함수를_씀(): + label = (HERE.parents[1] / "B05_Profile" / "B05_Profile_UI_RouteEdit_Label.ts").read_text( + encoding="utf-8" + ) + assert "info.textContent = curveInfoText(state.curveOn, state.innerAngleDeg" in label From 2360e6083450705ed2666e6a8b93bfe9118bfbf9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:33:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=8C=AC):=20=EA=B0=80=EC=9A=B4=EB=8D=B0=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=ED=8C=AC=20=ED=8A=90=20=E2=80=94=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=EB=A7=88=EB=8B=A4=20=EC=A7=80=EA=B8=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EB=8D=94=ED=95=A8=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=ED=8F=AC=EC=9D=B8=ED=84=B0=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EC=B6=94=20=EB=86=93=EC=B9=A8=20=C2=B7=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=9E=83=EC=9D=8C=EC=97=90=20=ED=8C=AC=20?= =?UTF-8?q?=EB=81=9D=20(401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 까닭: 누를 때 쥔 offset 에 「처음부터 민 만큼」을 덮어써 끄는 중 휠 확대 · Ctrl+휠 회전 · 둘째 손가락이 바꾼 화면을 옛 값이 덮음(옛 코드 휠 섞음 27px · 둘째 손가락 224px · 캡처 잃음 90px 튐 → 0). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 30 ++- resources/tester/helper_401_routeedit_pan.cjs | 224 ++++++++++++++++++ resources/tester/test_401_routeedit_pan.py | 61 +++++ 3 files changed, 304 insertions(+), 11 deletions(-) create mode 100644 resources/tester/helper_401_routeedit_pan.cjs create mode 100644 resources/tester/test_401_routeedit_pan.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index aa004dab2..2cd6d527a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -47,7 +47,9 @@ export interface RouteEditNavigationParams { export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { const { canvas, getView, setView, getMeta, draw } = params; const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]); - let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 팬 중인 포인터 — 직전 자리만 쥔다(401). 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮으면 + * 끄는 중 휠 확대 · Ctrl+휠 회전이 바꾼 화면을 옛 값이 덮어 지도가 튄다 → 걸음마다 지금 화면에 더함. */ + let drag: { id: number; x: number; y: number; mask: number } | null = null; canvas.addEventListener( "wheel", @@ -92,31 +94,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void !(params.leftPan && event.button === 0) ) return; - const view = getView(); - dragStart = { + if (drag) return; // 둘째 손가락은 팬을 넘겨받지 않음(벌림 확대 몫) + // 쥔 단추(buttons 비트) — 가운데 4 · 왼쪽 1. 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔. + drag = { + id: event.pointerId, x: event.clientX, y: event.clientY, - offsetX: view.offsetX, - offsetY: view.offsetY, + mask: event.button === 1 ? 4 : 1, }; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", (event) => { - if (!dragStart) return; - const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y); - setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy }); + if (!drag || event.pointerId !== drag.id) return; + if (event.pointerType === "mouse" && !(event.buttons & drag.mask)) return stop(); + const [dx, dy] = unrotateDelta(event.clientX - drag.x, event.clientY - drag.y); + drag.x = event.clientX; + drag.y = event.clientY; + const view = getView(); + setView({ ...view, offsetX: view.offsetX + dx, offsetY: view.offsetY + dy }); draw(); }); - const stop = (): void => { - if (!dragStart) return; - dragStart = null; + const stop = (event?: PointerEvent): void => { + if (!drag || (event && event.pointerId !== drag.id)) return; + drag = null; canvas.style.removeProperty("cursor"); }; canvas.addEventListener("pointerup", stop); canvas.addEventListener("pointercancel", stop); + canvas.addEventListener("lostpointercapture", stop); } export interface NodeSpliceParams { diff --git a/resources/tester/helper_401_routeedit_pan.cjs b/resources/tester/helper_401_routeedit_pan.cjs new file mode 100644 index 000000000..115b910f4 --- /dev/null +++ b/resources/tester/helper_401_routeedit_pan.cjs @@ -0,0 +1,224 @@ +/* 계획노선 편집 창 **가운데 단추 팬 튐**(401 · 요구 250-3) 단위검증 헬퍼 — + * `_RouteEdit_Input.ts` 의 `bindRouteEditNavigation` 에 가짜 캔버스를 붙이고 포인터 · 휠 이벤트를 + * 흘려 보낸다. 회전은 진짜 `_RouteEdit_Rotate.ts` · 화면 셈은 진짜 B04 `metricToScreen`. + * 판정 = 「잡은 땅 점이 손(커서)을 따라가나」 — 보이는 자리 = rerotate(metricToScreen(...)). + */ +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(); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + }; +} +globalThis.window = { localStorage: memoryStorage(), sessionStorage: memoryStorage() }; +globalThis.localStorage = globalThis.window.localStorage; + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +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 localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts")); +const rotateMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Rotate.ts")); +const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts")); + +const RECT = { left: 10, top: 20, width: 800, height: 600 }; +const meta = { x_min: 0, y_min: 0, width_meters: 800, height_meters: 600 }; +const W = [300, 250]; // 잡을 땅 점(m) + +/** 창 하나 — 가짜 캔버스 · 진짜 회전 · 진짜 팬/휠. */ +function rig(rotationDeg = 0) { + const listeners = {}; + const canvas = { + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + getBoundingClientRect: () => RECT, + style: { cursor: "", removeProperty() {} }, + setPointerCapture() {}, + }; + let view = { + width: RECT.width, + height: RECT.height, + scale: 1, + offsetX: 0, + offsetY: 0, + mapRect: { x: 0, y: 0, width: RECT.width, height: RECT.height }, + }; + const rotation = rotateMod.createMapRotation({ + overlay: { querySelector: () => null }, + size: () => ({ width: RECT.width, height: RECT.height }), + onChange: () => {}, + }); + rotation.set((rotationDeg * Math.PI) / 180); + input.bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => (view = next), + getMeta: () => meta, + unrotateDelta: rotation.unrotateDelta, + rotateWheel: rotation.wheel, + draw: () => {}, + }); + const fire = (type, event) => + (listeners[type] ?? []).forEach((fn) => fn({ preventDefault() {}, ...event })); + /** 땅 점이 화면(client)에 보이는 자리. */ + const seen = ([x, y]) => { + const [px, py] = rotation.rerotate(...render.metricToScreen(meta, view, x, y)); + return [px + RECT.left, py + RECT.top]; + }; + return { fire, seen, view: () => view }; +} + +const mouse = (x, y, extra = {}) => ({ + pointerId: 1, + pointerType: "mouse", + clientX: x, + clientY: y, + button: 1, + buttons: 4, + ...extra, +}); +const gap = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 가운데 끌기 여러 걸음 — 걸음마다 잡은 점과 손의 거리 중 가장 큰 값. */ +function dragFollows(rotationDeg) { + const r = rig(rotationDeg); + let [x, y] = r.seen(W); + r.fire("pointerdown", mouse(x, y)); + let worst = 0; + for (const [dx, dy] of [ + [40, 0], + [0, -35], + [-120, 80], + [7, 3], + [300, -200], + ]) { + x += dx; + y += dy; + r.fire("pointermove", mouse(x, y, { button: -1 })); + worst = Math.max(worst, gap(r.seen(W), [x, y])); + } + r.fire("pointerup", mouse(x, y)); + return worst; +} + +/** 끄는 중 휠 확대(커서 자리) 뒤 다시 끌기 — 잡은 점이 손 밑에 남나. */ +function wheelMidDrag(rotationDeg) { + const r = rig(rotationDeg); + let [x, y] = r.seen(W); + r.fire("pointerdown", mouse(x, y)); + x += 50; + y += 20; + r.fire("pointermove", mouse(x, y, { button: -1 })); + r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: false }); + r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: false }); + const afterWheel = gap(r.seen(W), [x, y]); + x += 30; + y -= 60; + r.fire("pointermove", mouse(x, y, { button: -1 })); + return { afterWheel, afterMove: gap(r.seen(W), [x, y]), scale: r.view().scale }; +} + +/** 끄는 중 Ctrl+휠 회전 뒤 다시 끌기 — 땅 점이 손이 민 만큼(같은 방향 · 같은 길이) 움직이나. */ +function rotateMidDrag() { + const r = rig(15); + let [x, y] = r.seen(W); + r.fire("pointerdown", mouse(x, y)); + x += 20; + r.fire("pointermove", mouse(x, y, { button: -1 })); + r.fire("wheel", { clientX: x, clientY: y, deltaY: 100, deltaMode: 0, ctrlKey: true }); + const before = r.seen(W); + x += 60; + y += 45; + r.fire("pointermove", mouse(x, y, { button: -1 })); + const after = r.seen(W); + return gap([after[0] - before[0], after[1] - before[1]], [60, 45]); +} + +/** 창 밖에서 가운데 단추를 놓고 들어옴 — buttons 0 move 는 팬 끝 · 그 뒤 move 는 화면 그대로. */ +function releasedOutside() { + const r = rig(15); + const [x, y] = r.seen(W); + r.fire("pointerdown", mouse(x, y)); + r.fire("pointermove", mouse(x + 30, y, { button: -1 })); + const held = { ...r.view() }; + r.fire("pointermove", mouse(x + 400, y + 300, { button: -1, buttons: 0 })); + r.fire("pointermove", mouse(x + 10, y + 10, { button: -1, buttons: 0 })); + const lost = rig(0); + lost.fire("pointerdown", mouse(x, y)); + lost.fire("lostpointercapture", mouse(x, y)); + const lostBefore = { ...lost.view() }; + lost.fire("pointermove", mouse(x + 90, y, { button: -1 })); + return { + moved: Math.hypot(r.view().offsetX - held.offsetX, r.view().offsetY - held.offsetY), + lostMoved: Math.hypot( + lost.view().offsetX - lostBefore.offsetX, + lost.view().offsetY - lostBefore.offsetY, + ), + }; +} + +/** 손가락 둘 — 둘째 손가락이 팬을 넘겨받지 않음 · 첫 손가락은 계속 따라감. */ +function secondTouch() { + const r = rig(0); + let [x, y] = r.seen(W); + const touch = (id, px, py) => ({ + pointerId: id, + pointerType: "touch", + clientX: px, + clientY: py, + button: 0, + buttons: 1, + }); + r.fire("pointerdown", touch(1, x, y)); + r.fire("pointerdown", touch(2, x + 200, y + 100)); + r.fire("pointermove", touch(2, x + 260, y + 130)); + x += 25; + y += 15; + r.fire("pointermove", touch(1, x, y)); + return gap(r.seen(W), [x, y]); +} + +/** 왼쪽 단추(편집 창) — 팬 안 함. */ +function leftDoesNotPan() { + const r = rig(0); + const [x, y] = r.seen(W); + r.fire("pointerdown", mouse(x, y, { button: 0, buttons: 1 })); + r.fire("pointermove", mouse(x + 80, y + 40, { button: -1, buttons: 1 })); + return Math.hypot(r.view().offsetX, r.view().offsetY); +} + +process.stdout.write( + JSON.stringify({ + follow0: dragFollows(0), + follow15: dragFollows(15), + wheel0: wheelMidDrag(0), + wheel15: wheelMidDrag(15), + rotateMid: rotateMidDrag(), + outside: releasedOutside(), + secondTouch: secondTouch(), + leftPan: leftDoesNotPan(), + }), +); diff --git a/resources/tester/test_401_routeedit_pan.py b/resources/tester/test_401_routeedit_pan.py new file mode 100644 index 000000000..a28ac8b30 --- /dev/null +++ b/resources/tester/test_401_routeedit_pan.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — 가운데 단추 팬 튐(401 · 요구 250-3) 회귀검증. + +까닭: 팬이 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮어써, 끄는 중 휠 확대 · Ctrl+휠 회전 · +둘째 손가락이 바꾼 화면을 옛 값이 덮었다(지도가 튐). 고침 = 걸음마다 지금 화면에 더함 · 시작 +포인터만 · 단추 놓침(buttons) · 캡처 잃음에 팬 끝. + +Node 헬퍼(`helper_401_routeedit_pan.cjs`)가 진짜 `bindRouteEditNavigation` 에 가짜 캔버스를 붙여 돌린다. +판정 = 잡은 땅 점이 손(커서) 밑에 남나(px). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOL = 1e-6 + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_401_routeedit_pan.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_가운데_끌기_여러_걸음_손_따라감(results): + assert results["follow0"] < TOL + assert results["follow15"] < TOL + + +def test_끄는_중_휠_확대_뒤에도_튐_없음(results): + for key in ("wheel0", "wheel15"): + case = results[key] + assert case["scale"] < 1 # 휠이 실제로 배율을 바꿨나 + assert case["afterWheel"] < TOL + assert case["afterMove"] < TOL + + +def test_끄는_중_회전_뒤_손이_민_만큼_움직임(results): + assert results["rotateMid"] < TOL + + +def test_창_밖에서_놓고_들어오면_팬_끝(results): + assert results["outside"]["moved"] == pytest.approx(0, abs=TOL) + assert results["outside"]["lostMoved"] == pytest.approx(0, abs=TOL) + + +def test_둘째_손가락은_팬을_넘겨받지_않음(results): + assert results["secondTouch"] < TOL + + +def test_편집_창_왼쪽_단추는_팬_안_함(results): + assert results["leftPan"] == 0 From bd96e390b9542c8d89ae9b544e22d5a564d4a72d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:36:49 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(db=5Fmanagement):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20DB=20=ED=96=89=20=EB=B0=B1=EC=97=85=20?= =?UTF-8?q?=C2=B7=20=EB=90=98=EC=82=B4=EB=A6=AC=EA=B8=B0=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20+=20=EB=B3=B5=EC=A0=9C=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=EC=97=90=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=ED=98=95=EC=B8=B5=20=C2=B7=20=EC=A3=BC=EC=9D=B8=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20(402-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools_project_backup.py: projects 와 연쇄로 지워지는 표 21개 덤프(JSON · id 그대로) · 고른 프로젝트만 되살리기(한 트랜잭션) · 읽기 확인 - tools_clone_project.py: project_members · terrain_layers 복제 누락 메움 · --owner · --members 선택 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- db_management/tools_clone_project.py | 37 +++++- db_management/tools_project_backup.py | 174 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 db_management/tools_project_backup.py diff --git a/db_management/tools_clone_project.py b/db_management/tools_clone_project.py index 520a22d2d..19bcb3356 100644 --- a/db_management/tools_clone_project.py +++ b/db_management/tools_clone_project.py @@ -7,9 +7,9 @@ ⚠ 다만 **복제 중에 원본이 바뀌면 창마다 복제본이 달라진다** — 원본을 얼린 뒤 뜰 것. 무엇을 뜨나 - DB projects · input_files · processed_point_cloud · surface_models · routes · - route_points · route_statistics · longitudinal_sections · cross_sections · - project_workflow_stages + DB projects · project_members · input_files · processed_point_cloud · surface_models · + terrain_layers · routes · route_points · route_statistics · longitudinal_sections · + cross_sections · project_workflow_stages ⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다. 파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다. 치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**. @@ -19,6 +19,7 @@ 쓰는 법 ./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>" + [--owner <사용자 id>] [--members ] (없으면 원본 주인 · 참여자 그대로) """ import argparse @@ -42,6 +43,8 @@ def _args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).") parser.add_argument("source_id", help="원본 프로젝트 UUID") parser.add_argument("new_name", help="새 프로젝트 이름") + parser.add_argument("--owner", type=int, help="새 주인 사용자 id(없으면 원본 주인)") + parser.add_argument("--members", help="참여자 사용자 id 쉼표 목록(없으면 원본 참여자)") return parser.parse_args() @@ -87,6 +90,15 @@ async def main() -> None: "SELECT * FROM surface_models WHERE project_id = %s ORDER BY id", (SOURCE_ID,), ) + members = await _rows( + cursor, "SELECT * FROM project_members WHERE project_id = %s", (SOURCE_ID,) + ) + layers = await _rows( + cursor, + "SELECT t.* FROM terrain_layers t JOIN surface_models s" + " ON s.id = t.surface_model_id WHERE s.project_id = %s ORDER BY t.id", + (SOURCE_ID,), + ) routes = await _rows( cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,) ) @@ -158,8 +170,18 @@ async def main() -> None: project["id"] = new_id project["name"] = NEW_NAME project["storage_path"] = new_storage + if ARGS.owner is not None: + project["user_id"] = ARGS.owner await _insert(cursor, "projects", project) + member_ids = ( + [int(user) for user in ARGS.members.split(",") if user.strip()] + if ARGS.members is not None + else [row["user_id"] for row in members] + ) + for user in member_ids: + await _insert(cursor, "project_members", {"project_id": new_id, "user_id": user}) + file_map: dict[int, int] = {} for row in input_files: row = dict(row) @@ -186,6 +208,12 @@ async def main() -> None: ) model_map[old] = await _insert(cursor, "surface_models", row) + for row in layers: + row = dict(row) + row.pop("id") + row["surface_model_id"] = model_map[row["surface_model_id"]] + await _insert(cursor, "terrain_layers", row) + route_map: dict[int, int] = {} for row in routes: row = dict(row) @@ -228,7 +256,8 @@ async def main() -> None: await _insert(cursor, "project_workflow_stages", row) await connection.commit() print( - f"DB 복제 완료: 입력파일 {len(file_map)} · 지표면 {len(model_map)}" + f"DB 복제 완료: 참여자 {len(member_ids)} · 입력파일 {len(file_map)}" + f" · 지표면 {len(model_map)} · 지형층 {len(layers)}" f" · 노선 {len(route_map)}" f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}" ) diff --git a/db_management/tools_project_backup.py b/db_management/tools_project_backup.py new file mode 100644 index 000000000..49fb0c37a --- /dev/null +++ b/db_management/tools_project_backup.py @@ -0,0 +1,174 @@ +"""프로젝트 DB 행 백업 · 되살리기 — 프로젝트를 지우기 전에 뜨고, 잘못되면 그대로 되살린다. + +무엇을 뜨나 + `projects` 와 그 아래 연쇄(ON DELETE CASCADE)로 지워지는 표 전부 — TABLES 차례. + id 는 **그대로** 둔다(되살리면 옛 id · 옛 storage 경로가 다시 맞는다). + 파일(storage 폴더)은 이 도구 밖 — robocopy 로 따로 뜨고 되돌린다(tmp/402_계획.md). + +쓰는 법(./venv/Scripts/python.exe db_management/tools_project_backup.py …) + 뜨기 dump <파일.json> [--project ...] (없으면 전부) + 되살리기 restore <파일.json> [--project ...] + 읽기 확인 check <파일.json> [--project ...] + +⚠ 되살리기는 **지워진 뒤에만** — 같은 id 가 살아 있으면 INSERT 가 막혀 통째 되돌린다(한 트랜잭션). +""" + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +import aiomysql + +from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + +# (표, 프로젝트로 거르는 WHERE) — 부모가 앞. 되살리기는 이 차례로 넣는다. +TABLES = ( + ("projects", "id IN ({p})"), + ("project_members", "project_id IN ({p})"), + ("input_files", "project_id IN ({p})"), + ("processed_point_cloud", "project_id IN ({p})"), + ("surface_models", "project_id IN ({p})"), + ( + "terrain_layers", + "surface_model_id IN (SELECT id FROM surface_models WHERE project_id IN ({p}))", + ), + ("routes", "project_id IN ({p})"), + ("route_points", "route_id IN (SELECT id FROM routes WHERE project_id IN ({p}))"), + ("route_statistics", "route_id IN (SELECT id FROM routes WHERE project_id IN ({p}))"), + ("longitudinal_sections", "project_id IN ({p})"), + ("cross_sections", "project_id IN ({p})"), + ("structures", "project_id IN ({p})"), + ("project_workflow_stages", "project_id IN ({p})"), + ("project_versions", "project_id IN ({p})"), + ("project_automations", "project_id IN ({p})"), + ("quantity_items", "project_id IN ({p})"), + ("change_logs", "project_id IN ({p})"), + ("outputs", "project_id IN ({p})"), + ("output_files", "output_id IN (SELECT id FROM outputs WHERE project_id IN ({p}))"), + ("upload_sessions", "project_id IN ({p})"), + ( + "upload_chunks", + "session_id IN (SELECT id FROM upload_sessions WHERE project_id IN ({p}))", + ), +) + + +async def _connect(): + return await aiomysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + db=DB_NAME, + charset="utf8mb4", + ) + + +# 손자 표 → (부모 표, 부모를 가리키는 칸) — 프로젝트 id 를 부모 행에서 찾는다. +PARENTS = { + "terrain_layers": ("surface_models", "surface_model_id"), + "route_points": ("routes", "route_id"), + "route_statistics": ("routes", "route_id"), + "output_files": ("outputs", "output_id"), + "upload_chunks": ("upload_sessions", "session_id"), +} + + +async def dump(path: Path, projects: list[str]) -> None: + connection = await _connect() + try: + async with connection.cursor(aiomysql.DictCursor) as cursor: + if not projects: + await cursor.execute("SELECT id FROM projects ORDER BY name") + projects = [row["id"] for row in await cursor.fetchall()] + holders = ", ".join(["%s"] * len(projects)) + data: dict = {"projects": projects, "tables": {}} + for table, where in TABLES: + where_sql = where.format(p=holders) + count = where_sql.count("%s") // len(projects) + await cursor.execute( + f"SELECT * FROM `{table}` WHERE {where_sql}", tuple(projects) * count + ) + data["tables"][table] = list(await cursor.fetchall()) + finally: + connection.close() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, default=str), encoding="utf-8") + _report(data) + + +def _report(data: dict) -> None: + counts = {table: len(rows) for table, rows in data["tables"].items() if rows} + print(f"프로젝트 {len(data['projects'])} · 표별 행 {counts}") + + +def _pick(data: dict, projects: list[str]) -> dict: + """덤프에서 고른 프로젝트 행만.""" + tables = data["tables"] + if not projects: + return tables + wanted = set(projects) + owner = { + parent: {row["id"]: row["project_id"] for row in tables.get(parent, [])} + for parent, _ in PARENTS.values() + } + + def project_of(table: str, row: dict) -> str | None: + if table == "projects": + return row["id"] + if table in PARENTS: + parent, column = PARENTS[table] + return owner[parent].get(row[column]) + return row["project_id"] + + return { + table: [row for row in tables.get(table, []) if project_of(table, row) in wanted] + for table, _ in TABLES + } + + +async def restore(path: Path, projects: list[str]) -> None: + data = json.loads(path.read_text(encoding="utf-8")) + tables = _pick(data, projects) + connection = await _connect() + try: + await connection.begin() + async with connection.cursor() as cursor: + for table, _ in TABLES: + for row in tables.get(table, []): + names = ", ".join(f"`{key}`" for key in row) + holders = ", ".join(["%s"] * len(row)) + await cursor.execute( + f"INSERT INTO `{table}` ({names}) VALUES ({holders})", + tuple(row.values()), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + finally: + connection.close() + print("되살림:", {table: len(rows) for table, rows in tables.items() if rows}) + + +def main() -> None: + parser = argparse.ArgumentParser(description="프로젝트 DB 행 백업 · 되살리기") + parser.add_argument("action", choices=("dump", "restore", "check")) + parser.add_argument("file", type=Path) + parser.add_argument("--project", action="append", default=[], help="프로젝트 UUID(여러 번)") + args = parser.parse_args() + if args.action == "dump": + asyncio.run(dump(args.file, args.project)) + elif args.action == "restore": + asyncio.run(restore(args.file, args.project)) + else: + data = json.loads(args.file.read_text(encoding="utf-8")) + _report({"projects": data["projects"], "tables": _pick(data, args.project)}) + + +if __name__ == "__main__": + main() From fc35579f28ad215c56dbfcff825d288d9365e87b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:37:08 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=B0=BD):=20=EC=82=BC=EA=B0=81=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=80=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EB=82=B4=EA=B0=81=20N=C2=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=97=86=EC=9D=8C=E3=80=8D=20(403-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 3 ++- resources/tester/test_403_triangle_angle.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index f292f93cd..378add39f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -58,7 +58,8 @@ export function centerDirectionOf( * 칸이 막음. 155° 이상은 L 하한을 안 거는 까닭이 보이게. 곡선 없는 교각점(삼각)도 내각(403-1). */ export function curveInfoText(curveOn: boolean, inner: number | null, optional: boolean): string { const angle = inner ? `내각 ${Math.round(inner)}°` : ""; - if (!curveOn) return [angle, "곡선 없음 — 직선이 그대로 꺾입니다"].filter(Boolean).join(" · "); + // 한 줄에 들게 — 내각이 있으면 풀이 글은 뺌. + if (!curveOn) return angle ? `${angle} · 곡선 없음` : "곡선 없음 — 직선이 그대로 꺾입니다"; return angle && optional ? `${angle} · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능` : angle; diff --git a/resources/tester/test_403_triangle_angle.py b/resources/tester/test_403_triangle_angle.py index 21bcd1003..3943450cc 100644 --- a/resources/tester/test_403_triangle_angle.py +++ b/resources/tester/test_403_triangle_angle.py @@ -32,8 +32,8 @@ def test_곡선_있는_자리_그대로(texts): def test_삼각_자리도_내각(texts): - assert texts["triangle"] == "내각 133° · 곡선 없음 — 직선이 그대로 꺾입니다" - assert texts["triangleOptional"].startswith("내각 168° · ") + assert texts["triangle"] == "내각 133° · 곡선 없음" + assert texts["triangleOptional"] == "내각 168° · 곡선 없음" def test_내각_모르면_곡선_없음만(texts): From c3efa6dd7e2bed47f7ae393f4ae04cc65c04473d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:43:51 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=BB=A4):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=B8=A1=EC=A0=90=20=EB=A7=88=EC=BB=A4=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=EC=A0=95=EB=B3=B4=20=EC=B0=BD=20=C2=B7=20?= =?UTF-8?q?20=20m=20=EB=B2=95=EC=84=A0=20=C2=B7=20=EB=81=8C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=201=20m=20(399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마커 = 종단 알약과 같은 목록(laneStructures) · 노선 위 알약 꼴(종류 색) · 스냅 · 간격과 무관 - 고름 = 알약 고름과 같은 길(onStructureSelect · 공용 고름) · 알약 벌룬 그대로 · 좌우 20 m 법선 - 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 알약 끌기와 같은 길로 한 번 - 집기 차례 = 교각점 · 곡선 손잡이 다음 · 옛 마름모 지움 - 시험 test_399_plan_structure_marks · test_391 마름모 줄 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- A00_Common/design/design_view_profile.ts | 57 ++- B05_Profile/B05_Profile_UI_Page_Structures.ts | 5 + .../B05_Profile_UI_RouteEdit_Pointer.ts | 9 +- .../B05_Profile_UI_RouteEdit_Render.ts | 26 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 386 ++++++++++++++++++ .../B05_Profile_UI_Structures_Marks.ts | 32 +- .../B05_Profile_RouteEdit_StructureMarks.ts | 47 +++ .../helper_399_plan_structure_marks.cjs | 301 ++++++++++++++ .../tester/test_391_routeedit_plan_draw.py | 3 +- .../tester/test_399_plan_structure_marks.py | 128 ++++++ 10 files changed, 938 insertions(+), 56 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts create mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts create mode 100644 resources/tester/helper_399_plan_structure_marks.cjs create mode 100644 resources/tester/test_399_plan_structure_marks.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 47787bced..fd1dde7ec 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -84,6 +84,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections"; import { createProfile3d } from "./design_view_profile_3d"; +import { setPlanMarkSource } from "../../B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks"; type PipeReport = Parameters>[0]; @@ -159,6 +160,28 @@ export async function mountProfileView( profilePanel.setSelectedStation(target.stationId); if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId); }; + // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 + // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). 평면 구조물 마커(399)도 이 길. + const onStructureSelect = (structureId: string | null): void => { + const chainage = bridge.pipeMarkChainage(structureId); + // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178) + const keepId = chainage === null || bridge.stationlessPipeMark(structureId); + rememberPick(bridge.markChainage(structureId), keepId ? structureId : null); + if (chainage !== null) { + panel.structures.selectPipeByChainage(chainage); + selectStationOfPipe(chainage); + return; + } + panel.structures.selectById(structureId); + }; + const onStructureMarkMove = (structureId: string, toChainage: number): void => { + const chainage = bridge.pipeMarkChainage(structureId); + if (chainage !== null) { + profilePanel.drainage.movePipe(chainage, toChainage); + return; + } + panel.structures.moveById(structureId, toChainage); + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -192,28 +215,8 @@ export async function mountProfileView( // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. onBasinSelected: (chainageM) => pickFromMap(chainageM), onPipeSelected: (chainageM) => pickFromMap(chainageM), - // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 - // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). - onStructureSelect: (structureId) => { - const chainage = bridge.pipeMarkChainage(structureId); - // 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178) - const keepId = chainage === null || bridge.stationlessPipeMark(structureId); - rememberPick(bridge.markChainage(structureId), keepId ? structureId : null); - if (chainage !== null) { - panel.structures.selectPipeByChainage(chainage); - selectStationOfPipe(chainage); - return; - } - panel.structures.selectById(structureId); - }, - onStructureMarkMove: (structureId, toChainage) => { - const chainage = bridge.pipeMarkChainage(structureId); - if (chainage !== null) { - profilePanel.drainage.movePipe(chainage, toChainage); - return; - } - panel.structures.moveById(structureId, toChainage); - }, + onStructureSelect, + onStructureMarkMove, onStructureTypeAdd: (chainage, typeId) => panel.structures.addAt(chainage, typeId), }, ); @@ -376,6 +379,16 @@ export async function mountProfileView( renderStationLines: () => sections.redrawStationLines(), isRestoring: () => state.restoring, }); + // 평면 구조물 측점 마커(399) — 알약과 같은 목록 · 고름 · 옮김 + host.onDispose( + setPlanMarkSource(activeProjectId, { + marks: bridge.marks, + types: bridge.types, + intervalM: () => panel.values().stationInterval || 20, + select: onStructureSelect, + move: onStructureMarkMove, + }), + ); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) const profilePick = bindProfilePick( activeProjectId, diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 64b0af6f0..7420aaaa1 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -37,6 +37,7 @@ import { planStations } from "../common_util/common_util_structure_station"; import { plannedIrregular } from "../A00_Common/design/design_structure_status"; import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids"; import { pageStructures, pageTypesChecked } from "../A00_Common/design/design_page_data"; +import { planMarksChanged } from "./B05_Profile_UI_RouteEdit_StructureMarks"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -155,6 +156,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { function syncGraphStructures(): void { deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]); + planMarksChanged(deps.projectId); // 평면 구조물 마커 = 알약과 같은 목록(399) } /** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */ @@ -337,6 +339,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }, /** 측점 계획을 다시 맞춤 — 추가 측점이 바뀜(368-2). */ resyncStations: () => syncCrossDrainStations(), + /** 알약 레인 목록 · 종류 — 평면 구조물 마커가 같은 것을 씀(399). */ + marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks], + types: (): StructureType[] => planTypes, /** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */ irregularStations: () => irregularStations, /** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index f7b350137..b9c89ec5e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -12,6 +12,7 @@ import { pickPx } from "../A00_Common/touch_bridge"; import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { bindRouteEditKeys } from "./B05_Profile_UI_RouteEdit_Keys"; +import { bindStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks"; import { dragHandleTo as curveDragTo, type EditedCurve, @@ -191,6 +192,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragMoved = false; /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ let dragRaw: Vertex[] | null = null; + /** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */ + const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; @@ -236,6 +239,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP })() ) { /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ + } else if (marks.press(px, py)) { + /* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */ } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { @@ -288,6 +293,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const rect = canvas.getBoundingClientRect(); const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); const state = params.state(); + if (marks.move(px, py)) return; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). const node = dragHandle.node; @@ -361,7 +367,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP ? state.apexLock[overNode] ? "not-allowed" : "grab" - : handleAt(px, py) + : handleAt(px, py) || marks.over(px, py) ? "grab" : over ? "pointer" @@ -373,6 +379,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + marks.release(); // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 // 눌러야 한다. 놓는 순간에만 쌓는다. if (dragMoved) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 56716eec0..7c69b1794 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -29,12 +29,12 @@ import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdi import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; -import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock"; import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; +import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks"; /** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */ const NODE_R = 6; @@ -255,6 +255,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. drawContourLabels(context, scene, band, style); + drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래 // 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리. context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; @@ -396,33 +397,14 @@ function drawMeasureMarks( } /** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 = - * 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */ + * 구조물 측점 마커가 법선 · 정보 창까지(399 · `drawStructureMarks`). 절성토 색은 발자국 선이 보여 줌. */ function drawCrossWidth( context: CanvasRenderingContext2D, scene: RouteEditScene, line: ReadonlyArray, ): void { const watch = scene.crossWidth; - if (!watch) return; - if (!watch.structure) { - drawStationNormal(context, scene, line, watch.chainageM); - return; - } - const frame = stationFrameAt(line, watch.chainageM); - if (!frame) return; - const [cx, cy] = scene.toScreen(frame.origin); - context.save(); - context.setLineDash([]); - context.translate(cx, cy); - context.rotate(Math.PI / 4); - context.beginPath(); - context.rect(-7, -7, 14, 14); - context.fillStyle = "rgba(255, 196, 0, 0.35)"; - context.fill(); - context.lineWidth = 2.5; - context.strokeStyle = "#ff9800"; - context.stroke(); - context.restore(); + if (watch && !watch.structure) drawStationNormal(context, scene, line, watch.chainageM); } /** diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts new file mode 100644 index 000000000..d3a47406e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -0,0 +1,386 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_StructureMarks.ts + * 평면 **구조물 측점 마커**(PLAN 399 · 요구 250-2). + * + * · 목록 = 종단 아래 알약과 같은 것(`laneStructures` · 관 · 구조물 · 물넘이 · 사토장 …) · 노선 위 그 측점에 + * 알약 꼴(종류 색) · 일반 측점 눈금 · 스냅 · 간격과 무관. + * · 고름 = 알약 고름과 같은 길(`select` · 공용 고름 382-2 — 평면 마커 고름 = 종단 알약 강조 · 반대도). + * 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391). + * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · + * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). + * · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름. + * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { draftProjectId } from "../A00_Common/design/design_store"; +import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection"; +import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; +import { + balloonElement, + laneStructures, + positionText, + structurePillTexts, +} from "./B05_Profile_UI_Structures_Marks"; +import { balloonLines } from "./logic/B05_Profile_UI_Structures_Balloon"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; +import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; +import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; +import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; +import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; +import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; +import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import { arrowStep, isArrowKey, type ArrowKey } from "./logic/B05_Profile_RouteEdit_KeyNudge"; +import { + arrowSign, + markChainage, + markStep, + pickedMarkId, +} from "./logic/B05_Profile_RouteEdit_StructureMarks"; +import { stationLabel as formatStation } from "../common_util/common_util_station"; + +/** 마커를 잡았다고 볼 거리(px). */ +const MARK_HIT_PX = 10; +/** 알약 마커 반 길이 · 굵기(px) — 고르면 조금 크게. */ +const MARK_HALF_PX = 5; +const MARK_WIDTH_PX = 8; +/** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */ +const PICKED_RING = "#0ea5e9"; + +/** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */ +export interface PlanMarkSource { + marks: () => ReadonlyArray; + types: () => ReadonlyArray; + intervalM: () => number; + select: (structureId: string) => void; + move: (structureId: string, toChainageM: number) => void; +} + +const sources = new Map(); +/** 목록이 바뀌면 다시 그릴 편집 창(프로젝트마다). */ +const redraws = new Map void>>(); + +/** 알약 목록이 바뀜(추가 · 옮김 · 노선 따라감) — 그 프로젝트 편집 창을 다시 그림. */ +export function planMarksChanged(projectId: string): void { + redraws.get(projectId)?.forEach((redraw) => redraw()); +} + +/** 프로젝트의 마커 재료를 건넴 — 돌려준 함수로 뗌(보기를 떠날 때). */ +export function setPlanMarkSource(projectId: string, source: PlanMarkSource): () => void { + sources.set(projectId, source); + return () => void (sources.get(projectId) === source && sources.delete(projectId)); +} + +interface PlanMark { + id: string; + chainageM: number; + structure: StructureInstance; + type: StructureType | undefined; + name: string; +} + +/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로. */ +interface Moving { + id: string; + name: string; + from: number; + to: number; + totalM: number; +} +const movings = new WeakMap(); +const balloons = new WeakMap(); + +const lengthOf = (line: ReadonlyArray): number => + line.reduce( + (sum, vertex, index) => + index ? sum + Math.hypot(vertex[0] - line[index - 1][0], vertex[1] - line[index - 1][1]) : 0, + 0, + ); + +/** 지금 마커 목록(옮기는 것은 그 자리) · 고른 것 — 재료가 없으면 null. */ +function planMarks( + canvas: HTMLCanvasElement, +): { source: PlanMarkSource; marks: PlanMark[]; picked: string | null } | null { + const projectId = draftProjectId(); + const source = sources.get(projectId); + if (!source) return null; + const list = laneStructures(source.marks()); + const types = new Map(source.types().map((type) => [type.type_id, type])); + const texts = structurePillTexts(list, types); + const moving = movings.get(canvas) ?? null; + const marks = list + .filter((structure) => structure.structure_id) + .map((structure): PlanMark => { + const id = structure.structure_id as string; + return { + id, + chainageM: moving?.id === id ? moving.to : structureAnchorM(structure), + structure, + type: types.get(structure.type_id), + name: texts.get(id)?.name ?? structure.type_id, + }; + }); + // 옮기는 중엔 그것 — 관 고름은 누가거리만 들어 옮긴 자리로는 못 찾음 + const picked = moving?.id ?? pickedMarkId(marks, readSelection(projectId), PIPE_MATCH_M); + return { source, marks, picked }; +} + +/** 알약 꼴 마커 한 개 — 노선 방향으로 누운 둥근 막대(종류 색 · 흰 테 · 고르면 하늘 테). */ +function paintMark( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + mark: PlanMark, + picked: boolean, + halo: string, +): void { + const frame = stationFrameAt(line, mark.chainageM); + if (!frame) return; + const [x, y] = scene.toScreen(frame.origin); + const [fx, fy] = scene.toScreen([ + frame.origin[0] + frame.left[1], + frame.origin[1] - frame.left[0], + ]); // 앞쪽 1 m(왼쪽 축을 시계로 90°) + const half = picked ? MARK_HALF_PX + 2 : MARK_HALF_PX; + context.save(); + context.translate(x, y); + context.rotate(Math.atan2(fy - y, fx - x)); + context.lineCap = "round"; + const stroke = (color: string, width: number): void => { + context.strokeStyle = color; + context.lineWidth = width; + context.beginPath(); + context.moveTo(-half, 0); + context.lineTo(half, 0); + context.stroke(); + }; + if (picked) stroke(PICKED_RING, MARK_WIDTH_PX + 7); + stroke(halo, MARK_WIDTH_PX + 3); + stroke(mark.type?.style?.color ?? "#888", MARK_WIDTH_PX); + context.restore(); +} + +/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */ +export function drawStructureMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + style: CSSStyleDeclaration, +): void { + const canvas = context.canvas; + const found = line.length >= 2 ? planMarks(canvas) : null; + const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + const picked = found?.marks.find((mark) => mark.id === found.picked) ?? null; + found?.marks.forEach( + (mark) => mark !== picked && paintMark(context, scene, line, mark, false, halo), + ); + if (picked) { + drawStationNormal(context, scene, line, picked.chainageM); + paintMark(context, scene, line, picked, true, halo); + } + showBalloon(canvas, scene, line, picked && found ? { mark: picked, source: found.source } : null); +} + +/** 고른 마커 위 정보 창 — 알약 벌룬 그대로 · 마커 위(위가 모자라면 아래) · 누름은 지나감. */ +function showBalloon( + canvas: HTMLCanvasElement, + scene: RouteEditScene, + line: ReadonlyArray, + pick: { mark: PlanMark; source: PlanMarkSource } | null, +): void { + const kept = balloons.get(canvas); + const frame = pick && stationFrameAt(line, pick.mark.chainageM); + if (!pick || !frame || !canvas.parentElement) { + kept?.element.remove(); + balloons.delete(canvas); + return; + } + const { mark } = pick; + const structure = { ...mark.structure, chainage_m: mark.chainageM }; + const position = positionText(structure, pick.source.intervalM()); + const lines = balloonLines(structure, mark.type, { name: mark.name, position }); + const key = JSON.stringify([lines, mark.type?.style?.color]); + let element = kept?.element; + if (!element || kept?.key !== key) { + const next = balloonElement(lines, mark.type); + next.classList.add("is-center"); + next.style.pointerEvents = "none"; + if (element) element.replaceWith(next); + else canvas.parentElement.append(next); + element = next; + balloons.set(canvas, { key, element }); + } + // 그림은 캔버스 가운데를 축으로 돎 — 창 자리도 같은 만큼 돌림 + const [sx, sy] = scene.toScreen(frame.origin); + const cx = scene.view.width / 2; + const cy = scene.view.height / 2; + const cos = Math.cos(scene.rotationRad); + const sin = Math.sin(scene.rotationRad); + const x = cx + (sx - cx) * cos - (sy - cy) * sin; + const y = cy + (sx - cx) * sin + (sy - cy) * cos; + element.style.left = `${x}px`; + element.style.top = `${y}px`; + element.style.bottom = "auto"; + element.style.transform = + y < element.offsetHeight + 24 ? "translate(-50%, 14px)" : "translate(-50%, calc(-100% - 14px))"; +} + +export interface StructureMarkInput { + /** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */ + press: (px: number, py: number) => boolean; + /** 끄는 중이면 자리 바꿈 · true. */ + move: (px: number, py: number) => boolean; + /** 끄는 중이었으면 놓음(옮겼으면 한 번 옮김) · true. */ + release: () => boolean; + /** 마커 위인가 — 커서 몫. */ + over: (px: number, py: number) => boolean; +} + +/** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */ +export function bindStructureMarks( + params: RouteEditPointerParams, + dragging: () => boolean, +): StructureMarkInput { + const { canvas, status } = params; + const projectId = draftProjectId(); + const list = redraws.get(projectId) ?? new Set<() => void>(); + redraws.set(projectId, list); + const redraw = (): void => void (canvas.isConnected ? params.draw() : list.delete(redraw)); + list.add(redraw); + const lineNow = (): Vertex[] => { + const state = params.state(); + return state.plannedLine.length ? state.plannedLine : state.planned; + }; + const markAt = (px: number, py: number): PlanMark | null => { + const found = planMarks(canvas); + const line = lineNow(); + let best: PlanMark | null = null; + let bestDistance = pickPx(MARK_HIT_PX); + for (const mark of found?.marks ?? []) { + const frame = stationFrameAt(line, mark.chainageM); + if (!frame) continue; + const [x, y] = params.toScreen(frame.origin); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) [best, bestDistance] = [mark, distance]; + } + return best; + }; + const say = (moving: Moving, done: boolean): void => { + const interval = sources.get(draftProjectId())?.intervalM() ?? params.stationIntervalM; + const shift = moving.to - moving.from; + status.textContent = + `${params.routeHead()} — 구조물 ${moving.name} ${formatStation(moving.from, interval)} → ` + + `${formatStation(moving.to, interval)}(${shift > 0 ? "+" : ""}${shift} m)` + + (done ? " 옮김 — [저장]에서 정본." : " 옮기는 중."); + }; + /** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */ + const finish = (): boolean => { + const moving = movings.get(canvas); + if (!moving) return false; + movings.set(canvas, null); + if (moving.to !== moving.from) { + sources.get(draftProjectId())?.move(moving.id, moving.to); + say(moving, true); + } + params.draw(); + return true; + }; + const start = (mark: PlanMark): Moving => { + const moving = { + id: mark.id, + name: mark.name, + from: mark.chainageM, + to: mark.chainageM, + totalM: lengthOf(lineNow()), + }; + movings.set(canvas, moving); + return moving; + }; + + // 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복) + let holdKey: ArrowKey | null = null; + let timer = 0; + const metricAt = (x: number, y: number): Vertex => params.toMetric(...params.unrotate(x, y)); + const step = (moving: Moving, key: ArrowKey): void => { + const frame = stationFrameAt(lineNow(), moving.to); + if (!frame) return; + const tangent: Vertex = [frame.left[1], -frame.left[0]]; + moving.to = markStep( + moving.to, + arrowSign(key, arrowStep(key, false, metricAt), tangent), + moving.totalM, + ); + say(moving, false); + params.draw(); + }; + const release = (): void => { + window.clearTimeout(timer); + window.clearInterval(timer); + if (holdKey === null) return; + holdKey = null; + finish(); + }; + const detach = (): void => { + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("keyup", onKeyUp, true); + window.removeEventListener("blur", release); + }; + function onKeyDown(event: KeyboardEvent): void { + if (!canvas.isConnected) return detach(); + if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; + const target = event.target as HTMLElement | null; + if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return; + if (event.repeat || holdKey || dragging() || params.measureMode()) return; + if (params.state().picked >= 0 || movings.get(canvas)) return; // 교각점 방향키 몫 + const found = planMarks(canvas); + const mark = found?.marks.find((entry) => entry.id === found.picked); + if (!mark) return; + event.preventDefault(); + const moving = start(mark); + const key = (holdKey = event.key); + step(moving, key); + timer = window.setTimeout(() => { + timer = window.setInterval(() => holdKey === key && step(moving, key), HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + function onKeyUp(event: KeyboardEvent): void { + if (!canvas.isConnected) return detach(); + if (event.key === holdKey) release(); + } + window.addEventListener("keydown", onKeyDown, true); + window.addEventListener("keyup", onKeyUp, true); + window.addEventListener("blur", release); + + return { + press(px, py) { + const mark = markAt(px, py); + if (!mark) return false; + release(); + planMarks(canvas)?.source.select(mark.id); // 알약 고름과 같은 길(공용 고름) + start(mark); + params.draw(); + return true; + }, + move(px, py) { + const moving = movings.get(canvas); + if (!moving || holdKey) return false; + const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity); + const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to; + // 옛 소수 자리(80.17)는 반올림 자리(80)에 머무는 동안 그대로 — 눌렀다 떼기만 해도 옮겨지지 않게 + const to = near === Math.round(moving.from) ? moving.from : near; + if (to !== moving.to) { + moving.to = to; + say(moving, false); + params.draw(); + } + return true; + }, + release: () => !holdKey && finish(), + over: (px, py) => markAt(px, py) !== null, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 6e831fe3d..87b64fd38 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -78,7 +78,7 @@ export interface StructureMarksOptions { } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ -function positionText(structure: StructureInstance, intervalM: number): string { +export function positionText(structure: StructureInstance, intervalM: number): string { return structure.placement === "interval" ? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}` : formatStation(structure.chainage_m ?? 0, intervalM); @@ -141,6 +141,16 @@ export function structurePillTexts( ); } +/** 알약 자리 목록 — 노선 초안 동안은 묶음 follow 뒤 자리(평면 구조물 마커 399 도 이 목록). */ +export function laneStructures( + structures: ReadonlyArray, +): ReadonlyArray { + const pipe = pagePipeline(draftProjectId()); + return pipe?.input("route") + ? followedMarks(structures, pipe.input("structures"), pipe.input("pipes")) + : structures; +} + /** * 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다). */ @@ -151,10 +161,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); - const pipe = pagePipeline(draftProjectId()); - const structures = pipe?.input("route") - ? followedMarks(options.structures, pipe.input("structures"), pipe.input("pipes")) - : options.structures; + const structures = laneStructures(options.structures); // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. const texts = structurePillTexts(structures, typeMap); @@ -266,13 +273,20 @@ function buildBalloon( anchorPx: number, row: number | null, ): HTMLElement { - const balloon = document.createElement("div"); - balloon.className = "b05-structure__balloon"; + const balloon = balloonElement(lines, type); balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; - balloon.style.borderColor = type?.style?.color ?? "#888"; + // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). + queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); + return balloon; +} +/** 벌룬 몸(제목 + 줄 · 테 색) — 자리는 부르는 쪽(알약 레인 · 평면 구조물 마커 399). */ +export function balloonElement(lines: string[], type: StructureType | undefined): HTMLElement { + const balloon = document.createElement("div"); + balloon.className = "b05-structure__balloon"; + balloon.style.borderColor = type?.style?.color ?? "#888"; const [name, ...rest] = lines; const title = document.createElement("strong"); title.textContent = name; @@ -284,8 +298,6 @@ function buildBalloon( return span; }), ); - // 붙은 뒤 그 창의 틀에서 잼 — 만들 때는 아직 메인 문서 소속이라 새 창 최대화 때 멈춤(230-2). - queueMicrotask(() => frameOf(balloon, () => flipIfClipped(balloon))); return balloon; } diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts new file mode 100644 index 000000000..39165693a --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_RouteEdit_StructureMarks.ts + * 평면 구조물 측점 마커(399 · 요구 250-2) — 노선 따라 정수 m 자리 · 방향키 부호 · 고른 마커. + * + * · 끌기 · 방향키 = 노선 위 정수 m(시점 0 ~ 종점 내림 · 밖은 끝에 붙음) · 방향키 한 번 1 m(Shift 없음). + * · 방향키 부호 = 누른 화면 방향이 노선 앞쪽과 같은 쪽이면 + · 거의 직각이면 → · ↑ = +. + * · 고른 마커 = 공용 고름 구조물 id 와 같은 것 · id 없으면(관 고름) 그 누가거리 가까운 것. + * 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_StructureMarks`. + * ========================================================================== */ + +import type { ArrowKey } from "./B05_Profile_RouteEdit_KeyNudge"; +import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; + +/** 노선 위 정수 m 자리 — 0 ~ 종점(내림) 밖은 끝에 붙임. */ +export function markChainage(rawM: number, totalM: number): number { + return Math.min(Math.max(Math.round(rawM), 0), Math.max(0, Math.floor(totalM))); +} + +/** 방향키 한 번 — 지금 자리(정수로) ± 1 m. */ +export const markStep = (chainageM: number, sign: 1 | -1, totalM: number): number => + markChainage(Math.round(chainageM) + sign, totalM); + +/** 방향키 부호 — `keyDir`(누른 화면 방향의 도면 벡터) · `tangent`(노선 앞쪽) 같은 쪽이면 +1. */ +export function arrowSign(key: ArrowKey, keyDir: Vertex, tangent: Vertex): 1 | -1 { + const dot = keyDir[0] * tangent[0] + keyDir[1] * tangent[1]; + const size = Math.hypot(keyDir[0], keyDir[1]) * Math.hypot(tangent[0], tangent[1]); + if (Math.abs(dot) > size * 0.2) return dot > 0 ? 1 : -1; + return key === "ArrowRight" || key === "ArrowUp" ? 1 : -1; +} + +/** 고른 마커 id — 고름 구조물 id 와 같은 것 · id 없으면 그 누가거리 `tolM` 안 가장 가까운 것 · 없으면 null. */ +export function pickedMarkId( + marks: ReadonlyArray<{ id: string; chainageM: number }>, + selection: { chainageM: number | null; structureId: string | null } | null, + tolM: number, +): string | null { + if (!selection || selection.chainageM === null) return null; + if (selection.structureId) + return marks.some((mark) => mark.id === selection.structureId) ? selection.structureId : null; + let best: string | null = null; + let bestGap = tolM; + for (const mark of marks) { + const gap = Math.abs(mark.chainageM - selection.chainageM); + if (gap < bestGap) [best, bestGap] = [mark.id, gap]; + } + return best; +} diff --git a/resources/tester/helper_399_plan_structure_marks.cjs b/resources/tester/helper_399_plan_structure_marks.cjs new file mode 100644 index 000000000..4233dd9be --- /dev/null +++ b/resources/tester/helper_399_plan_structure_marks.cjs @@ -0,0 +1,301 @@ +/* 399 B05 평면 구조물 측점 마커(요구 250-2) — `_RouteEdit_StructureMarks` 를 Node 로 돌려 JSON 한 줄 + * (`test_399_plan_structure_marks.py` 가 판정). 알약 목록 · 벌룬 · 고름 · 가게는 가짜 · 노선 셈 · 법선 · + * 걸음 · 방향키 부호는 진짜(`_Input` · `_Hover` · `logic/_Frame` · `logic/_KeyNudge` · `logic/_StructureMarks`). + * node helper_399_plan_structure_marks.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); +const out = {}; +let selection = null; +const laneInputs = []; +const balloonsMade = []; + +/* 가짜 DOM — 벌룬 요소 · 창 열쇠 듣기 */ +function fakeElement() { + const element = { + style: {}, + classList: { add: (...names) => element.classes.push(...names) }, + classes: [], + offsetHeight: 30, + removed: false, + remove: () => (element.removed = true), + replaceWith: (next) => ((element.removed = true), parent.children.push(next)), + }; + return element; +} +const parent = { children: [], append: (child) => parent.children.push(child) }; +const listeners = {}; +global.window = { + addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn), + removeEventListener: () => {}, + setTimeout: () => 0, + setInterval: () => 0, + clearTimeout: () => {}, + clearInterval: () => {}, +}; +const key = (type, name) => + (listeners[type] || []).forEach((fn) => + fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }), + ); + +const STUBS = { + "../A00_Common/touch_bridge": { + pickPx: (v) => v, + isPinchWheel: () => false, + wheelZoomsIn: () => true, + }, + "../A00_Common/input_prefs": { isTrackpadPinch: () => false }, + "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 }, + "../A00_Common/design/design_store": { draftProjectId: () => "p1" }, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection }, + "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, + "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, + }, + "./B05_Profile_UI_Structures_Marks": { + laneStructures: (list) => (laneInputs.push(list), list), + structurePillTexts: (list) => + new Map(list.map((s) => [s.structure_id, { name: `이름 ${s.type_id}`, pill: s.type_id }])), + positionText: (s) => `pos ${s.chainage_m}`, + balloonElement: (lines, type) => { + const element = fakeElement(); + element.lines = lines; + element.color = type?.style?.color; + balloonsMade.push(element); + return element; + }, + }, + "./logic/B05_Profile_UI_Structures_Balloon": { + balloonLines: (structure, type, head) => [ + `${head.name} ${head.position}`, + `옵션 ${type?.name}`, + ], + }, +}; +const cache = new Map(); +function loadTs(full) { + 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 localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +/** 그린 것만 적는 가짜 캔버스 — 획 = 색 · 굵기 · 그때 옮긴 자리(translate) · 선 점. */ +function fakeContext(canvas) { + const log = []; + let at = [0, 0]; + let points = []; + const ctx = { + canvas, + save() {}, + restore: () => (at = [0, 0]), + setLineDash() {}, + translate: (x, y) => (at = [at[0] + x, at[1] + y]), + rotate() {}, + beginPath: () => (points = []), + moveTo: (x, y) => points.push([x, y]), + lineTo: (x, y) => points.push([x, y]), + arc() {}, + fill() {}, + stroke: () => log.push({ color: ctx.strokeStyle, width: ctx.lineWidth, at: [...at], points }), + }; + return { ctx, log }; +} + +const marks = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMarks.ts")); +const logic = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StructureMarks.ts")); + +// 노선 = 동서 200 m · 화면 2 px/m(위 = 북) · 구조물 A 40 m(색 #e11d48) · 관 80.17 m(옛 소수) +const EAST = [ + [0, 0], + [200, 0], +]; +const toScreen = ([x, y]) => [x * 2, -y * 2]; +const toMetric = (px, py) => [px / 2, -py / 2]; +const structures = [ + { structure_id: "A", type_id: "box", chainage_m: 40, start_m: null, end_m: null }, + { structure_id: "pipe-80.17", type_id: "pipe", chainage_m: 80.17, start_m: null, end_m: null }, +]; +const types = [ + { type_id: "box", name: "BOX", style: { color: "#e11d48" } }, + { type_id: "pipe", name: "배수관", style: { color: "#2563eb" } }, +]; +const selects = []; +const moves = []; +let redrawn = 0; +marks.setPlanMarkSource("p1", { + marks: () => structures, + types: () => types, + intervalM: () => 20, + select: (id) => selects.push(id), + move: (id, to) => moves.push([id, to]), +}); + +const canvas = { isConnected: true, parentElement: parent }; +let line = EAST; +let picked = -1; +const status = { textContent: "" }; +const scene = () => ({ toScreen, view: { width: 400, height: 300 }, rotationRad: 0 }); +const style = { getPropertyValue: () => "" }; +const draw = () => { + const fake = fakeContext(canvas); + marks.drawStructureMarks(fake.ctx, scene(), line, style); + return fake.log; +}; +const input = marks.bindStructureMarks( + { + canvas, + status, + stationIntervalM: 20, + state: () => ({ planned: line, plannedLine: line, picked }), + toScreen, + toMetric, + unrotate: (x, y) => [x, y], + draw: () => (redrawn += 1), + routeHead: () => "노선", + measureMode: () => false, + }, + () => false, +); + +// ① 그리기 — 알약 목록 그대로 · 종류 색 · 고름 없으면 법선 · 창 없음 +const plain = draw(); +out.laneGotSource = laneInputs[laneInputs.length - 1] === structures; +const fills = (log) => log.filter((s) => s.width === 8).map((s) => ({ color: s.color, at: s.at })); +out.plain = { marks: fills(plain), normals: plain.filter((s) => s.color === "#0ea5e9").length }; +out.balloonsAfterPlain = parent.children.filter((c) => !c.removed).length; + +// ② 공용 고름 = 관(누가거리만 · 382-2) → 그 마커 하늘 테 · 법선 · 창(알약 벌룬 줄) +selection = { chainageM: 80.17, structureId: null }; +const pickedPipe = draw(); +const ring = pickedPipe.filter((s) => s.color === "#0ea5e9" && s.width === 15); +const normal = pickedPipe.find((s) => s.color === "#0ea5e9" && s.width === 2.4); +const shown = parent.children.filter((c) => !c.removed); +out.pickedPipe = { + ringAt: ring.map((s) => s.at), + normal: normal && normal.points, + balloon: shown.length === 1 && { + lines: shown[0].lines, + left: shown[0].style.left, + top: shown[0].style.top, + color: shown[0].color, + }, +}; +selection = { chainageM: 40, structureId: "A" }; +draw(); +const shownA = parent.children.filter((c) => !c.removed); +out.pickedStructureBalloon = shownA.length === 1 && shownA[0].lines; +selection = null; +draw(); +out.balloonsAfterRelease = parent.children.filter((c) => !c.removed).length; + +// ③ 끌기 — A(화면 80, 0) 누름 = 고름 · 3 m 끌기(86 px) = 43 · 놓을 때 한 번 +out.pressMiss = input.press(300, 100); +out.pressA = input.press(80, 3); +out.selects = [...selects]; +input.move(83, 10); // 41.5 → 42 +input.move(86, -20); // 43 +out.dragStatus = status.textContent; +out.movesBeforeRelease = moves.length; +input.release(); +out.dragMove = moves[moves.length - 1]; +out.dragDoneStatus = status.textContent; +// 노선 밖 — 끝(200) · 시점(0)에 붙음 +input.press(80, 0); +input.move(9999, 0); +input.release(); +out.pastEnd = moves[moves.length - 1]; +input.press(80, 0); +input.move(-500, 0); +input.release(); +out.beforeStart = moves[moves.length - 1]; +// 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수 +const count = moves.length; +input.press(160.34, 0); +input.move(160.5, 1); +input.release(); +out.oldDecimalStay = moves.length === count; +input.press(160.34, 0); +input.move(162.2, 0); +input.release(); +out.oldDecimalMoved = moves[moves.length - 1]; + +// ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번 +selection = { chainageM: 40, structureId: "A" }; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyRight = moves[moves.length - 1]; +key("keydown", "ArrowLeft"); +key("keyup", "ArrowLeft"); +out.keyLeft = moves[moves.length - 1]; +key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 + +key("keyup", "ArrowUp"); +out.keyUpPerpendicular = moves[moves.length - 1]; +// 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로 +line = [ + [200, 0], + [0, 0], +]; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyRightWestward = moves[moves.length - 1]; +line = EAST; +// 교각점 고름 = 교각점 방향키 몫(마커 안 옮김) +picked = 2; +const before = moves.length; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyWithNodePicked = moves.length - before; +picked = -1; +// 고름 없음 = 안 옮김 +selection = null; +key("keydown", "ArrowRight"); +key("keyup", "ArrowRight"); +out.keyNoPick = moves.length - before; + +// ⑤ 목록 바뀜 알림 = 그 편집 창 다시 그림 +const was = redrawn; +marks.planMarksChanged("p1"); +out.redrawnOnChange = redrawn - was; + +// ⑥ 순수 셈 +out.logic = { + round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)], + clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)], + step: [logic.markStep(80.17, 1, 200), logic.markStep(0, -1, 200), logic.markStep(199, 1, 199.6)], + pickId: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 1, structureId: "A" }, + 0.51, + ), + pickNear: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 40.3, structureId: null }, + 0.51, + ), + pickFar: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 41, structureId: null }, + 0.51, + ), + pickOther: logic.pickedMarkId( + [{ id: "A", chainageM: 40 }], + { chainageM: 40, structureId: "Z" }, + 0.51, + ), +}; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_391_routeedit_plan_draw.py b/resources/tester/test_391_routeedit_plan_draw.py index fd66264de..bf2928ce2 100644 --- a/resources/tester/test_391_routeedit_plan_draw.py +++ b/resources/tester/test_391_routeedit_plan_draw.py @@ -46,7 +46,8 @@ def test_맴돔과_고름이_한_함수(): render = _read("B05_Profile_UI_RouteEdit_Render.ts") assert "drawStationNormal(context, scene, line, watch.chainageM);" in render assert "leftCut" not in render and "--b05-footprint-cut" not in render - assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로 + assert 'context.strokeStyle = "#ff9800";' not in render # 구조물 고름 = 마커 법선 · 창(399) + assert "drawStructureMarks(context, scene, line, style);" in render def test_MC_는_호_가운데(results): diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py new file mode 100644 index 000000000..f0a6d07df --- /dev/null +++ b/resources/tester/test_399_plan_structure_marks.py @@ -0,0 +1,128 @@ +"""399 B05 평면 구조물 측점 마커(요구 250-2) — 알약과 같은 목록 · 고름 = 공용 고름 · 정보 창(알약 벌룬) · +20 m 법선 · 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 한 번 옮김(알약과 같은 길). + +TS 셈은 `helper_399_plan_structure_marks.cjs`(Node · 노선 셈 · 법선 · 걸음은 진짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_399_plan_structure_marks.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs") + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_마커_목록은_알약_목록(got) -> None: + assert got["laneGotSource"] is True # 알약 레인과 같은 `laneStructures` + assert got["plain"]["marks"] == [ + {"color": "#e11d48", "at": [80, 0]}, # 40 m × 2 px · 종류 색 + {"color": "#2563eb", "at": [160.34, 0]}, + ] + assert got["plain"]["normals"] == 0 and got["balloonsAfterPlain"] == 0 + lane = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts") + assert "const structures = laneStructures(options.structures);" in lane + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert "laneStructures(source.marks())" in marks + bridge = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts") + assert "setStructures([...ownStructures, ...pipeMarks]);" in bridge + assert "marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks]," in bridge + assert "planMarksChanged(deps.projectId);" in bridge + + +def test_고름_정보_창_법선(got) -> None: + pipe = got["pickedPipe"] + assert pipe["ringAt"] == [[160.34, 0]] # 관 고름(누가거리만)도 그 마커 + assert pipe["normal"] == [[160.34, -40], [160.34, 40]] # 좌우 20 m + assert pipe["balloon"]["lines"] == ["이름 pipe pos 80.17", "옵션 배수관"] # 알약 벌룬 줄 + assert pipe["balloon"]["color"] == "#2563eb" + assert got["pickedStructureBalloon"] == ["이름 box pos 40", "옵션 BOX"] + assert got["balloonsAfterRelease"] == 0 + + +def test_공용_고름과_같은_길() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수 + assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view + render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts") + labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks") + nodes = render.index("scene.planned.forEach((vertex, index) => {") + assert labels < nodes # 라벨 위 · 교각점 아래 + + +def test_집기_차례는_교각점_손잡이_다음() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + node = pointer.index("if (hitNode >= 0) {") + handle = pointer.index("} else if (dragHandle) {") + mark = pointer.index("} else if (marks.press(px, py)) {") + tick = pointer.index('if (!pick || !("chainage" in pick)) return false;') + assert node < handle < mark < tick + + +def test_끌기_1m_정수_놓을_때_한_번(got) -> None: + assert got["pressMiss"] is False and got["pressA"] is True + assert got["selects"] == ["A"] + assert got["movesBeforeRelease"] == 0 # 끄는 동안은 미리보기만 + assert got["dragMove"] == ["A", 43] # 3 m + assert "2+0 → 2+3(+3 m) 옮김" in got["dragDoneStatus"] + assert got["oldDecimalStay"] is True # 옛 소수 자리 눌렀다 떼기 = 안 옮김 + assert got["oldDecimalMoved"] == ["pipe-80.17", 81] + + +def test_노선_밖_안_감(got) -> None: + assert got["pastEnd"] == ["A", 200] + assert got["beforeStart"] == ["A", 0] + assert got["logic"]["clamp"] == [0, 199] # 종점 199.6 → 199(정수 m 안쪽) + assert got["logic"]["step"] == [81, 0, 199] + + +def test_방향키_노선_따라_1m(got) -> None: + assert got["keyRight"] == ["A", 41] + assert got["keyLeft"] == ["A", 39] + assert got["keyUpPerpendicular"] == ["A", 41] # 직각 = → · ↑ 가 + + assert got["keyRightWestward"] == ["A", 39] # 노선이 서쪽으로 가면 → = 뒤로 + assert got["keyWithNodePicked"] == 0 # 교각점 고름 = 교각점 방향키 몫 + assert got["keyNoPick"] == 0 + + +def test_고른_마커_찾기(got) -> None: + logic = got["logic"] + assert logic["round"] == [41, 42] + assert (logic["pickId"], logic["pickNear"], logic["pickFar"], logic["pickOther"]) == ( + "A", + "A", + None, + None, + ) + assert got["redrawnOnChange"] == 1 + + +def test_편집기_줄_수_그대로() -> None: + edit = _read("B05_Profile", "B05_Profile_UI_RouteEdit.ts") + assert edit.count("\n") <= 695 + for name in ( + "B05_Profile_UI_RouteEdit_StructureMarks.ts", + "B05_Profile_UI_RouteEdit_Render.ts", + "B05_Profile_UI_RouteEdit_Pointer.ts", + ): + assert _read("B05_Profile", name).count("\n") <= 700