/* ============================================================================= * B06_Section_UI_Cross_Wall_Hatch.ts * 기슭막이 벽 **형태별 표현**(2026-08-30 사용자 지시 3) — 배관용·독립용·추가(다단)용 * 세 갈래가 모두 이 한 함수로 그린다. 종전에는 형태를 가리지 않고 돌 박스만 깔아 * 콘크리트·돌망태·통나무·바자가 전부 돌쌓기로 보였다. * * 표현은 벽 **전면 기운 띠**(이음선~전면, 두께 REVET_THICKNESS_M) 안에서만 그리고, * 벽 폴리곤 clip을 씌워 어떤 높이에서도 밖으로 새지 않게 한다(기존 규칙 유지). * * 형태 → 표현(Claude 제안, 2026-08-30 사용자 승인 대기 없이 적용): * · 돌쌓기(메) : 둥근 큰 돌 한 줄 — 줄눈 없이 사이가 벌어진 메쌓기. * · 돌쌓기(찰) : 같은 돌을 작게 + 켜마다 줄눈선 — 모르타르 채움을 나타낸다. * · 콘크리트 : 돌 없음 + 사선 해칭 — 도면 관례의 콘크리트 표기. * · 돌망태 : 2열 격자 칸(철망 상자) — 칸마다 각진 사각형. * · 통나무·목재틀 : 원(통나무 마구리)을 한 줄로 쌓는다. * · 바자 : 세로 말뚝 3줄 + 가로 엮음 눈금. * ========================================================================== */ import { REVET_THICKNESS_M } from "./B06_Section_UI_Cross_Culvert_Const"; 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"; /** * clipPath id 일련번호 — id는 **문서 전역**이라 같은 벽을 소유 측점과 연동 측점이 * 함께 그리면 이름이 겹쳤다. `url(#id)`는 문서에서 **처음 만난** clipPath를 쓰므로, * 뒤 카드의 해칭이 앞 카드 좌표로 잘려 통째로 사라졌다(2026-08-30 사용자: 기준 * 측점 벽을 옮기니 연동 측점 형태 표현이 다 없어졌다). 카드·다시 그리기마다 새 번호. */ let clipSeq = 0; /** 형태 문자열 → 표현 갈래. 판정 순서는 좁은 말(돌망태·바자)부터. */ export type WallHatch = "dry" | "wet" | "concrete" | "gabion" | "log" | "wattle"; export function hatchOfForm(form: string | null | undefined): WallHatch { if (!form) return "dry"; if (form.includes("돌망태")) return "gabion"; if (form.includes("통나무") || form.includes("목재")) return "log"; if (form.includes("바자")) return "wattle"; if (form.includes("콘크리트")) return "concrete"; if (form.includes("찰")) return "wet"; return "dry"; } /** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */ export interface BandFrame { point: (u: number, v: number) => [number, number]; /** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */ leanDegrees: number; /** 띠 길이(m) — 켜 수 산정 기준. */ spanM: number; pixelsPerMeter: number; } function line(a: [number, number], b: [number, number]): SVGLineElement { const el = document.createElementNS(SVG_NS, "line"); el.setAttribute("x1", String(a[0])); el.setAttribute("y1", String(a[1])); el.setAttribute("x2", String(b[0])); el.setAttribute("y2", String(b[1])); el.setAttribute("class", HATCH_CLASS); return el; } /** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */ export function cell( frame: BandFrame, u: number, v: number, widthM: number, heightM: number, radiusRatio: number, ): SVGRectElement { const [cx, cy] = frame.point(u, v); const widthPx = widthM * frame.pixelsPerMeter; const heightPx = heightM * frame.pixelsPerMeter; const rect = document.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", String(cx - widthPx / 2)); rect.setAttribute("y", String(cy - heightPx / 2)); rect.setAttribute("width", String(widthPx)); rect.setAttribute("height", String(heightPx)); rect.setAttribute("rx", String(Math.min(widthPx, heightPx) * radiusRatio)); rect.setAttribute("transform", `rotate(${frame.leanDegrees.toFixed(1)} ${cx} ${cy})`); rect.setAttribute("class", HATCH_CLASS); return rect; } /** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */ export function courses(spanM: number, targetM: number): number { return Math.max(2, Math.round(spanM / targetM)); } /** 돌 한 줄 — `thicknessM` = 띠 두께(벽 = 흙막이 띠 · 도수로/돌붙임 = 그 두께 · 90-2). */ export function drawStones( group: SVGElement, frame: BandFrame, wet: boolean, thicknessM = REVET_THICKNESS_M, ): void { const count = courses(frame.spanM, 0.45); const courseM = frame.spanM / count; const widthM = thicknessM * (wet ? 0.76 : 0.9); const heightM = courseM * (wet ? 0.7 : 0.86); for (let i = 0; i < count; i += 1) { group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3)); // 찰쌓기는 켜마다 줄눈선을 그어 모르타르로 채운 켜임을 보인다. if (wet && i > 0) group.append(line(frame.point(i / count, -0.5), frame.point(i / count, 0.5))); } } function drawConcrete(group: SVGElement, frame: BandFrame): void { // 사선 해칭 — 0.3m 간격, 띠를 가로질러 한 방향으로만 긋는다. const count = courses(frame.spanM, 0.3); const step = 1 / count; for (let i = 0; i <= count; i += 1) { const u = i * step; group.append(line(frame.point(u, -0.5), frame.point(Math.min(1, u + step * 0.9), 0.5))); } } function drawGabion(group: SVGElement, frame: BandFrame): void { // 철망 상자 — 2열 격자 칸. 돌보다 각지게(모서리 반경 거의 0) 그린다. const count = courses(frame.spanM, 0.5); const courseM = frame.spanM / count; const widthM = REVET_THICKNESS_M * 0.42; const heightM = courseM * 0.88; for (let i = 0; i < count; i += 1) { const u = (i + 0.5) / count; group.append(cell(frame, u, -0.24, widthM, heightM, 0.06)); group.append(cell(frame, u, 0.24, widthM, heightM, 0.06)); } } function drawLogs(group: SVGElement, frame: BandFrame): void { // 통나무 마구리 — 지름 0.3m 원을 한 줄로 쌓는다. const count = courses(frame.spanM, 0.3); const radiusPx = (Math.min(frame.spanM / count, REVET_THICKNESS_M) / 2) * 0.86 * frame.pixelsPerMeter; for (let i = 0; i < count; i += 1) { const [cx, cy] = frame.point((i + 0.5) / count, 0); const circle = document.createElementNS(SVG_NS, "circle"); circle.setAttribute("cx", String(cx)); circle.setAttribute("cy", String(cy)); circle.setAttribute("r", String(Math.max(1, radiusPx))); circle.setAttribute("class", HATCH_CLASS); group.append(circle); } } function drawWattle(group: SVGElement, frame: BandFrame): void { // 바자 — 세로 말뚝 3줄에 가로 엮음 눈금을 얹는다. for (const v of [-0.3, 0, 0.3]) group.append(line(frame.point(0, v), frame.point(1, v))); const count = courses(frame.spanM, 0.25); for (let i = 1; i < count; i += 1) { const u = i / count; group.append(line(frame.point(u, -0.42), frame.point(u, 0.42))); } } /** 물빼기 파이프 단면(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"; /** 직선 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) 띠로 고르게 나눠 띠마다 * 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 벽 두께만큼. */ 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)); 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); for (const side of [-0.5, 0.5]) { const level = center + side * DRAIN_DIAMETER_M; 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); } group.append(pipe); } } /** * 벽 1매의 내부 표현(이음선 + 형태별 해칭)을 그린다. `keyId`는 clipPath id 중복 방지용. * 배관 오버레이(`_Cross_Culvert`)와 독립 기슭막이(`_Cross_Wall`)가 같이 쓴다. */ export function appendWallHatch( layer: SVGElement, wall: WallLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, keyId: string, ): void { // 하단선이 원지반을 따라 기울어 있으므로 띠 바닥도 하단선 위에서 잡는다. const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset; const bottomAtOffset = (offset: number): number => Math.abs(bottomSpan) > 1e-9 ? wall.bottomBack.elevation + (wall.bottomFront.elevation - wall.bottomBack.elevation) * ((offset - wall.bottomBack.offset) / bottomSpan) : wall.bottomBack.elevation; // 벽을 가로지르던 대각 이음선은 **긋지 않는다**(2026-09-12 사용자: 필요 없음). // 종전에는 상단 변 중간점에서 바닥까지 형태와 무관하게 늘 그어, 돌쌓기·콘크리트 해칭 // 위로 선이 하나 더 지나가 도면이 지저분했다. clipSeq += 1; const clipId = `b06-revet-clip-${keyId}-${clipSeq}`; const clip = document.createElementNS(SVG_NS, "clipPath"); clip.setAttribute("id", clipId); const clipShape = document.createElementNS(SVG_NS, "polygon"); clipShape.setAttribute( "points", wall.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); clip.append(clipShape); const group = document.createElementNS(SVG_NS, "g"); group.setAttribute("clip-path", `url(#${clipId})`); layer.append(clip, group); const centerBase = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2); const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2); const embedBase = bottomAtOffset(centerBase); const spanM = Math.max(0.3, wall.topBack.elevation - embedBase); const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const frame: BandFrame = { spanM, pixelsPerMeter, point: (u, v) => [ x(centerBase + (centerTop - centerBase) * u + wall.outward * REVET_THICKNESS_M * v), toDisplayY(embedBase + spanM * u), ], leanDegrees: (Math.atan2( x(centerTop) - x(centerBase), -(toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase)), ) * 180) / Math.PI, }; switch (hatchOfForm(wall.form)) { case "wet": drawStones(group, frame, true); break; case "concrete": drawConcrete(group, frame); break; case "gabion": drawGabion(group, frame); break; case "log": drawLogs(group, frame); break; case "wattle": drawWattle(group, frame); break; default: drawStones(group, frame, false); } // 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2). if (wall.retaining) drawDrains(group, wall, x, toDisplayY); } /** * 도수로 · 돌붙임 띠 단면(90-2) — 윗면 = 사면선(겹친 점 뺌) · 변마다 아래쪽(사면 안쪽) 단위 법선 · * 둘레 = 윗면 → 두께만큼 오프셋한 아랫면(거꾸로). 횡단 그림과 B05 3D 띠 솔리드가 같은 것을 쓴다. */ export function chuteBandOutline( raw: OffsetPoint[], thickness: number, ): { top: OffsetPoint[]; normals: OffsetPoint[]; outline: OffsetPoint[] } | null { if (!(thickness > 0)) return null; const top = raw.filter( (p, i) => i === 0 || Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6, ); if (top.length < 2) return null; // 변마다 아래쪽(사면 안쪽) 단위 법선 const normals = top.slice(1).map((p, i) => { const dx = p.offset - top[i].offset; const dz = p.elevation - top[i].elevation; const length = Math.hypot(dx, dz); const sign = dx / length > 0 ? -1 : 1; return { offset: (sign * -dz) / length, elevation: (sign * dx) / length }; }); // 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지) const bottom = top.map((p, i) => { const a = normals[Math.max(i - 1, 0)]; const b = normals[Math.min(i, normals.length - 1)]; const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation }; const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5); return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k }; }); return { top, normals, outline: [...top, ...bottom.reverse()] }; } /** * 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한 * 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관). * 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴). */ export function appendChuteBand( layer: SVGElement, lines: OffsetPoint[][], chute: { structure: string; thickness_m: number }, x: (offset: number) => number, toDisplayY: (elevation: number) => number, ): void { const thickness = chute.thickness_m; if (!(thickness > 0)) return; const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)]; for (const raw of lines) { const shape = chuteBandOutline(raw, thickness); if (!shape) continue; const { top, normals, outline } = shape; const band = document.createElementNS(SVG_NS, "polygon"); band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" ")); band.setAttribute("class", "b06-chart__culvert-pitching"); const title = document.createElementNS(SVG_NS, "title"); title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`; band.append(title); clipSeq += 1; const clipId = `b06-chute-clip-${clipSeq}`; const clip = document.createElementNS(SVG_NS, "clipPath"); clip.setAttribute("id", clipId); const clipShape = document.createElementNS(SVG_NS, "polygon"); clipShape.setAttribute("points", band.getAttribute("points") ?? ""); clip.append(clipShape); const group = document.createElementNS(SVG_NS, "g"); group.setAttribute("clip-path", `url(#${clipId})`); top.slice(1).forEach((end, i) => { const start = top[i]; const normal = normals[i]; const [ax, ay] = px(start); const [bx, by] = px(end); drawStones( group, { spanM: Math.hypot(end.offset - start.offset, end.elevation - start.elevation), pixelsPerMeter, point: (u, v) => px({ offset: start.offset + (end.offset - start.offset) * u + normal.offset * thickness * (v + 0.5), elevation: start.elevation + (end.elevation - start.elevation) * u + normal.elevation * thickness * (v + 0.5), }), leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI, }, false, thickness, ); }); layer.append(band, clip, group); } }