diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts new file mode 100644 index 000000000..e22eb97a3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -0,0 +1,386 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Loft.ts + * 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 사용자 안) — 비탈을 따로 그려 넣는다. + * + * ① 측점 단면 = B06 설계선 비탈 꼭짓점(`_Loft_Profile.ts`) · 비탈 끝 = 평면도 외곽선과 같은 규칙. + * ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 직선 보간(바깥거리 · + * 높이 둘 다) → 비탈 끝 선 = 평면도 외곽선(비탈 끝 오프셋 노선 따라 보간). 성토 ↔ 절토가 + * 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양, 끝 오프셋만 보간). + * ① · ② 처럼 행마다 지형과 다시 맞추지 않는다. + * ③ 드레이프 = 비탈 끝 높이를 그 자리 지형에 맞추고 보정량을 노면부 끝 0 → 비탈 끝 1 로 퍼뜨림. + * ④ 곡선 안쪽 겹침 = 앞 행 단면에 닿으면 그 자리에서 자름(제안 ② 와 같은 청소). + * 지형 이음 · 구조물 구멍 · 구역 색 · 망 = 제안 ② 기계(`_Tin.ts` tinTerrain) 그대로 — 설계 점 원천만 바꿈. + * ========================================================================== */ + +import type * 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 { roadRows } from "./B05_Profile_UI_Corridor_Deform"; +import { noFillLimits } from "./B05_Profile_UI_Corridor_Deform_Structure"; +import { tinTerrain, type TinDesigner, type TinStats } from "./B05_Profile_UI_Corridor_Tin"; +import { + insideLoop, + SECTION_STEP_M, + structureTopLoops, + type TinDesign, +} from "./B05_Profile_UI_Corridor_Tin_Design"; +import { + EDGE_ANCHOR, + EDGE_DESIGN, + EDGE_HOLE, + Pslg, + SegGrid, + TAG_ANCHOR, + TAG_CUT, + TAG_FILL, + TAG_HOLE, +} from "./B05_Profile_UI_Corridor_Tin_Pslg"; +import { endOf, pairProfiles, stationLofts } from "./B05_Profile_UI_Corridor_Loft_Profile"; + +/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ +const MIN_CATCH_M = 0.02; +/** 종방향 선 — 사이 행 비탈 끝이 이만큼 바깥에 있어야 잇는다(m). */ +const LINE_GAP_M = 0.05; +/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ +const HOLE_REACH_M = 80; + +/** 한 행 · 한 측 단면 보기 — key 가 같은 행끼리 점 수가 같다(종방향 선). */ +interface View { + key: number; + kind: 0 | 1; + /** [s0, dz0, …] — 노면부 끝 기준. */ + pts: Float64Array; +} + +/** 드레이프한 행 단면 — 바깥거리 · 절대 높이(모델). */ +interface Slope { + s: Float64Array; + z: Float64Array; +} + +/** 제안 ③ 설계 점 · 브레이크라인 · 구멍 — 제안 ② 와 같은 꼴(TinDesign). */ +export const loftDesign: TinDesigner = (build, groundAt, bounds) => { + const rows = roadRows(build); + const n = rows.length; + const stations = stationLofts(build.sections ?? []); + if (n < 2 || !stations.length) return null; + const last = stations.length - 1; + const limits = noFillLimits(build.sections ?? [], 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 sx = (mx: number): number => mx - ox; + const sz = (my: number): number => -(my - oy); + const pslg = new Pslg(); + const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 }; + + // 행 틀 — 좌향 단위 벡터 · 반폭 · 중심. + const ux = new Float64Array(n); + const uy = new Float64Array(n); + const hw = new Float64Array(n); + rows.forEach((row, r) => { + const dx = row.left[0] - row.right[0]; + const dy = row.left[1] - row.right[1]; + const width = Math.hypot(dx, dy) || 1e-9; + ux[r] = dx / width; + uy[r] = dy / width; + hw[r] = width / 2; + }); + const anchorOf = (r: number, si: number) => (si === 0 ? rows[r].left : rows[r].right); + const pointAt = (r: number, si: number, s: number): [number, number] => { + const anchor = anchorOf(r, si); + const sign = si === 0 ? 1 : -1; + return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s]; + }; + + /** 누가거리를 감싼 측점 마디 [i, t] — 범위 밖은 끝 마디. */ + const bracket = (c: number): [number, number] => { + if (!last || c <= stations[0].chainage_m) return [0, 0]; + if (c >= stations[last].chainage_m) return [last - 1, 1]; + let lo = 0; + let hi = last; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (stations[mid].chainage_m <= c) lo = mid; + else hi = mid; + } + const span = stations[lo + 1].chainage_m - stations[lo].chainage_m; + return [lo, span > 1e-9 ? (c - stations[lo].chainage_m) / span : 0]; + }; + const pairs = new Map(); + const view = (i: number, t: number, si: number): View | null => { + const a = stations[i].sides[si]; + const b = stations[Math.min(i + 1, last)].sides[si]; + if (!a && !b) return null; + if (a && b && a.kind !== b.kind) { + // 성토 ↔ 절토 — 평면도처럼 가운데서 끊는다. 모양은 제 측점 것 · 끝 오프셋만 보간. + const half = t < 0.5 ? 0 : 1; + const own = half ? b : a; + const scale = (endOf(a) + (endOf(b) - endOf(a)) * t) / endOf(own); + return { key: i * 2 + half, kind: own.kind, pts: own.pts.map((v) => v * scale) }; + } + const slot = i * 2 + si; + let pair = pairs.get(slot); + if (!pair) { + pair = pairProfiles(a, b); + pairs.set(slot, pair); + } + const [pa, pb] = pair; + const kind = (a ?? b)?.kind ?? 0; + return { key: i * 2, kind, pts: pa.map((v, k) => v + (pb[k] - v) * t) }; + }; + /** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 s / 끝 비율. */ + const drape = (r: number, si: number, v: View): Slope | null => { + const m = v.pts.length / 2; + const end = v.pts[(m - 1) * 2]; + if (end < MIN_CATCH_M) return null; + const anchor = anchorOf(r, si); + const [ex, ey] = pointAt(r, si, end); + const ground = groundAt(ex, ey); + const delta = ground === null ? 0 : ground - (anchor[2] + v.pts[(m - 1) * 2 + 1]); + const s = new Float64Array(m); + const z = new Float64Array(m); + for (let k = 0; k < m; k += 1) { + s[k] = v.pts[k * 2]; + z[k] = anchor[2] + v.pts[k * 2 + 1] + (delta * s[k]) / end; + } + return { s, z }; + }; + const zAt = (slope: Slope, s: number): number => { + for (let k = 1; k < slope.s.length; k += 1) { + if (s <= slope.s[k] || k === slope.s.length - 1) { + const span = slope.s[k] - slope.s[k - 1]; + const t = span <= 1e-12 ? 1 : Math.min(1, (s - slope.s[k - 1]) / span); + return slope.z[k - 1] + (slope.z[k] - slope.z[k - 1]) * t; + } + } + return slope.z[0]; + }; + /** 성토 금지 고리 안은 원지반(또는 천장) 위로 못 올린다(제안 ② designZ 와 같은 규칙). */ + const limited = (x: number, y: number, z: number): number => { + const ground = groundAt(x, y); + if (ground === null || z <= ground) return z; + 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; + }; + const tagOf = (kind: number): number => (kind === 0 ? TAG_FILL : TAG_CUT); + + // 노면 끝 선 — 리본 바깥 끝 그대로(제안 ② 와 같음). + const anchors = [0, 1].map((si) => + rows.map((_, r) => { + const [x, y, z] = anchorOf(r, si); + 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 keyOf = [0, 1].map(() => new Int32Array(n).fill(-1)); + const reachOf = [0, 1].map(() => new Float64Array(n)); + const daylight = [0, 1].map(() => new Int32Array(n)); + [0, 1].forEach((si) => { + const fan = new SegGrid(2); + for (let r = 0; r < n; r += 1) { + daylight[si][r] = anchors[si][r]; + const [i, t] = bracket(rows[r].chainage_m); + const v = view(i, t, si); + const slope = v && drape(r, si, v); + if (!v || !slope) continue; + const end = slope.s[slope.s.length - 1]; + const ax = pslg.x[anchors[si][r]]; + const az = pslg.z[anchors[si][r]]; + const [dx, dy] = pointAt(r, si, end); + const hit = fan.firstHit(ax, az, sx(dx), sz(dy)); + let reach = end; + if (hit < 1) { + reach *= hit; + stats.bowtie += 1; + if (reach < MIN_CATCH_M) continue; + } + const [px, py] = pointAt(r, si, reach); + const z = limited(px, py, zAt(slope, reach)); + daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, tagOf(v.kind)); + keyOf[si][r] = v.key; + reachOf[si][r] = reach; + fan.add(r, ax, az, sx(px), sz(py)); + } + }); + + // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. + const left = Array.from(daylight[0]); + const right = Array.from(daylight[1]); + 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[n - 1]], EDGE_DESIGN); + pslg.addPath([anchors[1][n - 1], right[n - 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 stationAt = (c: number): number => + stations.findIndex((station) => Math.abs(station.chainage_m - c) < 1e-3); + const mark = new Uint8Array(n); + let previous = -Infinity; + for (let r = 0; r < n; r += 1) { + const c = rows[r].chainage_m; + const changed = r > 0 && [0, 1].some((si) => keyOf[si][r] !== keyOf[si][r - 1]); + if (changed) mark[r - 1] = 1; + if ( + r === 0 || + r === n - 1 || + changed || + stationAt(c) >= 0 || + c - previous >= SECTION_STEP_M - 1e-6 + ) { + mark[r] = 1; + previous = c; + } + } + + // 횡방향 선(노면 끝 → 꼭짓점 → 비탈 끝) · 같은 보기의 같은 번째 꼭짓점을 잇는 종방향 선. + [0, 1].forEach((si) => { + let before: { r: number; ids: Map } | null = null; + for (let r = 0; r < n; r += 1) { + if (!mark[r]) continue; + stats.sections += si === 0 ? 1 : 0; + const reach = reachOf[si][r]; + if (keyOf[si][r] < 0) { + before = null; + continue; + } + const j = stationAt(rows[r].chainage_m); + const spots: Array<[number, number]> = + j >= 0 + ? [ + ...(j > 0 ? [[j - 1, 1] as [number, number]] : []), + ...(j < last || !last ? [[j, 0] as [number, number]] : []), + ] + : [bracket(rows[r].chainage_m)]; + const ids = new Map(); + const path: Array<[number, number]> = []; + for (const [i, t] of spots) { + const v = view(i, t, si); + const slope = v && drape(r, si, v); + if (!v || !slope) continue; + const m = slope.s.length; + const id = new Int32Array(m).fill(-1); + for (let k = 1; k < m - 1; k += 1) { + if (slope.s[k] >= reach - LINE_GAP_M) break; + const [x, y] = pointAt(r, si, slope.s[k]); + id[k] = pslg.addPoint(sx(x), sz(y), limited(x, y, slope.z[k]) - oz, tagOf(v.kind)); + path.push([slope.s[k], id[k]]); + } + ids.set(v.key, { s: slope.s, id }); + } + path.sort((a, b) => a[0] - b[0]); + const line = [anchors[si][r], ...path.map(([, id]) => id), daylight[si][r]]; + pslg.addPath( + line.filter((id, k) => k === 0 || id !== line[k - 1]), + EDGE_DESIGN, + ); + if (before) { + for (const [key, now] of ids) { + const then = before.ids.get(key); + if (!then || then.id.length !== now.id.length) continue; + for (let k = 1; k < now.id.length - 1; k += 1) { + if (then.id[k] < 0 || now.id[k] < 0) continue; + // 사이 행 비탈 끝이 그 꼭짓점보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감). + let inside = true; + for (let q = before.r + 1; q < r && inside; q += 1) { + const f = (q - before.r) / (r - before.r); + const s = then.s[k] + (now.s[k] - then.s[k]) * f; + if (keyOf[si][q] < 0 || reachOf[si][q] < s + LINE_GAP_M) inside = false; + } + if (!inside) continue; + pslg.addEdge(then.id[k], now.id[k], EDGE_DESIGN); + stats.bendLines += 1; + } + } + } + before = { r, ids }; + } + }); + + // 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(제안 ② 와 같은 드레이프). + const surfaceAt = (x: number, y: number): number | null => { + let best: { r: number; t: number; lateral: number } | null = null; + const along = (r: number): number => + (x - (rows[r].left[0] + rows[r].right[0]) / 2) * uy[r] - + (y - (rows[r].left[1] + rows[r].right[1]) / 2) * ux[r]; + const lateral = (r: number): number => + (x - (rows[r].left[0] + rows[r].right[0]) / 2) * ux[r] + + (y - (rows[r].left[1] + rows[r].right[1]) / 2) * uy[r]; + // ponytail: 행 전수 훑기 — 구멍 둘레 점만 부르므로 충분 · 느려지면 `_Deform.ts` locate 격자로. + let a0 = along(0); + for (let r = 0; r + 1 < n; r += 1) { + const a1 = along(r + 1); + if (a0 >= 0 && a1 < 0) { + const t = a0 / (a0 - a1); + const o = lateral(r) * (1 - t) + lateral(r + 1) * t; + if (Math.abs(o) < HOLE_REACH_M && (!best || Math.abs(o) < Math.abs(best.lateral))) { + best = { r, t, lateral: o }; + } + } + a0 = a1; + } + if (!best) return null; + const si = best.lateral >= 0 ? 0 : 1; + const height = (r: number): number | null => { + const s = Math.abs(best!.lateral) - hw[r]; + if (s <= 0) return anchorOf(r, si)[2]; + if (keyOf[si][r] < 0 || s >= reachOf[si][r]) return null; + const [i, t] = bracket(rows[r].chainage_m); + const v = view(i, t, si); + const slope = v && drape(r, si, v); + return slope ? zAt(slope, s) : null; + }; + const h0 = height(best.r); + const h1 = height(best.r + 1); + if (h0 === null || h1 === null) return null; + return h0 + (h1 - h0) * best.t; + }; + const holes: Array> = []; + for (const loop of structureTopLoops(build.structures ?? [])) { + const ids = loop.map(([x, y, top]) => { + const surface = surfaceAt(x, y) ?? groundAt(x, y) ?? 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]]); + const design: TinDesign = { pslg, outer, holes, road: { left: plan(0), right: plan(1) }, stats }; + return design; +}; + +/** 제안 ③ 지형 — 제안 ② 의 지형 이음 · 구멍 · 구역 색에 측점 횡단 기반 사면을 넣는다. */ +export function loftTerrain( + split: TerrainBandSplit, + build: CorridorBuildResult, + bounds: ModelBounds, + heightIndex: TerrainHeightIndex, +): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { + return tinTerrain(split, build, bounds, heightIndex, loftDesign); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts new file mode 100644 index 000000000..befb18a12 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts @@ -0,0 +1,171 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Loft_Profile.ts + * 3D 제안 ③(계획서 115-8)의 **측점 비탈 단면** · 이웃 측점 단면 짝 맞추기 — 순수 기하. + * + * · 측점 단면 = B06 설계선(design_line)의 비탈 시작(노면부 바깥 끝) → 비탈 끝 원 꼭짓점. + * 비탈 끝 · 종류는 `_Station.ts` classifyStation(조각 끝 = catchOffset) 그대로 — + * 평면도 외곽선 `B07_DesignDetail_Engine_Cad_PlanKinds.py` slope_end 와 짝(설계선이 + * 원지반에 처음 닿는 오프셋 · 못 닿으면 설계선 끝). + * · 짝 맞추기 = 꺾임(소단 · 암/토사)끼리 안쪽부터 차례로 짝 · 남는 꺾임은 마지막 마디에서 + * 길이 비율 → 두 단면이 같은 점 수. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { classifyStation, pieceKey, type OffsetPoint } from "./B05_Profile_UI_Corridor_Station"; + +/** 꺾임으로 남길 단면 꺾임각(도) — 새 방식(`_Deform.ts` BEND_DEG)과 같은 값. */ +const BEND_DEG = 8; + +/** 측 하나의 비탈 단면 — 바깥거리 s(오름) · 비탈 시작 기준 높이 dz. kind 0 성토 · 1 절토. */ +export interface LoftProfile { + kind: 0 | 1; + /** [s0, dz0, s1, dz1, …] — 끝 점 = 비탈 끝. */ + pts: Float64Array; +} + +export interface StationLoft { + chainage_m: number; + /** [좌, 우] — 비탈이 없으면 null. */ + sides: [LoftProfile | null, LoftProfile | null]; +} + +/** 끝 점 바깥거리(비탈 끝 오프셋). */ +export function endOf(profile: LoftProfile | null): number { + return profile ? profile.pts[profile.pts.length - 2] : 0; +} + +/** 두 꼭짓점 사이 꺾이지 않은 점은 뺀다(끝 점은 남김). */ +function bendsOnly(points: Array<[number, number]>): Array<[number, number]> { + const out: Array<[number, number]> = [points[0]]; + for (let i = 1; i < points.length - 1; i += 1) { + const [s0, z0] = out[out.length - 1]; + const [s1, z1] = points[i]; + const [s2, z2] = points[i + 1]; + if (Math.hypot(s1 - s0, z1 - z0) < 1e-6) continue; + const a0 = Math.atan2(z1 - z0, s1 - s0); + const a1 = Math.atan2(z2 - z1, s2 - s1); + if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) out.push(points[i]); + } + out.push(points[points.length - 1]); + return out; +} + +/** 설계선에서 [시작, 끝] 사이 원 꼭짓점 — 시작에서 바깥으로(바깥거리 · 높이 차). */ +function slopeVertices( + line: ReadonlyArray, + start: number, + end: number, +): Array<[number, number]> { + const sign = end >= start ? 1 : -1; + const zAt = (offset: number): number => { + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + if (offset <= b.offset_m + 1e-9) { + const span = b.offset_m - a.offset_m; + const t = span <= 1e-12 ? 0 : (offset - a.offset_m) / span; + return a.elevation_m + (b.elevation_m - a.elevation_m) * Math.max(0, Math.min(1, t)); + } + } + return line[line.length - 1].elevation_m; + }; + const z0 = zAt(start); + const reach = Math.abs(end - start); + const points: Array<[number, number]> = [[0, 0]]; + const inner = line + .map((p): [number, number] => [(p.offset_m - start) * sign, p.elevation_m - z0]) + .filter(([s]) => s > 1e-6 && s < reach - 1e-6) + .sort((a, b) => a[0] - b[0]); + points.push(...inner, [reach, zAt(end) - z0]); + return points; +} + +/** 측점마다 좌우 비탈 단면 — 분류가 안 되는 측점은 뺀다. 누가거리 오름차순. */ +export function stationLofts(sections: ReadonlyArray): StationLoft[] { + const out: StationLoft[] = []; + for (const section of sections) { + const pieces = classifyStation(section); + const raw = section.design?.design_line; + if (!pieces || !raw) continue; + const line = [...raw].sort((a, b) => a.offset_m - b.offset_m); + const sides = (["left", "right"] as const).map((side): LoftProfile | null => { + const fill = pieces.pieces.get(pieceKey("fill", side)); + const piece = fill ?? pieces.pieces.get(pieceKey("cut", side)); + if (!piece || piece.length < 2) return null; + const lo = piece[0].offset_m; + const hi = piece[piece.length - 1].offset_m; + const [start, end] = side === "left" ? [lo, hi] : [hi, lo]; + const points = bendsOnly(slopeVertices(line, start, end)); + if (points[points.length - 1][0] < 1e-6) return null; + return { kind: fill ? 0 : 1, pts: Float64Array.from(points.flat()) }; + }) as StationLoft["sides"]; + out.push({ chainage_m: section.chainage_m, sides }); + } + return out.sort((a, b) => a.chainage_m - b.chainage_m); +} + +/** 꼭짓점 [from, to] 마디의 정규 호길이(0~1). */ +function arcParams(pts: Float64Array, from: number, to: number): number[] { + const lengths = [0]; + for (let i = from + 1; i <= to; i += 1) { + const ds = pts[i * 2] - pts[i * 2 - 2]; + const dz = pts[i * 2 + 1] - pts[i * 2 - 1]; + lengths.push(lengths[lengths.length - 1] + Math.hypot(ds, dz)); + } + const total = lengths[lengths.length - 1]; + return lengths.map((value, i) => (total > 1e-9 ? value / total : i / (lengths.length - 1))); +} + +/** from 에서 시작하는 마디에서 정규 호길이 u 자리(params = arcParams). */ +function pointAtParam( + pts: Float64Array, + from: number, + params: number[], + u: number, +): [number, number] { + for (let k = 1; k < params.length; k += 1) { + if (u <= params[k] + 1e-12 || k === params.length - 1) { + const span = params[k] - params[k - 1]; + const t = span <= 1e-12 ? 0 : Math.max(0, Math.min(1, (u - params[k - 1]) / span)); + const i = from + k - 1; + return [ + pts[i * 2] + (pts[i * 2 + 2] - pts[i * 2]) * t, + pts[i * 2 + 1] + (pts[i * 2 + 3] - pts[i * 2 + 1]) * t, + ]; + } + } + return [pts[from * 2], pts[from * 2 + 1]]; +} + +/** + * 두 단면을 같은 점 수로 — 꺾임끼리 안쪽부터 짝(적은 쪽 수만큼) · 마디마다 두 쪽 꼭짓점의 + * 정규 호길이를 합쳐 다시 짚는다. null(비탈 없음)은 시작 한 점으로 오그라든 단면. + */ +export function pairProfiles( + a: LoftProfile | null, + b: LoftProfile | null, +): [Float64Array, Float64Array] { + const pa = a?.pts ?? new Float64Array(4); + const pb = b?.pts ?? new Float64Array(4); + const na = pa.length / 2; + const nb = pb.length / 2; + const k = Math.min(na, nb) - 2; + const keysA = [...Array.from({ length: k + 1 }, (_, i) => i), na - 1]; + const keysB = [...Array.from({ length: k + 1 }, (_, i) => i), nb - 1]; + const outA: number[] = [pa[0], pa[1]]; + const outB: number[] = [pb[0], pb[1]]; + for (let m = 0; m + 1 < keysA.length; m += 1) { + const [a0, a1] = [keysA[m], keysA[m + 1]]; + const [b0, b1] = [keysB[m], keysB[m + 1]]; + const ua = arcParams(pa, a0, a1); + const ub = arcParams(pb, b0, b1); + const params = [...new Set([...ua, ...ub].map((u) => Math.round(u * 1e9) / 1e9))] + .filter((u) => u > 0) + .sort((x, y) => x - y); + for (const u of params) { + outA.push(...pointAtParam(pa, a0, ua, u)); + outB.push(...pointAtParam(pb, b0, ub, u)); + } + } + return [Float64Array.from(outA), Float64Array.from(outB)]; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Modes.ts b/B05_Profile/B05_Profile_UI_Corridor_Modes.ts index c613cbfec..eb73d357d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Modes.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Modes.ts @@ -18,6 +18,7 @@ import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; import { tinTerrain } from "./B05_Profile_UI_Corridor_Tin"; +import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft"; import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; @@ -27,6 +28,7 @@ export const MODE_SLOTS = [ ["legacy-a", "옛 A"], ["new1", "새 ①"], ["proposal2", "제안 ②"], + ["proposal3", "제안 ③"], ] as const; export type CorridorModeId = (typeof MODE_SLOTS)[number][0]; @@ -164,6 +166,13 @@ export const CORRIDOR_MODES: CorridorMode[] = [ terrain: ({ split, build, bounds, heightIndex }) => heightIndex ? tinTerrain(split, build, bounds, heightIndex) : null, }, + // 제안 ③(115-8) — 노면부만 그리고 비탈은 측점 횡단 보간(평면도 외곽선 규칙) · 지형 이음은 ② 기계. + { + id: "proposal3", + corridor: (build) => roadOnly(build), + terrain: ({ split, build, bounds, heightIndex }) => + heightIndex ? loftTerrain(split, build, bounds, heightIndex) : null, + }, ]; export function modeOf(id: CorridorModeId): CorridorMode | null { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts index 0c4e4b920..43db3be55 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -166,32 +166,22 @@ function harvestPart( return { kept, removed }; } -/** - * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 변형 장을 못 - * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. - */ -export function tinTerrain( - split: TerrainBandSplit, +/** 설계 점 · 브레이크라인 · 구멍을 내는 자리 — `groundAt` = 원지반 높이(모델 좌표). 못 세우면 null. */ +export type TinDesigner = ( build: CorridorBuildResult, + groundAt: (x: number, y: number) => number | null, 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; +) => TinDesign | null; + +/** 제안 ② 설계 — 새 방식 변형 장(행마다 비탈 끝)에서. */ +const deformDesign: TinDesigner = (build, groundAt, bounds) => { + const sections = build.sections ?? []; 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( + return buildTinDesign( field, source, groundAt, @@ -200,6 +190,31 @@ export function tinTerrain( sections.map((section) => section.chainage_m), bounds, ); +}; + +/** + * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못 + * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. + * `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③). + */ +export function tinTerrain( + split: TerrainBandSplit, + build: CorridorBuildResult, + bounds: ModelBounds, + heightIndex: TerrainHeightIndex, + designer: TinDesigner = deformDesign, +): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { + const started = performance.now(); + if (!build.sections?.length) return null; + 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 design = designer(build, groundAt, bounds); + if (!design) return null; const outer = new LoopIndex([design.outer]); const holes = new LoopIndex(design.holes); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts index edd2c1394..44b9c2c9b 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin_Design.ts @@ -125,7 +125,7 @@ export function structureTopLoops( } /** 점이 고리 안인가(광선 투사 · 모델 좌표). */ -function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { +export 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]; diff --git a/resources/tester/helper_115_8_corridor_loft.cjs b/resources/tester/helper_115_8_corridor_loft.cjs new file mode 100644 index 000000000..a3c8ef1ef --- /dev/null +++ b/resources/tester/helper_115_8_corridor_loft.cjs @@ -0,0 +1,519 @@ +/* PLAN 115-8 단위검증 헬퍼 — B05 3D 제안 ③ 측점 횡단 기반 사면 합성 지형 검사. + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. + * (트랜스파일 틀 · 합성 측점 · 격자 지형 · 틈 셈은 helper_115_6_corridor_tin.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 { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft")); +const { pairProfiles } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Profile")); +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 = loftTerrain(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; +const STRAIGHT = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; + +/** 설계 구역 둘레의 비탈 끝 점(노면 끝 아님) — 모델 [x, y, z]. */ +function daylightPoints(out, bounds) { + 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 pslg = out.design.pslg; + const pts = []; + for (let i = 0; i < pslg.count; i += 1) { + if (pslg.tag[i] !== PSLG.TAG_FILL && pslg.tag[i] !== PSLG.TAG_CUT) continue; + pts.push({ x: pslg.x[i] + ox, y: -pslg.z[i] + oy, z: pslg.y[i] + oz, tag: pslg.tag[i], id: i }); + } + // 둘레 = 같은 x(행)에서 가장 바깥 점. + const outer = new Map(); + pts.forEach((p) => { + const k = `${Math.round(p.x * 1e3)}:${p.y > 0 ? 1 : -1}`; + const was = outer.get(k); + if (!was || Math.abs(p.y) > Math.abs(was.y)) outer.set(k, p); + }); + return [...outer.values()]; +} + +// ① 직선 도로 · 측점 0 · 20 · 40(성토 1:1.5 · 지반 96 · 94 · 96) — 사이 비탈 끝 = 오프셋 직선 보간 · +// 측점 단면 = B06 · 드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 공사 밖 원래 그대로. +{ + const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; + const base = (x) => (x <= 20 ? 96 - 0.1 * Math.max(x, 0) : 94 + 0.1 * Math.min(x - 20, 20)); + // 측점 자리(x = 0 · 20 · 40)에서 0 인 굴곡 — 측점 단면은 B06 지반(평탄)과 같고 사이는 출렁인다. + const bump = (x, y) => 0.3 * Math.sin((Math.PI * x) / 20) * Math.cos(y / 4); + const heightAt = (x, y) => base(x) + bump(x, y); + const sections = [ + station(0, 0, 0, 0, 1, 96, 1.5), + station(20, 20, 0, 0, 1, 94, 1.5), + station(40, 40, 0, 0, 1, 96, 1.5), + ]; + const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt }; + const { out, before, positions, heightIndex } = run({ sections, route: STRAIGHT, terrainSpec: spec }); + const tris = trianglesOf(out.root); + const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25)); + // 비탈 끝 오프셋 — 측점 사이 = 6 → 9 → 6(+ 노면 반폭) 직선 보간. + let offsetErr = 0; + let drapeErr = 0; + let rowsChecked = 0; + daylightPoints(out, bounds).forEach((p) => { + if (p.x < 0 || p.x > 40) return; + const w = p.x <= 20 ? 6 + (3 * p.x) / 20 : 9 - (3 * (p.x - 20)) / 20; + offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w)); + const mesh = heightIndex.heightAt(p.x - 20, -p.y) + 100; // 망 지형(격자 삼각형) 높이 + drapeErr = Math.max(drapeErr, Math.abs(p.z - mesh)); + rowsChecked += 1; + }); + // 측점 20 단면 — 망을 그 선에서 재어 B06 설계선(1:1.5 → 비탈 끝에서 지반)과 견준다. + const tin = tinHeights(out); + let sectionErr = 0; + for (const side of [1, -1]) { + for (let s = 0.05; s <= 12; s += 0.05) { + const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20 + if (z === null) continue; + sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94))); + } + } + // 측점 사이(x = 10) 단면은 지형에 맞춰 다시 그리지 않는다 — 노면 끝 0 → 비탈 끝 1 비율 보정. + results.straight = { + stats: out.stats, + gaps: gap.gaps, + rowsChecked, + offsetErr: r6(offsetErr), + drapeErr: r6(drapeErr), + sectionErr: r6(sectionErr), + originalUntouched: before.every((value, i) => value === positions[i]), + lostVerts: lostVertices(tris, before, out), + }; +} + +// ② 성토 ↔ 절토 — 측점 0 성토(지반 96) · 20 · 40 절토(지반 104). 바뀜은 평면도처럼 가운데(10)에서 끊음 · +// 비탈 끝 오프셋은 그대로 직선 보간 · 끊는 자리를 건너는 종방향 선 없음 · 틈 0. +{ + const bounds = { x: [-10, 50], y: [-25, 25], z: [90, 110] }; + const heightAt = (x) => 96 + 0.4 * Math.min(Math.max(x, 0), 20); + const sections = [ + station(0, 0, 0, 0, 1, 96, 1.5), + station(20, 20, 0, 0, 1, 104, 1.0), + station(40, 40, 0, 0, 1, 104, 1.0), + ]; + const spec = { bounds, x0: -10, x1: 50, y0: -25, y1: 25, step: 0.5, heightAt }; + const { out } = run({ sections, route: STRAIGHT, terrainSpec: spec }); + const tris = trianglesOf(out.root); + const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25)); + const pts = daylightPoints(out, bounds); + let wrongSide = 0; + let offsetErr = 0; + pts.forEach((p) => { + if (p.x < 0 || p.x > 20) return; + if (p.tag === PSLG.TAG_FILL && p.x >= 10 + 1e-6) wrongSide += 1; + if (p.tag === PSLG.TAG_CUT && p.x < 10 - 0.2 - 1e-6) wrongSide += 1; + const w = 6 + ((4 - 6) * p.x) / 20; + offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w)); + }); + // 성토 점 ↔ 절토 점을 잇는 제약 변(노면 끝 · 지형 말고) — 끊었으면 횡방향으로도 없어야 한다. + const pslg = out.design.pslg; + let crossEdges = 0; + for (let e = 0; e < pslg.kinds.length; e += 1) { + const a = pslg.tag[pslg.edges[e * 2]]; + const b = pslg.tag[pslg.edges[e * 2 + 1]]; + if (pslg.kinds[e] !== PSLG.EDGE_DESIGN) continue; + const fillCut = (a === PSLG.TAG_FILL && b === PSLG.TAG_CUT) || (a === PSLG.TAG_CUT && b === PSLG.TAG_FILL); + const ax = pslg.x[pslg.edges[e * 2]] + 20; + const bx = pslg.x[pslg.edges[e * 2 + 1]] + 20; + if (fillCut && Math.abs(ax - bx) > 0.21) crossEdges += 1; + } + const fills = pts.filter((p) => p.tag === PSLG.TAG_FILL && p.x >= 0 && p.x <= 20).length; + const cuts = pts.filter((p) => p.tag === PSLG.TAG_CUT && p.x >= 0 && p.x <= 20).length; + results.flip = { + stats: out.stats, + gaps: gap.gaps, + wrongSide, + fills, + cuts, + crossEdges, + offsetErr: r6(offsetErr), + }; +} + +// ③ 짝 맞추기 — 소단 둘 단면 대 소단 하나 단면 · 같은 점 수 · 첫 꺾임끼리 짝. +{ + const a = { kind: 0, pts: Float64Array.from([0, 0, 6, -4, 7.5, -4, 13.5, -8, 15, -8, 18, -10]) }; + const b = { kind: 0, pts: Float64Array.from([0, 0, 3, -2, 4.5, -2, 9, -5]) }; + const [pa, pb] = pairProfiles(a, b); + const idxOf = (p, s, z) => { + for (let k = 0; k < p.length / 2; k += 1) { + if (Math.abs(p[k * 2] - s) < 1e-9 && Math.abs(p[k * 2 + 1] - z) < 1e-9) return k; + } + return -1; + }; + const [pn, pq] = pairProfiles(null, b); + results.pair = { + sameCount: pa.length === pb.length, + firstBend: idxOf(pa, 6, -4) === idxOf(pb, 3, -2) && idxOf(pa, 6, -4) > 0, + secondBend: idxOf(pa, 7.5, -4) === idxOf(pb, 4.5, -2), + ends: pa[pa.length - 2] === 18 && pb[pb.length - 2] === 9, + nullCollapses: pn.length === pq.length && pn.every((v) => v === 0), + }; +} + +// ④ 급곡선(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; + 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 }; +} + +// ⑤ 소단 단면 — 측점 0 · 40 소단(1:1.5 로 4 m · 소단 1.5 m · 다시 1:1.5) · 20 은 소단 없음 → 꺾임 종방향 선 · 틈 0. +{ + 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 = [berm(0), station(20, 20, 0, 0, 1, 92, 1.5), berm(40)]; + const { out } = run({ + sections, + route: STRAIGHT, + terrainSpec: { bounds, x0: -10, x1: 50, y0: -30, y1: 30, step: 0.5, heightAt: () => 92 }, + }); + const line = sections[0].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(-20, -side * o); // 측점 0 = 씬 x −20 + if (z !== null) sectionErr = Math.max(sectionErr, Math.abs(z + 97.5 - b06(side * o))); + } + } + const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -10, 50, -30, 30)); + results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps }; +} + +// ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록. +{ + globalThis.window = globalThis.window || {}; + const Modes = loadTs(B05("B05_Profile_UI_Corridor_Modes")); + const mode = Modes.modeOf("proposal3"); + results.registry = { + slot: Modes.MODE_SLOTS.some(([id, label]) => id === "proposal3" && label === "제안 ③"), + registered: Boolean(mode), + hasTerrain: typeof mode?.terrain === "function", + noIndex: mode?.terrain ? mode.terrain({ heightIndex: null }) : "none", + }; +} + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_5_corridor_improve.py b/resources/tester/test_115_5_corridor_improve.py index 6f7a3d810..b719075ed 100644 --- a/resources/tester/test_115_5_corridor_improve.py +++ b/resources/tester/test_115_5_corridor_improve.py @@ -69,8 +69,20 @@ def test_structure_top_hole_and_drape(results): def test_mode_registry_and_migration(results): r = results["modes"] - assert r["slots"] == ["current:지금", "legacy-a:옛 A", "new1:새 ①", "proposal2:제안 ②"] - assert r["registered"] == ["current", "legacy-a", "new1", "proposal2"] # 제안 ② 등록(115-6) + assert r["slots"] == [ + "current:지금", + "legacy-a:옛 A", + "new1:새 ①", + "proposal2:제안 ②", + "proposal3:제안 ③", + ] + assert r["registered"] == [ + "current", + "legacy-a", + "new1", + "proposal2", + "proposal3", + ] # 제안 ② · ③ 등록(115-6 · 115-8) assert r["defaultMode"] == "new1" and r["fresh"] == "new1" assert r["newHasTerrain"] is True assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금 diff --git a/resources/tester/test_115_8_corridor_loft.py b/resources/tester/test_115_8_corridor_loft.py new file mode 100644 index 000000000..be4fce7ac --- /dev/null +++ b/resources/tester/test_115_8_corridor_loft.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""PLAN 115-8 — B05 3D 제안 ③ 측점 횡단 기반 사면 검사. + +Node 도우미(helper_115_8_corridor_loft.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 +결과(JSON)를 단언한다. 사이 비탈 끝 = 오프셋 직선 보간 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 · +드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_115_8_corridor_loft.cjs")], + capture_output=True, + text=True, + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_between_stations_daylight_is_offset_lerp(results): + r = results["straight"] + assert r["rowsChecked"] > 300 + assert r["offsetErr"] < 1e-4 # 비탈 끝 = 측점 오프셋 6 → 9 → 6 직선 보간(평면도 외곽선) + + +def test_station_section_matches_b06(results): + assert results["straight"]["sectionErr"] < 1e-4 # 측점 20 단면 = 성토 1:1.5 → 지반 + assert results["berm"]["sectionErr"] < 1e-4 # 소단 꼭짓점 그대로(재표본 없음) + + +def test_daylight_draped_on_terrain(results): + assert results["straight"]["drapeErr"] < 1e-4 # 비탈 끝 높이 = 망 지형 + + +def test_no_gap_and_terrain_outside_untouched(results): + for name in ("straight", "flip", "curve", "berm"): + assert results[name]["gaps"] == 0, name + assert results[name]["stats"]["failed"] == 0, name + r = results["straight"] + assert r["originalUntouched"] is True and r["lostVerts"] == 0 + + +def test_fill_cut_change_breaks_like_plan(results): + r = results["flip"] + assert r["fills"] > 0 and r["cuts"] > 0 + assert r["wrongSide"] == 0 # 바뀜 = 두 측점 가운데(10 m) + assert r["crossEdges"] == 0 # 끊는 자리를 건너는 종방향 선 없음 + assert r["offsetErr"] < 1e-4 # 비탈 끝 오프셋은 그대로 보간 + + +def test_curve_bowtie_cleared(results): + r = results["curve"] + assert r["stats"]["bowtie"] > 0 and r["selfCross"] == 0 + + +def test_pairing_bends(results): + r = results["pair"] + assert all(r.values()), r + assert results["berm"]["stats"]["bendLines"] > 0 # 같은 번째 꼭짓점 종방향 선 + + +def test_registered_as_proposal3(results): + r = results["registry"] + assert r["slot"] is True and r["registered"] is True and r["hasTerrain"] is True + assert r["noIndex"] is None