feat(B06): 306 횡단 맹암거 안 엇갈려 겹친 돌 무늬 · 그림 전용 · 고르기 차례 = 안쪽 판정면 맨 아래 · 테두리 판정 면 판정 맨 위

- 돌 무늬: 켜마다 반 칸 엇갈림 · 이웃 · 위아래와 겹침 · 기울기 ±22° 번갈아 교차 · 맹암거 색(카드 · [상세] 모달 같은 그림)
- 맹암거 무리 pointer-events none — 성토 띠 · 포장 · 넓은 관 판정면이 맹암거 안에서도 골라짐
- 차례: 보라 선 > 맹암거 테두리 > 구조물 판정면(작은 것 위) > 그림 · 띠 > 맹암거 안쪽
- 시험 test_306_underdrain_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-13 18:07:53 +09:00
co-authored by Claude Opus 5.5
parent c06ad0835f
commit 0a8008ce09
5 changed files with 427 additions and 6 deletions
+31 -1
View File
@@ -5,12 +5,19 @@
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
* 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위.
* ========================================================================== */
import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick";
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
const LINE_HIT_CLASS = "b06-chart__design-hit";
/** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */
const UNDERDRAIN_GROUP = "b06-chart__underdrain";
const UNDERDRAIN_BODY = "b06-chart__underdrain-body";
const EDGE_CLASS = "b06-chart__shape-hit--edge";
/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */
const DRAG_PX = 3;
@@ -33,13 +40,36 @@ export function pointsArea(points: string | null): number {
/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */
export function stackPickTargets(layer: SVGElement): void {
const kids = [...layer.children];
// 맹암거 판정면 = 형상 고름이 몸 둘레(`thickenDown`)로 꽂은 것
const bodies = new Set(
kids
.filter((el) => el.classList.contains(UNDERDRAIN_GROUP))
.flatMap((group) => [...group.children])
.filter((el) => el.classList.contains(UNDERDRAIN_BODY))
.map((el) => thickenDown(el.getAttribute("points") ?? "")),
);
const inners = kids.filter(
(el) =>
el.classList.contains("b06-chart__shape-hit") && bodies.has(el.getAttribute("points") ?? ""),
);
const edges = inners.map((inner) => {
layer.prepend(inner);
const edge = inner.cloneNode() as SVGElement;
edge.classList.add(EDGE_CLASS);
edge.addEventListener("click", (event) => {
event.stopPropagation();
inner.dispatchEvent(new MouseEvent("click")); // 고름은 안쪽 판정면 창구 하나
});
return edge;
});
const surfaces = kids
.filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name)))
.filter((el) => !inners.includes(el))
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
.sort((a, b) => b.area - a.area)
.map(({ el }) => el);
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
layer.append(...surfaces, ...lines);
layer.append(...surfaces, ...edges, ...lines);
let down: { x: number; y: number } | null = null;
layer.addEventListener(
"pointerdown",
+25 -4
View File
@@ -5,17 +5,39 @@
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`).
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch";
import { cell, courses, type BandFrame } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
const SVG_NS = "http://www.w3.org/2000/svg";
let clipSeq = 0;
/** 돌 한 칸(m · 보기용) · 이웃과 겹침 배수 · 번갈아 기울기(도). */
const STONE_M = 0.25;
const STONE_OVERLAP = 1.4;
const STONE_TILT_DEG = 22;
/** 엇갈려 겹친 돌 무늬(306-1) — 켜(가로줄)마다 반 칸 밀고 · 돌은 칸보다 커서 이웃 · 위아래와 겹침 ·
* 기울기를 번갈아 서로 교차. `frame` v = −0.5(노면) ~ 0.5(옵셋선). */
export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: number): void {
const rows = courses(heightM, STONE_M);
const count = courses(frame.spanM, STONE_M);
const across = (heightM / rows) * STONE_OVERLAP;
const along = (frame.spanM / count) * STONE_OVERLAP;
for (let r = 0; r < rows; r += 1) {
const shift = r % 2 ? 0 : 0.5;
for (let i = 0; i + shift <= count; i += 1) {
const tilt = (i + r) % 2 ? STONE_TILT_DEG : -STONE_TILT_DEG;
const lean = { ...frame, leanDegrees: frame.leanDegrees + tilt };
group.append(cell(lean, (i + shift) / count, (r + 0.5) / rows - 0.5, across, along, 0.45));
}
}
}
type Edge = { offset_m: number; elevation_m: number };
@@ -120,7 +142,7 @@ export function appendUnderdrainOverlay(
};
const [ax, ay] = px(at(0, 0));
const [bx, by] = px(at(1, 0));
drawStones(
drawCrossedStones(
stones,
{
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
@@ -128,7 +150,6 @@ export function appendUnderdrainOverlay(
point: (u, v) => px(at(u, v)),
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
},
false,
height,
);
group.append(body, clip, stones);
@@ -267,7 +267,25 @@
stroke-width: 1;
}
/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */
/* 맹암거(288-1): 노면 아래 돌 무늬 형상 · 그림 전용 — 누름은 판정면(306-2) */
.b06-chart__underdrain {
pointer-events: none;
}
/* 엇갈려 겹친 돌(306-1) — 맹암거 색 · 밝음 · 어두움 모두 읽힘 */
.b06-chart__underdrain .b06-chart__culvert-stone {
stroke: #3aa0a8;
opacity: 0.75;
}
/* 맹암거 테두리 판정(306-2) — 면 판정 맨 위 · 안쪽 판정면은 맨 아래 */
.b06-chart__shape-hit.b06-chart__shape-hit--edge {
fill: none;
stroke: transparent;
stroke-width: 8;
pointer-events: stroke;
}
.b06-chart__underdrain-body {
fill: color-mix(in srgb, #3aa0a8 16%, transparent);
stroke: #3aa0a8;
@@ -0,0 +1,246 @@
/* PLAN 306 단위검증 헬퍼 — 맹암거 돌 무늬(306-1) · 고르기 차례(306-2).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_306_underdrain_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 cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`));
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
return new Proxy({}, { get: () => () => {} });
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
/* ── 흉내 DOM ─────────────────────────────────────────────────────────── */
class Node {
constructor(tag, name = tag) {
this.tag = tag;
this.name = name;
this.attrs = {};
this.children = [];
this.listeners = [];
const self = this;
this.classList = {
contains: (c) => (self.attrs.class ?? "").split(/\s+/).includes(c),
add: (c) => (self.attrs.class = `${self.attrs.class ?? ""} ${c}`.trim()),
};
}
setAttribute(k, v) {
this.attrs[k] = String(v);
}
getAttribute(k) {
return this.attrs[k] ?? null;
}
hasAttribute(k) {
return k in this.attrs;
}
/** `[속성]` 꼴만 — 형상 고름이 쓰는 것. */
querySelectorAll(sel) {
const attr = /^\[([\w-]+)\]$/.exec(sel)[1];
return this.children.flatMap((k) => [
...(k.hasAttribute(attr) ? [k] : []),
...k.querySelectorAll(sel),
]);
}
append(...kids) {
kids.forEach((k) => {
const at = this.children.indexOf(k);
if (at >= 0) this.children.splice(at, 1);
this.children.push(k);
});
}
prepend(...kids) {
kids.forEach((k) => {
const at = this.children.indexOf(k);
if (at >= 0) this.children.splice(at, 1);
});
this.children.unshift(...kids);
}
insertBefore(k, ref) {
const at = this.children.indexOf(k);
if (at >= 0) this.children.splice(at, 1);
const to = ref ? this.children.indexOf(ref) : -1;
this.children.splice(to >= 0 ? to : this.children.length, 0, k);
}
cloneNode() {
const copy = new Node(this.tag, `${this.name}+edge`);
copy.attrs = { ...this.attrs };
return copy;
}
addEventListener(type, fn) {
this.listeners.push({ type, fn });
}
dispatchEvent(event) {
this.listeners.filter((l) => l.type === event.type).forEach((l) => l.fn(event));
return true;
}
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
globalThis.MouseEvent = class {
constructor(type) {
this.type = type;
this.clientX = 0;
this.clientY = 0;
}
stopPropagation() {}
};
const clickEvent = () => ({ type: "click", clientX: 0, clientY: 0, stopPropagation() {} });
const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts"));
const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts"));
const order = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Pick_Order.ts"));
const out = {};
/* ── 306-1 돌 무늬 ────────────────────────────────────────────────────── */
// 288 시험과 같은 단면 — 좌 절토 · 우 성토 · 노면 6 m · 암거 높이 1 m · 1 m = 20 px
const design = {
road_edges: {
left: { offset_m: 3, elevation_m: 100.15 },
right: { offset_m: -3, elevation_m: 99.85 },
},
design_line: [
{ offset_m: 6, elevation_m: 102.9 },
{ offset_m: 3, elevation_m: 100.15 },
{ offset_m: -3, elevation_m: 99.85 },
{ offset_m: -7, elevation_m: 97.2 },
],
};
const PPM = 20;
const X = (o) => 300 - o * PPM;
const Y = (e) => 2100 - e * PPM;
const svg = new Node("g", "layer");
const group = draw.appendUnderdrainOverlay(
svg,
{ structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 },
design,
X,
Y,
);
const stones = group.children.find((k) => k.tag === "g").children;
const rotate = (s) => /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(s.attrs.transform);
out.stones = stones.map((s) => {
const [, deg, cx, cy] = rotate(s).map(Number);
return {
cls: s.attrs.class,
deg,
cx,
cy,
w: Number(s.attrs.width),
h: Number(s.attrs.height),
};
});
out.groupClass = group.attrs.class;
/* ── 306-2 고르기 차례 ────────────────────────────────────────────────── */
const poly = (name, cls, pts) => {
const n = new Node("polygon", name);
n.setAttribute("class", cls);
n.setAttribute("points", pts.map((p) => p.join(",")).join(" "));
return n;
};
const box = (x0, y0, x1, y1) => [
[x0, y0],
[x1, y0],
[x1, y1],
[x0, y1],
];
// 꽂힌 옛 차례(카드와 같음) — 지면선 · 면적 띠 · 포장 · 맹암거 무리 · 관 판정면 · 보라 선
const layer = new Node("g", "plot");
const band = new Node("g", "band");
band.setAttribute("class", "b06-chart__area b06-chart__area--fill");
band.append(poly("bandPoly", "", box(300, 100, 460, 160)));
const body = group.children.find((k) => k.attrs.class === "b06-chart__underdrain-body");
const bodyPts = body.attrs.points.split(" ").map((p) => p.split(",").map(Number));
layer.append(
band,
group,
poly("bigPipe", "b06-chart__culvert-revet-hit", box(100, 111, 600, 117)), // 맹암거(≈2600 px²)보다 넓음
poly("smallPipe", "b06-chart__culvert-revet-hit", box(340, 110, 346, 114)),
);
const picked = [];
shapePick.attachShapePick(layer, (id) => picked.push(id));
layer.children.find((k) => k.classList.contains("b06-chart__shape-hit")).name = "underdrain";
const line = new Node("polyline", "road");
line.setAttribute("class", "b06-chart__design-hit");
line.setAttribute("points", `${X(3)},${Y(100.15)} ${X(-3)},${Y(99.85)}`);
layer.append(line);
// 옛 차례(306 전) — 형상 판정면이 넓이 순으로 면 판정 사이에 섬
const SURF = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
const area = (n) => order.pointsArea(n.attrs.points ?? null);
const oldKids = [...layer.children];
const oldOrder = [
...oldKids.filter((k) => !SURF.some((c) => k.classList.contains(c)) && k !== line),
...oldKids
.filter((k) => SURF.some((c) => k.classList.contains(c)))
.sort((a, b) => area(b) - area(a)),
line,
];
order.stackPickTargets(layer);
out.order = layer.children.map((k) => k.name);
/* 누른 자리 → 맨 위에서 받는 것(흉내) — 그림 전용(맹암거 무리)은 안 받음 */
const inside = (pts, [x, y]) =>
pts.reduce((inn, [ax, ay], i) => {
const [bx, by] = pts[(i + 1) % pts.length];
return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn;
}, false);
const segGap = ([px, py], [ax, ay], [bx, by]) => {
const dx = bx - ax;
const dy = by - ay;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1)));
return Math.hypot(px - ax - t * dx, py - ay - t * dy);
};
const ptsOf = (n) => n.attrs.points.split(" ").map((p) => p.split(",").map(Number));
const ringGap = (pts, p) => Math.min(...pts.map((a, i) => segGap(p, a, pts[(i + 1) % pts.length])));
function receiver(kids, p) {
for (const k of [...kids].reverse()) {
if (k.classList.contains("b06-chart__underdrain")) continue; // pointer-events: none
if (k.classList.contains("b06-chart__design-hit")) {
const pts = ptsOf(k);
if (segGap(p, pts[0], pts[1]) <= 5) return k.name;
} else if (k.classList.contains("b06-chart__shape-hit--edge")) {
if (ringGap(ptsOf(k), p) <= 4) return k.name;
} else if (k.classList.contains("b06-chart__area")) {
if (k.children.some((c) => inside(ptsOf(c), p))) return k.name;
} else if (k.attrs.points && inside(ptsOf(k), p)) return k.name;
}
return null;
}
// 맹암거 몸 = (240,97) (360,103) (392.7,124.6) (263.1,118.2) · 띠 = x 300 부터 · 큰 관 = y 111~117
const PROBES = {
bandInUnderdrain: [330, 107], // 띠 · 맹암거 겹침(관 밖)
pipeInUnderdrain: [330, 114], // 큰 관 · 띠 · 맹암거 겹침
smallPipe: [343, 112],
underdrainOnly: [280, 107], // 맹암거만
border: [320, 121], // 아랫변 위 · 띠 안
road: [300, 100], // 노면 = 맹암거 윗변 · 보라 선
};
out.probesNew = Object.fromEntries(
Object.entries(PROBES).map(([k, p]) => [k, receiver(layer.children, p)]),
);
out.probesOld = Object.fromEntries(
Object.entries(PROBES).map(([k, p]) => [k, receiver(oldOrder, p)]),
);
// 테두리 누름 = 안쪽 판정면 창구 → 맹암거 id
const edge = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit--edge"));
edge.listeners.filter((l) => l.type === "click").forEach((l) => l.fn(clickEvent()));
out.picked = picked;
out.body = bodyPts;
out.edgeCount = layer.children.filter((k) =>
k.classList.contains("b06-chart__shape-hit--edge"),
).length;
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,106 @@
"""PLAN 306 — B06 횡단 맹암거 안 돌 무늬 · 고르기 차례(요구 200-1).
- 306-1 돌 무늬 = 켜마다 반 칸 엇갈리고 이웃 · 위아래와 겹친 돌 · 기울기 번갈아 교차 · 그림 전용(누름 안 받음)
- 306-2 고르기 차례 = 맹암거 안쪽 판정면 맨 아래(겹친 띠 · 관이 위) · 테두리 판정 = 면 판정 맨 위 ·
보라 선 맨 위 그대로 · 다른 구조물 차례(작은 것 위) 그대로
실제 TS 를 `helper_306_underdrain_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from collections import defaultdict
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="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_306_underdrain_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _rows(out: dict) -> list[list[dict]]:
"""돌을 가로(따라) · 노면 아래 깊이(켜) 좌표로 — 깊이 같은 것끼리 한 켜."""
(ax, ay), (bx, by) = out["body"][0], out["body"][1]
rows: dict[float, list[dict]] = defaultdict(list)
for s in out["stones"]:
depth = s["cy"] - (ay + (s["cx"] - ax) * (by - ay) / (bx - ax))
rows[round(depth, 1)].append({**s, "along": s["cx"]})
return [sorted(rows[k], key=lambda s: s["along"]) for k in sorted(rows)]
def test_돌_무늬_엇갈려_겹침_306_1(out):
assert out["groupClass"] == "b06-chart__underdrain"
assert out["stones"] and all(s["cls"] == "b06-chart__culvert-stone" for s in out["stones"])
rows = _rows(out)
assert len(rows) == 4 # 높이 1 m ÷ 0.25
step = rows[0][1]["along"] - rows[0][0]["along"]
gap = abs(rows[1][0]["along"] - rows[0][0]["along"]) % step
assert abs(gap - step / 2) < 1e-6 # 이웃 켜는 반 칸 엇갈림
for row in rows:
# 돌 길이(따라) > 간격 · 돌 높이(가로질러) > 켜 간격 → 이웃 · 위아래와 겹침
assert all(s["h"] > step * 1.3 for s in row)
degs = [s["deg"] for s in row]
assert all(a != b for a, b in zip(degs, degs[1:])) # 기울기 번갈아 → 교차
row_gap = 20 / len(rows) # 암거 높이 1 m = 20 px
assert all(s["w"] > row_gap for s in out["stones"])
assert len({s["deg"] for s in out["stones"]}) == 2
def test_돌_무늬는_누름_안_받음_306_1():
with open(os.path.join(B06, "B06_Section_UI_Style_Cross_Areas.css"), encoding="utf-8") as f:
css = f.read()
assert re.search(r"\.b06-chart__underdrain \{\s*pointer-events: none;", css)
assert re.search(r"\.b06-chart__shape-hit--edge \{[^}]*pointer-events: stroke;", css)
def test_고르기_차례_306_2(out):
# 안쪽 판정면 맨 아래 · 구조물 판정면 넓이 차례(작은 관 위) 그대로 · 테두리 = 면 판정 맨 위 · 보라 선 맨 위
assert out["order"] == [
"underdrain",
"band",
"g",
"bigPipe",
"smallPipe",
"underdrain+edge",
"road",
]
assert out["edgeCount"] == 1
def test_누른_자리_받는_것_306_2(out):
old, new = out["probesOld"], out["probesNew"]
# 옛 차례 — 맹암거 안 성토 띠 · 맹암거보다 넓은 관이 막힘
assert old["bandInUnderdrain"] == "underdrain" and old["pipeInUnderdrain"] == "underdrain"
assert new == {
"bandInUnderdrain": "band",
"pipeInUnderdrain": "bigPipe",
"smallPipe": "smallPipe",
"underdrainOnly": "underdrain",
"border": "underdrain+edge",
"road": "road",
}
def test_테두리_누름은_맹암거_고름_306_2(out):
assert out["picked"] == ["ud-1"]