feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침

- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 18:06:13 +09:00
co-authored by Claude Opus 5.5
parent c45814f910
commit cb4bab7bf2
37 changed files with 488 additions and 81 deletions
@@ -133,6 +133,8 @@ const panelModule = loadTs(
})),
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
writePendingStructures() {},
dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0)
staleDefinitionNotice: () => null, // 판 알림(322-2)
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": {
readPendingPipes: () => null,
@@ -165,6 +167,15 @@ const panelModule = loadTs(
setAdjustSideSlots() {},
},
"./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} },
// 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름
"../B05_Profile/B05_Profile_UI_Structures_Snap": {
snapNotice() {},
snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }),
},
"../A00_Common/design/design_pipe_ids": {
pipeIdAt: (at) => `pipe-${at.toFixed(2)}`,
followMovedPipe() {},
},
// 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌)
"../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"),
@@ -152,6 +152,8 @@ const panelModule = loadTs(
})),
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
writePendingStructures() {},
dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0)
staleDefinitionNotice: () => null, // 판 알림(322-2)
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": {
readPendingPipes: () => null,
@@ -184,6 +186,15 @@ const panelModule = loadTs(
setAdjustSideSlots() {},
},
"./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} },
// 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름
"../B05_Profile/B05_Profile_UI_Structures_Snap": {
snapNotice() {},
snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }),
},
"../A00_Common/design/design_pipe_ids": {
pipeIdAt: (at) => `pipe-${at.toFixed(2)}`,
followMovedPipe() {},
},
// 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌)
"../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"),
+6 -1
View File
@@ -73,7 +73,12 @@ const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", {
const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", {
"./B05_Profile_UI_Markers": MK3,
});
const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts");
// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match`(같은 자리 = 고름 모듈 거리)
const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel });
const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", {
"../A00_Common/design/design_pick_match": PM,
"../A00_Common/design/design_selection": DSel,
});
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": {
@@ -39,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5)
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
+7 -1
View File
@@ -78,6 +78,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5)
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
@@ -85,7 +86,12 @@ const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", {
"@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` },
});
const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts");
// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match`
const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel });
const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", {
"../A00_Common/design/design_pick_match": PM,
"../A00_Common/design/design_selection": DSel,
});
const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", {
"../A00_Common/design/design_selection": DSel,
"../A00_Common/design/design_pick_escape": ESC,
@@ -55,6 +55,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5)
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
@@ -42,6 +42,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5)
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
@@ -31,6 +31,14 @@ const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pa
{ "./B06_Section_UI_Cross_Culvert_Const": {} },
),
"./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} },
// 관 세션 id · 신원표(323-5) — 프로젝트 없음 = 옛 자리 이름
"../A00_Common/design/design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` },
"../A00_Common/design/design_pick_match": {
tagShape: (el, id, part) => {
if (id) el.setAttribute("data-structure-id", id);
if (part) el.setAttribute("data-part", part);
},
},
});
// 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01)
const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts"));
+5 -4
View File
@@ -183,13 +183,14 @@ async function run(setup, detail) {
out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept);
// 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴)
out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept);
// 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로
// 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · id = 관 세션 id 그대로(323-5) · 다른 고름은 그대로
const sel = load(file("A00_Common", "design", "design_selection.ts"));
sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" });
sel.followMovedPipe("P", 61, 75);
const pipeIds = load(file("A00_Common", "design", "design_pipe_ids.ts"));
sel.selectDesign("P", { chainageM: 61, structureId: pipeIds.pipeIdAt(61, "P") });
pipeIds.followMovedPipe("P", 61, 75);
const moved = sel.readSelection("P");
sel.selectDesign("P", { chainageM: 101, structureId: "w1" });
sel.followMovedPipe("P", 101, 120);
pipeIds.followMovedPipe("P", 101, 120);
out.follow = [moved, sel.readSelection("P")];
// 옛 소수 관(검증_sub1 견본) — B06 을 바로 열어도 B05 와 같은 정수 m 초안(`snapPlan` · 요구 167)
const snap = load(file("B05_Profile", "B05_Profile_UI_Structures_Snap.ts"));
+94
View File
@@ -0,0 +1,94 @@
/* PLAN 323-5 고름 한 칸 헬퍼 — 진짜 `design_pipe_ids.ts`(관 세션 id) · `design_selection.ts`(고름 · 관 옮김 따라가기) ·
* `design_pick_match.ts`(고름 강조 한 규칙)를 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON.
* 짝 = `test_323_5_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const memory = new Map();
const store = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => memory.set(k, String(v)),
removeItem: (k) => memory.delete(k),
key: (i) => [...memory.keys()][i] ?? null,
get length() {
return memory.size;
},
};
globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} };
globalThis.sessionStorage = store;
globalThis.localStorage = store;
const cache = new Map();
function load(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 need = (spec) => load(path.join(path.dirname(full), `${spec}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const D = (name) => path.join(ROOT, "A00_Common", "design", `${name}.ts`);
const ids = load(D("design_pipe_ids"));
const sel = load(D("design_selection"));
const match = load(D("design_pick_match"));
const out = {};
// ① 관 세션 id — 자리마다 차례 · 같은 자리(0.51 안)면 같은 id · 프로젝트 없으면 옛 자리 이름
ids.usePipeIds("P");
const a = ids.pipeIdAt(85.05);
out.ids = [a, ids.pipeIdAt(85), ids.pipeIdAt(173), ids.pipeChainageOf(a)];
ids.usePipeIds(null);
out.legacy = [ids.pipeIdAt(85), ids.pipeChainageOf("pipe-85.00"), ids.pipeChainageOf("w1")];
ids.usePipeIds("P");
// ② 옮겨도 id 그대로 — 고름도 자리만 새로(부재키 그대로) · 새 자리 id = 그 id
sel.selectDesign("P", { chainageM: 85.05, structureId: a }, { partKey: "inlet" });
ids.followMovedPipe("P", 85.05, 90);
out.moved = [sel.readSelection("P"), ids.pipeIdAt(90), ids.pipeChainageOf(a)];
// 다른 것을 골랐으면 고름 그대로 · 관 id 는 따라감
const b = ids.pipeIdAt(173);
sel.selectDesign("P", { chainageM: 300, structureId: "w1" });
ids.followMovedPipe("P", 173, 180);
out.other = [sel.readSelection("P"), ids.pipeIdAt(180) === b];
// ③ 강조 한 규칙
const L = match.pickLights;
out.lights = {
idOverPlace: L({ chainageM: 93, structureId: "w1" }, { chainageM: 101, structureId: "w1" }),
idDiffers: L({ chainageM: 101, structureId: "w2" }, { chainageM: 101, structureId: "w1" }),
placeNear: L({ chainageM: 85, structureId: null }, { chainageM: 85.4 }),
placeFar: L({ chainageM: 85, structureId: null }, { chainageM: 85.6 }),
partOnly: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "outlet" }),
partSame: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "inlet" }),
partNoTag: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85 }),
wholePick: L({ chainageM: 85 }, { chainageM: 85, part: "outlet" }),
none: L(null, { chainageM: 85 }),
};
// ④ 카드 통째 형상 칠하기 — 부분 · 관 계열 · 포장층은 제 손잡이 몫
class Shape {
constructor(attrs) {
this.attrs = attrs;
this.on = false;
this.classList = { toggle: (_c, v) => (this.on = v) };
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
}
const shapes = [
new Shape({ "data-structure-id": "ud-1" }),
new Shape({ "data-structure-id": "w1", "data-part": "own" }),
new Shape({ "data-structure-id": a, "data-part": "concrete" }),
new Shape({ "data-structure-id": "pave-1", "data-pave-type": "pavement_concrete" }),
];
const root = { querySelectorAll: () => shapes };
match.paintPicked(root, { structureId: "ud-1" });
const lit1 = shapes.map((s) => s.on);
match.paintPicked(root, { structureId: "w1" });
out.paint = [lit1, shapes.map((s) => s.on)];
process.stdout.write(JSON.stringify(out));
+3 -1
View File
@@ -116,7 +116,9 @@ def test_배선():
assert "openId" not in panel # 범위 안 빈 카드 = 연 노면공 지킴(165-1) 없앰 · 189-1
# 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔)
at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);")
assert at < panel.index("deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);", at)
assert at < panel.index(
"deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));", at
)
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view
assert "held" not in view # 범위 카드 재누름 = 그대로(167-3) 없앰 · 189-1
+10 -3
View File
@@ -60,13 +60,20 @@ def test_물넘이포장_측점길은_콘크리트_안켬(out):
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
assert "highlightShapes(card, pickedMarkId);" in view
shape = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
assert 'if (id.startsWith("pipe-")) continue;' in shape
assert "paintPicked(root, { structureId })" in shape # 고름 강조 한 규칙(323-5)
paint = _read("A00_Common", "design", "design_pick_match.ts")
assert (
'if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;' in paint
)
# 네 길이 모이는 자리 — 카드 고름 리스너 · 목록 관 줄 · 폼 관 열기 · 측점 없는 관
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "sectionView.selectMark(mark, false);" in cross
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);" in panel # 카드 → 폼
assert "deps.markStructure?.(chainageM === null ? null : `pipe-${" in panel # 목록 관 줄
# 알약 id = 관 세션 id(323-5)
assert (
"deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));" in panel
) # 카드 → 폼
assert "deps.markStructure?.(chainageM === null ? null : pipeIdAt(" in panel # 목록 관 줄
# 측점 길은 「관」 벽 고름을 안 엶 — 카드만 고르면 벽 고름을 비움(그대로)
assert "stationControls.revetOffset.select(target.chainage_m, null);" in cross
+1 -1
View File
@@ -74,7 +74,7 @@ def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None:
def test_pick_follows_moved_pipe(flows: dict) -> None:
moved, other = flows["follow"]
assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-75.00")
assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-s1") # 세션 id 그대로(323-5)
assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로
+67
View File
@@ -0,0 +1,67 @@
"""PLAN 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(`pickLights`).
- 관 id = 자리마다 차례(`pipe-s1` …) · 같은 자리(PIPE_MATCH_M 안)면 같은 id · 프로젝트 없으면 옛 자리 이름.
- 관을 옮기면 id 가 새 자리로 · 그 관을 고른 고름은 자리만 새로(부재키 그대로) · 다른 고름은 그대로.
- 강조 = id 둘 다 있으면 id · 아니면 같은 자리 · 고른 부분이 있으면 그 부분만 — 카드 형상 · 3D 같은 함수.
- 카드 통째 형상 칠하기는 부분(조정창 부재) · 관 계열 · 포장층을 안 건드림(307).
TS 쪽 = `helper_323_5_pick.cjs`(Node).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_323_5_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(run.stdout)
def test_session_ids(out: dict) -> None:
assert out["ids"] == ["pipe-s1", "pipe-s1", "pipe-s2", 85.05]
assert out["legacy"] == ["pipe-85.00", 85, None]
def test_move_keeps_id_and_pick(out: dict) -> None:
picked, at90, moved_to = out["moved"]
assert picked == {"chainageM": 90, "structureId": "pipe-s1", "partKey": "inlet"}
assert (at90, moved_to) == ("pipe-s1", 90)
other, same = out["other"]
assert other["structureId"] == "w1" and other["chainageM"] == 300 and same is True
def test_one_highlight_rule(out: dict) -> None:
assert out["lights"] == {
"idOverPlace": True,
"idDiffers": False,
"placeNear": True,
"placeFar": False,
"partOnly": False,
"partSame": True,
"partNoTag": False,
"wholePick": True,
"none": False,
}
def test_card_paint_whole_shapes_only(out: dict) -> None:
assert out["paint"] == [[True, False, False, False], [False, False, False, False]]