feat(B06): 옹벽 물빼기 파이프 단면 형상(73-2)
- 옹벽(retaining_wall)만 · 반올림(높이/1.5) 개 · 띠 가운데 · 두 줄 평행선 · 앞쪽 낮게 - 공용 벽 무늬 안이라 B07 횡단면도 · 서버 Node 범위도 같은 그림 - 색 = --color-text-secondary 토큰 · 툴팁 「물빼기(보기용)」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -93,6 +93,8 @@ export interface WallLayout {
|
||||
* invert) − 0.5m 표고의 수평선. 전면 발끝은 1:0.3 경사선을 하단까지 연장한 자리다. */
|
||||
bottomBack: OffsetPoint;
|
||||
bottomFront: OffsetPoint;
|
||||
/** 옹벽(구조물 `retaining_wall`) — 안 무늬에 물빼기 파이프 단면을 더함(73-2). */
|
||||
retaining?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 사용자 확정).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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), "색 토큰 없음"
|
||||
Reference in New Issue
Block a user