From d583936b4772b029e4920428009ab99c92739f09 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 22:35:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=203D=20=EC=A0=9C=EC=95=88=20?= =?UTF-8?q?=E2=91=A1=20=EB=8B=A8=EC=9D=BC=20=EC=82=BC=EA=B0=81=EB=A7=9D(TI?= =?UTF-8?q?N)=20=EC=99=84=EC=84=B1=20=EC=A7=80=EB=B0=98=20=E2=80=94=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=A0=90=20=C2=B7=20=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=81=AC=EB=9D=BC=EC=9D=B8=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=ED=98=95=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EA=B5=AC=EB=A9=8D=20=C2=B7=20=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=EC=83=89(115-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 셋: _Tin(지형 이음 · 색) · _Tin_Design(설계 점 · 브레이크라인 · 겹침 청소 · 윗면 구멍) · _Tin_Pslg(PSLG · 교차 나눔 · 제약 들로네 · 격자 색인) - 새 방식 변형 장(측점 단면 · 메시 비탈 끝)을 읽기만 · 지형은 설계 구역에 닿는 삼각형만 버리고 원래 바깥 정점 · 변을 제약으로 다시 세움 - 의존성 둘(delaunator 5.1 · @kninnug/constrainautor 4.1 · ISC) · vite 별칭 · tsconfig 경로 - 시험 test_115_6_corridor_tin.py 7(틈 0 · 브레이크라인 = 변 · 측점 단면 · 공사 밖 정점 · 구멍 · 급곡선 겹침 · 소단) - 3D 방식 등록은 sub4 등록부 받은 뒤 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_UI_Corridor_Tin.ts | 354 +++++++++++ .../B05_Profile_UI_Corridor_Tin_Design.ts | 340 +++++++++++ .../B05_Profile_UI_Corridor_Tin_Pslg.ts | 542 +++++++++++++++++ config/package-lock.json | 26 + config/package.json | 2 + package.json | 2 + .../tester/helper_115_6_corridor_tin.cjs | 557 ++++++++++++++++++ resources/tester/test_115_6_corridor_tin.py | 80 +++ tsconfig.json | 2 + vite.config.ts | 5 + 10 files changed, 1910 insertions(+) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Tin.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Tin_Pslg.ts create mode 100644 resources/tester/helper_115_6_corridor_tin.cjs create mode 100644 resources/tester/test_115_6_corridor_tin.py diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts new file mode 100644 index 000000000..a5a4d8860 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -0,0 +1,354 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Tin.ts + * 3D 제안 ② **단일 삼각망(TIN) 완성 지반**(계획서 115-6 · Civil 3D · OpenRoads 와 같은 원리). + * + * ① 설계 점 · 브레이크라인(`_Tin_Design.ts`) — 측점 단면 · 1 m 보간 단면 · 비탈 끝 폐곡선. + * ② 지형 이음 — 설계 구역(+ 구조물 구멍)에 닿는 지형 삼각형만 버리고, 그 삼각형의 바깥 + * 정점 · 변(원래 그대로)을 점 · 제약으로 넣어 제약 들로네 한 번 → 닿지 않은 지형은 원본, + * 버린 자리 안 바깥 지형도 원래 변이 제약이라 원래 삼각형 그대로 · 틈 0. + * ③ 구조물 솔리드는 그대로 — 윗면 외곽 안 삼각형만 지운다. + * ④ 색 = 삼각형마다 구역(성토 · 절토 = 면 색 · 원지반 = 원래 정점 색) · 법선 = 원본과 같게. + * + * 원본 · 분할본 geometry 는 건드리지 않는다(사본만). 변형 장 · 구조물 모듈은 읽기만 한다. + * ========================================================================== */ + +import * as THREE from "three"; +import type { ModelBounds } from "./B05_Profile_UI_Markers"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; +import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; +import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; +import { CellRegion } from "./B05_Profile_UI_Corridor_Region"; +import { buildDeformField, stationProfileSource } from "./B05_Profile_UI_Corridor_Deform"; +import { noFillLimits, structureSlopes } from "./B05_Profile_UI_Corridor_Deform_Structure"; +import { buildTinDesign, type TinDesign } from "./B05_Profile_UI_Corridor_Tin_Design"; +import { + EDGE_TERRAIN, + LoopIndex, + segmentTouchesTriangle, + SegGrid, + TAG_CUT, + TAG_FILL, + TAG_TERRAIN, + TriGrid, +} from "./B05_Profile_UI_Corridor_Tin_Pslg"; + +/** 절토 · 성토 색 — 새 방식(`_Deform_Terrain.ts`) · 리본 색과 같은 값. */ +const CUT_COLOR = new THREE.Color(0xb5179e); +const FILL_COLOR = new THREE.Color(0x06b6d4); +/** 원지반 색을 못 찾은 점(지형 밖) — 회색. */ +const FALLBACK_RGB = [0.6, 0.6, 0.6]; +/** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */ +const TINT_MIN_M = 0.01; +const GRID_M = 2; +/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */ +const ON_BOUNDARY_M = 1e-3; + +/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */ +class RoadStrip extends CellRegion { + constructor(road: TinDesign["road"]) { + super(); + const p = ([x, z]: [number, number]) => ({ x, z }); + for (let i = 0; i + 1 < road.left.length; i += 1) { + this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1])); + } + this.buildIndex(); + } +} + +/** 화면 밖 수치 확인용(`__corridorTin`). */ +export interface TinStats { + rows: number; + sections: number; + bowtie: number; + bendLines: number; + holes: number; + splits: number; + removed: number; + points: number; + constraints: number; + failed: number; + triangles: number; + ms: number; +} + +/** 지형 조각 하나에서 설계 구역에 닿는 삼각형을 골라 낸다 — 바깥 정점 · 변은 PSLG 로. */ +function harvestPart( + geometry: THREE.BufferGeometry, + design: TinDesign, + inRegion: (x: number, z: number) => boolean, + boundary: SegGrid, + box: { minX: number; maxX: number; minZ: number; maxZ: number }, + removedGrid: TriGrid, + removedRgb: number[], +): { kept: number[]; removed: number } { + const position = geometry.getAttribute("position") as THREE.BufferAttribute; + const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + const index = geometry.getIndex(); + const triCount = index ? index.count / 3 : position.count / 3; + const state = new Int8Array(position.count); // 0 모름 · 1 안 · 2 밖 + const inside = (v: number): boolean => { + if (!state[v]) { + const x = position.getX(v); + const z = position.getZ(v); + // 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게). + state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2; + } + return state[v] === 1; + }; + const ids = new Int32Array(position.count).fill(-1); + const pslg = design.pslg; + const idOf = (v: number): number => { + if (ids[v] < 0) { + ids[v] = pslg.addPoint(position.getX(v), position.getZ(v), position.getY(v), TAG_TERRAIN); + if (color && pslg.tag[ids[v]] === TAG_TERRAIN) { + pslg.rgb[ids[v] * 3] = color.getX(v); + pslg.rgb[ids[v] * 3 + 1] = color.getY(v); + pslg.rgb[ids[v] * 3 + 2] = color.getZ(v); + } + } + return ids[v]; + }; + const kept: number[] = []; + const plan = new Float64Array(6); + let removed = 0; + for (let t = 0; t < triCount; t += 1) { + const v = [0, 1, 2].map((k) => (index ? index.getX(t * 3 + k) : t * 3 + k)); + for (let k = 0; k < 3; k += 1) { + plan[k * 2] = position.getX(v[k]); + plan[k * 2 + 1] = position.getZ(v[k]); + } + const minX = Math.min(plan[0], plan[2], plan[4]); + const maxX = Math.max(plan[0], plan[2], plan[4]); + const minZ = Math.min(plan[1], plan[3], plan[5]); + const maxZ = Math.max(plan[1], plan[3], plan[5]); + let hit = false; + if (!(maxX < box.minX || minX > box.maxX || maxZ < box.minZ || minZ > box.maxZ)) { + hit = v.some(inside); + if (!hit) { + const s = boundary.segs; + hit = boundary + .near(minX, minZ, maxX, maxZ) + .some((id) => + segmentTouchesTriangle(s[id * 4], s[id * 4 + 1], s[id * 4 + 2], s[id * 4 + 3], plan), + ); + } + } + if (!hit) { + kept.push(v[0], v[1], v[2]); + continue; + } + removed += 1; + removedGrid.add(plan); + v.forEach((vertex) => { + if (color) removedRgb.push(color.getX(vertex), color.getY(vertex), color.getZ(vertex)); + else removedRgb.push(...FALLBACK_RGB); + }); + // 바깥 정점 · 바깥 변 — 원래 모양 그대로 다시 세우는 제약. + for (let k = 0; k < 3; k += 1) { + const a = v[k]; + const b = v[(k + 1) % 3]; + if (inside(a) || inside(b)) continue; + if ( + boundary.touches( + plan[k * 2], + plan[k * 2 + 1], + plan[((k + 1) % 3) * 2], + plan[((k + 1) % 3) * 2 + 1], + ) + ) + continue; + pslg.addEdge(idOf(a), idOf(b), EDGE_TERRAIN); + } + v.forEach((vertex) => { + if (!inside(vertex)) idOf(vertex); + }); + } + return { kept, removed }; +} + +/** + * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 변형 장을 못 + * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. + */ +export function tinTerrain( + split: TerrainBandSplit, + build: CorridorBuildResult, + bounds: ModelBounds, + heightIndex: TerrainHeightIndex, +): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { + const started = performance.now(); + const sections = build.sections; + if (!sections?.length) return null; + const structures = build.structures ?? []; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const groundAt = (x: number, y: number): number | null => { + const height = heightIndex.heightAt(x - ox, -(y - oy)); + return height === null ? null : height + oz; + }; + const source = stationProfileSource(sections, structureSlopes(sections)); + const limits = noFillLimits(sections, structures); + const field = buildDeformField(build, source, groundAt, limits); + if (!field) return null; + const design = buildTinDesign( + field, + source, + groundAt, + limits, + structures, + sections.map((section) => section.chainage_m), + bounds, + ); + + const outer = new LoopIndex([design.outer]); + const holes = new LoopIndex(design.holes); + const inRegion = (x: number, z: number): boolean => outer.contains(x, z) || holes.contains(x, z); + const boundary = new SegGrid(GRID_M); + let segment = 0; + [design.outer, ...design.holes].forEach((loop) => { + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + boundary.add(segment, loop[j][0], loop[j][1], loop[i][0], loop[i][1]); + segment += 1; + } + }); + const box = { + minX: Math.min(outer.minX, holes.minX), + maxX: Math.max(outer.maxX, holes.maxX), + minZ: Math.min(outer.minZ, holes.minZ), + maxZ: Math.max(outer.maxZ, holes.maxZ), + }; + + const root = new THREE.Group(); + root.name = "terrain-tin"; + const removedGrid = new TriGrid(GRID_M); + const removedRgb: number[] = []; + let removed = 0; + let material = null as THREE.Material | null; + let itemSize = 3; + let hasColor = false; + let hasNormal = false; + split.near.forEach((part) => { + if (part.points) { + // 포인트클라우드는 망 대상이 아니다 — 분할본 그대로 얹는다. + const points = new THREE.Points(part.geometry, part.material as THREE.Material); + points.userData.owned = false; + root.add(points); + return; + } + const harvest = harvestPart( + part.geometry, + design, + inRegion, + boundary, + box, + removedGrid, + removedRgb, + ); + removed += harvest.removed; + if (harvest.removed && !material) { + material = Array.isArray(part.material) ? part.material[0] : part.material; + const color = part.geometry.getAttribute("color"); + hasColor = Boolean(color); + itemSize = color?.itemSize ?? 3; + hasNormal = Boolean(part.geometry.getAttribute("normal")); + } + // 남은 원래 삼각형 — 사본(정점 버퍼 그대로 복제 · 인덱스만 추림). + const geometry = part.geometry.clone(); + geometry.setIndex(harvest.kept); + const mesh = new THREE.Mesh(geometry, part.material); + mesh.userData.owned = true; + root.add(mesh); + }); + + const pslg = design.pslg; + // 설계 · 구멍 점의 원지반 색 — 버린 지형 삼각형 안 무게중심 보간. + for (let i = 0; i < pslg.count; i += 1) { + if (!Number.isNaN(pslg.rgb[i * 3])) continue; + const t = removedGrid.find(pslg.x[i], pslg.z[i]); + const w = t >= 0 ? removedGrid.weights(t, pslg.x[i], pslg.z[i]) : null; + for (let c = 0; c < 3; c += 1) { + pslg.rgb[i * 3 + c] = w + ? w[0] * removedRgb[t * 9 + c] + + w[1] * removedRgb[t * 9 + 3 + c] + + w[2] * removedRgb[t * 9 + 6 + c] + : FALLBACK_RGB[c]; + } + } + + const { triangles, failed } = pslg.triangulate(); + const road = new RoadStrip(design.road); + const positions: number[] = []; + const colors: number[] = []; + const keep: number[] = []; + for (let t = 0; t + 2 < triangles.length; t += 3) { + let a = triangles[t]; + let b = triangles[t + 1]; + let c = triangles[t + 2]; + const cx = (pslg.x[a] + pslg.x[b] + pslg.x[c]) / 3; + const cz = (pslg.z[a] + pslg.z[b] + pslg.z[c]) / 3; + if (removedGrid.find(cx, cz) < 0) continue; // 버린 자리 밖(볼록 껍질 채움) + if (road.contains({ x: cx, z: cz }) || holes.contains(cx, cz)) continue; + // 위를 보게 감기(씬 Y-up) — 평면 (x, z) 에서 시계 방향이면 뒤집는다. + const turn = + (pslg.x[b] - pslg.x[a]) * (pslg.z[c] - pslg.z[a]) - + (pslg.z[b] - pslg.z[a]) * (pslg.x[c] - pslg.x[a]); + if (turn > 0) [b, c] = [c, b]; + // 구역 — 설계 구역 안이면 꼬리표 다수결(없으면 높이 차 부호) · 밖이면 원지반. + let zone = 0; + if (outer.contains(cx, cz)) { + let fill = 0; + let cut = 0; + [a, b, c].forEach((id) => { + if (pslg.tag[id] === TAG_FILL) fill += 1; + if (pslg.tag[id] === TAG_CUT) cut += 1; + }); + if (fill || cut) zone = fill >= cut ? TAG_FILL : TAG_CUT; + else { + const ground = heightIndex.heightAt(cx, cz); + const delta = ground === null ? 0 : (pslg.y[a] + pslg.y[b] + pslg.y[c]) / 3 - ground; + if (Math.abs(delta) >= TINT_MIN_M) zone = delta > 0 ? TAG_FILL : TAG_CUT; + } + } + const tint = zone === TAG_FILL ? FILL_COLOR : zone === TAG_CUT ? CUT_COLOR : null; + [a, b, c].forEach((id) => { + positions.push(pslg.x[id], pslg.y[id], pslg.z[id]); + const rgb = tint + ? [tint.r, tint.g, tint.b] + : [pslg.rgb[id * 3], pslg.rgb[id * 3 + 1], pslg.rgb[id * 3 + 2]]; + colors.push(...rgb); + if (itemSize === 4) colors.push(1); + keep.push(tint ? 1 : 0); + }); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + if (hasColor) geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, itemSize)); + // 원본에 법선이 있으면 면 법선(비색인 = 면마다 따로) · 없으면 원본처럼 flatShading 재질 몫. + if (hasNormal) geometry.computeVertexNormals(); + geometry.computeBoundingSphere(); + geometry.computeBoundingBox(); + const mesh = new THREE.Mesh( + geometry, + material ?? new THREE.MeshLambertMaterial({ vertexColors: true }), + ); + mesh.name = "terrain-tin-mesh"; + mesh.userData.owned = true; + mesh.userData.keepColor = Uint8Array.from(keep); + root.add(mesh); + split.far.forEach((part) => { + const far = new THREE.Mesh(part.geometry, part.material); + far.userData.owned = false; + root.add(far); + }); + const stats: TinStats = { + ...design.stats, + removed, + points: pslg.count, + constraints: pslg.kinds.length, + failed, + triangles: positions.length / 9, + ms: Math.round(performance.now() - started), + }; + return { root, stats, design }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts new file mode 100644 index 000000000..edd2c1394 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts @@ -0,0 +1,340 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Tin_Design.ts + * 3D 제안 ② 단일 삼각망(TIN)의 **설계 점 · 브레이크라인**(계획서 115-6) — 순수 기하. + * + * · 행 = 새 방식 변형 장(`_Deform.ts` buildDeformField)의 노면부 행(0.2 m · 노선 길이 기준). + * 비탈 끝 · 단면 꺾임은 그 장이 측점 단면(classifyStation)에서 메시 지반으로 찾은 값. + * · 노면 끝 선(행마다) · 비탈 끝 선(행마다) = 종방향 브레이크라인 · 둘이 설계 구역 둘레. + * · 단면 행(측점 + 1 m 걸음) = 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임을 잇는 종방향 선. + * · 곡선 안쪽 겹침(bow-tie) = 앞 행 단면과 교차하는 행은 그 측에서 뺀다(겹친 점 제거). + * · 구조물 = 솔리드 윗면 외곽 고리(구멍) · 둘레 높이 = min(설계면, 윗면). + * + * 변형 장 · 단면 출처는 읽기만 한다. + * ========================================================================== */ + +import type { ModelBounds } from "./B05_Profile_UI_Markers"; +import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; +import { + profileZ, + type DeformField, + type DeformLimit, + type ProfileSource, +} from "./B05_Profile_UI_Corridor_Deform"; +import { + EDGE_ANCHOR, + EDGE_DESIGN, + EDGE_HOLE, + Pslg, + SegGrid, + TAG_ANCHOR, + TAG_CUT, + TAG_FILL, + TAG_HOLE, +} from "./B05_Profile_UI_Corridor_Tin_Pslg"; + +/** 단면 행 걸음(m) — 측점 사이 보간 단면. */ +export const SECTION_STEP_M = 1; +/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ +const MIN_CATCH_M = 0.02; +/** 비탈 끝에서 이 안쪽 꺾임만 점으로 둔다(m). */ +const BEND_GAP_M = 0.05; +/** 비탈 끝 단면선 높이가 지반과 이만큼 안이면 지반 높이(m) — 못 찾은 끝(단면 끝)은 설계 높이. */ +const DAYLIGHT_SNAP_M = 0.1; +/** 윗면으로 보는 높이 여유(m) · 윗면 최소 폭(m). */ +const TOP_TOLERANCE_M = 0.05; +const TOP_MIN_WIDTH_M = 0.05; + +const SIDES = ["left", "right"] as const; + +export interface TinDesign { + pslg: Pslg; + /** 설계 구역 둘레(씬 평면) — 좌 비탈 끝 → 끝 마구리 → 우 비탈 끝 → 시작 마구리. */ + outer: Array<[number, number]>; + /** 구조물 윗면 구멍 고리(씬 평면). */ + holes: Array>; + /** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */ + road: { left: Array<[number, number]>; right: Array<[number, number]> }; + stats: { + rows: number; + sections: number; + bowtie: number; + bendLines: number; + holes: number; + splits: number; + }; +} + +/** 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈). */ +export function structureTopLoops( + structures: ReadonlyArray, +): Array> { + const loops: Array> = []; + for (const structure of structures) { + const rings = structure.rings; + if (structure.kind === "pipe" || !rings || rings.length < 2) continue; + let run: Array<{ + x: number; + y: number; + ux: number; + uy: number; + lo: number; + hi: number; + z: number; + }> = []; + const flush = (): void => { + if (run.length >= 2) { + const loop: Array<[number, number, number]> = run.map((r) => [ + r.x + r.ux * r.lo, + r.y + r.uy * r.lo, + r.z, + ]); + for (let i = run.length - 1; i >= 0; i -= 1) { + const r = run[i]; + loop.push([r.x + r.ux * r.hi, r.y + r.uy * r.hi, r.z]); + } + loops.push(loop); + } + run = []; + }; + rings.forEach((frame, i) => { + const polygon = structure.polygons?.[i] ?? structure.polygon; + if (!polygon?.length) return flush(); + let top = -Infinity; + polygon.forEach(([, elevation]) => (top = Math.max(top, elevation))); + let lo = Infinity; + let hi = -Infinity; + polygon.forEach(([offset, elevation]) => { + if (elevation < top - TOP_TOLERANCE_M) return; + lo = Math.min(lo, offset); + hi = Math.max(hi, offset); + }); + if (hi - lo < TOP_MIN_WIDTH_M) return flush(); + run.push({ + x: frame.cx, + y: frame.cy, + ux: frame.leftX, + uy: frame.leftY, + lo, + hi, + z: top + frame.dz, + }); + }); + flush(); + } + return loops; +} + +/** 점이 고리 안인가(광선 투사 · 모델 좌표). */ +function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { + let hit = false; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + const [xi, yi] = loop[i]; + const [xj, yj] = loop[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +/** + * 설계 점 · 브레이크라인 · 구멍을 PSLG 로 모은다(교차 제약은 나눠 둠). 지형 점은 부르는 쪽이 더한다. + * `groundAt` = 원지반 높이(모델 좌표 · 메시). + */ +export function buildTinDesign( + field: DeformField, + source: ProfileSource, + groundAt: (x: number, y: number) => number | null, + limits: ReadonlyArray, + structures: ReadonlyArray, + stationChainages: ReadonlyArray, + bounds: ModelBounds, +): TinDesign { + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const sx = (mx: number): number => mx - ox; + const sz = (my: number): number => -(my - oy); + const pslg = new Pslg(); + const rows = field.rows; + const n = rows.length; + const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 }; + + // 노면 끝 선 — 리본 바깥 끝 그대로(높이 = 리본). + const anchors = SIDES.map((side) => + rows.map((row) => { + const [x, y, z] = side === "left" ? row.left : row.right; + return pslg.addPoint(sx(x), sz(y), z - oz, TAG_ANCHOR); + }), + ); + anchors.forEach((ids) => pslg.addPath(ids, EDGE_ANCHOR)); + pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR); + pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR); + + /** 그 행 · 측 단면의 설계 높이(모델) — 성토는 지반 위로 · 절토는 아래로만 · 성토 금지 고리. */ + const designZ = (r: number, si: number, ki: number, s: number): number | null => { + const row = rows[r]; + const anchor = si === 0 ? row.left : row.right; + const shapes = source(row.chainage_m, SIDES[si]); + const profile = ki === 0 ? shapes.fill : shapes.cut; + if (!profile) return null; + const rel = profileZ(profile, s, shapes.noExtend); + if (rel === null) return null; + const [x, y] = field.pointAt(r, si, s); + const ground = groundAt(x, y); + let z = anchor[2] + rel; + if (ground !== null) { + z = ki === 0 ? Math.max(z, ground) : Math.min(z, ground); + for (const limit of limits) { + if (z > ground && insideLoop(limit.loop, x, y)) + z = Math.min(z, Math.max(ground, limit.ceiling ?? ground)); + } + } + return z; + }; + + /** 행 · 측의 비탈 — 종류(0 성토 · 1 절토)와 비탈 끝 거리. 없으면 null. */ + const slopeOf = (r: number, si: number): { ki: number; reach: number } | null => { + const fill = field.catchAt(r, si, 0); + const cut = field.catchAt(r, si, 1); + const ki = fill >= cut ? 0 : 1; + const reach = Math.max(fill, cut); + return reach >= MIN_CATCH_M ? { ki, reach } : null; + }; + + // 비탈 끝 점 · 곡선 안쪽 겹침 청소 — 앞 행들 단면 선분에 닿으면 처음 닿는 자리에서 자른다(겹친 점 제거). + const kindOf = SIDES.map(() => new Int8Array(n).fill(-1)); + const reachOf = SIDES.map(() => new Float64Array(n)); + const daylight = SIDES.map(() => new Int32Array(n).fill(-1)); + SIDES.forEach((_, si) => { + const fan = new SegGrid(2); + for (let r = 0; r < n; r += 1) { + const slope = slopeOf(r, si); + const anchor = anchors[si][r]; + daylight[si][r] = anchor; + if (!slope) continue; + let reach = slope.reach; + const ax = pslg.x[anchor]; + const az = pslg.z[anchor]; + const [dx, dy] = field.pointAt(r, si, reach); + const hit = fan.firstHit(ax, az, sx(dx), sz(dy)); + if (hit < 1) { + reach *= hit; + stats.bowtie += 1; + if (reach < MIN_CATCH_M) continue; + } + const [px, py] = field.pointAt(r, si, reach); + const line = designZ(r, si, slope.ki, reach); + const ground = groundAt(px, py); + const z = + hit >= 1 && ground !== null && (line === null || Math.abs(line - ground) < DAYLIGHT_SNAP_M) + ? ground + : (line ?? ground ?? rows[r].left[2]); + daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, slope.ki === 0 ? TAG_FILL : TAG_CUT); + kindOf[si][r] = slope.ki; + reachOf[si][r] = reach; + fan.add(r, ax, az, sx(px), sz(py)); + } + }); + + // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. + const left: number[] = []; + const right: number[] = []; + for (let r = 0; r < n; r += 1) { + left.push(daylight[0][r]); + right.push(daylight[1][r]); + } + pslg.addPath(left, EDGE_DESIGN); + pslg.addPath(right, EDGE_DESIGN); + pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN); + pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN); + pslg.addPath([anchors[0][n - 1], left[left.length - 1]], EDGE_DESIGN); + pslg.addPath([anchors[1][n - 1], right[right.length - 1]], EDGE_DESIGN); + const loopIds = [ + anchors[0][0], + ...left, + anchors[0][n - 1], + anchors[1][n - 1], + ...right.reverse(), + anchors[1][0], + ]; + const outer = loopIds + .filter((id, i) => i === 0 || id !== loopIds[i - 1]) + .map((id): [number, number] => [pslg.x[id], pslg.z[id]]); + + // 단면 행 — 측점 + 1 m 걸음. 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임 종방향 선. + const isStation = (chainage: number): boolean => + stationChainages.some((c) => Math.abs(c - chainage) < 1e-3); + const sectionRows: number[] = []; + for (let r = 0; r < n; r += 1) { + const chainage = rows[r].chainage_m; + const previous = sectionRows.length + ? rows[sectionRows[sectionRows.length - 1]].chainage_m + : -Infinity; + if ( + r === 0 || + r === n - 1 || + isStation(chainage) || + chainage - previous >= SECTION_STEP_M - 1e-6 + ) { + sectionRows.push(r); + } + } + stats.sections = sectionRows.length; + SIDES.forEach((_, si) => { + let previous: { r: number; ki: number; bends: Map } | null = null; + for (const r of sectionRows) { + const ki = kindOf[si][r]; + const reach = reachOf[si][r]; + if (ki < 0) { + previous = null; + continue; + } + const bends = new Map(); + const path = [anchors[si][r]]; + for (const [col, s] of field.bendsAt(r, si, ki)) { + if (s <= MIN_CATCH_M || s >= reach - BEND_GAP_M) continue; + const z = designZ(r, si, ki, s); + if (z === null) continue; + const [x, y] = field.pointAt(r, si, s); + const id = pslg.addPoint(sx(x), sz(y), z - oz, ki === 0 ? TAG_FILL : TAG_CUT); + path.push(id); + bends.set(col, [id, s]); + } + path.push(daylight[si][r]); + pslg.addPath(path, EDGE_DESIGN); + if (previous && previous.ki === ki) { + for (const [col, [id, s]] of bends) { + const before = previous.bends.get(col); + if (!before) continue; + // 사이 행 비탈 끝이 그 꺾임보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감). + let inside = true; + for (let k = previous.r + 1; k < r && inside; k += 1) { + const t = (k - previous.r) / (r - previous.r); + if (reachOf[si][k] < before[1] + (s - before[1]) * t + BEND_GAP_M) inside = false; + } + if (!inside) continue; + pslg.addEdge(before[0], id, EDGE_DESIGN); + stats.bendLines += 1; + } + } + previous = { r, ki, bends }; + } + }); + + // 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(드레이프). + const holes: Array> = []; + for (const loop of structureTopLoops(structures)) { + const ids = loop.map(([x, y, top]) => { + const ground = groundAt(x, y); + const surface = field.deformZ(x, y, ground ?? top); + return pslg.addPoint(sx(x), sz(y), Math.min(surface, top) - oz, TAG_HOLE); + }); + pslg.addPath(ids, EDGE_HOLE, true); + holes.push(loop.map(([x, y]): [number, number] => [sx(x), sz(y)])); + } + stats.holes = holes.length; + stats.splits = pslg.splitCrossings(); + + const plan = (si: number): Array<[number, number]> => + anchors[si].map((id): [number, number] => [pslg.x[id], pslg.z[id]]); + return { pslg, outer, holes, road: { left: plan(0), right: plan(1) }, stats }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin_Pslg.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin_Pslg.ts new file mode 100644 index 000000000..ee72c01ed --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin_Pslg.ts @@ -0,0 +1,542 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Tin_Pslg.ts + * 3D 제안 ② 단일 삼각망(TIN)의 **평면 직선 그래프(PSLG)** 도구 — 순수 기하(계획서 115-6). + * + * · 점 · 제약 변 모음(중복 점 1 mm 병합 · 같은 변 한 번). + * · 설계 · 구멍 제약끼리 교차하면 교점으로 나눔(T자 접점 포함). + * · 제약 들로네 한 번 — delaunator + constrainautor(ISC). + * · 격자 색인 셋 — 고리 안 판정(LoopIndex) · 경계 선분 후보(SegGrid) · 삼각형 찾기(TriGrid). + * + * 좌표는 씬 수평면(x, z) · 높이 y(뷰어 규약). Three.js 없음. + * ========================================================================== */ + +import Delaunator from "delaunator"; +import Constrainautor from "@kninnug/constrainautor"; + +/** 점 꼬리표 — 병합 때 큰 쪽이 높이 · 꼬리표를 가진다. */ +export const TAG_TERRAIN = 0; +export const TAG_FILL = 1; +export const TAG_CUT = 2; +export const TAG_HOLE = 3; +export const TAG_ANCHOR = 4; + +/** 변 종류 — 교차 나눔 때 높이를 어느 쪽에서 읽을지 정한다. */ +export const EDGE_TERRAIN = 0; +export const EDGE_DESIGN = 1; +export const EDGE_HOLE = 2; +export const EDGE_ANCHOR = 3; + +/** 같은 점으로 보는 거리(m) — 1 mm 칸으로 묶는다. */ +const MERGE_M = 1e-3; +/** 선분 위에 놓인 점으로 보는 거리(m). */ +const ON_SEGMENT_M = 1e-4; +const GRID_M = 2; + +export class Pslg { + readonly x: number[] = []; + readonly z: number[] = []; + readonly y: number[] = []; + readonly tag: number[] = []; + /** 지형 점의 원래 색(r, g, b) — 지형이 아니면 NaN(뒤에서 채운다). */ + readonly rgb: number[] = []; + readonly edges: number[] = []; + readonly kinds: number[] = []; + private keys = new Map(); + private edgeKeys = new Set(); + + get count(): number { + return this.x.length; + } + + addPoint(x: number, z: number, y: number, tag: number): number { + const key = `${Math.round(x / MERGE_M)},${Math.round(z / MERGE_M)}`; + const found = this.keys.get(key); + if (found !== undefined) { + if (tag > this.tag[found]) { + this.y[found] = y; + this.tag[found] = tag; + } + return found; + } + const id = this.x.length; + this.x.push(x); + this.z.push(z); + this.y.push(y); + this.tag.push(tag); + this.rgb.push(NaN, NaN, NaN); + this.keys.set(key, id); + return id; + } + + addEdge(a: number, b: number, kind: number): void { + if (a === b) return; + const key = a < b ? a * 4194304 + b : b * 4194304 + a; + if (this.edgeKeys.has(key)) return; + this.edgeKeys.add(key); + this.edges.push(a, b); + this.kinds.push(kind); + } + + /** 꺾은선을 변으로 — 이웃 점이 같으면 건너뛴다. */ + addPath(ids: ReadonlyArray, kind: number, closed = false): void { + for (let i = 0; i + 1 < ids.length; i += 1) this.addEdge(ids[i], ids[i + 1], kind); + if (closed && ids.length > 2) this.addEdge(ids[ids.length - 1], ids[0], kind); + } + + /** 두 변 교점의 높이 — 노면 끝 > 설계 > 구멍(설계 · 구멍이면 낮은 쪽 = 드레이프). */ + private crossHeight(ka: number, ya: number, kb: number, yb: number): [number, number] { + if (ka === EDGE_ANCHOR) return [ya, TAG_ANCHOR]; + if (kb === EDGE_ANCHOR) return [yb, TAG_ANCHOR]; + if (ka === EDGE_HOLE && kb === EDGE_HOLE) return [Math.min(ya, yb), TAG_HOLE]; + if (ka === EDGE_HOLE) return [Math.min(ya, yb), TAG_HOLE]; + if (kb === EDGE_HOLE) return [Math.min(ya, yb), TAG_HOLE]; + return [ya, TAG_FILL]; + } + + /** + * 지형이 아닌 제약끼리 교차 · 접점을 찾아 나눈다(constrainautor 는 교차 제약을 못 받는다). + * 한 번이면 끝 — 새 점은 원래 두 변 위에 있어 다른 변과 새로 만나지 않는다. 나눈 수를 낸다. + */ + splitCrossings(): number { + const ids: number[] = []; + for (let e = 0; e < this.kinds.length; e += 1) if (this.kinds[e] !== EDGE_TERRAIN) ids.push(e); + const grid = new SegGrid(GRID_M); + ids.forEach((e) => + grid.add( + e, + this.x[this.edges[e * 2]], + this.z[this.edges[e * 2]], + this.x[this.edges[e * 2 + 1]], + this.z[this.edges[e * 2 + 1]], + ), + ); + const cuts = new Map>(); + const cut = (e: number, t: number, id: number): void => { + const list = cuts.get(e) ?? []; + list.push([t, id]); + cuts.set(e, list); + }; + const seen = new Set(); + let count = 0; + for (const e of ids) { + const a = this.edges[e * 2]; + const b = this.edges[e * 2 + 1]; + const ax = this.x[a]; + const az = this.z[a]; + const bx = this.x[b]; + const bz = this.z[b]; + for (const f of grid.near( + Math.min(ax, bx), + Math.min(az, bz), + Math.max(ax, bx), + Math.max(az, bz), + )) { + if (f <= e) continue; + const pair = e * 4194304 + f; + if (seen.has(pair)) continue; + seen.add(pair); + const c = this.edges[f * 2]; + const d = this.edges[f * 2 + 1]; + // 끝점이 상대 변 안쪽에 놓임(T자) — 상대만 나눈다. + for (const [p, other, oa, ob] of [ + [a, f, c, d], + [b, f, c, d], + [c, e, a, b], + [d, e, a, b], + ] as const) { + if (p === oa || p === ob) continue; + const t = onSegment(this.x[p], this.z[p], this.x[oa], this.z[oa], this.x[ob], this.z[ob]); + if (t !== null) { + cut(other, t, p); + count += 1; + } + } + if (a === c || a === d || b === c || b === d) continue; + const hit = crossing(ax, az, bx, bz, this.x[c], this.z[c], this.x[d], this.z[d]); + if (!hit) continue; + const [s, u] = hit; + const ya = this.y[a] + (this.y[b] - this.y[a]) * s; + const yc = this.y[c] + (this.y[d] - this.y[c]) * u; + const [y, tag] = this.crossHeight(this.kinds[e], ya, this.kinds[f], yc); + const id = this.addPoint(ax + (bx - ax) * s, az + (bz - az) * s, y, tag); + cut(e, s, id); + cut(f, u, id); + count += 1; + } + } + if (!cuts.size) return 0; + const edges = this.edges.slice(); + const kinds = this.kinds.slice(); + this.edges.length = 0; + this.kinds.length = 0; + this.edgeKeys.clear(); + for (let e = 0; e < kinds.length; e += 1) { + const list = cuts.get(e); + if (!list) { + this.addEdge(edges[e * 2], edges[e * 2 + 1], kinds[e]); + continue; + } + list.sort((p, q) => p[0] - q[0]); + this.addPath([edges[e * 2], ...list.map(([, id]) => id), edges[e * 2 + 1]], kinds[e]); + } + return count; + } + + /** + * 제약 들로네 한 번. 못 넣은 제약 수를 함께 낸다(점 관통 · 퇴화 — 그 변만 빠진다). + * 삼각형은 점 번호 세 개씩. + */ + triangulate(): { triangles: Uint32Array; failed: number } { + const n = this.count; + if (n < 3) return { triangles: new Uint32Array(0), failed: 0 }; + let cx = 0; + let cz = 0; + for (let i = 0; i < n; i += 1) { + cx += this.x[i]; + cz += this.z[i]; + } + cx /= n; + cz /= n; + const coords = new Float64Array(n * 2); + for (let i = 0; i < n; i += 1) { + coords[i * 2] = this.x[i] - cx; + coords[i * 2 + 1] = this.z[i] - cz; + } + const del = new Delaunator(coords); + if (del.triangles.length < 3) return { triangles: new Uint32Array(0), failed: 0 }; + const con = new Constrainautor(del); + let failed = 0; + for (let e = 0; e < this.kinds.length; e += 1) { + try { + con.constrainOne(this.edges[e * 2], this.edges[e * 2 + 1]); + } catch { + failed += 1; + } + } + return { triangles: Uint32Array.from(del.triangles), failed }; + } +} + +/** 점이 선분 (a, b) 안쪽에 놓였으면 매개변수 t(0~1 사이) · 아니면 null. */ +export function onSegment( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number | null { + const dx = bx - ax; + const dz = bz - az; + const length2 = dx * dx + dz * dz; + if (length2 < 1e-12) return null; + const t = ((px - ax) * dx + (pz - az) * dz) / length2; + if (t <= 1e-6 || t >= 1 - 1e-6) return null; + const qx = ax + dx * t - px; + const qz = az + dz * t - pz; + return qx * qx + qz * qz <= ON_SEGMENT_M * ON_SEGMENT_M ? t : null; +} + +/** 두 선분의 엄격한 교차 — 두 매개변수(0~1 안쪽) · 없으면 null. */ +export function crossing( + ax: number, + az: number, + bx: number, + bz: number, + cx: number, + cz: number, + dx: number, + dz: number, +): [number, number] | null { + const rx = bx - ax; + const rz = bz - az; + const sx = dx - cx; + const sz = dz - cz; + const denominator = rx * sz - rz * sx; + if (Math.abs(denominator) < 1e-12) return null; + const t = ((cx - ax) * sz - (cz - az) * sx) / denominator; + const u = ((cx - ax) * rz - (cz - az) * rx) / denominator; + if (t <= 1e-9 || t >= 1 - 1e-9 || u <= 1e-9 || u >= 1 - 1e-9) return null; + return [t, u]; +} + +/** 선분이 삼각형에 닿는가(끝점이 안 · 변과 교차 · 접촉 포함 — 보수적으로). */ +export function segmentTouchesTriangle( + ax: number, + az: number, + bx: number, + bz: number, + t: ArrayLike, +): boolean { + if (inTriangle(ax, az, t) || inTriangle(bx, bz, t)) return true; + for (let k = 0; k < 3; k += 1) { + const j = (k + 1) % 3; + if (segmentsTouch(ax, az, bx, bz, t[k * 2], t[k * 2 + 1], t[j * 2], t[j * 2 + 1])) return true; + } + return false; +} + +/** 두 선분이 닿는가(끝 접촉 포함). */ +export function segmentsTouch( + ax: number, + az: number, + bx: number, + bz: number, + cx: number, + cz: number, + dx: number, + dz: number, +): boolean { + const o1 = orient(ax, az, bx, bz, cx, cz); + const o2 = orient(ax, az, bx, bz, dx, dz); + const o3 = orient(cx, cz, dx, dz, ax, az); + const o4 = orient(cx, cz, dx, dz, bx, bz); + if (o1 * o2 > 0 || o3 * o4 > 0) return false; + if (o1 === 0 && o2 === 0) { + // 한 직선 위 — 구간이 겹치는지. + const overlap = (p: number, q: number, r: number, s: number): boolean => + Math.max(Math.min(p, q), Math.min(r, s)) <= Math.min(Math.max(p, q), Math.max(r, s)); + return overlap(ax, bx, cx, dx) && overlap(az, bz, cz, dz); + } + return true; +} + +function orient(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number { + const value = (bx - ax) * (cz - az) - (bz - az) * (cx - ax); + return Math.abs(value) < 1e-12 ? 0 : Math.sign(value); +} + +/** 삼각형(평면 [x0,z0,x1,z1,x2,z2]) 안(경계 포함)인가. */ +export function inTriangle(px: number, pz: number, t: ArrayLike, at = 0): boolean { + const d1 = orient(t[at], t[at + 1], t[at + 2], t[at + 3], px, pz); + const d2 = orient(t[at + 2], t[at + 3], t[at + 4], t[at + 5], px, pz); + const d3 = orient(t[at + 4], t[at + 5], t[at], t[at + 1], px, pz); + return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0)); +} + +/** 선분 격자 색인 — 상자 안 후보만 낸다. */ +export class SegGrid { + private cells = new Map(); + readonly segs: number[] = []; + private stamp: number[] = []; + private tick = 0; + + constructor(private cell: number) {} + + private key(c: number, r: number): number { + return (c + 32768) * 65536 + (r + 32768); + } + + add(id: number, ax: number, az: number, bx: number, bz: number): void { + this.segs[id * 4] = ax; + this.segs[id * 4 + 1] = az; + this.segs[id * 4 + 2] = bx; + this.segs[id * 4 + 3] = bz; + this.stamp[id] = 0; + const c0 = Math.floor(Math.min(ax, bx) / this.cell); + const c1 = Math.floor(Math.max(ax, bx) / this.cell); + const r0 = Math.floor(Math.min(az, bz) / this.cell); + const r1 = Math.floor(Math.max(az, bz) / this.cell); + for (let c = c0; c <= c1; c += 1) { + for (let r = r0; r <= r1; r += 1) { + const key = this.key(c, r); + const list = this.cells.get(key); + if (list) list.push(id); + else this.cells.set(key, [id]); + } + } + } + + near(minX: number, minZ: number, maxX: number, maxZ: number): number[] { + this.tick += 1; + const out: number[] = []; + const c0 = Math.floor(minX / this.cell); + const c1 = Math.floor(maxX / this.cell); + const r0 = Math.floor(minZ / this.cell); + const r1 = Math.floor(maxZ / this.cell); + for (let c = c0; c <= c1; c += 1) { + for (let r = r0; r <= r1; r += 1) { + const list = this.cells.get(this.key(c, r)); + if (!list) continue; + for (const id of list) { + if (this.stamp[id] === this.tick) continue; + this.stamp[id] = this.tick; + out.push(id); + } + } + } + return out; + } + + /** 점에서 eps 안에 지나는 색인 선분이 있는가(끝점 포함). */ + passesNear(x: number, z: number, eps: number): boolean { + for (const id of this.near(x - eps, z - eps, x + eps, z + eps)) { + const s = this.segs; + const ax = s[id * 4]; + const az = s[id * 4 + 1]; + const dx = s[id * 4 + 2] - ax; + const dz = s[id * 4 + 3] - az; + const length2 = dx * dx + dz * dz; + const t = + length2 < 1e-18 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / length2)); + if (Math.hypot(ax + dx * t - x, az + dz * t - z) <= eps) return true; + } + return false; + } + + /** + * 선분 (a → b) 가 색인 선분에 처음 닿는 매개변수(0 < t ≤ 1 · 상대 끝 접촉 포함) — 없으면 1 넘는 값. + * 한 직선 위 겹침은 셈하지 않는다. + */ + firstHit(ax: number, az: number, bx: number, bz: number): number { + let best = 2; + const rx = bx - ax; + const rz = bz - az; + for (const id of this.near( + Math.min(ax, bx), + Math.min(az, bz), + Math.max(ax, bx), + Math.max(az, bz), + )) { + const s = this.segs; + const cx = s[id * 4]; + const cz = s[id * 4 + 1]; + const sx = s[id * 4 + 2] - cx; + const sz = s[id * 4 + 3] - cz; + const denominator = rx * sz - rz * sx; + if (Math.abs(denominator) < 1e-12) continue; + const t = ((cx - ax) * sz - (cz - az) * sx) / denominator; + const u = ((cx - ax) * rz - (cz - az) * rx) / denominator; + if (t > 1e-6 && t <= 1 + 1e-9 && u >= -1e-9 && u <= 1 + 1e-9) best = Math.min(best, t); + } + return best; + } + + /** 선분 (a, b) 와 닿는 색인 선분이 있는가. */ + touches(ax: number, az: number, bx: number, bz: number): boolean { + for (const id of this.near( + Math.min(ax, bx), + Math.min(az, bz), + Math.max(ax, bx), + Math.max(az, bz), + )) { + const s = this.segs; + if (segmentsTouch(ax, az, bx, bz, s[id * 4], s[id * 4 + 1], s[id * 4 + 2], s[id * 4 + 3])) + return true; + } + return false; + } +} + +/** + * 닫힌 고리 모음 안 판정 — 고리마다 홀짝(광선 +x), 하나라도 홀이면 안. 선분을 z 띠(1 m)로 + * 묶어 긴 고리(설계 구역 둘레 수천 마디)도 띠 안 선분만 센다. + */ +export class LoopIndex { + private strips = new Map(); + private seg: number[] = []; + private loopOf: number[] = []; + private parity: Uint8Array; + minX = Infinity; + maxX = -Infinity; + minZ = Infinity; + maxZ = -Infinity; + readonly loops: ReadonlyArray>; + + constructor(loops: ReadonlyArray>) { + this.loops = loops; + this.parity = new Uint8Array(Math.max(1, loops.length)); + loops.forEach((loop, l) => { + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + const [ax, az] = loop[j]; + const [bx, bz] = loop[i]; + this.minX = Math.min(this.minX, bx); + this.maxX = Math.max(this.maxX, bx); + this.minZ = Math.min(this.minZ, bz); + this.maxZ = Math.max(this.maxZ, bz); + if (az === bz) continue; + const id = this.loopOf.length; + this.seg.push(ax, az, bx, bz); + this.loopOf.push(l); + for (let s = Math.floor(Math.min(az, bz)); s <= Math.floor(Math.max(az, bz)); s += 1) { + const list = this.strips.get(s); + if (list) list.push(id); + else this.strips.set(s, [id]); + } + } + }); + } + + contains(x: number, z: number): boolean { + if (x < this.minX || x > this.maxX || z < this.minZ || z > this.maxZ) return false; + const list = this.strips.get(Math.floor(z)); + if (!list) return false; + const touched: number[] = []; + for (const id of list) { + const ax = this.seg[id * 4]; + const az = this.seg[id * 4 + 1]; + const bx = this.seg[id * 4 + 2]; + const bz = this.seg[id * 4 + 3]; + if (az > z !== bz > z && x < ((bx - ax) * (z - az)) / (bz - az) + ax) { + this.parity[this.loopOf[id]] ^= 1; + touched.push(this.loopOf[id]); + } + } + let inside = false; + for (const l of touched) if (this.parity[l]) inside = true; + for (const l of touched) this.parity[l] = 0; + return inside; + } +} + +/** 삼각형 격자 색인 — 평면 점이 든 삼각형 번호(없으면 -1). */ +export class TriGrid { + private grid: SegGrid; + private count = 0; + /** 삼각형마다 [x0,z0,x1,z1,x2,z2]. */ + readonly plan: number[] = []; + + constructor(cell: number) { + this.grid = new SegGrid(cell); + } + + add(t: ArrayLike): number { + const id = this.count; + this.count += 1; + for (let k = 0; k < 6; k += 1) this.plan.push(t[k]); + // 상자를 대각 선분 하나로 색인한다(칸 덮기는 상자 기준). + this.grid.add( + id, + Math.min(t[0], t[2], t[4]), + Math.min(t[1], t[3], t[5]), + Math.max(t[0], t[2], t[4]), + Math.max(t[1], t[3], t[5]), + ); + return id; + } + + find(x: number, z: number): number { + for (const id of this.grid.near(x, z, x, z)) { + if (inTriangle(x, z, this.plan, id * 6)) return id; + } + return -1; + } + + /** 무게중심 가중치 — 그 삼각형 안 점의 (w0, w1, w2). */ + weights(id: number, x: number, z: number): [number, number, number] { + const p = this.plan; + const [x0, z0, x1, z1, x2, z2] = [ + p[id * 6], + p[id * 6 + 1], + p[id * 6 + 2], + p[id * 6 + 3], + p[id * 6 + 4], + p[id * 6 + 5], + ]; + const denominator = (z1 - z2) * (x0 - x2) + (x2 - x1) * (z0 - z2); + if (Math.abs(denominator) < 1e-12) return [1 / 3, 1 / 3, 1 / 3]; + const w0 = ((z1 - z2) * (x - x2) + (x2 - x1) * (z - z2)) / denominator; + const w1 = ((z2 - z0) * (x - x2) + (x0 - x2) * (z - z2)) / denominator; + return [w0, w1, 1 - w0 - w1]; + } +} diff --git a/config/package-lock.json b/config/package-lock.json index 87cef9ca6..93c132f2e 100644 --- a/config/package-lock.json +++ b/config/package-lock.json @@ -8,6 +8,8 @@ "name": "forest-road-webapp", "version": "0.1.0", "dependencies": { + "@kninnug/constrainautor": "^4.1.0", + "delaunator": "^5.1.0", "maplibre-gl": "^5.24.0", "three": "^0.185.0" }, @@ -60,6 +62,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@kninnug/constrainautor": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@kninnug/constrainautor/-/constrainautor-4.1.0.tgz", + "integrity": "sha512-YEkGtzMewRxPRZuK1QmFDmELwaSL4LSLmc0WXTmpT8AqdcR/Ueu77fiJZ2lwOwXUX8aBJpHuy/e/06/Ve2dPNQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.1" + } + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", @@ -529,6 +540,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1043,6 +1063,12 @@ "protocol-buffers-schema": "^3.3.1" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", diff --git a/config/package.json b/config/package.json index a5c079d1d..c05fe0f02 100644 --- a/config/package.json +++ b/config/package.json @@ -4,6 +4,8 @@ "description": "임도 설계 및 견적 자동화 프로그램 (프론트엔드)", "type": "module", "dependencies": { + "@kninnug/constrainautor": "^4.1.0", + "delaunator": "^5.1.0", "maplibre-gl": "^5.24.0", "three": "^0.185.0" }, diff --git a/package.json b/package.json index e520a8041..53baeccd0 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "format": "node ./config/node_modules/prettier/bin/prettier.cjs --write \"../**/*.{ts,css,html}\"" }, "dependencies": { + "@kninnug/constrainautor": "^4.1.0", + "delaunator": "^5.1.0", "maplibre-gl": "^5.24.0", "three": "^0.185.0" }, diff --git a/resources/tester/helper_115_6_corridor_tin.cjs b/resources/tester/helper_115_6_corridor_tin.cjs new file mode 100644 index 000000000..9f7ed3f25 --- /dev/null +++ b/resources/tester/helper_115_6_corridor_tin.cjs @@ -0,0 +1,557 @@ +/* PLAN 115-6 단위검증 헬퍼 — B05 3D 제안 ② 단일 삼각망(TIN) 합성 지형 검사. + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. + * (트랜스파일 틀은 helper_115_corridor_deform.cjs 와 같다.) + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three")); +const LIBS = { + three: THREE, + // 기본 내보내기(ESM default) 꼴로 감싼다. + // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). + delaunator: require(path.join(NM, "delaunator", "index.js")), + "@kninnug/constrainautor": { + __esModule: true, + default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (LIBS[request]) return LIBS[request]; + if (request.startsWith("@ui/") || request.startsWith("@config/")) { + return new Proxy({}, { get: () => () => undefined }); + } + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { tinTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Tin")); +const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); +const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); +const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg")); + +const HALF = 3; +const ROAD_Z = 100; + +/** 측점 하나 — 중심 (cx, cy) · 좌향 (lx, ly) · 지반 평탄 groundZ · 양측 같은 비탈 1:ratio. */ +function station(chainage, cx, cy, lx, ly, groundZ, ratio) { + const w = Math.abs(groundZ - ROAD_Z) * ratio; + const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut"; + const pts = [ + [-40, groundZ], + [-(HALF + w), groundZ], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [HALF + w, groundZ], + [40, groundZ], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: cx, + center_y: cy, + center_z: groundZ, + frame: { left_xy: [lx, ly] }, + samples, + design: { + section_mode: mode, + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +/** 격자 지형(모델 좌표 x0..x1 · y0..y1 · 간격 step) — 씬 좌표로 구운 메시. */ +function gridTerrain(bounds, x0, x1, y0, y1, step, heightAt) { + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const nx = Math.round((x1 - x0) / step) + 1; + const ny = Math.round((y1 - y0) / step) + 1; + const positions = new Float32Array(nx * ny * 3); + const colors = new Uint8Array(nx * ny * 4).fill(200); + for (let j = 0; j < ny; j += 1) { + for (let i = 0; i < nx; i += 1) { + const v = j * nx + i; + const x = x0 + i * step; + const y = y0 + j * step; + positions[v * 3] = x - ox; + positions[v * 3 + 1] = heightAt(x, y) - oz; + positions[v * 3 + 2] = -(y - oy); + } + } + const index = []; + for (let j = 0; j < ny - 1; j += 1) { + for (let i = 0; i < nx - 1; i += 1) { + const a = j * nx + i; + // 대각을 번갈아 — 들로네가 마음대로 고르면 원래 삼각형과 달라지는 자리를 일부러 만든다. + if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); + else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); + geometry.setIndex(index); + const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); + return { terrain, positions, index, ox, oy, oz }; +} + +const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`; +const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`; +/** PSLG 점(배정밀도) → 망 정점(단정밀도)과 같은 키. */ +const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z)); + +/** 결과 망 전체(남은 원래 + TIN) 삼각형을 [[x,y,z]×3] 로. */ +function trianglesOf(root) { + const out = []; + root.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const p = child.geometry.getAttribute("position"); + const idx = child.geometry.getIndex(); + const count = idx ? idx.count : p.count; + for (let t = 0; t < count; t += 3) { + const tri = []; + for (let k = 0; k < 3; k += 1) { + const v = idx ? idx.getX(t + k) : t + k; + tri.push([p.getX(v), p.getY(v), p.getZ(v)]); + } + out.push({ tri, tin: child.name === "terrain-tin-mesh" }); + } + }); + return out; +} + +/** 지형 바깥 테두리(모델 사각형 x0..x1 · y0..y1) 위 변인가 — 틈 셈에서 뺀다. */ +function frameOf(bounds, x0, x1, y0, y1) { + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const on = (p) => { + const x = p[0] + ox; + const y = -p[2] + oy; + return ( + Math.abs(x - x0) < 1e-3 || Math.abs(x - x1) < 1e-3 || Math.abs(y - y0) < 1e-3 || Math.abs(y - y1) < 1e-3 + ); + }; + return { boundary: (a, b) => on(a) && on(b) }; +} + +/** 제안 ② 망(TIN 조각)의 씬 높이 조회기. */ +function tinHeights(out) { + const p = out.root.getObjectByName("terrain-tin-mesh").geometry.getAttribute("position"); + const tris = []; + for (let t = 0; t < p.count; t += 3) { + tris.push([0, 1, 2].map((k) => [p.getX(t + k), p.getY(t + k), p.getZ(t + k)])); + } + return (sx, sz) => { + for (const [a, b, c] of tris) { + const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); + if (Math.abs(d) < 1e-12) continue; + const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d; + const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d; + const w2 = 1 - w0 - w1; + if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue; + return w0 * a[1] + w1 * b[1] + w2 * c[1]; + } + return null; + }; +} + +/** 한 번 돌리기 — 지형 · 노선 · 측점을 받아 결과와 검사 수치. */ +function run({ sections, route, terrainSpec, structures }) { + const build = buildCorridor(sections, route); + build.sections = sections; + build.structures = structures ?? []; + const { bounds, x0, x1, y0, y1, step, heightAt } = terrainSpec; + const { terrain, positions, index } = gridTerrain(bounds, x0, x1, y0, y1, step, heightAt); + const before = positions.slice(); + const split = new TerrainBandSplit(terrain, { + minX: -1e4, + maxX: 1e4, + minZ: -1e4, + maxZ: 1e4, + }); + const heightIndex = new TerrainHeightIndex(terrain); + const out = tinTerrain(split, build, bounds, heightIndex); + return { out, before, positions, index, build, heightIndex }; +} + +/** 틈 검사 — 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝(리본 이음) · 구멍 둘레가 아닌 것. */ +function gapEdges(tris, out, original) { + const pslg = out.design.pslg; + const tagAt = new Map(); + for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); + const uses = new Map(); + tris.forEach(({ tri }) => { + for (let k = 0; k < 3; k += 1) { + const a = tri[k]; + const b = tri[(k + 1) % 3]; + const ka = key(...a); + const kb = key(...b); + const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + const entry = uses.get(e) ?? { n: 0, a, b }; + entry.n += 1; + uses.set(e, entry); + } + }); + let gaps = 0; + let maxGapLen = 0; + for (const { n, a, b } of uses.values()) { + if (n !== 1) continue; + const ta = tagAt.get(pkey(a[0], a[2])); + const tb = tagAt.get(pkey(b[0], b[2])); + if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue; + if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue; + if (original.boundary(a, b)) continue; + gaps += 1; + maxGapLen = Math.max(maxGapLen, Math.hypot(a[0] - b[0], a[2] - b[2])); + } + return { gaps, maxGapLen }; +} + +/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ +function lostVertices(tris, before, out) { + const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]); + const border = new PSLG.SegGrid(2); + let id = 0; + [out.design.outer, ...out.design.holes].forEach((loop) => { + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + border.add(id, loop[j][0], loop[j][1], loop[i][0], loop[i][1]); + id += 1; + } + }); + const present = new Set(); + tris.forEach(({ tri }) => tri.forEach((p) => present.add(key(...p)))); + let lost = 0; + for (let v = 0; v < before.length / 3; v += 1) { + const x = before[v * 3]; + const z = before[v * 3 + 2]; + if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue; + if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1; + } + return lost; +} + +const results = {}; +const r6 = (v) => Math.round(v * 1e6) / 1e6; + +// ① 직선 도로 · 굴곡 지형 — 틈 0 · 브레이크라인 = 삼각형 변 · 측점 단면 = B06 · 공사 밖 원래 그대로. +{ + const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; + const bump = (x, y) => 96 + 0.3 * Math.sin(x / 3) * Math.cos(y / 4); + const sections = [0, 20, 40].map((c) => station(c, c, 0, 0, 1, 96, 1.5)); + const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt: bump }; + const { out, before, positions, index, heightIndex } = run({ + sections, + route: [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + ], + terrainSpec: spec, + }); + const tris = trianglesOf(out.root); + // 원래 지형 바깥 테두리(씬) — x = -10/50 · y = -25/25 선 위. + const ox = 20; + const onFrame = (p) => + Math.abs(p[0] + ox + 10) < 1e-4 || + Math.abs(p[0] + ox - 50) < 1e-4 || + Math.abs(-p[2] + 25) < 1e-4 || + Math.abs(-p[2] - 25) < 1e-4; + const original = { boundary: (a, b) => onFrame(a) && onFrame(b) }; + const gap = gapEdges(tris, out, original); + // 원래 삼각형 집합(정점 셋 키) — TIN 의 지형 정점만으로 된 삼각형은 원래 것이어야 한다. + const origTris = new Set(); + const vkey = (v) => key(before[v * 3], before[v * 3 + 1], before[v * 3 + 2]); + for (let t = 0; t < index.length; t += 3) { + origTris.add([vkey(index[t]), vkey(index[t + 1]), vkey(index[t + 2])].sort().join("|")); + } + const origVerts = new Set(); + for (let v = 0; v < before.length / 3; v += 1) origVerts.add(vkey(v)); + const pslg = out.design.pslg; + const tagAt = new Map(); + for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); + let terrainOnly = 0; + let notOriginal = 0; + let strayVerts = 0; + tris.forEach(({ tri, tin }) => { + if (!tin) return; + const tags = tri.map((p) => tagAt.get(pkey(p[0], p[2]))); + tri.forEach((p, k) => { + if (tags[k] === PSLG.TAG_TERRAIN && !origVerts.has(key(...p))) strayVerts += 1; + }); + if (tags.every((t) => t === PSLG.TAG_TERRAIN)) { + terrainOnly += 1; + if (!origTris.has(tri.map((p) => key(...p)).sort().join("|"))) notOriginal += 1; + } + }); + // 브레이크라인 = 망의 변 — 설계 · 노면 끝 제약이 모두 변으로 들어갔나. + const meshEdges = new Set(); + tris.forEach(({ tri }) => { + for (let k = 0; k < 3; k += 1) { + const a = pkey(tri[k][0], tri[k][2]); + const b = pkey(tri[(k + 1) % 3][0], tri[(k + 1) % 3][2]); + meshEdges.add(a < b ? `${a}|${b}` : `${b}|${a}`); + } + }); + let designEdges = 0; + let missingEdges = 0; + for (let e = 0; e < pslg.kinds.length; e += 1) { + if (pslg.kinds[e] === PSLG.EDGE_TERRAIN) continue; + designEdges += 1; + const a = fkey(pslg.x[pslg.edges[e * 2]], pslg.z[pslg.edges[e * 2]]); + const b = fkey(pslg.x[pslg.edges[e * 2 + 1]], pslg.z[pslg.edges[e * 2 + 1]]); + if (!meshEdges.has(a < b ? `${a}|${b}` : `${b}|${a}`)) missingEdges += 1; + } + // 측점(20) 단면 — 망을 그 선에서 재어 B06 설계선(성토 1:1.5 → 비탈 끝에서 지반)과 견준다. + const tinMesh = out.root.getObjectByName("terrain-tin-mesh"); + const tinTris = []; + const tp = tinMesh.geometry.getAttribute("position"); + for (let t = 0; t < tp.count; t += 3) { + tinTris.push([0, 1, 2].map((k) => [tp.getX(t + k), tp.getY(t + k), tp.getZ(t + k)])); + } + const heightOn = (sx, sz) => { + for (const [a, b, c] of tinTris) { + const d = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]); + if (Math.abs(d) < 1e-12) continue; + const w0 = ((b[2] - c[2]) * (sx - c[0]) + (c[0] - b[0]) * (sz - c[2])) / d; + const w1 = ((c[2] - a[2]) * (sx - c[0]) + (a[0] - c[0]) * (sz - c[2])) / d; + const w2 = 1 - w0 - w1; + if (w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9) continue; + return w0 * a[1] + w1 * b[1] + w2 * c[1]; + } + return null; + }; + let sectionErr = 0; + let toeErr = 0; + for (const side of [1, -1]) { + let toe = null; + for (let s = 0.05; s <= 8; s += 0.05) { + const y = side * (HALF + s); + const z = heightOn(20 - ox, -y); + if (z === null) continue; + const g = heightIndex.heightAt(20 - ox, -y) + 100; + const line = ROAD_Z - s / 1.5; + const expect = Math.max(line, g); + sectionErr = Math.max(sectionErr, Math.abs(z + 100 - expect)); + if (toe === null && line <= g) toe = s; + } + // 비탈 끝 점(설계) — 그 측 그 행의 PSLG 비탈 끝과 단면 교차 거리 차. + let best = Infinity; + for (let i = 0; i < pslg.count; i += 1) { + if (pslg.tag[i] !== PSLG.TAG_FILL || Math.abs(pslg.x[i] - (20 - ox)) > 1e-6) continue; + best = Math.min(best, Math.abs(Math.abs(pslg.z[i]) - HALF - toe)); + } + toeErr = Math.max(toeErr, best === Infinity ? 99 : best); + } + results.straight = { + stats: out.stats, + gaps: gap.gaps, + maxGapLen: r6(gap.maxGapLen), + originalUntouched: before.every((value, i) => value === positions[i]), + terrainOnly, + notOriginal, + strayVerts, + lostVerts: lostVertices(tris, before, out), + designEdges, + missingEdges, + sectionErr: r6(sectionErr), + toeErr: r6(toeErr), + }; +} + +// ② 구조물 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m · 윗면 100) 안 삼각형 0 · 둘레 높이 ≤ 윗면. +{ + const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; + const sections = [0, 20, 40].map((c) => station(c, c, 0, 0, 1, 96, 1.5)); + const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); + const wall = { + chainage_m: 20, + kind: "revet", + polygon: [ + [5, 94], + [6.3, 94], + [6, 99], + [5, 99], + ], + rings: [ring(19), ring(20), ring(21)], + }; + const { out } = run({ + sections, + route: [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + ], + terrainSpec: { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt: () => 96 }, + structures: [wall], + }); + const tris = trianglesOf(out.root); + let inHole = 0; + let nearHole = 0; + tris.forEach(({ tri }) => { + const sx = tri.reduce((s, p) => s + p[0], 0) / 3 + 20; + const sy = -tri.reduce((s, p) => s + p[2], 0) / 3; + if (sx > 19.02 && sx < 20.98 && sy > 5.02 && sy < 5.98) inHole += 1; + if (sx > 19.02 && sx < 20.98 && sy > 6.02 && sy < 6.5) nearHole += 1; + }); + const pslg = out.design.pslg; + let perimeter = 0; + let aboveTop = 0; + for (let i = 0; i < pslg.count; i += 1) { + if (pslg.tag[i] !== PSLG.TAG_HOLE) continue; + perimeter += 1; + if (pslg.y[i] + 100 > 99 + 1e-6) aboveTop += 1; + } + const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25)); + results.hole = { + holes: out.stats.holes, + failed: out.stats.failed, + inHole, + nearHole, + perimeter, + aboveTop, + gaps: gap.gaps, + }; +} + +// ③ 급곡선(R 12 m) · 높은 성토(8 m · 비탈 12 m) — 안쪽 단면 겹침 청소 · 둘레 꼬임 없음 · 제약 실패 0. +{ + const R = 12; + const route = []; + for (let a = 0; a <= 180; a += 2) { + const t = (a * Math.PI) / 180; + route.push({ x: R * Math.sin(t), y: R - R * Math.cos(t) }); + } + const sections = [0, 30, 60, 90, 120, 150, 180].map((deg) => { + const t = (deg * Math.PI) / 180; + // 진행 방향 (cos, sin) · 좌향 = (-sin, cos)(원 중심 쪽). + return station(R * t, R * Math.sin(t), R - R * Math.cos(t), -Math.sin(t), Math.cos(t), 92, 1.5); + }); + const bounds = { x: [-40, 40], y: [-30, 50], z: [80, 110] }; + const { out } = run({ + sections, + route, + terrainSpec: { bounds, x0: -40, x1: 40, y0: -30, y1: 50, step: 1, heightAt: () => 92 }, + }); + const outer = out.design.outer; + let selfCross = 0; + for (let i = 0; i < outer.length; i += 1) { + const a = outer[i]; + const b = outer[(i + 1) % outer.length]; + for (let j = i + 2; j < outer.length; j += 1) { + if ((j + 1) % outer.length === i) continue; + const c = outer[j]; + const d = outer[(j + 1) % outer.length]; + if (PSLG.crossing(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1])) selfCross += 1; + } + } + const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -40, 40, -30, 50)); + results.curve = { stats: out.stats, selfCross, gaps: gap.gaps }; +} + +// ④ 소단 — 성토 8 m(1:1.5 로 4 m · 소단 1.5 m · 다시 1:1.5) · 꺾임 종방향 선이 망의 변 · 측점 단면 그대로. +{ + const bounds = { x: [-10, 50], y: [-30, 30], z: [85, 110] }; + const berm = (c) => { + const st = station(c, c, 0, 0, 1, 92, 1.5); + const half = [ + [HALF, ROAD_Z], + [HALF + 6, ROAD_Z - 4], + [HALF + 7.5, ROAD_Z - 4], + [HALF + 13.5, 92], + [40, 92], + ]; + const pts = [...half.map(([o, z]) => [-o, z]).reverse(), [-2.5, ROAD_Z], [2.5, ROAD_Z], ...half]; + st.design.design_line = pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })); + return st; + }; + const sections = [0, 20, 40].map(berm); + const { out } = run({ + sections, + route: [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, + ], + terrainSpec: { bounds, x0: -10, x1: 50, y0: -30, y1: 30, step: 0.5, heightAt: () => 92 }, + }); + const tris = trianglesOf(out.root); + const meshEdges = new Set(); + tris.forEach(({ tri }) => { + for (let k = 0; k < 3; k += 1) { + const a = pkey(tri[k][0], tri[k][2]); + const b = pkey(tri[(k + 1) % 3][0], tri[(k + 1) % 3][2]); + meshEdges.add(a < b ? `${a}|${b}` : `${b}|${a}`); + } + }); + const pslg = out.design.pslg; + let missing = 0; + for (let e = 0; e < pslg.kinds.length; e += 1) { + if (pslg.kinds[e] === PSLG.EDGE_TERRAIN) continue; + const a = fkey(pslg.x[pslg.edges[e * 2]], pslg.z[pslg.edges[e * 2]]); + const b = fkey(pslg.x[pslg.edges[e * 2 + 1]], pslg.z[pslg.edges[e * 2 + 1]]); + if (!meshEdges.has(a < b ? `${a}|${b}` : `${b}|${a}`)) missing += 1; + } + // 측점 20 단면 — 망 높이 대 B06 설계선(소단 포함 · 지반 92 아래로는 안 감). + const line = sections[1].design.design_line; + const b06 = (o) => { + for (let i = 0; i + 1 < line.length; i += 1) { + const a = line[i]; + const b = line[i + 1]; + if (o >= a.offset_m && o <= b.offset_m) { + const t = (o - a.offset_m) / (b.offset_m - a.offset_m || 1); + return Math.max(92, a.elevation_m + (b.elevation_m - a.elevation_m) * t); + } + } + return 92; + }; + const tin = tinHeights(out); + let sectionErr = 0; + for (let o = HALF + 0.05; o <= HALF + 16; o += 0.05) { + for (const side of [1, -1]) { + const z = tin(0, -side * o); // 씬 x = 20 − 20 · 씬 z = −y + if (z !== null) sectionErr = Math.max(sectionErr, Math.abs(z + 97.5 - b06(side * o))); + } + } + const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -30, 30)); + results.berm = { stats: out.stats, missing, sectionErr: r6(sectionErr), gaps: gap.gaps }; +} + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_6_corridor_tin.py b/resources/tester/test_115_6_corridor_tin.py new file mode 100644 index 000000000..2eb65b456 --- /dev/null +++ b/resources/tester/test_115_6_corridor_tin.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""PLAN 115-6 — B05 3D 제안 ② 단일 삼각망(TIN) 합성 지형 검사. + +Node 도우미(helper_115_6_corridor_tin.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 +결과(JSON)를 단언한다. 틈 0 · 브레이크라인 = 삼각형 변 · 측점 단면 = B06 · 공사 밖 정점 그대로 · +구조물 구멍 안 삼각형 0 · 곡선 안쪽 겹침 청소 · 등록부. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_115_6_corridor_tin.cjs")], + capture_output=True, + text=True, + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_straight_no_gap_and_constraints_are_edges(results): + r = results["straight"] + assert r["stats"]["failed"] == 0 # 제약 못 넣은 것 없음 + assert r["gaps"] == 0 # 리본 이음 · 지형 테두리 말고 한 번만 쓰인 변 없음 + assert r["designEdges"] > 0 and r["missingEdges"] == 0 # 브레이크라인 = 삼각형 변 + + +def test_straight_station_section_matches_b06(results): + r = results["straight"] + assert r["sectionErr"] < 1e-4 # 측점 20 단면 = 성토 1:1.5 → 지반 + assert r["toeErr"] < 0.06 # 비탈 끝 자리(시험 걸음 0.05 m 안) + + +def test_straight_terrain_outside_untouched(results): + r = results["straight"] + assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로 + assert r["strayVerts"] == 0 # 망의 지형 정점은 모두 원래 정점 + assert r["lostVerts"] == 0 # 공사 밖 원래 정점 하나도 안 빠짐 + assert r["notOriginal"] <= 2 # 지형만으로 된 새 삼각형 = 둘레 위 정점을 뺀 자리뿐 + + +def test_structure_hole_empty_and_draped(results): + r = results["hole"] + assert r["holes"] == 1 and r["failed"] == 0 + assert r["inHole"] == 0 # 윗면 외곽 안 삼각형 없음 + assert r["perimeter"] >= 4 and r["aboveTop"] == 0 # 둘레 높이 ≤ 윗면(드레이프) + assert r["gaps"] == 0 + + +def test_tight_curve_bowtie_cleared(results): + r = results["curve"] + assert r["stats"]["bowtie"] > 0 # 안쪽 단면이 앞 단면에 닿아 잘림 + assert r["selfCross"] == 0 # 설계 구역 둘레 꼬임 없음 + assert r["stats"]["failed"] == 0 + assert r["gaps"] == 0 + + +def test_berm_bend_lines(results): + r = results["berm"] + assert r["stats"]["bendLines"] > 0 # 소단 꺾임 종방향 선 + assert r["missing"] == 0 + assert r["sectionErr"] < 0.1 # 측점 단면 ±0.1 m(조각 48열 재표본 오차 포함) + assert r["gaps"] == 0 + + +def test_dependencies_two_only(): + with open(os.path.join(ROOT, "config", "package.json"), encoding="utf-8") as f: + deps = json.load(f)["dependencies"] + assert deps.get("delaunator", "").startswith("^5") + assert deps.get("@kninnug/constrainautor", "").startswith("^4") diff --git a/tsconfig.json b/tsconfig.json index 256f2e4d2..746b2b900 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,8 @@ "three": ["config/node_modules/@types/three"], "three/*": ["config/node_modules/@types/three/*"], "maplibre-gl": ["config/node_modules/maplibre-gl"], + "delaunator": ["config/node_modules/delaunator"], + "@kninnug/constrainautor": ["config/node_modules/@kninnug/constrainautor"], } }, "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], diff --git a/vite.config.ts b/vite.config.ts index c7e98dd93..c9fbe8032 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,11 @@ export default { "@util": resolve(__dirname, "./common_util"), three: resolve(__dirname, "./config/node_modules/three"), "maplibre-gl": resolve(__dirname, "./config/node_modules/maplibre-gl"), + delaunator: resolve(__dirname, "./config/node_modules/delaunator"), + "@kninnug/constrainautor": resolve( + __dirname, + "./config/node_modules/@kninnug/constrainautor", + ), }, }, server: {