diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 9e71c5f6..4e740e74 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -206,14 +206,17 @@ function structureLoftGeometry( structure: CorridorStructure, origin: SceneOrigin, ): THREE.BufferGeometry | null { - const polygon = structure.polygon; const rings = structure.rings; + // 링별 폴리곤이 오면 그것을, 아니면 공통 폴리곤 하나를 모든 링에 쓴다(2026-08-25). + const perRing = structure.polygons; + const polygon = perRing?.[0] ?? structure.polygon; if (!polygon || polygon.length < 3 || !rings || rings.length < 2) return null; + if (perRing && perRing.length !== rings.length) return null; const count = polygon.length; const ringCount = rings.length; const positions = new Float32Array(ringCount * count * 3); rings.forEach((frame, ring) => { - polygon.forEach(([offset, elevation], index) => { + (perRing?.[ring] ?? polygon).forEach(([offset, elevation], index) => { const x = frame.cx + frame.leftX * offset; const y = frame.cy + frame.leftY * offset; const i = (ring * count + index) * 3; @@ -248,8 +251,14 @@ function structureLoftGeometry( return flat; } -/** 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 원통(관경 지름). */ -function structurePipeMesh(structure: CorridorStructure, origin: SceneOrigin): THREE.Mesh | null { +/** + * 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 **중공 튜브**(2026-08-25 사용자: + * 내부 유로 반영). 바깥·안쪽 원통을 열린 채로 겹치고 양 끝을 고리로 막는다. + */ +function structurePipeMesh( + structure: CorridorStructure, + origin: SceneOrigin, +): THREE.Object3D | null { const pipe = structure.pipe; const frame = structure.frame; if (!pipe || !frame) return null; @@ -267,15 +276,37 @@ function structurePipeMesh(structure: CorridorStructure, origin: SceneOrigin): T if (!(length > 0.1)) return null; // 관 하단선이 축이므로 반지름만큼 위로 올려 관 중심을 맞춘다. const radius = pipe.diameterM / 2; - const geometry = new THREE.CylinderGeometry(radius, radius, length, 16); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe }), + // 관벽 두께가 없으면 관경의 2%를 임시로 준다 — 면이 겹쳐 z-파이팅이 나지 않을 정도. + const wall = Math.min(Math.max(pipe.wallThicknessM ?? pipe.diameterM * 0.02, 0.01), radius * 0.9); + const inner = radius - wall; + const material = (side: THREE.Side): THREE.Material => + new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe, side }); + const group = new THREE.Group(); + group.add( + new THREE.Mesh( + new THREE.CylinderGeometry(radius, radius, length, 20, 1, true), + material(THREE.FrontSide), + ), + // 안쪽 벽은 관 속에서 보이므로 법선을 뒤집는다(내부 유로 면). + new THREE.Mesh( + new THREE.CylinderGeometry(inner, inner, length, 20, 1, true), + material(THREE.BackSide), + ), ); - mesh.position.copy(start.clone().add(end).multiplyScalar(0.5)); - mesh.position.y += radius; - mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize()); - return mesh; + // 양 끝 관벽 단면 — 고리로 막아 튜브가 열린 파이프로 보이게 한다. + for (const sign of [1, -1]) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(inner, radius, 20), + material(THREE.DoubleSide), + ); + ring.rotation.x = Math.PI / 2; + ring.position.y = (sign * length) / 2; + group.add(ring); + } + group.position.copy(start.clone().add(end).multiplyScalar(0.5)); + group.position.y += radius; + group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize()); + return group; } /** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 3009cfd8..446bff53 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -21,12 +21,20 @@ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_A import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST, } from "../B06_Section/B06_Section_UI_Cross_Ford"; import type { FordLayout } from "../B06_Section/B06_Section_UI_Cross_Ford"; +import { + boreBandAt, + boreRingChainages, + boxSolids, + splitByBand, +} from "./B05_Profile_UI_Corridor_Structures_Box"; +import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -53,10 +61,22 @@ export interface CorridorStructure { kind: "revet" | "basin" | "pipe"; /** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */ polygon?: Array<[number, number]>; + /** + * 링마다 다른 단면 폴리곤(길이 = rings.length, 꼭짓점 수는 링끼리 같아야 한다). + * 있으면 `polygon` 대신 쓴다 — 속 빈 구조물과 관 보어를 이 하나로 만든다 + * (2026-08-25 사용자: 중공 서피스 셸 + 배관 외측 기준 관통 컷). + * 빈 공간은 폴리곤을 **면적 0으로 눌러** 표현하므로 링 토폴로지가 항상 같다. + */ + polygons?: Array>; /** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */ rings?: StructureFrame[]; - /** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m). */ - pipe?: { start: [number, number]; end: [number, number]; diameterM: number }; + /** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m), 관벽 두께(m·중공 튜브). */ + pipe?: { + start: [number, number]; + end: [number, number]; + diameterM: number; + wallThicknessM?: number; + }; /** 배관이 앉는 측점 프레임(pipe 전용). */ frame?: StructureFrame; } @@ -64,6 +84,30 @@ export interface CorridorStructure { /** 스윕 프레임 간격(m) — 곡선 구간에서 벽이 노선을 따라 휘는 해상도. */ const SWEEP_STEP_M = 1.0; +/** 관 하단 두 점으로 보어 제원(반지름·중심 표고·유효 구간)을 만든다. */ +function pipeBore( + diameterM: number, + start: { offset: number; elevation: number }, + end: { offset: number; elevation: number }, +): { + radius: number; + centerAt: (offset: number) => number; + minOffset: number; + maxOffset: number; +} { + const radius = diameterM / 2; + const span = end.offset - start.offset; + return { + radius, + centerAt: (offset) => { + const t = Math.abs(span) < 1e-9 ? 0 : (offset - start.offset) / span; + return start.elevation + (end.elevation - start.elevation) * t + radius; + }, + minOffset: Math.min(start.offset, end.offset), + maxOffset: Math.max(start.offset, end.offset), + }; +} + /** 전/후 몫 확정 — 저장분 없으면 길이 절반씩(패널 기본과 동일). */ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [number, number] { const length = spec?.revet_length_m ?? fallbackLength; @@ -176,7 +220,7 @@ export function buildCorridorStructures( for (const section of crossSections) { const layout = culvertLayoutOf(section); const fordLayout = fordLayoutOf(section); - if (!layout && !fordLayout) continue; + if (!layout && !fordLayout && !section.box) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { cx: section.center_x, @@ -228,11 +272,82 @@ export function buildCorridorStructures( }); }; + /** 링 누가거리 목록으로 프레임을 만든다(보어처럼 촘촘한 간격이 필요할 때). */ + const ringsAt = (chainages: number[]): StructureFrame[] => + chainages.map((at) => { + const frame = frameAt(at); + if (!frame) return stationFrame; + return { + cx: frame.cx, + cy: frame.cy, + leftX: frame.leftX, + leftY: frame.leftY, + dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0, + }; + }); + + /** + * 관이 뚫고 지나는 구조물 — 단면을 위·아래 조각으로 갈라 링마다 관 지름만큼 + * 벌린다(2026-08-25 사용자: 배관 외측 서피스 기준 관통 컷). 관이 닿지 않는 + * 조각은 그대로 남는다(틈이 폴리곤 밖이면 자르기가 원본을 돌려준다). + */ + const pushPierced = ( + kind: "revet" | "basin", + points: Array<{ offset: number; elevation: number }>, + beforeM: number, + afterM: number, + bore: { + radius: number; + centerAt: (offset: number) => number; + minOffset: number; + maxOffset: number; + }, + ): void => { + if (points.length < 3) return; + const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); + const left = Math.max(...polygon.map(([offset]) => offset)); + const right = Math.min(...polygon.map(([offset]) => offset)); + // 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다. + if (left < bore.minOffset || right > bore.maxOffset) { + pushSwept(kind, points, beforeM, afterM); + return; + } + const chainages = boreRingChainages( + chainage - beforeM, + chainage + afterM, + SWEEP_STEP_M, + chainage, + bore.radius, + ); + const rings = ringsAt(chainages); + const center = bore.centerAt((left + right) / 2); + const upper: SectionPolygon[] = []; + const lower: SectionPolygon[] = []; + for (const at of chainages) { + const [top, bottom] = splitByBand(polygon, boreBandAt(at, chainage, bore.radius, center)); + upper.push(top); + lower.push(bottom); + } + for (const pieces of [upper, lower]) { + // 어느 링에서든 비면 그 조각은 통째로 버린다 — 링 토폴로지를 맞춰야 한다. + if (pieces.some((piece) => piece.length < 3)) continue; + solids.push({ chainage_m: chainage, kind, polygons: pieces, rings }); + } + }; + + if (section.box) solids.push(...boxSolids(section, frameAt, stationFrame)); + if (fordLayout) { // 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다. const half = Math.max(fordLayout.ford.span_m, 0.5) / 2; + const invertPoints = fordLayout.pipe.outer; + const fordBore = pipeBore( + fordLayout.ford.diameter_m, + { offset: invertPoints[3].offset, elevation: invertPoints[3].elevation }, + { offset: invertPoints[2].offset, elevation: invertPoints[2].elevation }, + ); for (const side of fordLayout.sides) { - for (const part of side.parts) pushSwept("basin", part.points, half, half); + for (const part of side.parts) pushPierced("basin", part.points, half, half, fordBore); } pushSwept("basin", fordLayout.slabBridge, half, half); // 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련). @@ -248,6 +363,10 @@ export function buildCorridorStructures( start: [invert[3].offset, invert[3].elevation], end: [invert[2].offset, invert[2].elevation], diameterM: fordLayout.ford.diameter_m, + wallThicknessM: pipeWallThicknessM( + fordLayout.ford.pipe_kind, + fordLayout.ford.diameter_m, + ), }, frame: frame ? { @@ -265,9 +384,10 @@ export function buildCorridorStructures( const inletSpan = wallSpanOf(section, "inlet"); const outletSpan = wallSpanOf(section, "outlet"); const basinSpan = basinSpanOf(section); + const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet); for (const wall of layout.walls) { const span = wall.role === "inlet" ? inletSpan : outletSpan; - pushSwept("revet", wall.points, span.beforeM, span.afterM); + pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); } // 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다. for (const wall of layout.extraWalls) { @@ -292,7 +412,8 @@ export function buildCorridorStructures( } } if (minOffset < maxOffset && minElevation < maxElevation) { - pushSwept( + // 집수정도 관이 뚫고 들어오므로 같은 규칙으로 보어를 낸다(2026-08-25 사용자). + pushPierced( "basin", [ { offset: minOffset, elevation: minElevation }, @@ -302,6 +423,7 @@ export function buildCorridorStructures( ], basinSpan.beforeM, basinSpan.afterM, + culvertBore, ); } } @@ -313,6 +435,7 @@ export function buildCorridorStructures( start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation], end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation], diameterM: layout.culvert.diameter_m, + wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m), }, frame: stationFrame, }); @@ -322,6 +445,20 @@ export function buildCorridorStructures( /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ export function structureHashParts(section: CrossSection): Array { + const box = section.box; + if (box) { + return [ + "bx", + box.inner_width_m, + box.inner_height_m, + box.span_m, + box.cover_m, + box.wing_in.slab_extend_m, + box.wing_in.height_m ?? "", + box.wing_out.slab_extend_m, + box.wing_out.height_m ?? "", + ]; + } const ford = section.ford; if (ford) { const adjust = section.design?.ford_adjust; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts new file mode 100644 index 00000000..3da33ab3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -0,0 +1,252 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Structures_Box.ts + * BOX암거 3D 솔리드와 **관통 보어(구멍)** 계산 — 구조물 본체 + * (`_UI_Corridor_Structures.ts`)에서 700줄 제한으로 분리했다(2026-08-25). + * + * 공통 수법(사용자 확정: 중공 서피스 셸 + 배관 외측 기준 관통 컷): + * 구조물을 **위·아래 두 조각**으로 나눠 각각 로프트하고, 두 조각 사이를 링마다 + * 벌리면 그 틈이 곧 빈 공간이다. 틈을 0으로 눌러 두면 원래의 속 찬 단면이 된다. + * · BOX 구체 = 측벽 구간은 틈 0(속 참) → 내공 구간만 벌림 ⇒ 측벽이 저절로 생긴다. + * · 관통 보어 = 링(누가거리) y에서 틈 높이 2·√(r²−(y−y₀)²) ⇒ 원형 구멍. + * 링 토폴로지가 항상 같아 스티칭이 단순하고, 눌린 구간은 면적 0이라 겹침이 없다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box"; +import type { + CorridorStructure, + RouteFrame, + StructureFrame, +} from "./B05_Profile_UI_Corridor_Structures"; + +/** 단면 폴리곤 한 장 — [offset, elevation] 목록. */ +export type SectionPolygon = Array<[number, number]>; + +/** 링마다의 빈 틈(표고 아래·위). 같으면 그 링은 속이 차 있다. */ +export interface VoidBand { + bottom: number; + top: number; +} + +/** 조각 폴리곤 꼭짓점 수 — 잘린 조각이 3~5점이 되므로 최대치로 맞춰 둔다. */ +const PIECE_VERTICES = 6; + +/** 꼭짓점 수를 맞춘다 — 모자라면 마지막 점을 겹쳐 채운다(면적 0 삼각형). */ +function padTo(points: SectionPolygon, count: number): SectionPolygon { + if (points.length < 3) return []; + const padded = points.slice(0, count); + while (padded.length < count) padded.push(padded[padded.length - 1]); + return padded; +} + +/** 폴리곤을 수평선으로 자른다. `keepAbove`면 위쪽 조각, 아니면 아래쪽 조각. */ +export function clipAt(polygon: SectionPolygon, z: number, keepAbove: boolean): SectionPolygon { + const inside = ([, elevation]: [number, number]): boolean => + keepAbove ? elevation >= z : elevation <= z; + const cut: SectionPolygon = []; + for (let i = 0; i < polygon.length; i += 1) { + const current = polygon[i]; + const next = polygon[(i + 1) % polygon.length]; + const currentIn = inside(current); + if (currentIn) cut.push(current); + if (currentIn === inside(next)) continue; + const span = next[1] - current[1]; + const t = Math.abs(span) < 1e-9 ? 0 : (z - current[1]) / span; + cut.push([current[0] + (next[0] - current[0]) * t, z]); + } + return cut; +} + +/** + * 폴리곤을 위·아래 조각으로 나눈다(링마다 다른 틈). 틈이 0이면 경계면이 겹치므로 + * 두 조각이 원래 단면을 그대로 채운다. + */ +export function splitByBand( + polygon: SectionPolygon, + band: VoidBand, +): [SectionPolygon, SectionPolygon] { + return [ + padTo(clipAt(polygon, band.top, true), PIECE_VERTICES), + padTo(clipAt(polygon, band.bottom, false), PIECE_VERTICES), + ]; +} + +/** 관이 뚫는 구멍 — 링 누가거리에서의 반현(半弦)으로 틈을 만든다. */ +export function boreBandAt( + ringChainage: number, + pipeChainage: number, + radius: number, + centerElevation: number, +): VoidBand { + const offset = Math.abs(ringChainage - pipeChainage); + const half = offset >= radius ? 0 : Math.sqrt(radius * radius - offset * offset); + return { bottom: centerElevation - half, top: centerElevation + half }; +} + +/** + * 보어를 담을 링 누가거리 — 성긴 간격에 관 구간만 촘촘하게 끼워 넣는다. + * 관 지름이 1m 안팎이라 성긴 1m 간격으로는 원형이 사라진다. + */ +export function boreRingChainages( + from: number, + to: number, + coarseStep: number, + pipeChainage: number, + radius: number, +): number[] { + const values = new Set(); + const push = (value: number): void => { + if (value >= from - 1e-6 && value <= to + 1e-6) values.add(Number(value.toFixed(3))); + }; + push(from); + for (let at = from; at < to; at += coarseStep) push(at); + push(to); + const fine = Math.max(radius / 6, 0.05); + for (let at = pipeChainage - radius; at <= pipeChainage + radius + 1e-6; at += fine) push(at); + push(pipeChainage - radius); + push(pipeChainage + radius); + return [...values].sort((a, b) => a - b); +} + +/** BOX암거 3D 솔리드(구체 셸 + 날개벽 4매). 기하가 부족하면 빈 배열. */ +export function boxSolids( + section: CrossSection, + frameAt: (chainageM: number) => RouteFrame | null, + stationFrame: StructureFrame, +): CorridorStructure[] { + const box = section.box; + const layout = computeBoxLayout(section, section.samples); + if (!box || !layout) return []; + const chainage = section.chainage_m; + const halfSpan = box.span_m / 2; + const innerHalf = box.inner_width_m / 2; + const barrelTop = layout.topElevation - box.top_thickness_m; + const barrelBottom = barrelTop - box.inner_height_m; + const leftEdge = layout.topSlab[0].offset; + const rightEdge = layout.topSlab[1].offset; + const slabLeft = layout.bottomSlab[0].offset; + const slabRight = layout.bottomSlab[1].offset; + + // 측벽 자리는 틈 0(속 참), 내공 구간만 벌린다. 벽면을 수직으로 세우려고 경계 + // 누가거리마다 링을 두 장 겹쳐 둔다(길이 0 구간 = 벽 안쪽 면). + const ringPlan: Array<{ at: number; open: boolean }> = [ + { at: chainage - halfSpan, open: false }, + { at: chainage - innerHalf, open: false }, + { at: chainage - innerHalf, open: true }, + { at: chainage + innerHalf, open: true }, + { at: chainage + innerHalf, open: false }, + { at: chainage + halfSpan, open: false }, + ]; + const rings = ringPlan.map(({ at }) => { + const frame = frameAt(at); + return frame + ? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 } + : stationFrame; + }); + const upper: SectionPolygon[] = []; + const lower: SectionPolygon[] = []; + for (const { open } of ringPlan) { + const band: VoidBand = open + ? { bottom: barrelBottom, top: barrelTop } + : { bottom: barrelTop, top: barrelTop }; + upper.push([ + [leftEdge, layout.topElevation], + [rightEdge, layout.topElevation], + [rightEdge, band.top], + [leftEdge, band.top], + ]); + lower.push([ + [slabLeft, band.bottom], + [slabRight, band.bottom], + [slabRight, layout.bottomElevation], + [slabLeft, layout.bottomElevation], + ]); + } + const solids: CorridorStructure[] = [ + { chainage_m: chainage, kind: "basin", polygons: upper, rings }, + { chainage_m: chainage, kind: "basin", polygons: lower, rings }, + ]; + solids.push(...wingSolids(section, layout, frameAt, stationFrame)); + return solids; +} + +/** + * 날개벽 4매 — 박스 네 모서리에서 각도만큼 벌어져 나간다(2026-08-25 사용자: 3D에 + * 날개벽과 바닥이 있어야 한다). 노선이 아니라 **날개 축**을 따라 프레임을 만들어 + * 같은 로프트에 태우고, 높이는 구체 높이에서 짧은쪽 높이로 체감시킨다. + */ +function wingSolids( + section: CrossSection, + layout: NonNullable>, + frameAt: (chainageM: number) => RouteFrame | null, + stationFrame: StructureFrame, +): CorridorStructure[] { + const box = section.box; + if (!box) return []; + const chainage = section.chainage_m; + const here = frameAt(chainage); + const ahead = frameAt(chainage + 1) ?? here; + if (!here || !ahead) return []; + // 노선 진행 방향 — 앞 측점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다. + let dirX = ahead.cx - here.cx; + let dirY = ahead.cy - here.cy; + const norm = Math.hypot(dirX, dirY); + if (norm < 1e-6) { + dirX = -here.leftY; + dirY = here.leftX; + } else { + dirX /= norm; + dirY /= norm; + } + const inletOnLeft = (section.uphill_side ?? "left") === "left"; + const halfSpan = box.span_m / 2; + const thickness = box.wall_thickness_m; + const solids: CorridorStructure[] = []; + + for (const side of ["left", "right"] as const) { + const wing = (side === "left") === inletOnLeft ? box.wing_in : box.wing_out; + if (!wing.installed) continue; + const length = Math.max(wing.length_m ?? 0, 0); + if (length <= 0.05) continue; + const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180; + // 구체 축(= 좌향) 기준으로 벌어진다. 박스 양 끝에서 도로 앞·뒤로 한 장씩. + const axisSign = side === "left" ? 1 : -1; + const edgeOffset = side === "left" ? layout.topSlab[0].offset : layout.topSlab[1].offset; + const height0 = layout.topElevation - layout.bottomElevation; + const height1 = Math.max(wing.height_m ?? height0, 0.3); + for (const along of [1, -1]) { + const startX = here.cx + here.leftX * edgeOffset + dirX * along * halfSpan; + const startY = here.cy + here.leftY * edgeOffset + dirY * along * halfSpan; + // 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터. + const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle); + const wingY = here.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle); + const wingNorm = Math.hypot(wingX, wingY) || 1; + const ux = wingX / wingNorm; + const uy = wingY / wingNorm; + const rings: StructureFrame[] = []; + const polygons: SectionPolygon[] = []; + const steps = 4; + for (let i = 0; i <= steps; i += 1) { + const t = (length * i) / steps; + rings.push({ + cx: startX + ux * t, + cy: startY + uy * t, + // 패널 두께 방향 = 날개 축의 법선. + leftX: -uy, + leftY: ux, + dz: 0, + }); + const top = layout.bottomElevation + height0 + ((height1 - height0) * i) / steps; + polygons.push([ + [-thickness / 2, top], + [thickness / 2, top], + [thickness / 2, layout.bottomElevation], + [-thickness / 2, layout.bottomElevation], + ]); + } + solids.push({ chainage_m: chainage, kind: "revet", polygons, rings }); + } + } + if (!solids.length && stationFrame) return []; + return solids; +} diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 0730f9c4..356f29fe 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -248,6 +248,24 @@ export interface FordSet { wing_out: FordWingSpec; } +/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */ +export interface BoxSet { + type: "box"; + /** 사용자 입력 내공(유로) 폭·높이(m). */ + inner_width_m: number; + inner_height_m: number; + /** 부재 두께(m) — 세월교 승계(2026-08-25 사용자 확정). */ + wall_thickness_m: number; + slab_thickness_m: number; + top_thickness_m: number; + /** 암거 위 복토(m) — 별표2 "복토 흙 두께 50㎝ 이상" 교차 참조. */ + cover_m: number; + /** 도로 진행 방향 길이(m) = 내공 폭 + 측벽 2장. 기준 측점 전후로 절반씩 걸친다. */ + span_m: number; + wing_in: FordWingSpec; + wing_out: FordWingSpec; +} + export interface CrossSection extends SectionStation { samples: SectionSample[]; /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ @@ -256,6 +274,8 @@ export interface CrossSection extends SectionStation { culvert?: CulvertSet; /** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */ ford?: FordSet; + /** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */ + box?: BoxSet; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 3eec1497..1db16673 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -26,6 +26,7 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from common_util.common_util_drainage_pipes import ( + PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_PIPE, parse_pipe_points, @@ -68,6 +69,11 @@ FORD_WALL_THICKNESS_M = 0.2 # 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다. FORD_DEFAULT_WIDTH_M = 10.0 +# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다 +# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다. +# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상". +BOX_COVER_M = 0.5 + def pipe_points_file(project_root: Path) -> Path: """프로젝트 저장소 안의 관 지점 정본 경로.""" @@ -248,6 +254,34 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: } +def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: + """BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). + + 구체 축은 계류 방향이라 횡단도에는 종단 절단면(상판·내공·저판)이 보인다. + 상판 윗면은 노면에서 복토 0.5m 아래이고, 저판은 날개벽 투영만큼 계류 상·하류로 + 길어진다 — 연장 산식은 세월교와 같다. + """ + defaults = _registry_defaults("box_culvert") + values = dict(options or {}) + inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0)) + inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0)) + wall = FORD_WALL_THICKNESS_M + slab = FORD_SLAB_THICKNESS_M + return { + "type": "box", + "inner_width_m": inner_width or 2.0, + "inner_height_m": inner_height or 2.0, + "wall_thickness_m": wall, + "slab_thickness_m": slab, + "top_thickness_m": slab, + "cover_m": BOX_COVER_M, + # 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다. + "span_m": (inner_width or 2.0) + 2 * wall, + "wing_in": _wing_spec(values, defaults, "in"), + "wing_out": _wing_spec(values, defaults, "out"), + } + + def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: """관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다. @@ -264,11 +298,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: return {} sets: dict[float, dict[str, Any]] = {} for point in parse_pipe_points(document.get("points")): - # BOX암거·물넘이포장은 그림이 다르다 — 배관과 세월교만 그린다. + # 물넘이포장은 그림이 다르다 — 배관·세월교·BOX암거만 그린다. if point.facility == PIPE_FACILITY_PIPE: spec = _culvert_set(point.options) elif point.facility == PIPE_FACILITY_FORD_BRIDGE: spec = _ford_set(point.options) + elif point.facility == PIPE_FACILITY_BOX: + spec = _box_set(point.options) else: continue sets[round(float(point.chainage_m), 2)] = spec @@ -286,11 +322,12 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] if chainage is None: continue for pipe_chainage, spec in sets.items(): - # 세월교 구체는 월류 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다. + # 세월교 구체·BOX암거는 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다. reach = _CHAINAGE_TOLERANCE_M + (_number(spec.get("span_m"), 0.0) or 0.0) / 2 if abs(chainage - pipe_chainage) <= reach: - # 세월교는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. - section["ford" if spec.get("type") == "ford" else "culvert"] = spec + # 세월교·BOX암거는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. + kind = spec.get("type") + section["ford" if kind == "ford" else "box" if kind == "box" else "culvert"] = spec attached += 1 break return attached diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts new file mode 100644 index 00000000..41816084 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Box.ts + * BOX암거 측점 횡단 카드의 구체(상판·저판·내공) **오버레이 그리기**. + * + * 기하 계산은 `B06_Section_UI_Cross_Box_Geom.ts`가 맡는다(700줄 제한 분리). + * 여기서는 계산 결과(`BoxLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지. + * + * 그리는 순서 = 시공 순서: ① 저판 → ② 상판 → ③ 내공 윤곽·라벨. + * 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다. + * ========================================================================== */ + +import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; + +export { computeBoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; +export type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. */ +export function appendBoxOverlay( + layer: SVGElement, + layout: BoxLayout, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): void { + const { box } = layout; + const toXY = (point: OffsetPoint): [number, number] => [ + x(point.offset), + toDisplayY(point.elevation), + ]; + const polygon = ( + points: OffsetPoint[], + className: string, + tooltip: string, + ): SVGPolygonElement => { + const shape = document.createElementNS(SVG_NS, "polygon"); + shape.setAttribute("points", points.map((point) => toXY(point).join(",")).join(" ")); + shape.setAttribute("class", className); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = tooltip; + shape.append(title); + return shape; + }; + + const wingText = (wing: { + installed: boolean; + length_m: number | null; + angle_deg: number | null; + }): string => (wing.installed ? `${wing.length_m ?? 0}m·${wing.angle_deg ?? 0}°` : "없음"); + + // ① 저판 — 날개벽 투영만큼 계류 상·하류로 길어진다. + layer.append( + polygon( + layout.bottomSlab, + "b06-chart__ford-slab", + `BOX암거 저판 — 길이 ${layout.slabLengthM.toFixed(2)}m · 두께 ${box.slab_thickness_m.toFixed(2)}m` + + ` (날개벽 유입 ${wingText(box.wing_in)} / 유출 ${wingText(box.wing_out)} 투영 연장 반영)` + + (layout.bedGapM > 0.05 + ? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음` + : layout.bedGapM < -0.05 + ? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)` + : ""), + ), + ); + + // ② 상판 — 윗면이 노면 아래 복토 두께만큼 내려온다. + layer.append( + polygon( + layout.topSlab, + "b06-chart__ford-slab", + `BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 −${box.cover_m.toFixed(2)}m(복토)`, + ), + ); + + // ③ 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다. + const barrel = polygon( + layout.barrel, + "b06-chart__box-barrel", + `BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` + + ` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m`, + ); + layer.append(barrel); + + const label = document.createElementNS(SVG_NS, "text"); + const [labelX, labelY] = toXY(layout.label.at); + label.setAttribute("x", String(labelX)); + label.setAttribute("y", String(labelY)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__culvert-label"); + label.textContent = layout.label.text; + layer.append(label); +} diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts new file mode 100644 index 00000000..bb6a0b39 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Box_Geom.ts + * BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한). + * 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지). + * + * 구성(2026-08-25 사용자 확정): + * · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다. + * · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장). + * · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식. + * · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다. + * · 구체가 노면 아래 묻히므로 **계획선은 자르지 않는다** — 노견 밖 성토 비탈이 실제로 + * 존재한다(세월교는 측벽이 비탈을 대신해 잘랐다). + * 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다. + * ========================================================================== */ + +import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; + +export interface BoxLayout { + box: BoxSet; + /** 상판 폴리곤(노견~노견). */ + topSlab: OffsetPoint[]; + /** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */ + bottomSlab: OffsetPoint[]; + /** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */ + barrel: OffsetPoint[]; + /** 상판 윗면·저판 밑면 표고. */ + topElevation: number; + bottomElevation: number; + /** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */ + slabLengthM: number; + /** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */ + bedGapM: number; + label: { at: OffsetPoint; text: string }; +} + +/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */ +function roadTopFactory( + section: CrossSection, + left: { offset_m: number; elevation_m: number }, + right: { offset_m: number; elevation_m: number }, +): (offset: number) => number { + const designAt = designInterpolator(section.design?.design_line); + if (designAt) return designAt; + const span = left.offset_m - right.offset_m; + return (offset: number): number => { + if (Math.abs(span) < 1e-6) return left.elevation_m; + const t = (offset - right.offset_m) / span; + return right.elevation_m + (left.elevation_m - right.elevation_m) * t; + }; +} + +/** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */ +function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] { + return [ + { offset: left, elevation: top }, + { offset: right, elevation: top }, + { offset: right, elevation: bottom }, + { offset: left, elevation: bottom }, + ]; +} + +/** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */ +export function computeBoxLayout( + section: CrossSection, + groundSamples: SectionSample[], +): BoxLayout | null { + const box = section.box; + if (!box) return null; + const edges = section.design?.road_edges; + if (!edges) return null; + const groundAt = groundInterpolator(groundSamples); + if (!groundAt) return null; + + // 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백). + const inletOnLeft = (section.uphill_side ?? "left") === "left"; + const roadTopAt = roadTopFactory(section, edges.left, edges.right); + const leftEdge = edges.left.offset_m; + const rightEdge = edges.right.offset_m; + if (!(leftEdge > rightEdge)) return null; + + // 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다. + const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)); + const topElevation = roadLow - box.cover_m; + const barrelTop = topElevation - box.top_thickness_m; + const barrelBottom = barrelTop - box.inner_height_m; + const bottomElevation = barrelBottom - box.slab_thickness_m; + + // 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값). + const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0); + const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0); + const slabLeft = leftEdge + leftExtend; + const slabRight = rightEdge - rightExtend; + + // 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다. + let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge)); + for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) { + bed = Math.min(bed, groundAt(offset)); + } + + return { + box, + topSlab: rect(leftEdge, rightEdge, topElevation, barrelTop), + bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation), + barrel: rect(leftEdge, rightEdge, barrelTop, barrelBottom), + topElevation, + bottomElevation, + slabLengthM: Math.abs(slabLeft - slabRight), + bedGapM: Number((bottomElevation - bed).toFixed(3)), + label: { + at: { offset: (leftEdge + rightEdge) / 2, elevation: (barrelTop + barrelBottom) / 2 }, + text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`, + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 347c8550..f407a841 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -24,9 +24,10 @@ import { type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; +import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel"; -import { fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford"; +import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import type { FordHighlightSetter, FordWallRole } from "./B06_Section_UI_Cross_Ford"; import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -183,8 +184,7 @@ export function createCrossSectionCard( let showFordPanel = (_visible: boolean): void => {}; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; - /** 지금 그린 관 길이(m) — 조정창 표기용(배수관 측점 아니면 null). */ - let culvertPipeLengthM: number | null = null; + let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; @@ -442,12 +442,10 @@ export function createCrossSectionCard( culvertLink, revetLink, ); - // 세월교 구체 기하 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25). - const fordLayout = computeFordLayout( - section, - section.samples, - ford?.adjustFor(section.chainage_m), - ); + // 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25). + const boxLayout = computeBoxLayout(section, section.samples); + const adjust = ford?.adjustFor(section.chainage_m); + const fordLayout = computeFordLayout(section, section.samples, adjust); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendCrossDesignOverlay( @@ -458,7 +456,7 @@ export function createCrossSectionCard( drawSamples, culvertLayout?.designTrim ?? fordLayout?.designTrim ?? undefined, ); - // 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의. + // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( plotLayer, @@ -468,9 +466,8 @@ export function createCrossSectionCard( toDisplayY, ); } - // 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19). - // 관은 소유 측점 전용이라 링크 카드에서는 길이를 비운다. 나머지 상태는 링크 - // 카드도 채운다 — 조정창을 열어 위치·연동을 만져야 한다(2026-08-24 사용자). + // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점 + // 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24). culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null); culvertInletIsBasin = !!culvertLayout?.basin; if (culvertLayout) { @@ -481,6 +478,7 @@ export function createCrossSectionCard( culvertWallSpecs = state.wallSpecs; culvertAppliedD = state.appliedD; } + if (boxLayout) appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY); if (fordLayout) { setFordActive = appendFordOverlay( plotLayer, @@ -490,8 +488,7 @@ export function createCrossSectionCard( ford ? toggleFordWall : undefined, ); if (activeFordWall) setFordActive(activeFordWall); - fordSlabLengthM = fordLayout.slabLengthM; - fordWallHeights = new Map(fordLayout.sides.map((side) => [side.role, side.heightM])); + ({ slabLengthM: fordSlabLengthM, heights: fordWallHeights } = fordCardState(fordLayout)); } if (culvertLayout) { setRevetActive = appendCulvertOverlay( diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 51ab6c0d..92a39e2b 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -7,7 +7,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { FordPanelDeps, FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; -import type { FordWallRole } from "./B06_Section_UI_Cross_Ford"; +import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford"; /** 조정창 배선에 필요한 카드 상태. 계산 결과는 그릴 때마다 바뀌므로 함수로 받는다. */ export interface FordPanelContext { @@ -50,3 +50,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps { close: context.close, }; } + +/** 조정창이 쓸 세월교 카드 상태 — 마지막 기하 결과에서 뽑는다(카드 렌더러 700줄 분리). */ +export function fordCardState(layout: FordLayout): { + slabLengthM: number; + heights: Map; +} { + return { + slabLengthM: layout.slabLengthM, + heights: new Map(layout.sides.map((side) => [side.role, side.heightM])), + }; +} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index aedca727..4813878c 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -309,3 +309,12 @@ stroke-width: 1.2; stroke-linejoin: round; } + +/* BOX암거 내공(유로) — 면은 비우고 윤곽만. 물이 지나는 빈 공간이라 채우지 않는다. */ +.b06-chart__box-barrel { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 4 3; + stroke-linejoin: round; +}