From f7be3f976566b0d74e43c3508621e1d68fb336aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 29 Aug 2026 18:12:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=ED=98=95=ED=83=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B3=A0=20=EB=8B=A4=EB=8B=A8=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=84=20=EB=94=B0=EB=A1=9C=20=EC=84=B8=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옵션 토글 켜짐 색을 주 액션 색으로 바꾼다(붉은 채움이 경고처럼 읽혔다). - 같은 문구 토스트가 연달아 오면 앞 토스트가 떠 있는 동안 무시한다. - 벽 내부 표현을 형태별로 나눈다: 메=돌, 찰=돌+줄눈, 콘크리트=사선 해칭, 돌망태=2열 격자, 통나무=원, 바자=말뚝+엮음. 배관용·독립용·추가용이 공용 `appendWallHatch` 하나를 쓴다(배관 오버레이의 중복 코드 제거). - 추가(다단) 기슭막이를 고르면 유입구·유출구 아래에 전용 칸이 서고 형태·높이·구간값·옵션 행이 그 칸으로 옮겨 간다. 선택이 풀리면 사라진다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Drainage_Facility.ts | 16 ++ B06_Section/B06_Section_UI_Adjust_Dock.ts | 50 +++- B06_Section/B06_Section_UI_Cross_Culvert.ts | 72 +----- .../B06_Section_UI_Cross_Structure_Panel.ts | 14 +- B06_Section/B06_Section_UI_Cross_Wall.ts | 66 +---- .../B06_Section_UI_Cross_Wall_Hatch.ts | 230 ++++++++++++++++++ .../B06_Section_UI_Page_Structures_Panel.ts | 6 +- .../B06_Section_UI_Style_Cross_Controls.css | 22 +- ui_template/ui_template_elements.ts | 15 +- 9 files changed, 340 insertions(+), 151 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 0936561c..9425eb2b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -181,6 +181,8 @@ export interface FacilityOptionsForm { /** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */ inletSlot: HTMLElement; outletSlot: HTMLElement; + /** 다단(추가) 기슭막이 행이 옮겨 붙는 자리 — 비면 그룹째 숨는다(2026-08-30 지시 4). */ + extraSlot: HTMLElement; /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ inletStructure: () => InletStructureKind; setInletStructure: (value: InletStructureKind) => void; @@ -269,6 +271,15 @@ export function createFacilityOptionsForm( outletSlot, ); + // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 + // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 + // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). + const extraGroup = group("추가 기슭막이"); + const extraSlot = document.createElement("div"); + extraSlot.className = "b05-structure__adjust-slot"; + extraGroup.body.append(extraSlot); + extraGroup.root.hidden = true; + // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── // 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과 // 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다. @@ -406,6 +417,7 @@ export function createFacilityOptionsForm( pipeRow, inletGroup.root, outletGroup.root, + extraGroup.root, revetGroup.root, boxWrap, wingInFields.root, @@ -439,6 +451,9 @@ export function createFacilityOptionsForm( fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; revetGroup.root.hidden = current !== "revetment"; + // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, + // 선택이 풀리면 스스로 사라진다. + extraGroup.root.hidden = extraSlot.childElementCount === 0; if (isFord) syncFordSummary(); if (isBox) syncBoxSize(); if (hasWing) { @@ -498,6 +513,7 @@ export function createFacilityOptionsForm( root, inletSlot, outletSlot, + extraSlot, inletStructure: () => inletKindOf(inletType.value).structure, setInletStructure(value) { // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 33a54f20..a169a526 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -13,14 +13,34 @@ let dockRoot: HTMLDetailsElement | null = null; let slot: HTMLElement | null = null; let placeholder: HTMLElement | null = null; -/** 유입구·유출구 그룹의 이식 자리(2026-08-29 사용자 지시 4). B06 패널이 등록한다. */ -let sideSlots: { inlet: HTMLElement; outlet: HTMLElement } | null = null; +/** 유입구·유출구·추가 기슭막이 그룹의 이식 자리(2026-08-29 지시 4, 2026-08-30 지시 4). + * B06 패널이 등록한다. */ +let sideSlots: { inlet: HTMLElement; outlet: HTMLElement; extra: HTMLElement } | null = null; -/** 단 수·옵션 행을 옮겨 붙일 자리를 등록한다(계곡 통과 시설 폼의 유입구·유출구). */ -export function setAdjustSideSlots(next: { inlet: HTMLElement; outlet: HTMLElement }): void { +/** 행을 옮겨 붙일 자리를 등록한다(계곡 통과 시설 폼의 유입구·유출구·추가 기슭막이). */ +export function setAdjustSideSlots(next: { + inlet: HTMLElement; + outlet: HTMLElement; + extra: HTMLElement; +}): void { sideSlots = next; } +/** 다단 벽에서 옮겨 오는 행 — 폼에 자기 칸이 없어 형태·높이·구간값까지 전부 온다. */ +const EXTRA_ROWS = [ + ".b06-structure-panel__struct--form", + ".b06-structure-panel__struct--height", + ".b06-structure-panel__struct--span", + ".b06-structure-panel__struct--tier", + ".b06-structure-panel__struct--options", +].join(", "); + +/** 유입구·유출구는 폼이 이미 가진 항목 말고 단 수·옵션만 받는다. */ +const SIDE_ROWS = [ + ".b06-structure-panel__struct--tier", + ".b06-structure-panel__struct--options", +].join(", "); + /** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { if (!target) return false; @@ -39,9 +59,12 @@ function clearSideSlots(): void { const { row, anchor } = movedRows.pop()!; anchor.parentNode?.replaceChild(row, anchor); } - for (const target of [sideSlots?.inlet, sideSlots?.outlet]) { + for (const target of [sideSlots?.inlet, sideSlots?.outlet, sideSlots?.extra]) { target?.replaceChildren(); - target?.closest("fieldset")?.classList.remove("is-active"); + const group = target?.closest("fieldset"); + group?.classList.remove("is-active"); + // 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다. + if (group && target === sideSlots?.extra) group.hidden = true; } } @@ -52,12 +75,17 @@ function clearSideSlots(): void { function relocateSideRows(panelRoot: HTMLElement): void { clearSideSlots(); const side = panelRoot.dataset.side; - if (!sideSlots || (side !== "inlet" && side !== "outlet")) return; + if (!sideSlots || (side !== "inlet" && side !== "outlet" && side !== "extra")) return; const target = sideSlots[side]; - if (!slotUsable(target)) return; - const rows = panelRoot.querySelectorAll( - ".b06-structure-panel__struct--tier, .b06-structure-panel__struct--options", - ); + // 추가 기슭막이 칸은 평소 숨어 있다 — 고른 벽이 다단이면 여기서 세운다. + if (side === "extra") { + const group = target.closest("fieldset"); + if (!group) return; + group.hidden = false; + const legend = group.querySelector("legend"); + if (legend) legend.textContent = panelRoot.dataset.extraTitle || "추가 기슭막이"; + } else if (!slotUsable(target)) return; + const rows = panelRoot.querySelectorAll(side === "extra" ? EXTRA_ROWS : SIDE_ROWS); rows.forEach((row) => { if (row.classList.contains("is-hidden")) return; // 제자리에 표식을 남기고 옮긴다 — 선택이 바뀌면 이 자리로 되돌린다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 6a67e9a9..be936d9b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -13,7 +13,6 @@ import { REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, - REVET_THICKNESS_M, pipeWallThicknessM, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Geom"; @@ -23,6 +22,7 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; +import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. @@ -181,73 +181,9 @@ export function appendCulvertOverlay( if (onSelectRevet) revetShape.classList.add("is-selectable"); revetShapes.set(wallKey, revetShape); layer.append(revetShape); - // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선): - // 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다. - const joint = document.createElementNS(SVG_NS, "line"); - // 하단선이 원지반을 따라 기울어 있으므로 이음선 바닥도 하단선 위에서 잡는다. - const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M; - 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; - joint.setAttribute("x1", String(x(wall.topJoint.offset))); - joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation))); - joint.setAttribute("x2", String(x(jointBaseOffset))); - joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset)))); - joint.setAttribute("class", "b06-chart__culvert-stone"); - layer.append(joint); - // 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다. - // 좌·우 벽은 outward 부호로 자동 반전된다. 벽 높이는 지형마다 달라지므로 - // ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려 - // 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정). - const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; - const clipId = `b06-revet-clip-${wallKey}-${Math.round(wall.backOffset * 100)}`; - const clip = document.createElementNS(SVG_NS, "clipPath"); - clip.setAttribute("id", clipId); - clip.append( - polygon( - wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), - "", - "", - ), - ); - const stoneGroup = document.createElementNS(SVG_NS, "g"); - stoneGroup.setAttribute("clip-path", `url(#${clipId})`); - layer.append(clip, stoneGroup); - - const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2); - const embedBase = bottomAtOffset(centerBaseOffset); - const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase); - const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45)); - const stoneHeight = stoneSpan / stoneCount; - // 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다. - const centerBase = centerBaseOffset; - const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2); - // 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다). - const axisX = x(centerTop) - x(centerBase); - const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase); - const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI; - for (let i = 0; i < stoneCount; i += 1) { - const fraction = (i + 0.5) / stoneCount; - const centerOffset = centerBase + (centerTop - centerBase) * fraction; - const centerElevation = embedBase + stoneSpan * fraction; - const cx = x(centerOffset); - const cy = toDisplayY(centerElevation); - const stone = document.createElementNS(SVG_NS, "rect"); - const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9; - const heightPx = stoneHeight * pixelsPerMeter * 0.86; - stone.setAttribute("x", String(cx - widthPx / 2)); - stone.setAttribute("y", String(cy - heightPx / 2)); - stone.setAttribute("width", String(widthPx)); - stone.setAttribute("height", String(heightPx)); - stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3)); - stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`); - stone.setAttribute("class", "b06-chart__culvert-stone"); - stoneGroup.append(stone); - } + // 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이 + // 맡는다 — 독립 기슭막이(`_Cross_Wall`)와 같은 함수다(2026-08-30 사용자 지시 3). + appendWallHatch(layer, wall, x, toDisplayY, wallKey); } if (layout.basin) { const basin = layout.basin; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 4d93473d..dd2445b3 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -237,6 +237,8 @@ export function buildStructurePanel( // 높이 행 — ±0.1m, 한계는 재질이 정한다. const heightParts = makeRow(L("B06_Cross_Height_Label")); const heightRow = heightParts.row; + // 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4). + heightRow.classList.add("b06-structure-panel__struct--height"); const heightValue = document.createElement("span"); heightValue.className = "b06-structure-panel__hval"; const heightMinus = makeButton( @@ -391,6 +393,8 @@ export function buildStructurePanel( readout, makeButton("+", `${label} ${stepM}m 늘리기`, () => step(stepM)), ); + // 다단 벽에서는 세 구간값 행도 [추가 기슭막이] 그룹으로 함께 옮겨 간다. + parts.row.classList.add("b06-structure-panel__struct--span"); spanRows.push({ row: parts.row, value: readout, field }); }; makeSpanRow("길이", "lengthM"); @@ -569,12 +573,16 @@ export function buildStructurePanel( tierChannel = key === "outlet" ? "outlet" : key === "inlet" ? "basin" : null; // 좌측 [횡단 조정] 사본의 단 수·옵션 행이 어느 그룹(유입구·유출구)으로 옮겨 // 붙을지 알린다(2026-08-29 지시 4). 다단 벽은 자기 채널 쪽으로 간다. - root.dataset.side = - tierChannel === "outlet" || key.startsWith("extra") + // 다단(추가) 벽은 유입·유출 어느 칸에도 자기 자리가 없다 — 유입구·유출구 아래 + // **전용 칸**으로 보낸다(2026-08-30 사용자 지시 4). + root.dataset.side = isExtra + ? "extra" + : tierChannel === "outlet" ? "outlet" - : tierChannel === "basin" || key.startsWith("bextra") + : tierChannel === "basin" ? "inlet" : ""; + root.dataset.extraTitle = isExtra ? (title.textContent ?? "") : ""; const tiers = tierChannel === "outlet" ? deps.extraState() diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 8d014974..2e05e2e6 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -18,6 +18,7 @@ import { } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -119,68 +120,9 @@ export function drawRevetWall( ); layer.append(revetShape); - // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계) — 상단 변 중간점에서 바닥으로. - const joint = document.createElementNS(SVG_NS, "line"); - const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M; - 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; - joint.setAttribute("x1", String(x(wall.topJoint.offset))); - joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation))); - joint.setAttribute("x2", String(x(jointBaseOffset))); - joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset)))); - joint.setAttribute("class", "b06-chart__culvert-stone"); - layer.append(joint); - - // 돌 해칭 — 큰 돌이 한 줄로 쌓인 계류측 기운 띠. 벽 폴리곤 clip 안에만 그려 어떤 - // 높이에서도 돌이 벽 밖으로 새지 않게 한다. - const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; - const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`; - const clip = document.createElementNS(SVG_NS, "clipPath"); - clip.setAttribute("id", clipId); - clip.append( - polygon( - wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), - "", - "", - ), - ); - const stoneGroup = document.createElementNS(SVG_NS, "g"); - stoneGroup.setAttribute("clip-path", `url(#${clipId})`); - layer.append(clip, stoneGroup); - - const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2); - const embedBase = bottomAtOffset(centerBaseOffset); - const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase); - const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45)); - const stoneHeight = stoneSpan / stoneCount; - const centerBase = centerBaseOffset; - const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2); - const axisX = x(centerTop) - x(centerBase); - const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase); - const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI; - for (let i = 0; i < stoneCount; i += 1) { - const fraction = (i + 0.5) / stoneCount; - const centerOffset = centerBase + (centerTop - centerBase) * fraction; - const centerElevation = embedBase + stoneSpan * fraction; - const cx = x(centerOffset); - const cy = toDisplayY(centerElevation); - const stone = document.createElementNS(SVG_NS, "rect"); - const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9; - const heightPx = stoneHeight * pixelsPerMeter * 0.86; - stone.setAttribute("x", String(cx - widthPx / 2)); - stone.setAttribute("y", String(cy - heightPx / 2)); - stone.setAttribute("width", String(widthPx)); - stone.setAttribute("height", String(heightPx)); - stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3)); - stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`); - stone.setAttribute("class", "b06-chart__culvert-stone"); - stoneGroup.append(stone); - } + // 이음선 + **형태별 표현**(돌/콘크리트/돌망태/통나무/바자)은 공용 해칭이 맡는다 + // (2026-08-30 사용자 지시 3) — 배관 오버레이와 같은 함수를 쓴다. + appendWallHatch(layer, wall, x, toDisplayY, keyId); return revetShape; } diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts new file mode 100644 index 00000000..85c9982a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -0,0 +1,230 @@ +/* ============================================================================= + * 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); + } +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 3380398f..9ff174f4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -203,7 +203,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc resetAdjustDock(); section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot()); // 단 수·옵션 행은 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4). - setAdjustSideSlots({ inlet: section.facility.inletSlot, outlet: section.facility.outletSlot }); + setAdjustSideSlots({ + inlet: section.facility.inletSlot, + outlet: section.facility.outletSlot, + extra: section.facility.extraSlot, + }); async function load(): Promise { if (!deps.projectId) return; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 2d7b7918..e27a0b25 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -30,10 +30,12 @@ padding: 0 var(--spacing-8); } +/* 켜짐 색은 **주 액션 색**이다(2026-08-30 사용자: 붉은 채움이 경고처럼 보인다). + * 경고·삭제 자리를 비워 두고, 옵션이 켜졌다는 사실만 조용히 알린다. */ .b06-structure-panel__toggle.is-on { - border-color: var(--color-danger); - background: var(--color-danger); - color: var(--color-text-inverse, #fff); + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); } .b06-structure-panel.is-hidden, @@ -348,6 +350,16 @@ color: var(--color-text-body); } +/* 옮겨 온 형태 드롭다운(다단 벽) — 폼의 다른 선택칸과 같은 한 덩어리로 앉힌다. */ +.b05-structure__adjust-slot .b06-structure-panel__select { + min-width: 0; + flex: 1 1 auto; + border: 0; + background: transparent; + color: var(--color-text); + font-size: var(--text-caption); +} + .b05-structure__adjust-slot .b06-structure-panel__hval { display: flex; min-width: 0; @@ -398,8 +410,8 @@ } .b05-structure__adjust-slot .b06-structure-panel__toggle.is-on { - background: var(--color-danger); - color: var(--color-text-inverse, #fff); + background: var(--color-primary); + color: var(--color-primary-text); font-weight: var(--font-weight-medium); } diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 7a68ec0d..3cb7d4fa 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -278,8 +278,21 @@ function ensureToastContainer(): HTMLDivElement { return toastContainer; } -/** 토스트 표시. durationMs 후 자동 소멸. */ +/** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */ +let lastToast: { key: string; until: number } | null = null; + +/** + * 토스트 표시. durationMs 후 자동 소멸. + * + * **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서 + * 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는 + * 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다. + */ export function showToast(message: string, kind: ToastKind = "info", durationMs = 3000): void { + const key = `${kind}::${message}`; + const now = Date.now(); + if (lastToast && lastToast.key === key && now < lastToast.until) return; + lastToast = { key, until: now + durationMs }; const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message }); ensureToastContainer().append(toast); // 진입 애니메이션 트리거