diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 441e3dbc..13d08cb2 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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": "유출 기슭막이 높이", diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 9f9e5cbb..02d52d80 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 ?? [], }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 450950a1..392b3c32 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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(); + /** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */ + const fixedSlopeKeys = new Set(); 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) }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index f88f4893..9e71c5f6 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -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 = { + 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; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 8d7ee2a6..d35508db 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -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 = { 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; /** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */ ground: ((offset: number) => number) | null; + /** + * 배수관 세트 기슭막이의 종방향 점유 구간(2026-08-23 사용자 — 성토부 형태 변경). + * 이 구간 안 행은 그 측의 비탈을 이 측점의 트림 사면(노견 연장 + 벽 상단 종결) + * 그대로 쓰고 **지반 트림을 하지 않는다** — 벽에서 끝난 사면을 지반까지 도로 + * 늘리면 안 된다. + */ + wallSpans?: Partial>; } 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> = {}; + 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(); (["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 }, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts new file mode 100644 index 00000000..21fb9915 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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(); + +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 { + 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}` : "", + ]; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 448b6839..84113560 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 8e4406e5..3ab454dc 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -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(); + }); }, }; } diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index ce217c27..235ddc47 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -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), diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 8402739f..bb7c17b0 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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 사용자 확정). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 24d75834..fcec3dc9 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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, } )