fix(B06): 옹벽 물빼기 파이프 끝을 벽 면에서 자름(73-2)

- B07 수확은 화면 밖 SVG 라 clip 이 안 먹음 — 벽 밖 0.5 m 가 실려 그림 범위가 바뀌던 것
- 파이프 가운데 = 그 높이의 벽 두께 중심 · 시험에 벽 밖 판정 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-07 08:04:04 +09:00
co-authored by Claude Opus 5.5
parent 05eb49c1e0
commit b8e0587465
2 changed files with 48 additions and 10 deletions
+41 -7
View File
@@ -17,7 +17,7 @@
* ========================================================================== */
import { REVET_THICKNESS_M } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
const SVG_NS = "http://www.w3.org/2000/svg";
const HATCH_CLASS = "b06-chart__culvert-stone";
@@ -159,9 +159,32 @@ const DRAIN_DIAMETER_M = 0.15;
const DRAIN_FALL = 0.1;
const DRAIN_CLASS = "b06-chart__wall-drain";
/** 직선 a→b 가 볼록 벽 단면 안을 지나는 구간 — clip 이 없는 곳(B07 오프스크린 수확)도 벽 밖으로 안 샘. */
function insideRun(a: OffsetPoint, b: OffsetPoint, polygon: OffsetPoint[]): OffsetPoint[] | null {
const dx = b.offset - a.offset;
const dy = b.elevation - a.elevation;
const hits: number[] = [];
polygon.forEach((p, i) => {
const q = polygon[(i + 1) % polygon.length];
const ex = q.offset - p.offset;
const ey = q.elevation - p.elevation;
const denominator = dx * ey - dy * ex;
if (Math.abs(denominator) < 1e-12) return;
const t = ((p.offset - a.offset) * ey - (p.elevation - a.elevation) * ex) / denominator;
const u = ((p.offset - a.offset) * dy - (p.elevation - a.elevation) * dx) / denominator;
if (u >= -1e-9 && u <= 1 + 1e-9) hits.push(t);
});
if (hits.length < 2) return null;
const at = (t: number): OffsetPoint => ({
offset: a.offset + dx * t,
elevation: a.elevation + dy * t,
});
return [at(Math.min(...hits)), at(Math.max(...hits))];
}
/**
* 옹벽 물빼기 — 높이(바닥~상단)를 반올림(높이 / 1.5)개(최소 1) 띠로 고르게 나눠 띠마다
* 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 끝은 벽 clip 이 자름.
* 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 벽 두께만큼.
*/
function drawDrains(
group: SVGElement,
@@ -172,20 +195,31 @@ function drawDrains(
const bottom = wall.bottomBack.elevation;
const height = wall.topBack.elevation - bottom;
const count = Math.max(1, Math.round(height / DRAIN_BAND_M));
const back = wall.backOffset;
const [, , topFront, bottomFront] = wall.points;
for (let i = 0; i < count; i += 1) {
const center = bottom + (height * (i + 0.5)) / count;
// 그 높이의 전면 — 전면은 기울어 위로 갈수록 배면에 가까움.
const front =
bottomFront.offset + (topFront.offset - bottomFront.offset) * ((center - bottom) / height);
const fall = (DRAIN_FALL * Math.abs(front - back)) / 2;
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)]);
const run = insideRun(
{ offset: back, elevation: level + fall },
{ offset: front, elevation: level - fall },
wall.points,
);
if (!run) continue;
const el = line(
[x(run[0].offset), y(run[0].elevation)],
[x(run[1].offset), y(run[1].elevation)],
);
el.removeAttribute("class"); // 색은 묶음(`.b06-chart__wall-drain line`)이 정함
pipe.append(el);
}
+7 -3
View File
@@ -45,6 +45,8 @@ const drains = (heightM, form, retaining, outward = 1) => {
const layer = make("svg");
appendWallHatch(layer, wall, (o) => o, (e) => -e, "t");
const bottom = wall.bottomBack.elevation;
const xs = wall.points.map((p) => p.offset);
const [lo, hi] = [Math.min(...xs) - 1e-6, Math.max(...xs) + 1e-6];
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);
@@ -53,6 +55,7 @@ const drains = (heightM, form, retaining, outward = 1) => {
lines: lines.length,
center: (mid[0] + mid[1]) / 2 - bottom,
frontLower: drop.every((d) => d > 0),
inside: lines.every((l) => [l.attrs.x1, l.attrs.x2].every((v) => lo <= +v && +v <= hi)),
title: pipe.children.find((c) => c.tag === "title")?.text,
cls: lines.map((l) => l.attrs.class ?? null),
};
@@ -104,14 +107,15 @@ def test_옹벽만_높이_15m_띠마다_파이프_하나(tmp_path: Path) -> None
)
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 [p["center"] for p in got["h15"]] == pytest.approx([0.75], abs=0.01)
assert [p["center"] for p in got["h30"]] == pytest.approx([0.75, 2.25], abs=0.01)
assert [p["center"] for p in got["h30right"]] == pytest.approx([0.75, 2.25], abs=0.01)
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["inside"], "파이프가 벽 밖으로 나감(B07 수확은 clip 이 없음)"
assert pipe["title"] == "물빼기(보기용)"
assert pipe["cls"] == [None, None], "선 색은 묶음 토큰이 정함"