- 충돌 24 파일 = import 줄 다툼 — 518-2 새 경로(Cross_Culvert · Culvert_Geom · Extra · Wire · Types · Box_Geom · Ford_Geom · Ford_Pavement) + 518-3 새 경로(Cross_Body_Guard · Wall_Geom · Wall_Hatch_Geom · Excavation_Geom · Revetment_Geom · Structure_Layouts · Route_Carry_Patch)
- 518-2 logic 셋이 부르던 UI_Cross_Wall · Wall_Hatch · Body_Guard = 518-3 logic 경로로 · 518-3 새 logic 넷의 옛 Culvert_Types · Extra 경로 = 518-2 logic 으로
- test_472_3 GEOMETRY_PULLS 남은 3 줄(Design_Surface · Excavation · Wall_Hatch) 걷음 = 빈 목록
- Corridor_Structures prettier
- 확인 = 518-1 · 518-2 · 518-3 고친 시험 + golden_472 73 파일 444 통과 · typecheck 0 · design-calc · corridor 묶음 · 옛 판(1d5d63875) ↔ 합친 판 덧그림 대조 938 측점 같음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Wall_Hatch.ts
|
|
* 기슭막이 벽 **형태별 표현**(2026-08-30 사용자 지시 3) — 배관용·독립용·추가(다단)용
|
|
* 세 갈래가 모두 이 한 함수로 그린다. 종전에는 형태를 가리지 않고 돌 박스만 깔아
|
|
* 콘크리트·돌망태·통나무·바자가 전부 돌쌓기로 보였다.
|
|
*
|
|
* 표현은 벽 **전면 기운 띠**(이음선~전면, 두께 REVET_THICKNESS_M) 안에서만 그리고,
|
|
* 벽 폴리곤 clip을 씌워 어떤 높이에서도 밖으로 새지 않게 한다(기존 규칙 유지).
|
|
*
|
|
* 형태 → 표현(Claude 제안, 2026-08-30 사용자 승인 대기 없이 적용):
|
|
* · 돌쌓기(메) : 둥근 큰 돌 한 줄 — 줄눈 없이 사이가 벌어진 메쌓기.
|
|
* · 돌쌓기(찰) : 같은 돌을 작게 + 켜마다 줄눈선 — 모르타르 채움을 나타낸다.
|
|
* · 콘크리트 : 돌 없음 + 사선 해칭 — 도면 관례의 콘크리트 표기.
|
|
* · 돌망태 : 2열 격자 칸(철망 상자) — 칸마다 각진 사각형.
|
|
* · 통나무·목재틀 : 원(통나무 마구리)을 한 줄로 쌓는다.
|
|
* · 바자 : 세로 말뚝 3줄 + 가로 엮음 눈금.
|
|
*
|
|
* 띠 · 물빼기 · 도수로 띠 기하(m 좌표)는 `logic/B06_Section_Cross_Wall_Hatch_Geom`(472-3).
|
|
* ========================================================================== */
|
|
|
|
import { REVET_THICKNESS_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
|
import type { OffsetPoint, WallLayout } from "./logic/B06_Section_Cross_Culvert_Types";
|
|
import {
|
|
chuteBandOutline,
|
|
chuteRun,
|
|
courses,
|
|
hatchOfForm,
|
|
wallDrainRuns,
|
|
wallHatchBand,
|
|
} from "./logic/B06_Section_Cross_Wall_Hatch_Geom";
|
|
|
|
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;
|
|
|
|
/** 띠 안의 국소 좌표 → 화면 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;
|
|
}
|
|
|
|
/** 돌 한 줄 — `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) — 보기용 · 자리는 `wallDrainRuns`. */
|
|
const DRAIN_CLASS = "b06-chart__wall-drain";
|
|
|
|
/** 옹벽 물빼기 — 물빼기마다 두 줄 평행선(벽 안 구간 · `wallDrainRuns`). */
|
|
function drawDrains(
|
|
group: SVGElement,
|
|
wall: WallLayout,
|
|
x: (offset: number) => number,
|
|
y: (elevation: number) => number,
|
|
): void {
|
|
for (const runs of wallDrainRuns(wall)) {
|
|
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 run of runs) {
|
|
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 {
|
|
// 벽을 가로지르던 대각 이음선은 **긋지 않는다**(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, centerTop, embedBase, spanM } = wallHatchBand(wall);
|
|
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) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
|
|
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
|
|
* 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴).
|
|
* 절토 조각(`kind: "cut"` · 152-3)은 3도 절토선 따라.
|
|
*/
|
|
export function appendChuteBand(
|
|
layer: SVGElement,
|
|
allLines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
|
|
chute: { structure: string; thickness_m: number; length_m?: number },
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4). */
|
|
ground?: readonly OffsetPoint[],
|
|
): void {
|
|
const thickness = chute.thickness_m;
|
|
if (!(thickness > 0)) return;
|
|
const lines = chuteRun(allLines, chute.length_m, ground);
|
|
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
|
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
|
|
for (const line of lines) {
|
|
const shape = chuteBandOutline(line.points, 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");
|
|
const along = line.kind === "cut" ? "3도 절토선" : "성토사면";
|
|
const reach = chute.length_m ? `길이 ${chute.length_m.toFixed(2)}m` : "원지반까지";
|
|
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 ${reach}`;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 도수로 · 돌붙임 띠 고름(152-4 · 118-1 관 고름 꼴) — 그린 띠(관 · 박스 · 세월교 · 물넘이)마다 투명 판정면.
|
|
* 벽 판정면 아래 · 보라 선 판정 위(판정면 클래스를 같이 씀). 띠가 없으면 null · 강조 setter 반환.
|
|
*/
|
|
export function attachChuteBandPick(
|
|
layer: SVGElement,
|
|
onPick?: () => void,
|
|
): ((active: boolean) => void) | null {
|
|
const kids = [...layer.children];
|
|
const bands = kids.filter((el) => el.classList.contains("b06-chart__culvert-pitching"));
|
|
if (!bands.length) return null;
|
|
const firstHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"));
|
|
for (const band of onPick ? bands : []) {
|
|
const hit = document.createElementNS(SVG_NS, "polygon");
|
|
hit.setAttribute("points", band.getAttribute("points") ?? "");
|
|
hit.setAttribute("class", "b06-chart__culvert-revet-hit b06-chart__chute-hit");
|
|
const title = document.createElementNS(SVG_NS, "title");
|
|
title.textContent = "도수로 · 돌붙임 선택";
|
|
hit.append(title);
|
|
hit.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
onPick?.();
|
|
});
|
|
layer.insertBefore(hit, firstHit ?? null);
|
|
}
|
|
return (active) => bands.forEach((band) => band.classList.toggle("is-active", active));
|
|
}
|