feat(B05·B06): BOX암거 횡단도 · 3D 중공 셸·날개벽·관통 보어
BOX암거를 세월교와 같은 계류 방향 절단면으로 그리고, 3D는 속이 빈 서피스 셸로 만든다. 관통 컷·중공 튜브는 배수관 세트와 세월교에도 함께 적용한다. B06 횡단도 - `_box_set()` — 내공 폭·높이(B05 옵션), 부재 두께 세월교 승계(측벽 0.2·상하판 0.3), 복토 0.5m(별표2 교차 참조), 날개벽 투영 저판 연장 - 상판 윗면 = 노면 − 복토, 구체는 수평 설치(교본 3장). 계류 하상과의 고저차는 툴팁 표기 - 구체가 노면 아래 묻히므로 계획선은 자르지 않는다(세월교와 다른 점) 3D - 로프트가 **링별 폴리곤**을 받는다 — 구조물을 위·아래 조각으로 갈라 링마다 틈을 벌려 빈 공간을 만든다. 틈 0이면 속 찬 단면 그대로라 토폴로지가 유지된다 - BOX = 내공 구간만 틈을 벌려 측벽이 저절로 생기는 셸 + 날개벽 4매(날개 축 프레임) - 관통 보어 = 링 y에서 틈 높이 2·√(r²−(y−y₀)²) → 배관 외측 기준 원형 구멍. 기슭막이·집수정·세월교 측벽에 적용 - 배관은 중공 튜브(바깥·안쪽 원통 + 양 끝 고리)로 내부 유로를 보인다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)를 책임진다. */
|
||||
|
||||
@@ -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<Array<[number, number]>>;
|
||||
/** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */
|
||||
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<string | number> {
|
||||
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;
|
||||
|
||||
@@ -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<number>();
|
||||
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<ReturnType<typeof computeBoxLayout>>,
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<FordWallRole, number>;
|
||||
} {
|
||||
return {
|
||||
slabLengthM: layout.slabLengthM,
|
||||
heights: new Map(layout.sides.map((side) => [side.role, side.heightM])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user