feat(B05): 3D 제안 ② 단일 삼각망(TIN) 완성 지반 — 설계 점 · 브레이크라인 · 지형 이음 · 구조물 구멍 · 구역 색(115-6)

- 새 파일 셋: _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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-08 22:35:14 +09:00
co-authored by Claude Opus 5.5
parent 72f02a2d64
commit d583936b47
10 changed files with 1910 additions and 0 deletions
+354
View File
@@ -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 };
}
@@ -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<Array<[number, number]>>;
/** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */
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<CorridorStructure>,
): Array<Array<[number, number, number]>> {
const loops: Array<Array<[number, number, number]>> = [];
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<DeformLimit>,
structures: ReadonlyArray<CorridorStructure>,
stationChainages: ReadonlyArray<number>,
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<number, [number, number]> } | 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<number, [number, number]>();
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<Array<[number, number]>> = [];
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 };
}
@@ -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<string, number>();
private edgeKeys = new Set<number>();
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<number>, 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<number, Array<[number, number]>>();
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<number>();
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<number>,
): 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<number>, 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<number, number[]>();
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<number, number[]>();
private seg: number[] = [];
private loopOf: number[] = [];
private parity: Uint8Array;
minX = Infinity;
maxX = -Infinity;
minZ = Infinity;
maxZ = -Infinity;
readonly loops: ReadonlyArray<ReadonlyArray<[number, number]>>;
constructor(loops: ReadonlyArray<ReadonlyArray<[number, number]>>) {
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>): 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];
}
}
+26
View File
@@ -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",
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
@@ -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));
@@ -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")
+2
View File
@@ -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"],
+5
View File
@@ -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: {