fix(b06): 물넘이 콘크리트 누름 — 얇은 형상 판정면 아래로 최소 두께 12 px(보라 띠 밖) · 누름 = 그 물넘이 고름(걸친 연동 관 · 자기 다른 관 아님) · 경사각도 상자 (294-7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-12 22:35:58 +09:00
co-authored by Claude Opus 5.5
parent e2e71ad3ca
commit db5b7656db
11 changed files with 355 additions and 17 deletions
@@ -0,0 +1,198 @@
/* PLAN 294-7 · 295-4 단위검증 헬퍼 — 물넘이 콘크리트 누름(얇은 판정면 · 자기 물넘이 고름) ·
* 보라 선 첫 누름이 남은 부재 고름을 안 되살림. 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 짝 = `test_294_7_ford_slab_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B06 = path.join(ROOT, "B06_Section");
class FakeStorage {
getItem(key) {
return Object.prototype.hasOwnProperty.call(this, key) ? this[key] : null;
}
setItem(key, value) {
this[key] = String(value);
}
removeItem(key) {
delete this[key];
}
}
globalThis.window = { sessionStorage: new FakeStorage(), localStorage: new FakeStorage() };
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
globalThis.requestAnimationFrame = (fn) => fn();
const STUBS = {
"./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} },
"./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} },
"./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
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.endsWith(".css")) return {};
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const SP = loadTs(path.join(B06, "B06_Section_UI_Cross_Shape_Pick.ts"));
const PO = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts"));
const SEL = loadTs(path.join(B06, "B06_Section_UI_Page_Structures_Selection.ts"));
const DS = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"));
const out = {};
/* ── ① 얇은 콘크리트 판정면 — 기본 배율 4 px · 노면선 y=100 ─────────────── */
const slab = "100,100 200,96 200,100 100,104"; // 기운 노면 · 두께 4
const thick = SP.thickenDown(slab);
const pts = (s) => s.split(" ").map((p) => p.split(",").map(Number));
const inside = (p, ring) => {
let inn = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [ax, ay] = ring[i];
const [bx, by] = ring[j];
if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn;
}
return inn;
};
const gap = (p, [a, b]) => {
const d = [b[0] - a[0], b[1] - a[1]];
const t = Math.max(
0,
Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)),
);
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
};
// 카드 층 흉내 — 보라 노면선 판정(굵기 10 = 위아래 5) · 형상 판정면 · 중심 십자 없음
const node = (name, classes, points) => ({
name,
classList: { contains: (c) => classes.includes(c) },
getAttribute: (a) => (a === "points" ? points : null),
});
function stacked(shapePoints) {
const kids = [];
const layer = {
children: kids,
append: (...nodes) =>
nodes.forEach((n) => {
const at = kids.indexOf(n);
if (at >= 0) kids.splice(at, 1);
kids.push(n);
}),
addEventListener: () => {},
};
layer.append(
node("road", ["b06-chart__design-hit"], "100,100 200,96"),
node("slab", ["b06-chart__shape-hit"], shapePoints),
);
PO.stackPickTargets(layer);
return kids;
}
const topAt = (p, kids) =>
[...kids]
.reverse()
.find((n) =>
n.classList.contains("b06-chart__design-hit")
? gap(p, pts(n.getAttribute("points"))) <= 5
: inside(p, pts(n.getAttribute("points"))),
)?.name ?? null;
const now = stacked(thick);
const old = stacked(slab);
out.slab = {
thick,
bottomAt150: (pts(thick)[2][1] + pts(thick)[3][1]) / 2, // 아랫변 가운데
justBelow: topAt([150, 106], now), // 노면선 6 px 아래
justBelowOld: topAt([150, 106], old),
onLine: topAt([150, 99], now), // 노면선 위 = 보라 선(끌기 · 185)
insideOld: topAt([150, 101], old), // 옛 판정면 안쪽도 보라 띠에 덮임
order: now.map((n) => n.name),
tallUnchanged: SP.thickenDown("0,0 30,0 30,20 0,20") === "0,0 30,0 30,20 0,20",
};
/* ── ② 형상 누름 길 — 물넘이 id = 그 누가거리 · 맹암거 = 알림 ─────────────── */
const routed = [];
const from = { dispatchEvent: (event) => routed.push(["event", event.detail]) };
globalThis.CustomEvent = class {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
const route = SP.shapePickRoute(from, (at) => routed.push(["pipe", at]));
route("pipe-90.00");
route("ud-1");
out.routed = routed;
/* ── ③ 폼에 올릴 시설(주인 측점) — 연동 관 95 m 가 걸친 물넘이 90 m 카드 ──── */
const sections = [
{ chainage_m: 80, station_id: "s80", culvert: { c: 80 }, ford_pavement: { chainage_m: 90 } },
{ chainage_m: 90, station_id: "s90", ford_pavement: { chainage_m: 90 } },
{ chainage_m: 95, station_id: "s95", culvert: { c: 95 } },
{ chainage_m: 110, station_id: "s110", culvert: { c: 110 } },
];
const log = [];
const stationControls = {
revetOffset: { select: (at, key) => log.push(["select", at, key]), highlightFor: () => null },
ford: { select: () => {} },
box: { select: () => {} },
structureSpan: {
// 연동 관 95 m 의 연장(유출 흙막이 10 m)이 90 m 카드까지 옴
ownerOf: (s) =>
s.culvert ? s : s.chainage_m >= 85 && s.chainage_m <= 105 ? sections[2] : null,
},
inletStructure: { set: () => {}, valueFor: () => "auto" },
};
const panel = {
root: { addEventListener: () => {}, querySelectorAll: () => [], contains: () => false },
facility: { onInletStructureChange: () => {}, setInletStructure: () => {} },
currentChainage: () => null,
showAtChainage: (at) => log.push(["form", at]),
releasePipe: () => log.push(["release"]),
getStructures: () => [],
selectStructure: () => {},
};
SEL.wireStructureSelection(
stationControls,
() => ({ cross_sections: sections }),
panel,
(at, key) => log.push(["remember", at, key]),
);
const run = (...args) => {
log.length = 0;
stationControls.revetOffset.select(...args);
return log.filter(([kind]) => kind !== "select");
};
out.owner = {
slabOwnCard: run(90, "pipe"), // 형상 누름 · 되살림 = 그 물넘이 90(옛 = 연동 관 95)
slabHint: run(90, "pipe", 90),
slabLinkedCard: run(80, "pipe", 90), // 자기 관 있는 이웃 카드의 연동 콘크리트 = 물넘이
pipeBody: run(80, "pipe"), // 관 몸통 = 자기 관 그대로
chute: run(90, "chute"),
};
/* ── ④ 보라 선 첫 누름(295-4) — 남은 부재키(도수로 띠)를 카드 고름이 지키지 않게 ── */
const P = "p1";
const seed = () => {
DS.selectDesign(P, { chainageM: null, structureId: null });
DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "chute" });
};
seed();
DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 옛 길 = 카드 고름만
const oldKey = DS.readSelection(P)?.partKey ?? null;
seed();
DS.selectDesign(P, { chainageM: 110, structureId: null }, { partKey: "", from: "b06" }); // 선 첫 누름 = 풂 먼저
DS.selectDesign(P, { chainageM: 110, structureId: null }, { from: "b06" }); // 그다음 카드 고름
out.firstLine = { oldKey, newKey: DS.readSelection(P)?.partKey ?? null };
// 다시 누름 = 풂 — 고름 표시(마이크로태스크)가 풀린 뒤
queueMicrotask(() => {
out.owner.release = run(90, null, 90);
process.stdout.write(JSON.stringify(out));
});
+1 -1
View File
@@ -112,5 +112,5 @@ def test_벽_본체_동시_고름_금지_그대로() -> None:
in station
)
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "const toggleRevet = (key: RevetPick): void => {" in view
assert "const toggleRevet = (key: RevetPick, ownerM?: number): void => {" in view
assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view
+4 -1
View File
@@ -61,6 +61,9 @@ def test_좌측_상자_강조_배선() -> None:
)
selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts")
assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection
assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection
assert (
'ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined))'
in selection
)
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__culvert-pitching.is-active {" in css
@@ -120,7 +120,7 @@ def test_형상_강조와_표식(ts):
def test_보라_손잡이는_계획고만_형상_누름은_고름만():
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
# 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위)
assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
assert view.index("attachShapePick(") < view.index("attachDesignLinePick({")
pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
assert "onDesignChange" not in pick and "design_elevation" not in pick
@@ -152,4 +152,4 @@ def test_맹암거_형상_누름은_그_구조물_고름(ts):
assert "grid.addEventListener(SHAPE_PICK_EVENT, (event) => {" in view
assert "if (id) pavementSelectListener?.(id);" in view # = structuresPanel.selectStructure(id)
cross = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert 'revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),' in cross
assert 'revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),' in cross
@@ -64,5 +64,5 @@ def test_배관이면_관_상자_그대로(out):
def test_형상_고름_길_그대로():
view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
# 물넘이 콘크리트 · 맹암거 형상 누름 = 관 지점 고름 키 "pipe"(287-5) · 다시 누름 = 풂(toggle)
assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
assert 'setShapeActive(key === "pipe");' in view
@@ -0,0 +1,86 @@
"""PLAN 294-7 — 물넘이 콘크리트 누름 고침(브레인 검증 297).
- 기본 배율 콘크리트(약 4 px) = 보라 노면선 판정 띠(위아래 5 px)에 덮였음 → 형상 판정면을 아래쪽으로
최소 두께(12 px)까지 늘림 · 노면선 바로 아래 누름 = 콘크리트 · 노면선 위는 보라 선 그대로(185 끌기)
- 콘크리트 누름 = 그 물넘이(자기 id 누가거리) 고름 — 카드에 걸친 연동 관(95 m) · 자기 다른 관으로 안 감 ·
자기 관 없는 카드의 「관」 되살림도 물넘이 · 관 몸통 누름은 자기 관 그대로 · 다시 누름 = 풂
- 맹암거 형상 = 알림(목록이 id 로 고름) 그대로
실제 TS 를 `helper_294_7_ford_slab_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
B06 = os.path.join(ROOT, "B06_Section")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(name: str) -> str:
with open(os.path.join(B06, name), encoding="utf-8") as f:
return f.read().replace(chr(13), "")
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_294_7_ford_slab_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_기본_배율_노면선_바로_아래_누름은_콘크리트(out):
s = out["slab"]
assert s["thick"] == "100,100 200,96 200,108 100,112" # 아랫변만 8 내림(평균 두께 4 → 12)
assert s["bottomAt150"] == 110
assert s["justBelowOld"] is None and s["justBelow"] == "slab" # 노면선 6 px 아래
assert s["insideOld"] == "road" # 옛 판정면은 통째로 보라 띠 안
assert s["onLine"] == "road" # 노면선 위 = 보라 선 그대로(끌기 · 185)
assert s["order"] == ["slab", "road"]
assert s["tallUnchanged"] is True # 두꺼운 형상(맹암거 등)은 그대로
def test_형상_누름_길(out):
assert out["routed"] == [["pipe", 90], ["event", "ud-1"]]
def test_연동_관_있는_카드에서도_자기_물넘이(out):
o = out["owner"]
assert o["slabOwnCard"] == [["remember", 90, "pipe"], ["form", 90]] # 옛 = 연동 관 95
assert o["slabHint"] == [["remember", 90, "pipe"], ["form", 90]]
assert o["slabLinkedCard"] == [["remember", 90, "pipe"], ["form", 90]]
assert o["pipeBody"] == [["remember", 80, "pipe"], ["form", 80]]
assert o["chute"] == [["remember", 90, "chute"], ["form", 90]]
def test_다시_누름은_풂(out):
assert out["owner"]["release"] == [["remember", 90, ""], ["release"]]
def test_배선_경사각도_상자():
view = _read("B06_Section_UI_Cross_View.ts")
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view
assert 'setShapeActive(key === "pipe");' in view
assert len(view.splitlines()) <= 700
shape = _read("B06_Section_UI_Cross_Shape_Pick.ts")
assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape
# 폼에 물넘이(관 상자 없음) = 「경사각도」 상자(294-2 그대로)
dock = _read("B06_Section_UI_Adjust_Dock.ts")
assert 'side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX)' in dock