diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index e3fd89830..63febf573 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -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", diff --git a/B06_Section/B06_Section_UI_Cross_Underdrain.ts b/B06_Section/B06_Section_UI_Cross_Underdrain.ts index 162887718..743c564ef 100644 --- a/B06_Section/B06_Section_UI_Cross_Underdrain.ts +++ b/B06_Section/B06_Section_UI_Cross_Underdrain.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 434e10a85..0fc23a43b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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; diff --git a/resources/tester/helper_306_underdrain_pick.cjs b/resources/tester/helper_306_underdrain_pick.cjs new file mode 100644 index 000000000..5b281e5d7 --- /dev/null +++ b/resources/tester/helper_306_underdrain_pick.cjs @@ -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)); diff --git a/resources/tester/test_306_underdrain_pick.py b/resources/tester/test_306_underdrain_pick.py new file mode 100644 index 000000000..bea0ba96a --- /dev/null +++ b/resources/tester/test_306_underdrain_pick.py @@ -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"]