feat(B05): 3D 예상형상에 배수관 세트 구조물 반영
B06 횡단(computeCulvertLayout)을 그대로 재사용해 3D 코리도에 기슭막이·집수정· 배관을 세우고, 그 구간 성토/절토 단면을 횡단도와 같은 형상으로 다시 그린다. 구조물 솔리드(_Corridor_Structures.ts 신규) - 기슭막이: 단면 폴리곤을 노선 따라 1m 간격 로프트 스윕(직선 압출 아님) - 집수정: 부재 외곽 직육면체, 연장 basin_length_m(기본 2m) 전후 균등 - 배관: 관 하단선 축 원통(관경 지름) - 스윕 프레임에 계획고 차(dz)를 실어 종단 경사 반영 - flat 법선 + EdgesGeometry 모서리 검은선(성토부·노폭 리본과 같은 규칙) 단면 재구성(_Corridor_Station.ts) - 구조물 측 비탈 = 노견 → (노폭 연장) → 구조물 상단 → 전면 → 벽 하단 성토부선 → 다단 기슭막이 → 지반/절토선 끝 한 줄(PIECE_COLS cut·fill 9 -> 20) - 성토로 늘어난 노폭은 비탈이 아니라 노견 리본을 넓힌다 - 집수정 계류측 cutLine까지 이어 원지반을 그만큼 걷어낸다 구간 처리(_Corridor_Build.ts) - 구조물 점유 구간(기준측점 전/후)이 다음 측점을 넘어가도 같은 단면 유지 - 그 구간은 지반 트림 생략, 비탈 앵커를 노견 조각 바깥 끝으로 통일 길이·전후 입력 - 레지스트리 pipe 옵션 추가: inlet/outlet_revet_before_m·after_m(5/5), inlet_basin_length_m(2) - B05 배수관 서브폼에 기준측점 전/후 칸(전+후=길이 연동)·집수정 길이 칸 - _side_spec이 전/후·집수정 길이를 CulvertSideSpec으로 전달 비정규 측점 프레임 정정(_Page_Helpers.ts) - left_xy 선형보간(단위길이 깨짐·방향이 현 쪽으로 누움) -> 노선 폴리라인 접선에서 직접 산출(left = (-ty, tx)), 폴백은 각도 보간 BUILD_VERSION 10 — 저장본 자동 만료 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,15 @@
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_basin_length_m",
|
||||
"label": "집수정 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_form",
|
||||
"label": "유입 기슭막이 형태",
|
||||
@@ -74,6 +83,24 @@
|
||||
"default": 10,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_before_m",
|
||||
"label": "유입 기슭막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_after_m",
|
||||
"label": "유입 기슭막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_height_m",
|
||||
"label": "유입 기슭막이 높이",
|
||||
@@ -108,6 +135,24 @@
|
||||
"default": 10,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_before_m",
|
||||
"label": "유출 기슭막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_after_m",
|
||||
"label": "유출 기슭막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_height_m",
|
||||
"label": "유출 기슭막이 높이",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type CorridorBuildResult,
|
||||
type CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures";
|
||||
|
||||
/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */
|
||||
export interface CorridorStructureHook {
|
||||
@@ -41,6 +42,8 @@ interface CorridorEnvelope {
|
||||
outline: CorridorBuildResult["outline"];
|
||||
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
|
||||
caps?: CorridorBuildResult["caps"];
|
||||
/** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */
|
||||
structures?: CorridorBuildResult["structures"];
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -73,7 +76,7 @@ function fnv1a(text: string): string {
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
const BUILD_VERSION = 7; // 측구 지형 스냅 오염 저장본 만료(뷰어 in-place 변형 수정).
|
||||
const BUILD_VERSION = 10; // 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드 추가(2026-08-23).
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
@@ -101,6 +104,8 @@ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePo
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
// 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다.
|
||||
structureHashParts(section).forEach((part) => parts.push(part));
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
@@ -135,6 +140,7 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
structures: build.structures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +155,7 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
structures: envelope.structures ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
} from "./B05_Profile_UI_Corridor_Station";
|
||||
|
||||
export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station";
|
||||
import {
|
||||
buildCorridorStructures,
|
||||
type CorridorStructure,
|
||||
type RouteFrame,
|
||||
} from "./B05_Profile_UI_Corridor_Structures";
|
||||
export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
|
||||
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
|
||||
export interface CorridorRibbon {
|
||||
@@ -58,6 +64,8 @@ export interface CorridorBuildResult {
|
||||
outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> };
|
||||
/** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */
|
||||
caps: CorridorCap[];
|
||||
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
|
||||
structures: CorridorStructure[];
|
||||
}
|
||||
|
||||
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분).
|
||||
@@ -251,6 +259,33 @@ export function buildCorridor(
|
||||
outerRight: OffsetPoint;
|
||||
}> = [];
|
||||
|
||||
/**
|
||||
* 구조물(기슭막이·집수정)이 종방향으로 점유하는 측점 찾기 — **측점 경계를 넘어도**
|
||||
* 그 구간 전체가 같은 단면을 쓴다(2026-08-23 사용자: 전/후 5m가 다음 측점에 걸리면
|
||||
* 그 측점에도 같은 기슭막이가 이어져야 3D 길이가 맞다). 겹치면 가장 가까운 측점.
|
||||
*/
|
||||
const structureSources = stations.filter((station) => station.wallSpans);
|
||||
const structureAt = (chainageM: number, side: "left" | "right"): StationPieces | null => {
|
||||
let best: StationPieces | null = null;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const station of structureSources) {
|
||||
const span = station.wallSpans?.[side];
|
||||
if (!span) continue;
|
||||
if (
|
||||
chainageM < station.chainage_m - span.beforeM - 1e-9 ||
|
||||
chainageM > station.chainage_m + span.afterM + 1e-9
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const distance = Math.abs(chainageM - station.chainage_m);
|
||||
if (distance < bestDistance) {
|
||||
best = station;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
for (let i = 0; i < stations.length - 1; i += 1) {
|
||||
const s0 = stations[i];
|
||||
const s1 = stations[i + 1];
|
||||
@@ -286,6 +321,8 @@ export function buildCorridor(
|
||||
((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t)
|
||||
: 0;
|
||||
const sections = new Map<string, OffsetPoint[]>();
|
||||
/** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */
|
||||
const fixedSlopeKeys = new Set<string>();
|
||||
keys.forEach((key) => {
|
||||
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
|
||||
const hasA = s0.pieces.has(key);
|
||||
@@ -321,19 +358,38 @@ export function buildCorridor(
|
||||
// 절·성토는 상대 측점의 축퇴점으로 모핑하지 않는다(2026-08-23 사용자:
|
||||
// 완전 분리). 비탈 시작점 기준 **상대 좌표**를 보간해 두고, 아래 공통
|
||||
// 블록에서 행 앵커(측구 끝/노견 끝)에 얹은 뒤 행 지반과 교차시켜 종결한다.
|
||||
const relA = s0.slopeRelative.get(key);
|
||||
const relB = s1.slopeRelative.get(key);
|
||||
const relative =
|
||||
relA && relB
|
||||
? lerpPoints(relA, relB, t)
|
||||
: (relA ?? relB)?.map((point) => ({ ...point }));
|
||||
if (!relative || relative.length < 2) return;
|
||||
points = relative;
|
||||
// 구조물 점유 구간(2026-08-23 사용자)은 그 측점의 트림 사면(노견 연장
|
||||
// + 구조물 상단 종결)을 **그대로** 쓰고 지반 트림을 생략한다.
|
||||
const fixedStation = structureAt(chainage, side as "left" | "right");
|
||||
if (fixedStation) {
|
||||
const fixed = fixedStation.slopeRelative.get(key);
|
||||
if (!fixed || fixed.length < 2) return;
|
||||
points = fixed.map((point) => ({ ...point }));
|
||||
fixedSlopeKeys.add(key);
|
||||
} else {
|
||||
const relA = s0.slopeRelative.get(key);
|
||||
const relB = s1.slopeRelative.get(key);
|
||||
const relative =
|
||||
relA && relB
|
||||
? lerpPoints(relA, relB, t)
|
||||
: (relA ?? relB)?.map((point) => ({ ...point }));
|
||||
if (!relative || relative.length < 2) return;
|
||||
points = relative;
|
||||
}
|
||||
} else {
|
||||
const pa = s0.pieces.get(key);
|
||||
const pb = s1.pieces.get(key);
|
||||
if (!pa || !pb) return;
|
||||
points = lerpPoints(pa, pb, t);
|
||||
// 노견은 구조물 구간에서 **넓어진 폭 그대로** 고정한다(2026-08-23 사용자:
|
||||
// 성토로 늘어난 노폭 반영). 구조물이 없는 구간은 종전대로 보간.
|
||||
const widened =
|
||||
kind === "shoulder" ? structureAt(chainage, side as "left" | "right") : null;
|
||||
const fixed = widened?.pieces.get(key);
|
||||
if (fixed) {
|
||||
points = fixed.map((point) => ({ ...point }));
|
||||
} else {
|
||||
const pa = s0.pieces.get(key);
|
||||
const pb = s1.pieces.get(key);
|
||||
if (!pa || !pb) return;
|
||||
points = lerpPoints(pa, pb, t);
|
||||
}
|
||||
}
|
||||
if (shift !== 0 && kind !== "cut" && kind !== "fill") applyProfileShift(points, shift);
|
||||
sections.set(key, points);
|
||||
@@ -345,15 +401,23 @@ export function buildCorridor(
|
||||
const ditchPoints = sections.get(pieceKey("ditch", side));
|
||||
const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left;
|
||||
const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left;
|
||||
// 비탈 앵커 = 도로부 바깥 끝. 측구가 있으면 측구 끝, 없으면 **노견 조각의
|
||||
// 바깥 끝**(구조물 구간에서 넓어진 노폭이 그대로 반영된다 — 2026-08-23).
|
||||
const shoulderPoints = sections.get(pieceKey("shoulder", side));
|
||||
const shoulderOuter = shoulderPoints
|
||||
? side === "right"
|
||||
? shoulderPoints[0]
|
||||
: shoulderPoints[shoulderPoints.length - 1]
|
||||
: null;
|
||||
const anchor = ditchPoints
|
||||
? side === "right"
|
||||
? ditchPoints[0]
|
||||
: ditchPoints[ditchPoints.length - 1]
|
||||
: {
|
||||
: (shoulderOuter ?? {
|
||||
offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t,
|
||||
// 도로부는 종단 보정을 전량 받는다 — 그 값을 얹어야 노면과 높이가 맞는다.
|
||||
elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t + shift,
|
||||
};
|
||||
});
|
||||
(["cut", "fill"] as const).forEach((kind) => {
|
||||
const slopeKey = pieceKey(kind, side);
|
||||
const slope = sections.get(slopeKey);
|
||||
@@ -363,6 +427,8 @@ export function buildCorridor(
|
||||
point.offset_m += anchor.offset_m;
|
||||
point.elevation_m += anchor.elevation_m;
|
||||
});
|
||||
// 기슭막이 구간 — 사면은 벽 이음선 상단에서 끝난다(지반 트림 금지).
|
||||
if (fixedSlopeKeys.has(slopeKey)) return;
|
||||
if (!groundRow) return;
|
||||
// 절토는 지반 위로 만나는 곳까지, 성토는 아래로 만나는 곳까지 — 지반이
|
||||
// 반대편이면 이 행에 그 종류는 없다(조각 드롭 → 리본 자연 종료 = 분리).
|
||||
@@ -490,5 +556,30 @@ export function buildCorridor(
|
||||
});
|
||||
if (points.length >= 2) caps.push({ points });
|
||||
});
|
||||
return { ribbons, outline, caps };
|
||||
// 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단과 같은 계산을 그대로 얹는다.
|
||||
// 스윕 프레임은 코리도 행과 같은 규약(평면 스플라인 + 좌향 slerp)을 쓴다.
|
||||
const frameAt = (at: number): RouteFrame | null => {
|
||||
const first = stations[0];
|
||||
const last = stations[stations.length - 1];
|
||||
const clamped = Math.min(Math.max(at, first.chainage_m), last.chainage_m);
|
||||
let index = 0;
|
||||
while (index < stations.length - 2 && stations[index + 1].chainage_m < clamped) index += 1;
|
||||
const a = stations[index];
|
||||
const b = stations[index + 1];
|
||||
const span = b.chainage_m - a.chainage_m;
|
||||
const t = span <= 1e-9 ? 0 : (clamped - a.chainage_m) / span;
|
||||
const xy = sampler?.(clamped) ?? {
|
||||
x: a.center.x + (b.center.x - a.center.x) * t,
|
||||
y: a.center.y + (b.center.y - a.center.y) * t,
|
||||
};
|
||||
const left = slerpLeft(a.left, b.left, t);
|
||||
return {
|
||||
cx: xy.x,
|
||||
cy: xy.y,
|
||||
leftX: left.x,
|
||||
leftY: left.y,
|
||||
designZ: profileZ?.(clamped) ?? null,
|
||||
};
|
||||
};
|
||||
return { ribbons, outline, caps, structures: buildCorridorStructures(crossSections, frameAt) };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
CorridorCap,
|
||||
CorridorKind,
|
||||
CorridorRibbon,
|
||||
CorridorStructure,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
/**
|
||||
@@ -188,6 +189,95 @@ function capGeometry(cap: CorridorCap, origin: SceneOrigin): THREE.BufferGeometr
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** 구조물 모서리 추출 임계각(도) — 이보다 꺾인 모서리만 검은선으로 긋는다.
|
||||
* 스윕 분할선(거의 평평한 이음)은 걸러지고 단면 모서리·마구리만 남는다. */
|
||||
const STRUCTURE_EDGE_ANGLE_DEG = 18;
|
||||
|
||||
/** 배수관 세트 구조물 색 — 기슭막이(돌빛 황갈), 집수정(콘크리트 회청), 배관(강관 청). */
|
||||
const STRUCTURE_COLORS: Record<CorridorStructure["kind"], number> = {
|
||||
revet: 0xc9a86a,
|
||||
basin: 0x8d93a3,
|
||||
pipe: 0x4f83cc,
|
||||
};
|
||||
|
||||
/** 단면 폴리곤(offset/표고)을 스윕 프레임 열에 앉혀 노선을 따라 로프트한 솔리드
|
||||
* (2026-08-23 사용자 — 직선 압출이 아니라 노선 스윕). */
|
||||
function structureLoftGeometry(
|
||||
structure: CorridorStructure,
|
||||
origin: SceneOrigin,
|
||||
): THREE.BufferGeometry | null {
|
||||
const polygon = structure.polygon;
|
||||
const rings = structure.rings;
|
||||
if (!polygon || polygon.length < 3 || !rings || rings.length < 2) 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) => {
|
||||
const x = frame.cx + frame.leftX * offset;
|
||||
const y = frame.cy + frame.leftY * offset;
|
||||
const i = (ring * count + index) * 3;
|
||||
positions[i] = x - origin.cx;
|
||||
// 종단 경사 — 프레임마다 계획고 차이를 얹는다(2026-08-23 사용자).
|
||||
positions[i + 1] = elevation + frame.dz - origin.cz;
|
||||
positions[i + 2] = -(y - origin.cy);
|
||||
});
|
||||
});
|
||||
const indices: number[] = [];
|
||||
const lastBase = (ringCount - 1) * count;
|
||||
for (let i = 1; i < count - 1; i += 1) {
|
||||
indices.push(0, i, i + 1); // 앞 마구리(부채꼴 — 단면 폴리곤은 볼록 사각형이다)
|
||||
indices.push(lastBase, lastBase + i + 1, lastBase + i); // 뒷 마구리(반대 감김)
|
||||
}
|
||||
for (let ring = 0; ring < ringCount - 1; ring += 1) {
|
||||
const a0 = ring * count;
|
||||
const b0 = a0 + count;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const j = (i + 1) % count;
|
||||
indices.push(a0 + i, b0 + i, a0 + j, a0 + j, b0 + i, b0 + j);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex(indices);
|
||||
// 면마다 자기 법선(flat) — 인덱스를 공유한 채 평균 법선을 쓰면 모서리가 뭉개져
|
||||
// 구조물이 찌그러져 보인다(2026-08-23 사용자 지적: 엣지를 살릴 것).
|
||||
const flat = geometry.toNonIndexed();
|
||||
geometry.dispose();
|
||||
flat.computeVertexNormals();
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 원통(관경 지름). */
|
||||
function structurePipeMesh(structure: CorridorStructure, origin: SceneOrigin): THREE.Mesh | null {
|
||||
const pipe = structure.pipe;
|
||||
const frame = structure.frame;
|
||||
if (!pipe || !frame) return null;
|
||||
const { cx, cy, leftX, leftY } = frame;
|
||||
const toScene = ([offset, elevation]: [number, number]): THREE.Vector3 =>
|
||||
new THREE.Vector3(
|
||||
cx + leftX * offset - origin.cx,
|
||||
elevation - origin.cz,
|
||||
-(cy + leftY * offset - origin.cy),
|
||||
);
|
||||
const start = toScene(pipe.start);
|
||||
const end = toScene(pipe.end);
|
||||
const axis = end.clone().sub(start);
|
||||
const length = axis.length();
|
||||
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 }),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */
|
||||
export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
@@ -228,5 +318,39 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
mesh.name = `corridor-cap:${index}`;
|
||||
group.add(mesh);
|
||||
});
|
||||
// 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단 계산 그대로의 솔리드
|
||||
// (2026-08-23 사용자). 리본 격자와 무관한 독립 물체라 개별 Mesh로 얹는다.
|
||||
(build.structures ?? []).forEach((structure, index) => {
|
||||
if (structure.kind === "pipe") {
|
||||
const mesh = structurePipeMesh(structure, origin);
|
||||
if (mesh) {
|
||||
mesh.name = `corridor-structure:pipe:${index}`;
|
||||
group.add(mesh);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const geometry = structureLoftGeometry(structure, origin);
|
||||
if (!geometry) return;
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshLambertMaterial({
|
||||
color: STRUCTURE_COLORS[structure.kind],
|
||||
side: THREE.DoubleSide,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: POLYGON_OFFSET_FACTOR,
|
||||
polygonOffsetUnits: POLYGON_OFFSET_FACTOR,
|
||||
}),
|
||||
);
|
||||
mesh.name = `corridor-structure:${structure.kind}:${index}`;
|
||||
group.add(mesh);
|
||||
// 모서리 검은선 — 성토부·노폭 리본과 같은 규칙으로 경계를 또렷하게
|
||||
// (2026-08-23 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다.
|
||||
const edges = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(geometry, STRUCTURE_EDGE_ANGLE_DEG),
|
||||
new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.9 }),
|
||||
);
|
||||
edges.name = `corridor-structure-edge:${structure.kind}:${index}`;
|
||||
group.add(edges);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -10,17 +10,21 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { basinSpanOf, culvertLayoutOf, wallSpanOf } from "./B05_Profile_UI_Corridor_Structures";
|
||||
|
||||
export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill";
|
||||
export type CorridorSide = "left" | "right" | "center";
|
||||
|
||||
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */
|
||||
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히.
|
||||
* 절·성토는 구조물 구간에서 [노견 → 연장 → 벽 상단 → 벽 전면 → 성토부선 → 다단
|
||||
* 벽들 → 지반]까지 한 줄로 담아야 해 넉넉히 잡는다(2026-08-23 사용자 — 기슭막이
|
||||
* 하단 성토사면·추가 기슭막이 반영). */
|
||||
export const PIECE_COLS: Record<CorridorKind, number> = {
|
||||
carriageway: 3,
|
||||
shoulder: 2,
|
||||
ditch: 5,
|
||||
cut: 9,
|
||||
fill: 9,
|
||||
cut: 20,
|
||||
fill: 20,
|
||||
};
|
||||
|
||||
export interface XY {
|
||||
@@ -62,6 +66,13 @@ export interface StationPieces {
|
||||
slopeRelative: Map<string, OffsetPoint[]>;
|
||||
/** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */
|
||||
ground: ((offset: number) => number) | null;
|
||||
/**
|
||||
* 배수관 세트 기슭막이의 종방향 점유 구간(2026-08-23 사용자 — 성토부 형태 변경).
|
||||
* 이 구간 안 행은 그 측의 비탈을 이 측점의 트림 사면(노견 연장 + 벽 상단 종결)
|
||||
* 그대로 쓰고 **지반 트림을 하지 않는다** — 벽에서 끝난 사면을 지반까지 도로
|
||||
* 늘리면 안 된다.
|
||||
*/
|
||||
wallSpans?: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>>;
|
||||
}
|
||||
|
||||
export function pieceKey(kind: CorridorKind, side: CorridorSide): string {
|
||||
@@ -265,6 +276,120 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
if (endL > slopeStartL + 1e-9) put(leftRole, "left", slopeStartL, endL);
|
||||
if (endR < slopeStartR - 1e-9) put(rightRole, "right", endR, slopeStartR);
|
||||
|
||||
// ── 배수관 세트 측점(2026-08-23 사용자) — 구조물이 선 측의 단면을 B06 횡단
|
||||
// 그대로 다시 그린다. 그 측 비탈 한 줄에 담는 것:
|
||||
// 노견 → (성토로 늘어난 노폭 = 수평 연장) → 구조물 상단 → 구조물 전면 →
|
||||
// 벽 하단 성토부선 → 추가(다단) 기슭막이들 → 지반(또는 절토선 끝).
|
||||
// 노폭 연장분은 비탈이 아니라 **노견 리본**을 넓히고, 집수정이 원지반을
|
||||
// 파고들면 절토선 끝까지 이어 그 만큼 원지반을 걷어낸다.
|
||||
let wallSpans: StationPieces["wallSpans"];
|
||||
const culvertLayout = culvertLayoutOf(section);
|
||||
if (culvertLayout) {
|
||||
const layout = culvertLayout;
|
||||
const inletSide: "left" | "right" =
|
||||
(section.uphill_side ?? "left") === "left" ? "left" : "right";
|
||||
const outletSide: "left" | "right" = inletSide === "left" ? "right" : "left";
|
||||
const spans: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>> = {};
|
||||
const collected = new Map<"left" | "right", OffsetPoint[]>();
|
||||
const push = (
|
||||
side: "left" | "right",
|
||||
points: ReadonlyArray<{ offset: number; elevation: number }>,
|
||||
): void => {
|
||||
const bucket = collected.get(side) ?? [];
|
||||
points.forEach((point) =>
|
||||
bucket.push({ offset_m: point.offset, elevation_m: point.elevation }),
|
||||
);
|
||||
collected.set(side, bucket);
|
||||
};
|
||||
const trimSlopeOf = (side: "left" | "right") =>
|
||||
side === "left" ? layout.designTrim?.maxSlope : layout.designTrim?.minSlope;
|
||||
|
||||
for (const wall of layout.walls) {
|
||||
const side: "left" | "right" = wall.outward > 0 ? "left" : "right";
|
||||
const slope = trimSlopeOf(side);
|
||||
if (slope) push(side, slope.points);
|
||||
// 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다.
|
||||
push(side, [wall.topJoint, wall.bottomFront]);
|
||||
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
|
||||
}
|
||||
// 벽 하단 성토부선·다단 기슭막이(2026-08-23 사용자) — 유출측과 집수정 계류측.
|
||||
layout.outletFill.segments.forEach((segment) => push(outletSide, segment.points));
|
||||
layout.extraWalls.forEach((wall) => push(outletSide, [wall.topJoint, wall.bottomFront]));
|
||||
layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points));
|
||||
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
|
||||
if (layout.inletFill) push(inletSide, layout.inletFill.points);
|
||||
|
||||
let basinCut = false;
|
||||
if (layout.basin) {
|
||||
const slope = trimSlopeOf(inletSide);
|
||||
const trimOffset =
|
||||
inletSide === "left" ? layout.designTrim?.maxOffset : layout.designTrim?.minOffset;
|
||||
const trimElevation =
|
||||
inletSide === "left" ? layout.designTrim?.maxElevation : layout.designTrim?.minElevation;
|
||||
if (slope) {
|
||||
push(inletSide, slope.points);
|
||||
} else if (trimOffset != null && trimElevation != null && Number.isFinite(trimOffset)) {
|
||||
push(inletSide, [
|
||||
{
|
||||
offset: inletSide === "left" ? roadL : roadR,
|
||||
elevation: design.road_edges[inletSide].elevation_m,
|
||||
},
|
||||
{ offset: trimOffset, elevation: trimElevation },
|
||||
]);
|
||||
}
|
||||
// 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다.
|
||||
if (layout.basin.cutLine) {
|
||||
push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]);
|
||||
basinCut = true;
|
||||
}
|
||||
spans[inletSide] = basinSpanOf(section);
|
||||
}
|
||||
|
||||
(["left", "right"] as const).forEach((side) => {
|
||||
const span = spans[side];
|
||||
const points = collected.get(side);
|
||||
if (!span || !points || points.length < 2) return;
|
||||
const outward = side === "left" ? 1 : -1;
|
||||
const edge = {
|
||||
offset_m: side === "left" ? roadL : roadR,
|
||||
elevation_m: design.road_edges[side].elevation_m,
|
||||
};
|
||||
let silhouette = points;
|
||||
// 노폭 연장(성토로 늘어난 노견)은 비탈이 아니라 노견 조각을 넓힌다.
|
||||
if (!(ditchWidth > 0 && ditchSide === side)) {
|
||||
let widening: OffsetPoint | null = null;
|
||||
for (const point of silhouette) {
|
||||
const outer = (point.offset_m - edge.offset_m) * outward;
|
||||
if (outer > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) {
|
||||
if (!widening || (point.offset_m - widening.offset_m) * outward > 0) widening = point;
|
||||
}
|
||||
}
|
||||
if (widening) {
|
||||
const shoulder = pieces.get(pieceKey("shoulder", side));
|
||||
if (shoulder && shoulder.length >= 2) {
|
||||
const outer = side === "left" ? shoulder[shoulder.length - 1] : shoulder[0];
|
||||
outer.offset_m = widening.offset_m;
|
||||
outer.elevation_m = widening.elevation_m;
|
||||
}
|
||||
const cutAt = widening;
|
||||
silhouette = silhouette.filter(
|
||||
(point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (silhouette.length < 2) return;
|
||||
silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const kind: CorridorKind =
|
||||
basinCut && side === inletSide ? "cut" : side === "left" ? leftRole : rightRole;
|
||||
// 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다.
|
||||
(["cut", "fill"] as const).forEach((other) => {
|
||||
if (other !== kind) pieces.delete(pieceKey(other, side));
|
||||
});
|
||||
pieces.set(pieceKey(kind, side), resample(silhouette, PIECE_COLS[kind]));
|
||||
wallSpans = wallSpans ?? {};
|
||||
wallSpans[side] = span;
|
||||
});
|
||||
}
|
||||
// 비탈 상대 좌표 — 안쪽(도로측) 끝점을 원점으로 둔다(행 앵커에 얹는 용도).
|
||||
const slopeRelative = new Map<string, OffsetPoint[]>();
|
||||
(["left", "right"] as const).forEach((side) => {
|
||||
@@ -293,6 +418,7 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
},
|
||||
slopeRelative,
|
||||
ground,
|
||||
wallSpans,
|
||||
ditchRelative: relativeToRoadEdge(pieces, {
|
||||
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
|
||||
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures.ts
|
||||
* 배수관 세트(배관·기슭막이·집수정) **3D 예상형상 솔리드** 계산 — 순수 기하,
|
||||
* Three.js 무의존(2026-08-23 사용자 지시: 3D가 B06 횡단을 그대로 따르게).
|
||||
*
|
||||
* 원칙: 단면 기하는 재구현하지 않는다 — B06 횡단 카드와 같은
|
||||
* `computeCulvertLayout`을 그대로 불러 벽·집수정·관 좌표(offset/표고)를 받는다.
|
||||
* · 기슭막이 벽: 단면 폴리곤을 **노선을 따라 스윕**(1m 간격 프레임 로프트 —
|
||||
* 2026-08-23 사용자: 직선 압출 금지). 연장 = B05 사이드 패널의 길이·기준측점
|
||||
* 전/후(revet_length/before/after_m, 기본 10m·5/5).
|
||||
* · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23
|
||||
* 사용자). 연장 = basin_length_m(기본 2m), 전후 동일.
|
||||
* · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경.
|
||||
* B06 조정창의 세션 조작값(4축 x·d·h·m)은 세션 전용이라 여기서는 자동 배치
|
||||
* 기준이다 — 영속값(design.inlet_structure·basin_adjust)만 반영한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
|
||||
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
|
||||
export interface StructureFrame {
|
||||
cx: number;
|
||||
cy: number;
|
||||
leftX: number;
|
||||
leftY: number;
|
||||
/** 측점 기준 계획고 차이(m) — 구조물이 종단 경사를 타고 오르내린다(2026-08-23). */
|
||||
dz: number;
|
||||
}
|
||||
|
||||
/** 노선 위 한 지점의 프레임 + 그 자리 계획고(없으면 null) — 빌더가 넘겨준다. */
|
||||
export interface RouteFrame {
|
||||
cx: number;
|
||||
cy: number;
|
||||
leftX: number;
|
||||
leftY: number;
|
||||
designZ: number | null;
|
||||
}
|
||||
|
||||
/** 구조물 솔리드 하나 — 직렬화 가능한 평문 데이터(코리도 저장본에 그대로 담긴다). */
|
||||
export interface CorridorStructure {
|
||||
chainage_m: number;
|
||||
kind: "revet" | "basin" | "pipe";
|
||||
/** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */
|
||||
polygon?: Array<[number, number]>;
|
||||
/** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */
|
||||
rings?: StructureFrame[];
|
||||
/** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m). */
|
||||
pipe?: { start: [number, number]; end: [number, number]; diameterM: number };
|
||||
/** 배관이 앉는 측점 프레임(pipe 전용). */
|
||||
frame?: StructureFrame;
|
||||
}
|
||||
|
||||
/** 스윕 프레임 간격(m) — 곡선 구간에서 벽이 노선을 따라 휘는 해상도. */
|
||||
const SWEEP_STEP_M = 1.0;
|
||||
|
||||
/** 전/후 몫 확정 — 저장분 없으면 길이 절반씩(패널 기본과 동일). */
|
||||
function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [number, number] {
|
||||
const length = spec?.revet_length_m ?? fallbackLength;
|
||||
const before = spec?.revet_before_m ?? length / 2;
|
||||
const after = spec?.revet_after_m ?? Math.max(length - before, 0);
|
||||
return [Math.max(before, 0), Math.max(after, 0)];
|
||||
}
|
||||
|
||||
/** 측점별 배수관 세트 기하 캐시 — classifyStation(성토부 트림)과 구조물 빌더가
|
||||
* 같은 계산을 나눠 쓴다. 실패(null)도 캐시해 재계산을 막는다. */
|
||||
const layoutCache = new WeakMap<CrossSection, CulvertLayout | null>();
|
||||
|
||||
export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
|
||||
if (!section.culvert || !section.design) return null;
|
||||
if (layoutCache.has(section)) return layoutCache.get(section) ?? null;
|
||||
let layout: CulvertLayout | null = null;
|
||||
try {
|
||||
layout = computeCulvertLayout(
|
||||
section,
|
||||
section.samples,
|
||||
undefined,
|
||||
section.design.inlet_structure ?? "auto",
|
||||
section.design.basin_adjust,
|
||||
);
|
||||
} catch {
|
||||
layout = null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
|
||||
}
|
||||
layoutCache.set(section, layout);
|
||||
return layout;
|
||||
}
|
||||
|
||||
/** 구조물의 종방향 점유 구간(기준측점 전/후 m). */
|
||||
export interface StructureSpan {
|
||||
beforeM: number;
|
||||
afterM: number;
|
||||
}
|
||||
|
||||
/** 기슭막이 벽의 종방향 점유 구간(기준측점 전/후 m) — 성토부 트림·스윕이 함께 쓴다. */
|
||||
export function wallSpanOf(section: CrossSection, role: "inlet" | "outlet"): StructureSpan {
|
||||
const spec = role === "inlet" ? section.culvert?.inlet : section.culvert?.outlet;
|
||||
const [beforeM, afterM] = splitOf(spec, 10);
|
||||
return { beforeM, afterM };
|
||||
}
|
||||
|
||||
/** 집수정의 종방향 점유 구간 — 전후 동일(길이/2), 기본 2m(2026-08-23 사용자). */
|
||||
export function basinSpanOf(section: CrossSection): StructureSpan {
|
||||
const half = Math.max(section.culvert?.inlet.basin_length_m ?? 2, 0.1) / 2;
|
||||
return { beforeM: half, afterM: half };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배수관 세트가 있는 측점들의 구조물 솔리드 목록.
|
||||
* `frameAt`은 노선 누가거리 → 스윕 프레임(코리도 빌더의 평면 스플라인·좌향 보간과
|
||||
* 같은 규약)이다. 실패한 측점은 조용히 건너뛴다.
|
||||
*/
|
||||
export function buildCorridorStructures(
|
||||
crossSections: CrossSection[],
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
): CorridorStructure[] {
|
||||
const solids: CorridorStructure[] = [];
|
||||
for (const section of crossSections) {
|
||||
const layout = culvertLayoutOf(section);
|
||||
if (!layout) continue;
|
||||
const chainage = section.chainage_m;
|
||||
const stationFrame: StructureFrame = {
|
||||
cx: section.center_x,
|
||||
cy: section.center_y,
|
||||
leftX: section.frame.left_xy[0],
|
||||
leftY: section.frame.left_xy[1],
|
||||
dz: 0,
|
||||
};
|
||||
// 측점 자리 계획고 — 스윕 각 지점의 계획고 차이가 곧 종단 경사다(2026-08-23).
|
||||
const baseZ = frameAt(chainage)?.designZ ?? null;
|
||||
const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => {
|
||||
const from = chainage - beforeM;
|
||||
const to = chainage + afterM;
|
||||
const count = Math.max(2, Math.ceil((to - from) / SWEEP_STEP_M) + 1);
|
||||
const rings: StructureFrame[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const at = from + ((to - from) * i) / (count - 1);
|
||||
const frame = frameAt(at);
|
||||
if (!frame) {
|
||||
rings.push(stationFrame);
|
||||
continue;
|
||||
}
|
||||
rings.push({
|
||||
cx: frame.cx,
|
||||
cy: frame.cy,
|
||||
leftX: frame.leftX,
|
||||
leftY: frame.leftY,
|
||||
dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0,
|
||||
});
|
||||
}
|
||||
return rings;
|
||||
};
|
||||
|
||||
const inletSpan = wallSpanOf(section, "inlet");
|
||||
const outletSpan = wallSpanOf(section, "outlet");
|
||||
const basinLength = Math.max(section.culvert?.inlet.basin_length_m ?? 2, 0.1);
|
||||
|
||||
const pushSwept = (
|
||||
kind: "revet" | "basin",
|
||||
points: Array<{ offset: number; elevation: number }>,
|
||||
beforeM: number,
|
||||
afterM: number,
|
||||
): void => {
|
||||
if (points.length < 3) return;
|
||||
solids.push({
|
||||
chainage_m: chainage,
|
||||
kind,
|
||||
polygon: points.map((p) => [p.offset, p.elevation]),
|
||||
rings: ringsOf(beforeM, afterM),
|
||||
});
|
||||
};
|
||||
|
||||
for (const wall of layout.walls) {
|
||||
const span = wall.role === "inlet" ? inletSpan : outletSpan;
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM);
|
||||
}
|
||||
// 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다.
|
||||
for (const wall of layout.extraWalls) {
|
||||
pushSwept("revet", wall.points, outletSpan.beforeM, outletSpan.afterM);
|
||||
}
|
||||
for (const wall of layout.basinExtras) {
|
||||
pushSwept("revet", wall.points, basinLength / 2, basinLength / 2);
|
||||
}
|
||||
|
||||
if (layout.basin) {
|
||||
// 집수정 = 부재 외곽을 감싼 **직육면체**(2026-08-23 사용자).
|
||||
let minOffset = Number.POSITIVE_INFINITY;
|
||||
let maxOffset = Number.NEGATIVE_INFINITY;
|
||||
let minElevation = Number.POSITIVE_INFINITY;
|
||||
let maxElevation = Number.NEGATIVE_INFINITY;
|
||||
for (const part of layout.basin.parts) {
|
||||
for (const p of part.points) {
|
||||
minOffset = Math.min(minOffset, p.offset);
|
||||
maxOffset = Math.max(maxOffset, p.offset);
|
||||
minElevation = Math.min(minElevation, p.elevation);
|
||||
maxElevation = Math.max(maxElevation, p.elevation);
|
||||
}
|
||||
}
|
||||
if (minOffset < maxOffset && minElevation < maxElevation) {
|
||||
pushSwept(
|
||||
"basin",
|
||||
[
|
||||
{ offset: minOffset, elevation: minElevation },
|
||||
{ offset: minOffset, elevation: maxElevation },
|
||||
{ offset: maxOffset, elevation: maxElevation },
|
||||
{ offset: maxOffset, elevation: minElevation },
|
||||
],
|
||||
basinLength / 2,
|
||||
basinLength / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
solids.push({
|
||||
chainage_m: chainage,
|
||||
kind: "pipe",
|
||||
pipe: {
|
||||
start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation],
|
||||
end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation],
|
||||
diameterM: layout.culvert.diameter_m,
|
||||
},
|
||||
frame: stationFrame,
|
||||
});
|
||||
}
|
||||
return solids;
|
||||
}
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return [];
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
spec.revet_form ?? "",
|
||||
spec.revet_height_m ?? "",
|
||||
spec.revet_length_m ?? "",
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
return [
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
side(culvert.inlet),
|
||||
side(culvert.outlet),
|
||||
section.design?.inlet_structure ?? "",
|
||||
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
|
||||
];
|
||||
}
|
||||
@@ -194,7 +194,10 @@ export function createFacilityOptionsForm(
|
||||
"돌쌓기(메)",
|
||||
]);
|
||||
const basinMaterialField = labeled("형태", basinMaterial);
|
||||
const basinRows = [grid(labeled("형식", basinForm))];
|
||||
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
|
||||
const basinLength = numberInput("0.1");
|
||||
basinLength.value = "2";
|
||||
const basinRows = [grid(labeled("형식", basinForm), labeled("길이 (m)", basinLength))];
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(찰)",
|
||||
@@ -371,7 +374,7 @@ export function createFacilityOptionsForm(
|
||||
callbacks.onChange?.();
|
||||
}
|
||||
|
||||
[inletType, outletType, basinForm, basinMaterial].forEach((input) =>
|
||||
[inletType, outletType, basinForm, basinMaterial, basinLength].forEach((input) =>
|
||||
input.addEventListener("change", emit),
|
||||
);
|
||||
inletRevet.onChange(emit);
|
||||
@@ -406,6 +409,7 @@ export function createFacilityOptionsForm(
|
||||
outletType.value = text("outlet_type") || "기슭막이";
|
||||
basinForm.value = text("inlet_basin_form");
|
||||
basinMaterial.value = text("inlet_basin_material");
|
||||
basinLength.value = text("inlet_basin_length_m") || "2";
|
||||
inletRevet.write(options);
|
||||
outletRevet.write(options);
|
||||
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
|
||||
@@ -451,6 +455,7 @@ export function createFacilityOptionsForm(
|
||||
if (inletType.value === "집수정") {
|
||||
if (basinForm.value) options.inlet_basin_form = basinForm.value;
|
||||
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
|
||||
putNumber(options, "inlet_basin_length_m", basinLength.value);
|
||||
} else if (inletType.value === "기슭막이") {
|
||||
inletRevet.read(options);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ export interface RevetmentKeys {
|
||||
form: string;
|
||||
length?: string;
|
||||
height: string;
|
||||
/** 기준측점 전/후 몫(m) — 3D 예상형상 종방향 배치(2026-08-23 사용자). */
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
|
||||
@@ -128,24 +131,32 @@ export interface RevetmentDefaults {
|
||||
form: string;
|
||||
length: string;
|
||||
height: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}
|
||||
|
||||
/** 부위와 무관한 공통 기본 치수 — 길이 10m·높이 2.5m. */
|
||||
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
|
||||
export const REVET_COMMON_DEFAULTS = {
|
||||
length: "10",
|
||||
height: "2.5",
|
||||
before: "5",
|
||||
after: "5",
|
||||
} as const;
|
||||
|
||||
export const INLET_REVET_KEYS: RevetmentKeys = {
|
||||
form: "inlet_revet_form",
|
||||
length: "inlet_revet_length_m",
|
||||
height: "inlet_revet_height_m",
|
||||
before: "inlet_revet_before_m",
|
||||
after: "inlet_revet_after_m",
|
||||
};
|
||||
|
||||
export const OUTLET_REVET_KEYS: RevetmentKeys = {
|
||||
form: "outlet_revet_form",
|
||||
length: "outlet_revet_length_m",
|
||||
height: "outlet_revet_height_m",
|
||||
before: "outlet_revet_before_m",
|
||||
after: "outlet_revet_after_m",
|
||||
};
|
||||
|
||||
export interface RevetmentFields {
|
||||
@@ -175,14 +186,37 @@ export function createRevetmentFields(
|
||||
if (length) length.value = defaults.length;
|
||||
const height = numberInput("0.1");
|
||||
height.value = defaults.height;
|
||||
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
|
||||
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
|
||||
const before = keys.before && length ? numberInput("0.1") : null;
|
||||
const after = keys.after && length ? numberInput("0.1") : null;
|
||||
if (before) before.value = defaults.before;
|
||||
if (after) after.value = defaults.after;
|
||||
let lastSplitEdit: "before" | "after" = "before";
|
||||
const readNumber = (input: HTMLInputElement | null): number => {
|
||||
const value = input ? Number.parseFloat(input.value) : Number.NaN;
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
const syncSplit = (source: "length" | "before" | "after"): void => {
|
||||
if (!length || !before || !after) return;
|
||||
if (source !== "length") lastSplitEdit = source;
|
||||
const total = readNumber(length);
|
||||
const keep = lastSplitEdit === "after" ? after : before;
|
||||
const kept = Math.min(readNumber(keep), total);
|
||||
keep.value = kept.toFixed(1);
|
||||
(keep === before ? after : before).value = Math.max(total - kept, 0).toFixed(1);
|
||||
};
|
||||
|
||||
const formField = labeled("형태", form);
|
||||
const lengthField = length ? labeled("길이 (m)", length) : null;
|
||||
const heightField = labeled("높이 (m)", height);
|
||||
const beforeField = before ? labeled("기준측점 전 (m)", before) : null;
|
||||
const afterField = after ? labeled("기준측점 후 (m)", after) : null;
|
||||
|
||||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||||
const rows = [
|
||||
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
|
||||
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -195,6 +229,9 @@ export function createRevetmentFields(
|
||||
form.value = text(keys.form) || defaults.form;
|
||||
if (length && keys.length) length.value = text(keys.length) || defaults.length;
|
||||
height.value = text(keys.height) || defaults.height;
|
||||
if (before && keys.before) before.value = text(keys.before) || defaults.before;
|
||||
if (after && keys.after) after.value = text(keys.after) || defaults.after;
|
||||
syncSplit("length");
|
||||
},
|
||||
read(target) {
|
||||
const putNumber = (key: string, raw: string): void => {
|
||||
@@ -204,10 +241,29 @@ export function createRevetmentFields(
|
||||
if (form.value) target[keys.form] = form.value;
|
||||
if (length && keys.length) putNumber(keys.length, length.value);
|
||||
putNumber(keys.height, height.value);
|
||||
// 전/후는 0도 유효(기준이 곧 시작/끝) — putNumber(>0)와 달리 0을 남긴다.
|
||||
const putSplit = (key: string | undefined, input: HTMLInputElement | null): void => {
|
||||
if (!key || !input) return;
|
||||
const value = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(value) && value >= 0) target[key] = Number(value.toFixed(1));
|
||||
};
|
||||
putSplit(keys.before, before);
|
||||
putSplit(keys.after, after);
|
||||
},
|
||||
onChange(handler) {
|
||||
[form, height].forEach((input) => input.addEventListener("change", handler));
|
||||
length?.addEventListener("change", handler);
|
||||
length?.addEventListener("change", () => {
|
||||
syncSplit("length");
|
||||
handler();
|
||||
});
|
||||
before?.addEventListener("change", () => {
|
||||
syncSplit("before");
|
||||
handler();
|
||||
});
|
||||
after?.addEventListener("change", () => {
|
||||
syncSplit("after");
|
||||
handler();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +123,45 @@ export function chainageToPolylineXY(
|
||||
return { x: last.x, y: last.y };
|
||||
}
|
||||
|
||||
/** 단위벡터 두 개를 각도로 보간 — 선형보간과 달리 길이가 1로 유지된다. */
|
||||
function slerpUnit(a: [number, number], b: [number, number], t: number): [number, number] {
|
||||
const angleA = Math.atan2(a[1], a[0]);
|
||||
let delta = Math.atan2(b[1], b[0]) - angleA;
|
||||
if (delta > Math.PI) delta -= Math.PI * 2;
|
||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||
const angle = angleA + delta * t;
|
||||
return [Math.cos(angle), Math.sin(angle)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선 폴리라인 위 chainage 지점의 **좌향 단위벡터**(횡단 법선) — 백엔드 프레임과
|
||||
* 같은 규약 `left = (−ty, tx)`(`B05_Profile_Engine_Sections_Core.py`).
|
||||
*
|
||||
* 비정규(구조물) 측점 프레임을 규칙 측점 두 개의 left_xy **선형보간**으로 만들면
|
||||
* 곡선 구간에서 단위길이가 깨지고 방향도 현(chord) 쪽으로 눕는다 — 그 측점의 횡단
|
||||
* 가로선·구조물이 노선 법선과 어긋나 보인다(2026-08-23 사용자 지적: 4+4.3 법선).
|
||||
* 폴리라인 접선에서 직접 뽑으면 그 자리의 실제 법선이 된다.
|
||||
*/
|
||||
export function chainageToPolylineLeft(
|
||||
polyline: ReadonlyArray<{ x: number; y: number }>,
|
||||
chainage: number,
|
||||
): [number, number] | null {
|
||||
if (polyline.length < 2) return null;
|
||||
let travelled = 0;
|
||||
let fallback: [number, number] | null = null;
|
||||
for (let index = 1; index < polyline.length; index += 1) {
|
||||
const from = polyline[index - 1];
|
||||
const to = polyline[index];
|
||||
const span = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
if (span < 1e-9) continue;
|
||||
const left: [number, number] = [-(to.y - from.y) / span, (to.x - from.x) / span];
|
||||
fallback = left;
|
||||
if (travelled + span >= chainage - 1e-6) return left;
|
||||
travelled += span;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
|
||||
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
|
||||
@@ -164,10 +203,10 @@ export function interpolateIrregularStations(
|
||||
center_y: lerp(lo.center_y, hi.center_y),
|
||||
center_z: centerZ,
|
||||
frame: {
|
||||
left_xy: [
|
||||
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
|
||||
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
|
||||
],
|
||||
// 단위벡터는 선형보간하면 길이가 줄고 방향이 현 쪽으로 눕는다 — 각도로
|
||||
// 보간해 단위길이를 지킨다(2026-08-23). 폴리라인이 있으면 아래에서 실제
|
||||
// 접선 법선으로 다시 덮는다.
|
||||
left_xy: slerpUnit(lo.frame.left_xy, hi.frame.left_xy, t),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -178,9 +217,13 @@ export function interpolateIrregularStations(
|
||||
// 중심 좌표는 노선 폴리라인에서 정확히 — 곡선에서 규칙 측점 직선보간이 만드는
|
||||
// 모서리 잘림(3D 마커가 노선 옆으로 새는 현상)을 없앤다(2026-08-19).
|
||||
const onRoute = chainageToPolylineXY(routePolyline, entry.chainage_m);
|
||||
// 법선(좌향)도 같은 폴리라인에서 뽑는다 — 중심만 노선 위에 얹고 프레임은
|
||||
// 측점 보간값을 쓰면 그 측점의 횡단이 노선과 어긋난다(2026-08-23 사용자).
|
||||
const onRouteLeft = chainageToPolylineLeft(routePolyline, entry.chainage_m);
|
||||
return {
|
||||
...anchor,
|
||||
...(onRoute ? { center_x: onRoute.x, center_y: onRoute.y } : {}),
|
||||
...(onRouteLeft ? { frame: { left_xy: onRouteLeft } } : {}),
|
||||
station_id: irregularStationId(entry.id),
|
||||
chainage_m: entry.chainage_m,
|
||||
label: irregularLabel(entry),
|
||||
|
||||
@@ -195,6 +195,11 @@ export interface CulvertSideSpec {
|
||||
revet_form?: string | null;
|
||||
revet_height_m?: number | null;
|
||||
revet_length_m?: number | null;
|
||||
/** 기준측점 전/후 종방향 몫(m) — 3D 예상형상 배치(2026-08-23). 없으면 길이 절반씩. */
|
||||
revet_before_m?: number | null;
|
||||
revet_after_m?: number | null;
|
||||
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 전후 동일, 기본 2m. */
|
||||
basin_length_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
|
||||
face_slope?: number;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
|
||||
@@ -104,6 +104,12 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
|
||||
structure = options.get(f"{side}_type") or defaults.get(f"{side}_type") or "기슭막이"
|
||||
spec: dict[str, Any] = {"role": side, "structure": str(structure)}
|
||||
if spec["structure"] == INLET_STRUCTURE_BASIN:
|
||||
# 집수정 종방향 길이(3D 예상형상용, 기본 2m — 2026-08-23 사용자).
|
||||
# 전후 길이는 동일(길이/2씩)로 본다.
|
||||
spec["basin_length_m"] = _number(
|
||||
options.get("inlet_basin_length_m"),
|
||||
_number(defaults.get("inlet_basin_length_m"), 2.0),
|
||||
)
|
||||
return spec
|
||||
|
||||
height = _number(
|
||||
@@ -115,11 +121,23 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
|
||||
_number(defaults.get(f"{side}_revet_length_m"), None),
|
||||
)
|
||||
form = options.get(f"{side}_revet_form") or defaults.get(f"{side}_revet_form")
|
||||
# 기준측점 전/후 몶(3D 예상형상 종방향 배치 — 2026-08-23 사용자).
|
||||
# 저장분이 없으면 길이 절반씩.
|
||||
before = _number(
|
||||
options.get(f"{side}_revet_before_m"),
|
||||
_number(defaults.get(f"{side}_revet_before_m"), None),
|
||||
)
|
||||
after = _number(
|
||||
options.get(f"{side}_revet_after_m"),
|
||||
_number(defaults.get(f"{side}_revet_after_m"), None),
|
||||
)
|
||||
spec.update(
|
||||
{
|
||||
"revet_form": str(form) if form else None,
|
||||
"revet_height_m": height,
|
||||
"revet_length_m": length,
|
||||
"revet_before_m": before,
|
||||
"revet_after_m": after,
|
||||
"face_slope": REVET_FACE_SLOPE,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user