feat(b06): [상세] 모달 구조물 선 = 다른 선 나눔 기준만 · 고를 땐 한 선 통째 · 닫힌 영역 · 옛 저장 조각 · 서버 셈 그대로 + 295 시험 (295-3)

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:02:04 +09:00
co-authored by Claude Opus 5.5
parent 4eccca40d0
commit 8b87186a26
5 changed files with 447 additions and 6 deletions
@@ -16,6 +16,8 @@ export type LineKind = CrossPickPiece["line"];
export interface SplitPiece {
line: LineKind;
points: Pt[];
/** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */
group?: number;
}
/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */
@@ -221,7 +223,12 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
const [c0, c1] = [marks[k], marks[k + 1]];
const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1));
const points = [at(c0), ...inner, at(c1)];
if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points });
if (pathLength(points) >= MIN_PIECE_M)
out.push({
line: line.line,
points,
...(line.group !== undefined && { group: line.group }),
});
}
});
return out;
@@ -36,6 +36,8 @@ import {
const SVG_NS = "http://www.w3.org/2000/svg";
export const PIECE_HIT_CLASS = "b06-detail__piece";
/** 구조물 선 묶음 걸침 표시(295-3) — 조각 판정 선 위 · 누름은 거리 판정이라 걸침만. */
export const PIECE_GROUP_CLASS = "b06-detail__piece-group";
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
export const FACE_HIT_CLASS = "b06-detail__face";
@@ -100,12 +102,14 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
else if (el.classList.contains("b06-chart__rock-boundary"))
others.push({ line: "rock", points });
}
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
// 구조물 선(295-3) = 다른 선을 나누는 기준은 그대로 · 자기 조각은 한 묶음(고를 때 통째)
const straight = mergeStraightPieces(runs).map((piece, index): SplitPiece => ({
line: piece.structure ? "structure" : "design",
points: [
[piece.from.offset, piece.from.elevation],
[piece.to.offset, piece.to.elevation],
],
...(piece.structure && { group: index }),
}));
return splitAtCrossings([...straight, ...others]);
}
@@ -117,6 +121,24 @@ export function toggleSelection(selected: number[], index: number, additive: boo
return selected.length === 1 && selected[0] === index ? [] : [index];
}
/** 묶음 고름 바꿈(295-3) — 구조물 선 조각 묶음을 한 몸으로 넣고 빼기 · 하나면 `toggleSelection` 과 같음. */
export function toggleMembers(selected: number[], members: number[], additive: boolean): number[] {
const all = members.every((m) => selected.includes(m));
if (additive)
return all
? selected.filter((i) => !members.includes(i))
: [...selected, ...members.filter((m) => !selected.includes(m))];
const same = all && selected.length === members.length;
return same ? [] : [...members];
}
/** 같은 묶음 조각 번호 — 묶음 없으면 그 하나. */
export function groupMembers(pieces: SplitPiece[], index: number): number[] {
const group = pieces[index]?.group;
if (group === undefined) return [index];
return pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
}
const LINE_NAME: Record<SplitPiece["line"], string> = {
design: "설계선",
structure: "구조물 선",
@@ -128,6 +150,10 @@ const LINE_NAME: Record<SplitPiece["line"], string> = {
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string {
if (!pieces.length) return "";
// 구조물 선 한 묶음(295-3) = 선 하나 — 첫 조각 앞 끝 ~ 끝 조각 뒤 끝
const group = pieces[0].group;
if (pieces.length > 1 && group !== undefined && pieces.every((p) => p.group === group))
pieces = [{ ...pieces[0], points: [pieces[0].points[0], pieces.at(-1)!.points.at(-1)!] }];
if (pieces.length === 1) {
const [piece] = pieces;
const from = piece.points[0];
@@ -152,8 +178,9 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
}
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const area = ringArea(pieces.map((piece) => piece.points));
const units = new Set(pieces.map((piece) => piece.group ?? piece)).size;
return (
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
`${units}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
);
}
@@ -382,7 +409,7 @@ export function attachDetailSelect(input: {
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
event.stopImmediatePropagation();
chosenFaces = [];
selected = toggleSelection(selected, pick.piece, additive);
selected = toggleMembers(selected, groupMembers(pieces, pick.piece), additive);
show();
input.picked(additive);
};
@@ -416,6 +443,19 @@ export function attachDetailSelect(input: {
layer?.append(hit);
return hit;
});
// 구조물 선 묶음 걸침 표시(295-3) — 조각 대신 선 하나로 비침
new Set(pieces.flatMap((piece) => (piece.group === undefined ? [] : [piece.group]))).forEach(
(group) => {
const members = pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
const el = document.createElementNS(SVG_NS, "polyline");
el.setAttribute(
"points",
[paths[members[0]][0], paths[members.at(-1)!].at(-1)!].join(" "),
);
el.setAttribute("class", PIECE_GROUP_CLASS);
layer?.append(el);
},
);
show();
},
setMode(kind) {
@@ -196,7 +196,8 @@
}
/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */
.b06-detail__piece {
.b06-detail__piece,
.b06-detail__piece-group {
fill: none;
stroke: transparent;
/* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */
@@ -207,7 +208,9 @@
cursor: pointer;
}
.b06-detail__piece:hover {
/* 구조물 선 묶음(295-3) — 나뉜 조각 위 한 줄 · 걸치면 선 하나로 비침 */
.b06-detail__piece:hover,
.b06-detail__piece-group:hover {
stroke: color-mix(in srgb, var(--color-accent) 25%, transparent);
}
@@ -0,0 +1,273 @@
/* PLAN 295 단위검증 헬퍼 — 횡단 카드 고르기 차례 · [상세] 모달 고르기 차례 · 첫 보기 · 구조물 선 안 나눔.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_295_pick_order_detail.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");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"./B06_Section_UI_Section_Common": { CROSS_PAD },
};
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 {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts"));
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
const H = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Home.ts"));
const out = {};
/* ── 카드: 흉내 층 ───────────────────────────────────────────────────── */
function node(name, classes, points) {
return {
name,
parent: null,
tagName: points && classes.includes("b06-chart__design-hit") ? "polyline" : "polygon",
classList: { contains: (c) => classes.includes(c) },
getAttribute: (a) => (a === "points" ? points : null),
};
}
function layer(kids) {
const listeners = [];
const self = {
children: [],
append: (...nodes) =>
nodes.forEach((n) => {
const at = self.children.indexOf(n);
if (at >= 0) self.children.splice(at, 1);
self.children.push(n);
}),
addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }),
fire: (type, event) => {
let stopped = false;
const full = { ...event, stopPropagation: () => (stopped = true) };
listeners.filter((l) => l.type === type && l.capture).forEach((l) => l.fn(full));
return stopped;
},
};
self.append(...kids);
return self;
}
const box = (x0, y0, x1, y1) => `${x0},${y0} ${x1},${y0} ${x1},${y1} ${x0},${y1}`;
const HIT = "b06-chart__culvert-revet-hit";
// 꽂힌 옛 차례 — 면적 띠(맨 아래) · 보라 선(구조물 판정면 앞) · 형상 · 도수로 띠 · 벽 · 관
const kids = [
node("band", ["b06-chart__area"], box(0, 0, 600, 300)),
node("slope", ["b06-chart__design-hit"], "400,100 500,200"),
node("top", ["b06-chart__design-hit"], "300,100 400,100"),
node("shape", ["b06-chart__shape-hit"], box(300, 100, 310, 110)), // 100
node("chute", [HIT, "b06-chart__chute-hit"], box(390, 90, 510, 210)), // 14400 — 성토 사면을 덮음
node("wall", [HIT], box(200, 100, 220, 300)), // 4000
node("pipe", [HIT], box(250, 250, 255, 255)), // 25
];
const L = layer(kids);
P.stackPickTargets(L);
out.cardOrder = L.children.map((n) => n.name);
// 누른 자리에서 맨 위 판정(뒤에서부터) — 다각형 안 · 선은 5 px 안
const inside = (p, pts) => {
let inn = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const [ax, ay] = pts[i];
const [bx, by] = pts[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]);
};
const ptsOf = (n) =>
n
.getAttribute("points")
.split(" ")
.map((q) => q.split(",").map(Number));
const topAt = (p, order) =>
[...order]
.reverse()
.find((n) =>
n.classList.contains("b06-chart__design-hit") ? gap(p, ptsOf(n)) <= 5 : inside(p, ptsOf(n)),
)?.name;
out.slopeClick = topAt([450, 150], L.children); // 띠 안 성토 사면 위
out.slopeClickOld = topAt([450, 150], kids); // 옛 차례
out.chuteClick = topAt([480, 120], L.children); // 띠 안 · 선 밖
out.pipeOverWall = topAt([252, 252], [...L.children]);
out.area = P.pointsArea(box(0, 0, 10, 20));
// 끌기 = 고름 아님
L.fire("pointerdown", { clientX: 10, clientY: 10 });
out.dragStopped = L.fire("click", { clientX: 20, clientY: 10 });
L.fire("pointerdown", { clientX: 10, clientY: 10 });
out.clickStopped = L.fire("click", { clientX: 11, clientY: 11 });
/* ── 모달: 고르기 차례 ───────────────────────────────────────────────── */
const sq = (x0, y0, x1, y1) => [
[x0, y0],
[x1, y0],
[x1, y1],
[x0, y1],
];
const big = sq(0, 0, 100, 100);
const small = sq(40, 40, 60, 60);
const edges = (ring) => ring.map((p, i) => [p, ring[(i + 1) % ring.length]]);
const paths = [...edges(big), ...edges(small)];
const pieces = paths.map((points) => ({ line: "design", points }));
const rings = [big, small];
const areas = [10000, 400];
const pick = (p, band = null) => S.pickAt(p, 1, paths, rings, areas, pieces, band);
out.modal = {
line: pick([50, 41]), // 작은 면 둘레 1 px
small: pick([50, 50]),
big: pick([20, 20]),
bandSmaller: pick([50, 50], 100),
bandBigger: pick([50, 50], 5000),
bandBigFace: pick([20, 20], 5000),
bandOnly: S.pickAt([500, 500], 1, paths, rings, areas, pieces, 50),
lineOverBand: pick([50, 41], 1),
};
/* ── 모달: 첫 보기 ───────────────────────────────────────────────────── */
const plot = { x: 50, y: 10, width: 500, height: 280 };
const center = [300, 150];
const drawn = [
[100, 120],
[500, 140],
[300, 360], // 아래로 210 — 큰 쪽
[200, 100],
[5000, 9000], // 가로 창 밖 = 셈 안 함
];
const home = H.detailHomeView(drawn, center, plot, 1, 16);
const map = ([x, y]) => [home.tx + home.scale * x, home.ty + home.scale * y];
out.home = {
scale: home.scale,
center: map(center),
plotCenter: [plot.x + plot.width / 2, plot.y + plot.height / 2],
fits: drawn.slice(0, 4).every(([x, y]) => {
const [sx, sy] = map([x, y]);
return (
sx >= plot.x - 1e-6 &&
sx <= plot.x + plot.width + 1e-6 &&
sy >= plot.y - 1e-6 &&
sy <= plot.y + plot.height + 1e-6
);
}),
flat: H.detailHomeView([[300, 150]], center, plot, 2, 16).scale,
};
/* ── 모달: 구조물 선 안 나눔 ─────────────────────────────────────────── */
// 상자(구조물 네 변 · 변마다 묶음) + 가로지르는 원지반선
const boxLines = edges(sq(0, 0, 10, 10)).map((points, group) => ({
line: "structure",
points,
group,
}));
const split = G.splitAtCrossings([
...boxLines,
{
line: "ground",
points: [
[-5, 5],
[15, 5],
],
},
]);
const left = split.findIndex(
(p) => p.line === "structure" && p.points.every(([x]) => Math.abs(x) < 1e-9),
);
const members = S.groupMembers(split, left);
const chosen = S.toggleMembers([], members, false);
out.structure = {
pieces: split.length,
groundPieces: split.filter((p) => p.line === "ground").length,
members: members.length,
chosen: chosen.length,
again: S.toggleMembers(chosen, members, false).length,
additiveOff: S.toggleMembers([...chosen, 99], members, true),
label: S.selectionLabel(
undefined,
chosen.map((i) => split[i]),
),
faces: G.findFaces(split)
.map((f) => Math.round(f.area * 100) / 100)
.sort(),
// 옛 저장 조각(구조물 선 아래 반쪽) — 그대로 다시 고름
oldPick: split.filter((p) => S.samePiece(p, { line: "structure", start: [0, 0], end: [0, 5] }))
.length,
wholeValue: G.piecesValue(
"length",
chosen.map((i) => G.pieceSource(split[i])),
{},
),
};
// collectPieces — 구조물 선(설계선 표식 없음)에 묶음 · 설계선은 없음
const axes = {
widthPx: 600,
heightPx: 300,
pixelsPerMeter: 10,
maxOffset: 20,
displayMax: 120,
elevationMid: 110,
exaggeration: 1,
};
const px = ([offset, elevation]) =>
[
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
CROSS_PAD.top +
(axes.displayMax -
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
axes.pixelsPerMeter,
].join(",");
const el = (classes, points, attrs = {}) => ({
tagName: "polyline",
classList: { contains: (name) => classes.includes(name) },
hasAttribute: (name) => name in attrs,
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
});
const collected = S.collectPieces(
[
el(
["b06-chart__design-cross"],
[
[-10, 110],
[10, 110],
],
{ "data-design-line": "" },
),
el(
["b06-chart__design-cross"],
[
[0, 105],
[0, 115],
],
),
],
axes,
);
out.collected = collected.map((p) => ({ line: p.line, group: p.group ?? null }));
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,118 @@
"""PLAN 295 — 횡단 카드 · [상세] 모달 고르기 차례 · 모달 첫 보기 · 구조물 선 안 나눔(294-5 포함).
- 295-1 카드 = 판정면 넓이 큰 것부터 아래(작은 것 위) · 보라 선(사면 손잡이) 맨 위 · 띠 있는 성토 사면 누름 = 사면 ·
끌기 = 고름 아님 / 모달 `pickAt` = 선 > 작은 면 > 큰 면 · 띠(띠는 영역보다 작을 때만)
- 295-2 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭
- 295-3 모달 구조물 선 = 다른 선을 나누는 기준 그대로 · 고를 땐 한 선 통째 · 닫힌 영역 그대로 · 옛 조각 다시 고름
실제 TS 를 `helper_295_pick_order_detail.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_295_pick_order_detail.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):
# 띠(14400) > 벽(4000) > 형상(100) > 관(25) · 보라 선 둘 맨 위 · 면적 띠는 맨 아래 그대로
assert out["cardOrder"] == ["band", "chute", "wall", "shape", "pipe", "slope", "top"]
assert out["area"] == 200
def test_카드_띠_있는_성토_사면_누름은_사면_294_5(out):
assert out["slopeClickOld"] == "chute" # 옛 차례 = 띠가 사면을 가림
assert out["slopeClick"] == "slope"
assert out["chuteClick"] == "chute" # 선 밖 띠 자리는 띠
assert out["pipeOverWall"] == "pipe"
def test_카드_끌기는_고름_아님(out):
assert out["dragStopped"] is True
assert out["clickStopped"] is False
def test_모달_선_작은_면_큰_면_띠(out):
m = out["modal"]
assert m["line"] == {"piece": 4}
assert m["small"] == {"face": 1}
assert m["big"] == {"face": 0}
assert m["bandBigger"] == {"face": 1} # 띠보다 좁은 영역이 앞(옛 「띠가 앞」 폐기)
assert m["bandSmaller"] == {"band": True}
assert m["bandBigFace"] == {"band": True}
assert m["bandOnly"] == {"band": True}
assert m["lineOverBand"] == {"piece": 4}
def test_모달_첫_보기_중심점_가운데(out):
h = out["home"]
assert h["center"] == pytest.approx(h["plotCenter"])
assert h["scale"] == pytest.approx(140 / 210) # 아래로 먼 쪽 210 px 이 반높이에
assert h["fits"] is True
assert h["flat"] == pytest.approx(2) # 세로가 낮으면 가로(카드 반폭) 배율
def test_모달_구조물_선_한_조각(out):
s = out["structure"]
assert s["groundPieces"] == 3 # 구조물 선이 원지반선을 나눔(기준 그대로)
assert s["members"] == 2 and s["chosen"] == 2 # 나뉜 조각 둘 = 한 선 통째
assert s["again"] == 0
assert s["additiveOff"] == [99]
assert s["label"].startswith("구조물 선 길이 10.00m")
assert s["wholeValue"] == pytest.approx(10)
assert out["collected"] == [
{"line": "design", "group": None},
{"line": "design", "group": None},
{"line": "structure", "group": 1},
{"line": "structure", "group": 1},
]
def test_모달_닫힌_영역_옛_조각(out):
s = out["structure"]
assert s["faces"] == [50, 50]
assert s["oldPick"] == 1
def test_배선():
view = _read("B06_Section_UI_Cross_View.ts")
assert view.index("setLineActive(pickedLineAt(") < view.index("stackPickTargets(plotLayer)")
select = " ".join(_read("B06_Section_UI_Cross_Detail_Select.ts").split())
assert "groupMembers(pieces, pick.piece)" in select
assert 'pick && "face" in pick' in select
table = _read("B06_Section_UI_Cross_Detail_Table.ts")
assert 'pointsArea(hit.getAttribute("points"))' in table
detail = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
assert "detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)" in detail
for name in ("B06_Section_UI_Cross_View.ts", "B06_Section_UI_Cross_Detail_Select.ts"):
assert len(_read(name).splitlines()) <= 700