/* ============================================================================= * B06_Section_Cross_Wall_Hatch_Geom.ts * 벽 안 무늬 · 물빼기 · 도수로 · 돌붙임 띠 **기하**(m 좌표) — 그리기는 * `../B06_Section_UI_Cross_Wall_Hatch`(472-3). 횡단 그림과 B05 3D 띠 솔리드가 같은 것을 쓴다. * ========================================================================== */ import { REVET_THICKNESS_M } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, WallLayout } from "./B06_Section_Cross_Culvert_Types"; /** 형태 문자열 → 표현 갈래. 판정 순서는 좁은 말(돌망태·바자)부터. */ 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"; } /** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */ export function courses(spanM: number, targetM: number): number { return Math.max(2, Math.round(spanM / targetM)); } /** 벽 전면 기운 띠(이음선~전면, 두께 REVET_THICKNESS_M)의 중심축 — 바닥 · 상단 offset · 바닥 표고 · 길이. */ export function wallHatchBand(wall: WallLayout): { centerBase: number; centerTop: number; embedBase: number; spanM: number; } { // 하단선이 원지반을 따라 기울어 있으므로 띠 바닥도 하단선 위에서 잡는다. 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; 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); return { centerBase, centerTop, embedBase, spanM }; } /** 물빼기 파이프 단면(73-2) — 보기용 고정값 · 지식DB 관경 · 간격과 별개. */ const DRAIN_BAND_M = 1.5; const DRAIN_DIAMETER_M = 0.15; /** 앞쪽이 낮게 — 벽 두께 1m 당 낙차(보기용). */ const DRAIN_FALL = 0.1; /** 직선 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) 띠로 고르게 나눠 띠마다 * 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 벽 두께만큼. * 물빼기마다 벽 안 구간 목록(0~2 줄). */ export function wallDrainRuns(wall: WallLayout): OffsetPoint[][][] { 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; const drains: OffsetPoint[][][] = []; 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 runs: OffsetPoint[][] = []; 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) runs.push(run); } drains.push(runs); } return drains; } /** * 도수로 · 돌붙임 띠 단면(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()] }; } /** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */ function trimPolyline( points: readonly OffsetPoint[], lengthM: number, ): { points: OffsetPoint[]; used: number } { const out = points.slice(0, 1); let used = 0; for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) { const [a, b] = [points[i - 1], points[i]]; const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation); if (step < 1e-9) continue; const t = Math.min((lengthM - used) / step, 1); out.push({ offset: a.offset + (b.offset - a.offset) * t, elevation: a.elevation + (b.elevation - a.elevation) * t, }); used += step * t; } return { points: out, used }; } /** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */ export function groundLine( samples: ReadonlyArray<{ offset_m?: number | null; elevation_m?: number | null; valid?: boolean; }>, ): OffsetPoint[] { return samples .filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null) .map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number })) .sort((a, b) => a.offset - b.offset); } /** * 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서 * 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선 * (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관). */ export function chuteRun( lines: readonly L[], lengthM?: number | null, ground?: readonly OffsetPoint[], ): L[] { const drawn = (list: L[]) => list.filter((line) => line.points.length > 1); if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]); let left = lengthM; const out: L[] = []; for (const line of lines) { if (left <= 1e-9) break; const cut = trimPolyline(line.points, left); out.push({ ...line, points: cut.points }); left -= cut.used; } const last = out[out.length - 1]; if (left > 1e-9 && ground?.length && last) { const first = lines[0].points[0]; const end = last.points[last.points.length - 1]; const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1; const along = [ end, ...ground .filter((p) => (p.offset - end.offset) * outward > 1e-6) .sort((a, b) => (a.offset - b.offset) * outward), ]; out.push({ ...last, points: trimPolyline(along, left).points }); } return drawn(out); } /** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작). * 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */ export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean { if (segment.kind !== "cut") return true; const { points } = segment; return points.length > 1 && points[points.length - 1].elevation <= points[0].elevation + 1e-6; }