From 05eb49c1e0127766437e5ebeab1043976686f696 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 7 Oct 2026 07:55:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=98=B9=EB=B2=BD=20=EB=AC=BC?= =?UTF-8?q?=EB=B9=BC=EA=B8=B0=20=ED=8C=8C=EC=9D=B4=ED=94=84=20=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=20=ED=98=95=EC=83=81(73-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옹벽(retaining_wall)만 · 반올림(높이/1.5) 개 · 띠 가운데 · 두 줄 평행선 · 앞쪽 낮게 - 공용 벽 무늬 안이라 B07 횡단면도 · 서버 Node 범위도 같은 그림 - 색 = --color-text-secondary 토큰 · 툴팁 「물빼기(보기용)」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B06_Section_UI_Cross_Culvert_Types.ts | 2 + B06_Section/B06_Section_UI_Cross_Revetment.ts | 1 + .../B06_Section_UI_Cross_Wall_Hatch.ts | 43 ++++++ .../B06_Section_UI_Style_Cross_Areas.css | 6 + resources/tester/test_b06_wall_drain_pipe.py | 123 ++++++++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 resources/tester/test_b06_wall_drain_pipe.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index abd8416ec..465397788 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -93,6 +93,8 @@ export interface WallLayout { * invert) − 0.5m 표고의 수평선. 전면 발끝은 1:0.3 경사선을 하단까지 연장한 자리다. */ bottomBack: OffsetPoint; bottomFront: OffsetPoint; + /** 옹벽(구조물 `retaining_wall`) — 안 무늬에 물빼기 파이프 단면을 더함(73-2). */ + retaining?: boolean; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 64191e0f7..15bb33634 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -266,6 +266,7 @@ export function computeRevetmentLayout( : { walls: [], segments: [], appliedAdjusts: [], addable: false }; const walls: WallLayout[] = [tier1, ...extras.walls]; + if (spec.type_id === "retaining_wall") for (const wall of walls) wall.retaining = true; const jt = tier1.topJoint; // 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고, // 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정). diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 9905c0c22..c7160e262 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -152,6 +152,47 @@ function drawWattle(group: SVGElement, frame: BandFrame): void { } } +/** 물빼기 파이프 단면(73-2) — 보기용 고정값 · 지식DB 관경 · 간격과 별개. */ +const DRAIN_BAND_M = 1.5; +const DRAIN_DIAMETER_M = 0.15; +/** 앞쪽이 낮게 — 벽 두께 1m 당 낙차(보기용). */ +const DRAIN_FALL = 0.1; +const DRAIN_CLASS = "b06-chart__wall-drain"; + +/** + * 옹벽 물빼기 — 높이(바닥~상단)를 반올림(높이 / 1.5)개(최소 1) 띠로 고르게 나눠 띠마다 + * 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 끝은 벽 clip 이 자름. + */ +function drawDrains( + group: SVGElement, + wall: WallLayout, + x: (offset: number) => number, + y: (elevation: number) => number, +): void { + const bottom = wall.bottomBack.elevation; + const height = wall.topBack.elevation - bottom; + const count = Math.max(1, Math.round(height / DRAIN_BAND_M)); + for (let i = 0; i < count; i += 1) { + const center = bottom + (height * (i + 0.5)) / count; + const pipe = document.createElementNS(SVG_NS, "g"); + pipe.setAttribute("class", DRAIN_CLASS); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = "물빼기(보기용)"; + pipe.append(title); + // 배면 바깥 0.5m ~ 전면 발끝 바깥 0.5m — 기울기를 유지한 채 벽 clip 이 두께만큼 남김. + const back = wall.backOffset - wall.outward * 0.5; + const front = wall.bottomFront.offset + wall.outward * 0.5; + const fall = (DRAIN_FALL * Math.abs(front - back)) / 2; + for (const side of [-0.5, 0.5]) { + const level = center + side * DRAIN_DIAMETER_M; + const el = line([x(back), y(level + fall)], [x(front), y(level - fall)]); + el.removeAttribute("class"); // 색은 묶음(`.b06-chart__wall-drain line`)이 정함 + pipe.append(el); + } + group.append(pipe); + } +} + /** * 벽 1매의 내부 표현(이음선 + 형태별 해칭)을 그린다. `keyId`는 clipPath id 중복 방지용. * 배관 오버레이(`_Cross_Culvert`)와 독립 기슭막이(`_Cross_Wall`)가 같이 쓴다. @@ -230,4 +271,6 @@ export function appendWallHatch( default: drawStones(group, frame, false); } + // 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2). + if (wall.retaining) drawDrains(group, wall, x, toDisplayY); } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 4e6925180..15d717a52 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -299,6 +299,12 @@ opacity: 0.85; } +/* 옹벽 물빼기 파이프 단면(73-2) — 두 줄 평행선 · 밝음 · 어두움 토큰을 따름. */ +.b06-chart__wall-drain line { + stroke: var(--color-text-secondary); + stroke-width: 1.2; +} + /* 유입 집수정 — 성토사면 ≤3m(절토 포함) 자동 또는 사용자 선택 단면 */ .b06-chart__culvert-basin { fill: color-mix(in srgb, #8a7a5c 32%, transparent); diff --git a/resources/tester/test_b06_wall_drain_pipe.py b/resources/tester/test_b06_wall_drain_pipe.py new file mode 100644 index 000000000..da5fda459 --- /dev/null +++ b/resources/tester/test_b06_wall_drain_pipe.py @@ -0,0 +1,123 @@ +"""옹벽 물빼기 파이프 단면(73-2) — 횡단도에서 옹벽을 가르는 보기용 형상. + +규칙(10-7 사용자 · 브레인): + ① 옹벽(`retaining_wall`)만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음. + ② 개수 = 반올림(벽 높이 / 1.5) · 최소 1 — 높이를 그 개수로 고르게 나눈 띠마다 가운데 하나. + 1.5 m = 0.75 m 에 1개 · 3 m = 0.75 · 2.25 m 에 2개. + ③ 공용 벽 무늬(`appendWallHatch`) 안 — B06 카드 · B07 횡단면도 · 서버 Node 가 한 벌. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +B06 = PROJECT_ROOT / "B06_Section" +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +const make = (tag) => ({ + tag, attrs: {}, children: [], text: "", + setAttribute(k, v) { this.attrs[k] = String(v); }, + removeAttribute(k) { delete this.attrs[k]; }, + getAttribute(k) { return this.attrs[k] ?? null; }, + append(...nodes) { this.children.push(...nodes); }, + set textContent(v) { this.text = v; }, +}); +globalThis.document = { createElementNS: (_ns, tag) => make(tag) }; +const { buildRevetWallGeometry } = await import("./B06_Section_UI_Cross_Wall.js"); +const { appendWallHatch } = await import("./B06_Section_UI_Cross_Wall_Hatch.js"); +const walk = (node, out = []) => { + if (node.tag === "g" && node.attrs.class === "b06-chart__wall-drain") out.push(node); + for (const child of node.children ?? []) walk(child, out); + return out; +}; +const drains = (heightM, form, retaining, outward = 1) => { + const wall = buildRevetWallGeometry({ + anchor: { offset: 5, elevation: 100 }, outward, height: heightM - 0.5, + material: "concrete", role: "outlet", form, + }); + if (retaining) wall.retaining = true; + const layer = make("svg"); + appendWallHatch(layer, wall, (o) => o, (e) => -e, "t"); + const bottom = wall.bottomBack.elevation; + return walk(layer).map((pipe) => { + const lines = pipe.children.filter((c) => c.tag === "line"); + const mid = lines.map((l) => -(Number(l.attrs.y1) + Number(l.attrs.y2)) / 2); + const drop = lines.map((l) => Number(l.attrs.y2) - Number(l.attrs.y1)); // x1 = 배면 + return { + lines: lines.length, + center: (mid[0] + mid[1]) / 2 - bottom, + frontLower: drop.every((d) => d > 0), + title: pipe.children.find((c) => c.tag === "title")?.text, + cls: lines.map((l) => l.attrs.class ?? null), + }; + }); +}; +writeFileSync(process.argv[2], JSON.stringify({ + h15: drains(1.5, "콘크리트", true), + h30: drains(3.0, "콘크리트", true), + h30right: drains(3.0, "중력식", true, -1), + low: drains(0.6, "콘크리트", true), + wet: drains(3.0, "돌쌓기(찰)", false), + concrete: drains(3.0, "콘크리트", false), +})); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_옹벽만_높이_15m_띠마다_파이프_하나(tmp_path: Path) -> None: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(B06 / "B06_Section_UI_Cross_Wall.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--skipLibCheck", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Wall.js")) + (base / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True + ) + got = json.loads(result.read_text(encoding="utf-8")) + + assert [p["center"] for p in got["h15"]] == pytest.approx([0.75]) + assert [p["center"] for p in got["h30"]] == pytest.approx([0.75, 2.25]) + assert [p["center"] for p in got["h30right"]] == pytest.approx([0.75, 2.25]) + assert len(got["low"]) == 1, "낮은 옹벽도 최소 1개" + assert got["wet"] == [] and got["concrete"] == [], "흙막이(찰쌓기 · 콘크리트)에 파이프가 섬" + for pipe in got["h15"] + got["h30"] + got["h30right"]: + assert pipe["lines"] == 2, "두 줄 평행선이 아님" + assert pipe["frontLower"], "앞쪽이 낮지 않음" + assert pipe["title"] == "물빼기(보기용)" + assert pipe["cls"] == [None, None], "선 색은 묶음 토큰이 정함" + + +def test_옹벽_표식은_구조물_종류로() -> None: + source = (B06 / "B06_Section_UI_Cross_Revetment.ts").read_text(encoding="utf-8") + assert 'spec.type_id === "retaining_wall"' in source + css = (B06 / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8") + assert re.search(r"\.b06-chart__wall-drain line \{[^}]*var\(--color-", css), "색 토큰 없음"