Files
Aislo/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts
T
eomsangdonandClaude Opus 5 f7be3f9765 feat(B06): 기슭막이를 형태대로 그리고 다단 칸을 따로 세운다
- 옵션 토글 켜짐 색을 주 액션 색으로 바꾼다(붉은 채움이 경고처럼 읽혔다).
- 같은 문구 토스트가 연달아 오면 앞 토스트가 떠 있는 동안 무시한다.
- 벽 내부 표현을 형태별로 나눈다: 메=돌, 찰=돌+줄눈, 콘크리트=사선 해칭,
  돌망태=2열 격자, 통나무=원, 바자=말뚝+엮음. 배관용·독립용·추가용이
  공용 `appendWallHatch` 하나를 쓴다(배관 오버레이의 중복 코드 제거).
- 추가(다단) 기슭막이를 고르면 유입구·유출구 아래에 전용 칸이 서고
  형태·높이·구간값·옵션 행이 그 칸으로 옮겨 간다. 선택이 풀리면 사라진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:12:28 +09:00

231 lines
9.3 KiB
TypeScript

/* =============================================================================
* 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 { WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
const SVG_NS = "http://www.w3.org/2000/svg";
const HATCH_CLASS = "b06-chart__culvert-stone";
/** 형태 문자열 → 표현 갈래. 판정 순서는 좁은 말(돌망태·바자)부터. */
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). */
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;
}
/** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */
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켜. */
function courses(spanM: number, targetM: number): number {
return Math.max(2, Math.round(spanM / targetM));
}
function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void {
const count = courses(frame.spanM, 0.45);
const courseM = frame.spanM / count;
const widthM = REVET_THICKNESS_M * (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)));
}
}
/**
* 벽 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;
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
// 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
layer.append(
line(
[x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
[x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
),
);
const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`;
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);
}
}