diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts index 737897588..93394cbd8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -1,15 +1,16 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Loft.ts - * 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 사용자 안) — 비탈을 따로 그려 넣는다. + * 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 115-9 · 115-10 · 사용자 안) — 비탈을 따로 그려 넣는다. * * ① 측점 단면 = B06 설계선 비탈 꼭짓점(`_Loft_Profile.ts`) · 비탈 끝 = 평면도 외곽선과 같은 규칙. - * ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 직선 보간(바깥거리 · - * 높이 둘 다) → 비탈 끝 선 = 평면도 외곽선(비탈 끝 오프셋 노선 따라 보간). 성토 ↔ 절토가 - * 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양, 끝 오프셋만 보간). + * ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 단조 3차 보간(바깥거리 · 높이). + * 성토 ↔ 절토가 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양). * ① · ② 처럼 행마다 지형과 다시 맞추지 않는다. - * ③ 드레이프 = 비탈 끝 높이를 그 자리 지형에 맞추고 보정량을 노면부 끝 0 → 비탈 끝 1 로 퍼뜨림. - * ④ 곡선 안쪽 겹침 = 앞 행 단면에 닿으면 그 자리에서 자름(제안 ② 와 같은 청소). + * ③ 비탈 끝 선 = 평면에서 고르고 곡선 안쪽 꼬임은 포락선으로(`_Loft_Toe.ts`) — 원뿔 · 톱니 없음. + * ④ 사면 격자 = 노선 1 m × 빗면 약 1 m(꺾임은 격자선) · 높이 = 단면 보간 + 드레이프(비탈 끝 높이를 + * 지형에 맞춘 보정량을 노견 끝 0 → 비탈 끝 1) · Taubin 면 평활화(`_Loft_Grid.ts`) → 실오라기 없음. * 지형 이음 · 구조물 구멍 · 구역 색 · 망 = 제안 ② 기계(`_Tin.ts` tinTerrain) 그대로 — 설계 점 원천만 바꿈. + * 음영 = 사면만 꺾임 법선(`_Loft_Shade.ts`). * ========================================================================== */ import type * as THREE from "three"; @@ -30,6 +31,7 @@ import { EDGE_ANCHOR, EDGE_DESIGN, EDGE_HOLE, + LoopIndex, Pslg, SegGrid, TAG_ANCHOR, @@ -47,14 +49,22 @@ import { stationLofts, } from "./B05_Profile_UI_Corridor_Loft_Profile"; import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade"; +import { gauss, smoothToe } from "./B05_Profile_UI_Corridor_Loft_Toe"; +import { taubin, type GridRow } from "./B05_Profile_UI_Corridor_Loft_Grid"; -/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ -const MIN_CATCH_M = 0.02; -/** 종방향 선 — 사이 행 비탈 끝이 이만큼 바깥에 있어야 잇는다(m). */ -const LINE_GAP_M = 0.05; -// ponytail: 드레이프 보정량 고르기 폭 ±2 m(행 0.2 m → 약 21 행 평균) — 지형 잡음 출렁임만 누름 · 비탈 끝 -// 점은 제 행 보정 그대로(지형 위 · 틈 0) · 측점 행은 B06 그대로라 고르지 않음. 넓히면 비탈 끝 바로 안 띠가 꺾임. -const DRAPE_SMOOTH_M = 2; +// ponytail: 이보다 짧은 비탈은 없는 것으로(10 cm) — 부채꼴 끝 0.2 m 행 사이 실오라기 삼각형을 막음. +const MIN_SLOPE_M = 0.1; +// ponytail: 사면 방향 격자 간격 1 m(빗면 길이) — 노선 방향 격자 행(1 m)과 맞춤 · 줄이면 점 수 제곱으로 늚. +const CELL_M = 1; +// ponytail: 곡선 안쪽 비탈 끝 거리 ≤ (곡률 반경 − 반폭) × 0.8 · 곡률은 ±2 m 가우스 — 비탈 끝 선이 곡률 중심을 +// 넘어 뒤집히는(원뿔) 자리를 막음 · 그만큼 비탈이 가팔라짐(드레이프가 지형에 맞춤). +const INNER_REACH_SHARE = 0.8; +const CURVE_SIGMA_M = 6; +// ponytail: 설계 점 사이 최소 0.3 m — 곡선 안쪽에서 몰리는 격자 · 비탈 끝 점을 솎아 실오라기를 막음 · +// 늘리면 곡선 안쪽 사면이 성겨짐. +const POINT_GAP_M = 0.3; +/** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */ +const HOLE_CLEAR_M = 0.3; /** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ const HOLE_REACH_M = 80; @@ -66,12 +76,23 @@ interface View { pts: Float64Array; } -/** 드레이프한 행 단면 — 바깥거리 · 절대 높이(모델). */ -interface Slope { - s: Float64Array; - z: Float64Array; +/** 단면 보기의 바깥거리 s 자리 높이 차(선형). */ +function dzAt(pts: Float64Array, s: number): number { + const m = pts.length / 2; + if (s <= pts[0]) return pts[1]; + for (let k = 1; k < m; k += 1) { + if (s <= pts[k * 2]) { + const span = pts[k * 2] - pts[k * 2 - 2]; + const t = span <= 1e-12 ? 1 : (s - pts[k * 2 - 2]) / span; + return pts[k * 2 - 1] + (pts[k * 2 + 1] - pts[k * 2 - 1]) * t; + } + } + return pts[m * 2 - 1]; } +/** 격자 행의 (보기 key, 단면 꼭짓점 번째) → 격자 점 번째. */ +type VertexIndex = Map>; + /** 제안 ③ 설계 점 · 브레이크라인 · 구멍 — 제안 ② 와 같은 꼴(TinDesign). */ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { const rows = roadRows(build); @@ -86,9 +107,17 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { 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 stats = { + rows: n, + sections: 0, + bowtie: 0, + bendLines: 0, + holes: 0, + splits: 0, + gridPoints: 0, + }; - // 행 틀 — 좌향 단위 벡터 · 반폭 · 중심. + // 행 틀 — 좌향 단위 벡터 · 반폭. const ux = new Float64Array(n); const uy = new Float64Array(n); const hw = new Float64Array(n); @@ -101,11 +130,27 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { 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]; - }; + // 곡률(법선 회전 / 누가거리 · ±2 m 고름) — 양수 = 좌회전(좌측이 곡선 안쪽). + const chainages = rows.map((row) => row.chainage_m); + const bend = new Float64Array(n); + for (let r = 0; r < n; r += 1) { + const a = Math.max(0, r - 1); + const b = Math.min(n - 1, r + 1); + let turn = Math.atan2(uy[b], ux[b]) - Math.atan2(uy[a], ux[a]); + if (turn > Math.PI) turn -= 2 * Math.PI; + if (turn < -Math.PI) turn += 2 * Math.PI; + const run = chainages[b] - chainages[a]; + bend[r] = run > 1e-9 ? turn / run : 0; + } + const curvature = gauss(chainages, bend, CURVE_SIGMA_M); + /** 곡선 안쪽 비탈 끝 거리 상한 — 노면 끝에서 곡률 중심까지의 몫(바깥쪽 · 직선은 무한). */ + const maxReachOf = (si: number): Float64Array => + Float64Array.from(curvature, (k, r) => { + const inner = si === 0 ? k > 0 : k < 0; + return inner && Math.abs(k) > 1e-9 + ? Math.max(0, (1 / Math.abs(k) - hw[r]) * INNER_REACH_SHARE) + : Infinity; + }); /** 누가거리를 감싼 측점 마디 [i, t] — 범위 밖은 끝 마디. */ const bracket = (c: number): [number, number] => { @@ -195,38 +240,6 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { for (let k = 2; k < pts.length; k += 2) pts[k] = Math.max(pts[k], pts[k - 2]); return { key: i * 2, kind, pts }; }; - /** 고른 드레이프 보정량(행 · 측) — NaN 이면 제 행 값. 아래 비탈 끝 앞에서 채운다. */ - const smoothLift = [0, 1].map(() => new Float64Array(n).fill(NaN)); - /** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 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]; - // 고른 몫은 s 비율 · 제 행에 남는 몫(지형 잡음)은 (s / 끝)² — 끝 점은 제 행 값(지형 위) 그대로. - const smooth = Number.isNaN(smoothLift[si][r]) ? delta : smoothLift[si][r]; - const f = s[k] / end; - z[k] = anchor[2] + v.pts[k * 2 + 1] + smooth * f + (delta - smooth) * f * f; - } - 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); @@ -238,6 +251,23 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { return z; }; const tagOf = (kind: number): number => (kind === 0 ? TAG_FILL : TAG_CUT); + const stationAt = (c: number): number => + stations.findIndex((station) => Math.abs(station.chainage_m - c) < 1e-3); + + // 구조물 윗면 고리(모델) — 격자 점을 그 안 · 둘레 가까이엔 두지 않는다. + const topLoops = structureTopLoops(build.structures ?? []); + const holeIndex = new LoopIndex( + topLoops.map((loop) => loop.map(([x, y]): [number, number] => [x, y])), + ); + const holeEdges = new SegGrid(2); + topLoops.forEach((loop, l) => + loop.forEach(([x0, y0], i) => { + const [x1, y1] = loop[(i + 1) % loop.length]; + holeEdges.add(l, x0, y0, x1, y1); + }), + ); + const nearHole = (x: number, y: number): boolean => + holeIndex.contains(x, y) || holeEdges.passesNear(x, y, HOLE_CLEAR_M); // 노면 끝 선 — 리본 바깥 끝 그대로(제안 ② 와 같음). const anchors = [0, 1].map((si) => @@ -250,93 +280,70 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR); pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR); - // 드레이프 보정량 고르기 — 측점 아닌 행만 · 비탈 있는 행끼리 ±2 m 평균. - const stationRow = (r: number): boolean => - stations.some((station) => Math.abs(station.chainage_m - rows[r].chainage_m) < 1e-3); - [0, 1].forEach((si) => { - const raw = new Float64Array(n).fill(NaN); - for (let r = 0; r < n; r += 1) { - const [i, t] = bracket(rows[r].chainage_m); - const v = view(i, t, si); - const slope = v && drape(r, si, v); - if (!slope) continue; - const m = slope.s.length; - raw[r] = slope.z[m - 1] - (anchorOf(r, si)[2] + (v as View).pts[(m - 1) * 2 + 1]); - } - let lo = 0; - let hi = 0; - let sum = 0; - let count = 0; - for (let r = 0; r < n; r += 1) { - const c = rows[r].chainage_m; - while (hi < n && rows[hi].chainage_m <= c + DRAPE_SMOOTH_M) { - if (!Number.isNaN(raw[hi])) ((sum += raw[hi]), (count += 1)); - hi += 1; - } - while (rows[lo].chainage_m < c - DRAPE_SMOOTH_M) { - if (!Number.isNaN(raw[lo])) ((sum -= raw[lo]), (count -= 1)); - lo += 1; - } - if (!Number.isNaN(raw[r]) && !stationRow(r) && count) smoothLift[si][r] = sum / count; - } - }); - - // 비탈 끝 — 행마다(평면도 외곽선과 같은 보간) · 곡선 안쪽 겹침은 앞 행 단면에 닿는 자리에서 자름. + // 행마다 단면 보기 · 비탈 끝(평면 고르기 · 포락선) · 드레이프 보정량. + const views = [0, 1].map((si) => + rows.map((row) => { + const [i, t] = bracket(row.chainage_m); + return view(i, t, si); + }), + ); const keyOf = [0, 1].map(() => new Int32Array(n).fill(-1)); + /** 비탈 끝(지형에 닿는 끝 · 고른 거리) 평면 자리 · 거리 · 보이는 몫 · 드레이프 보정량. */ + const toeX = [0, 1].map(() => new Float64Array(n)); + const toeY = [0, 1].map(() => new Float64Array(n)); const reachOf = [0, 1].map(() => new Float64Array(n)); - const daylight = [0, 1].map(() => new Int32Array(n)); + const shownOf = [0, 1].map(() => new Float64Array(n)); + const liftOf = [0, 1].map(() => new Float64Array(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 sign = si === 0 ? 1 : -1; + const has = new Uint8Array(n); + const reach = new Float64Array(n); + views[si].forEach((v, r) => { + const end = v ? endOf(v) : 0; + if (v && end >= MIN_SLOPE_M) { + has[r] = 1; + reach[r] = end; } - 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)); + }); + const toe = smoothToe({ + chainage: chainages, + ax: rows.map((_, r) => anchorOf(r, si)[0]), + ay: rows.map((_, r) => anchorOf(r, si)[1]), + nx: Array.from(ux, (v) => v * sign), + ny: Array.from(uy, (v) => v * sign), + reach, + has, + maxReach: maxReachOf(si), + pinned: Uint8Array.from(rows, (row) => (stationAt(row.chainage_m) >= 0 ? 1 : 0)), + }); + stats.bowtie += toe.folded; + for (let r = 0; r < n; r += 1) { + const v = views[si][r]; + const e = toe.reach[r]; + if (!v || !has[r] || e * toe.shown[r] < MIN_SLOPE_M) continue; + const [ax, ay, az] = anchorOf(r, si); + toeX[si][r] = ax + ux[r] * sign * e; + toeY[si][r] = ay + uy[r] * sign * e; + const ground = groundAt(toeX[si][r], toeY[si][r]); + liftOf[si][r] = ground === null ? 0 : ground - (az + v.pts[v.pts.length - 1]); + reachOf[si][r] = e; + shownOf[si][r] = toe.shown[r]; keyOf[si][r] = v.key; - reachOf[si][r] = reach; - fan.add(r, ax, az, sx(px), sz(py)); } }); + /** 행 · 측 사면 비율 u 자리 — 평면(노면 끝 → 비탈 끝 직선) · 높이(단면 + 드레이프). */ + const placeAt = (r: number, si: number, v: View, u: number): [number, number, number] => { + const [ax, ay, az] = anchorOf(r, si); + return [ + ax + (toeX[si][r] - ax) * u, + ay + (toeY[si][r] - ay) * u, + az + dzAt(v.pts, u * endOf(v)) + liftOf[si][r] * u, + ]; + }; + /** 비탈 끝까지 닿은 행(지형 위 끝 · 둘레) — 포락선에서 멈춘 행은 안쪽 골 · 마루 점. */ + const daylit = (si: number, r: number): boolean => shownOf[si][r] >= 1 - 1e-9; - // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. - 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); + // 격자 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤. const mark = new Uint8Array(n); let previous = -Infinity; for (let r = 0; r < n; r += 1) { @@ -354,69 +361,215 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { previous = c; } } + mark.forEach((m) => (stats.sections += m)); - // 횡방향 선(노면 끝 → 꼭짓점 → 비탈 끝) · 같은 보기의 같은 번째 꼭짓점을 잇는 종방향 선. + // 설계 점 솎기 — 이미 둔 설계 점에서 POINT_GAP_M 안이면 새 점을 두지 않는다(실오라기 막음). + const placed = new Map(); + const cellOf = (x: number, y: number): number => + Math.floor(x / POINT_GAP_M) * 1_000_003 + Math.floor(y / POINT_GAP_M); + const crowded = (x: number, y: number): boolean => { + const cx = Math.floor(x / POINT_GAP_M); + const cy = Math.floor(y / POINT_GAP_M); + for (let i = -1; i <= 1; i += 1) { + for (let j = -1; j <= 1; j += 1) { + const list = placed.get((cx + i) * 1_000_003 + cy + j); + if (!list) continue; + for (let k = 0; k < list.length; k += 2) { + if (Math.hypot(list[k] - x, list[k + 1] - y) < POINT_GAP_M) return true; + } + } + } + return false; + }; + const place = (x: number, y: number, z: number, tag: number): number => { + const key = cellOf(x, y); + const list = placed.get(key) ?? []; + list.push(x, y); + placed.set(key, list); + return pslg.addPoint(sx(x), sz(y), z - oz, tag); + }; + + // 비탈 끝 점 — 닿은 행은 지형 위(둘레) · 멈춘 행은 골 · 마루 점(안쪽). 격자 행은 늘 두고 나머지는 솎음. + const daylight = [0, 1].map(() => new Int32Array(n).fill(-1)); [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; + const v = views[si][r]; + if (keyOf[si][r] < 0 || !v) continue; + const [x, y, line] = placeAt(r, si, v, shownOf[si][r]); + // 격자 행 비탈 끝은 남김 · 포락선에서 멈춘 점(골 · 마루)은 격자 행이라도 몰리면 솎음. + if ((!mark[r] || !daylit(si, r)) && crowded(x, y)) continue; + const ground = daylit(si, r) ? groundAt(x, y) : null; + daylight[si][r] = place(x, y, limited(x, y, ground ?? line), tagOf(v.kind)); + } + }); + + // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. 비탈 없는 행은 + // 노면 끝 · 멈춘 행(포락선 안쪽)은 둘레에서 뺀다. + const rim = (si: number): number[] => { + const ids: number[] = []; + for (let r = 0; r < n; r += 1) { + if (keyOf[si][r] < 0) ids.push(anchors[si][r]); + else if (daylit(si, r) && daylight[si][r] >= 0) ids.push(daylight[si][r]); + } + return ids.filter((id, i) => i === 0 || id !== ids[i - 1]); + }; + const left = rim(0); + const right = rim(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[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 로 나눈 u · 높이. 측점 행은 다 고정. */ + const gridRowOf = (r: number, si: number, v: View): { row: GridRow; index: VertexIndex } => { + const j = stationAt(rows[r].chainage_m); + const station = j >= 0; + const top = shownOf[si][r]; + // 측점 행은 앞뒤 두 짝 맞춤의 꼭짓점을 다 담는다(둘 다 B06 단면 위). + const spots: Array<[number, number]> = station + ? [ + ...(j > 0 ? [[j - 1, 1] as [number, number]] : []), + ...(j < last || !last ? [[j, 0] as [number, number]] : []), + ] + : [bracket(rows[r].chainage_m)]; + const keys: Array<{ u: number; key: number; k: number }> = []; + for (const [i, t] of spots) { + const w = view(i, t, si); + if (!w) continue; + const end = endOf(w); + for (let k = 0; k < w.pts.length / 2; k += 1) { + const u = end > 1e-9 ? w.pts[k * 2] / end : k ? 1 : 0; + if (u < top - 1e-6) keys.push({ u, key: w.key, k }); + } + } + keys.push({ u: top, key: -1, k: -1 }); + keys.sort((a, b) => a.u - b.u); + const us: number[] = []; + const fixed: number[] = []; + const index: VertexIndex = new Map(); + const note = (key: number, k: number, at: number): void => { + if (key < 0) return; + const map = index.get(key) ?? new Map(); + map.set(k, at); + index.set(key, map); + }; + const end = endOf(v); + for (const item of keys) { + const u = Math.min(top, Math.max(0, item.u)); + const at = us.length - 1; + if (at >= 0 && u - us[at] < 1e-6) { + note(item.key, item.k, at); 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]]); + if (at >= 0) { + const plan = (u - us[at]) * reachOf[si][r]; + const rise = dzAt(v.pts, u * end) - dzAt(v.pts, us[at] * end); + const pieces = Math.ceil(Math.hypot(plan, rise) / CELL_M); + for (let p = 1; p < pieces; p += 1) { + us.push(us[at] + ((u - us[at]) * p) / pieces); + fixed.push(station ? 1 : 0); } - 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); + // 끝(비탈 끝 · 멈춘 자리) 바로 앞 POINT_GAP_M 안 점은 뺀다 — 끝 점과 실오라기. + if (item.key < 0) { + while (us.length > 1 && (u - us[us.length - 1]) * reachOf[si][r] < POINT_GAP_M) { + us.pop(); + fixed.pop(); + } + // 뺀 점을 가리키던 꼭짓점 번째는 지운다. + index.forEach((map) => map.forEach((at, k) => at >= us.length && map.delete(k))); + } + us.push(u); + fixed.push(1); + note(item.key, item.k, us.length - 1); + } + const row: GridRow = { + u: Float64Array.from(us), + z: Float64Array.from(us, (u) => placeAt(r, si, v, u)[2]), + fixed: Uint8Array.from(fixed), + }; + row.fixed[0] = 1; + row.fixed[row.u.length - 1] = 1; + return { row, index }; + }; + + // 사면 격자 — 측마다 격자 행 → Taubin → 점 · 측점 행 횡방향 선 · 꺾임 종방향 선. + [0, 1].forEach((si) => { + const marked: number[] = []; + const grid: Array = []; + const indices: VertexIndex[] = []; + for (let r = 0; r < n; r += 1) { + if (!mark[r]) continue; + marked.push(r); + const v = views[si][r]; + const made = keyOf[si][r] >= 0 && v ? gridRowOf(r, si, v) : null; + grid.push(made?.row ?? null); + indices.push(made?.index ?? new Map()); + } + taubin(grid); + let before: { r: number; ids: Int32Array; index: VertexIndex } | null = null; + marked.forEach((r, g) => { + const row = grid[g]; + const v = views[si][r]; + if (!row || !v) { + before = null; + return; + } + const ids = new Int32Array(row.u.length).fill(-1); + ids[0] = anchors[si][r]; + ids[row.u.length - 1] = daylight[si][r]; + for (let k = 1; k < row.u.length - 1; k += 1) { + const [x, y] = placeAt(r, si, v, row.u[k]); + // 꺾임(고정) 점은 솎지 않는다 — 소단 · 측점 단면이 남게. + const keepFixed = row.fixed[k] && daylit(si, r) && stationAt(rows[r].chainage_m) < 0; + if ( + nearHole(x, y) || + (!keepFixed && !(row.fixed[k] && stationAt(rows[r].chainage_m) >= 0) && crowded(x, y)) + ) + continue; + ids[k] = place(x, y, limited(x, y, row.z[k]), tagOf(v.kind)); + stats.gridPoints += 1; + } + if (stationAt(rows[r].chainage_m) >= 0) { + const line = Array.from(ids).filter((id) => id >= 0); + pslg.addPath( + line.filter((id, k) => k === 0 || id !== line[k - 1]), + EDGE_DESIGN, + ); + } + // 같은 보기 · 같은 번째 단면 꼭짓점(꺾임)을 이웃 격자 행끼리 잇는다 — 포락선에서 멈춘 행은 뺌. + const prior: { r: number; ids: Int32Array; index: VertexIndex } | null = before; + if (prior && daylit(si, r) && daylit(si, prior.r)) { + for (const [key, now] of indices[g]) { + const then = prior.index.get(key); + if (!then) continue; + for (const [k, at] of now) { + const was = then.get(k); + if (was === undefined || at === 0 || at === row.u.length - 1) continue; + if (was === 0 || was === prior.ids.length - 1) continue; + const a = prior.ids[was]; + const b = ids[at]; + if (a < 0 || b < 0 || a === b) continue; + pslg.addEdge(a, b, EDGE_DESIGN); stats.bendLines += 1; } } } - before = { r, ids }; - } + before = { r, ids, index: indices[g] }; + }); }); // 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(제안 ② 와 같은 드레이프). @@ -442,23 +595,22 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { a0 = a1; } if (!best) return null; - const si = best.lateral >= 0 ? 0 : 1; + const at = best; + const si = at.lateral >= 0 ? 0 : 1; const height = (r: number): number | null => { - const s = Math.abs(best!.lateral) - hw[r]; + const s = Math.abs(at.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 v = views[si][r]; + if (keyOf[si][r] < 0 || !v || s >= reachOf[si][r] * shownOf[si][r]) return null; + return placeAt(r, si, v, s / reachOf[si][r])[2]; }; - const h0 = height(best.r); - const h1 = height(best.r + 1); + const h0 = height(at.r); + const h1 = height(at.r + 1); if (h0 === null || h1 === null) return null; - return h0 + (h1 - h0) * best.t; + return h0 + (h1 - h0) * at.t; }; const holes: Array> = []; - for (const loop of structureTopLoops(build.structures ?? [])) { + for (const loop of topLoops) { 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); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Grid.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Grid.ts new file mode 100644 index 000000000..95ab09699 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Grid.ts @@ -0,0 +1,63 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Loft_Grid.ts + * 3D 제안 ③ **사면 격자 면 평활화**(계획서 115-10) — 순수 수치. + * + * 격자 행 = 사면 단면 하나(노견 끝 u = 0 → 비탈 끝 u = 1). 점마다 이웃 = 같은 행 앞뒤 점 · 앞뒤 행의 + * 같은 u 자리(그 행 선형 보간). 높이만 Taubin(λ · μ 번갈아 → 줄어들지 않음)으로 고른다. + * 고정 = 노견 끝 · 비탈 끝(지형 위) · 꺾임(소단 · 암/토사) · 측점 행(B06). + * ========================================================================== */ + +// ponytail: Taubin λ 0.5 · μ −0.53 · 10 번 — 1 m 격자에서 파장 수 m 지형 잡음 · 드레이프 출렁임만 누름 · +// 더 돌리면 측점 행 고정점 둘레가 오목해짐(측점 오차로 잼). +const LAMBDA = 0.5; +const MU = -0.53; +const ROUNDS = 10; + +export interface GridRow { + /** 사면 비율 0(노견 끝) ~ 1(비탈 끝) 오름차순. */ + u: Float64Array; + /** 높이(모델) — 평활화가 고쳐 쓴다. */ + z: Float64Array; + /** 1 = 고정. */ + fixed: Uint8Array; +} + +/** 행의 u 자리 높이(선형). */ +export function zAtU(row: GridRow, u: number): number { + const { u: us, z } = row; + if (u <= us[0]) return z[0]; + for (let k = 1; k < us.length; k += 1) { + if (u <= us[k]) { + const span = us[k] - us[k - 1]; + const t = span <= 1e-12 ? 1 : (u - us[k - 1]) / span; + return z[k - 1] + (z[k] - z[k - 1]) * t; + } + } + return z[z.length - 1]; +} + +/** 한 측 격자 행들(누가거리 차례 · null = 비탈 없음 → 이웃 끊김)의 높이를 Taubin 으로 고른다. */ +export function taubin(rows: Array, rounds = ROUNDS): void { + const step = (factor: number): void => { + const next = rows.map((row, k) => { + if (!row) return null; + const out = Float64Array.from(row.z); + const before = rows[k - 1] ?? null; + const after = rows[k + 1] ?? null; + for (let j = 1; j < row.u.length - 1; j += 1) { + if (row.fixed[j]) continue; + let sum = row.z[j - 1] + row.z[j + 1]; + let count = 2; + if (before) ((sum += zAtU(before, row.u[j])), (count += 1)); + if (after) ((sum += zAtU(after, row.u[j])), (count += 1)); + out[j] = row.z[j] + factor * (sum / count - row.z[j]); + } + return out; + }); + rows.forEach((row, k) => row && row.z.set(next[k] as Float64Array)); + }; + for (let i = 0; i < rounds; i += 1) { + step(LAMBDA); + step(MU); + } +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts new file mode 100644 index 000000000..a153c2745 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Toe.ts @@ -0,0 +1,154 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Loft_Toe.ts + * 3D 제안 ③ **비탈 끝 선 평활화 · 포락선**(계획서 115-10) — 평면 2D · 순수 기하. + * + * ① 비탈 끝 거리(행마다)를 곡선 안쪽 상한(국부 반경 몫)으로 줄이고 노선 따라 가우스로 고름 → + * 부채꼴 양옆 · 절성토 바뀜이 매끈히 줄어듦 · 측점 행은 단면 값(B06) 그대로. + * ② 포락선 — 두 행의 사면 선(노견 끝 → 비탈 끝)이 평면에서 엇갈리면 그 자리까지 더 먼 쪽이 그 자리에서 + * 멈춘다(두 비탈 면의 만나는 선 = 골 · 마루). 앞 행에만 대어 자르던 톱니 · 한 점으로 모이는 원뿔 없음. + * ========================================================================== */ + +import { crossing, SegGrid } from "./B05_Profile_UI_Corridor_Tin_Pslg"; + +// ponytail: 비탈 끝 거리 가우스 σ 1.5 m(±4.5 m 창) — 측점 사이 3차 보간은 이미 매끈해 바뀜 자리 · +// 부채꼴 양끝만 다듬음 · 측점 값은 고정이라 넓혀도 측점 오차는 그대로(사이 모양만 무뎌짐). +const REACH_SIGMA_M = 1.5; + +export interface ToeRows { + chainage: ArrayLike; + /** 노면부 끝(앵커) 평면 자리. */ + ax: ArrayLike; + ay: ArrayLike; + /** 바깥 법선(단위). */ + nx: ArrayLike; + ny: ArrayLike; + /** 단면 비탈 끝 거리 — 0 = 비탈 없음. */ + reach: ArrayLike; + /** 단면이 있는 행(비탈을 세울 수 있음). */ + has: ArrayLike; + /** 곡선 안쪽 비탈 끝 거리 상한(행마다 · 국부 반경 몫) — 넘으면 줄임. */ + maxReach: ArrayLike; + /** 측점 행(1) — 고른 뒤에도 비탈 끝 거리 = 단면 값(B06). */ + pinned: ArrayLike; +} + +export interface ToeLine { + /** 고른 비탈 끝 거리(지형에 닿는 끝 · 드레이프 기준). */ + reach: Float64Array; + /** 보이는 몫(0~1) — 1 = 비탈 끝까지(지형 위) · 1 밑 = 다른 행 사면과 만나는 선에서 멈춤. */ + shown: Float64Array; + /** 포락선에서 멈춘 행 수. */ + folded: number; +} + +/** 가우스 이동 평균(누가거리 기준 · ±3σ) — `only` 가 거짓인 행은 그대로(셈에는 든다). */ +export function gauss( + chainage: ArrayLike, + value: ArrayLike, + sigma: number, + only?: (r: number) => boolean, +): Float64Array { + const n = chainage.length; + const out = Float64Array.from(value as ArrayLike); + let lo = 0; + for (let r = 0; r < n; r += 1) { + if (only && !only(r)) continue; + const c = chainage[r]; + while (chainage[lo] < c - 3 * sigma) lo += 1; + let sum = 0; + let weight = 0; + for (let q = lo; q < n && chainage[q] <= c + 3 * sigma; q += 1) { + const w = Math.exp(-((chainage[q] - c) ** 2) / (2 * sigma * sigma)); + sum += w * value[q]; + weight += w; + } + out[r] = weight ? sum / weight : value[r]; + } + return out; +} + +/** 한 측 비탈 끝 — 거리 상한 · 고르기 · 측점 고정 → 포락선(엇갈린 사면 선은 먼 쪽이 멈춤). */ +export function smoothToe(rows: ToeRows): ToeLine { + const n = rows.chainage.length; + const raw = Float64Array.from(rows.reach as ArrayLike, (e, r) => + Math.min(e, rows.maxReach[r]), + ); + for (let r = 0; r < n; r += 1) if (!rows.has[r]) raw[r] = 0; + const reach = gauss(rows.chainage, raw, REACH_SIGMA_M); + // 측점 고정 — 측점에서 고르기로 생긴 차를 측점 사이로 부드럽게(smoothstep) 나눠 되돌림. + const pins: number[] = []; + // 비탈 없는 측점(0)도 고정점 — 이웃 측점의 큰 비탈이 번져 들지 않게. + for (let r = 0; r < n; r += 1) if (rows.pinned[r]) pins.push(r); + if (pins.length) { + const smooth = Float64Array.from(reach); + let p = 0; + for (let r = 0; r < n; r += 1) { + if (!rows.has[r]) continue; + while (p + 1 < pins.length && pins[p + 1] <= r) p += 1; + const a = pins[p]; + const b = pins[Math.min(p + 1, pins.length - 1)]; + const da = raw[a] - smooth[a]; + const db = raw[b] - smooth[b]; + const span = rows.chainage[b] - rows.chainage[a]; + const t = + r <= a || span <= 1e-9 ? 0 : Math.min(1, (rows.chainage[r] - rows.chainage[a]) / span); + const w = t * t * (3 - 2 * t); + reach[r] = Math.min(rows.maxReach[r], Math.max(0, reach[r] + da + (db - da) * w)); + } + } + for (let r = 0; r < n; r += 1) if (!rows.has[r]) reach[r] = 0; + + // 포락선 — 사면 선끼리 엇갈린 자리에서 그 자리까지 더 먼 선이 멈춘다(같으면 뒤 행). + const ex = (r: number): number => rows.ax[r] + rows.nx[r] * reach[r]; + const ey = (r: number): number => rows.ay[r] + rows.ny[r] * reach[r]; + const grid = new SegGrid(2); + for (let r = 0; r < n; r += 1) { + if (reach[r] > 0) grid.add(r, rows.ax[r], rows.ay[r], ex(r), ey(r)); + } + const shown = new Float64Array(n).fill(1); + let folded = 0; + for (let r = 0; r < n; r += 1) { + if (reach[r] <= 0) continue; + const x0 = rows.ax[r]; + const y0 = rows.ay[r]; + const x1 = ex(r); + const y1 = ey(r); + for (const q of grid.near( + Math.min(x0, x1), + Math.min(y0, y1), + Math.max(x0, x1), + Math.max(y0, y1), + )) { + if (q === r) continue; + const hit = crossing(x0, y0, x1, y1, rows.ax[q], rows.ay[q], ex(q), ey(q)); + if (!hit) continue; + const mine = hit[0] * reach[r]; + const theirs = hit[1] * reach[q]; + if (mine > theirs + 1e-6 || (Math.abs(mine - theirs) <= 1e-6 && r > q)) { + shown[r] = Math.min(shown[r], hit[0]); + } + } + if (shown[r] < 1) folded += 1; + } + return { reach, shown, folded }; +} + +/** 폴리라인 스스로 꼬임 수(이웃 아닌 선분 교차) — 시험 · 측정용. */ +export function selfCrossings(x: ArrayLike, y: ArrayLike): number { + const n = x.length; + const grid = new SegGrid(2); + for (let r = 0; r + 1 < n; r += 1) grid.add(r, x[r], y[r], x[r + 1], y[r + 1]); + let count = 0; + for (let i = 0; i + 1 < n; i += 1) { + for (const id of grid.near( + Math.min(x[i], x[i + 1]), + Math.min(y[i], y[i + 1]), + Math.max(x[i], x[i + 1]), + Math.max(y[i], y[i + 1]), + )) { + if (id >= i - 1) continue; + if (crossing(x[id], y[id], x[id + 1], y[id + 1], x[i], y[i], x[i + 1], y[i + 1])) count += 1; + } + } + return count; +} diff --git a/resources/tester/helper_115_8_corridor_loft.cjs b/resources/tester/helper_115_8_corridor_loft.cjs index 296513c8d..e343c451a 100644 --- a/resources/tester/helper_115_8_corridor_loft.cjs +++ b/resources/tester/helper_115_8_corridor_loft.cjs @@ -52,6 +52,8 @@ 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 { selfCrossings } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Toe")); +const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid")); 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")); @@ -158,6 +160,10 @@ function trianglesOf(root) { const v = idx ? idx.getX(t + k) : t + k; tri.push([p.getX(v), p.getY(v), p.getZ(v)]); } + // 평면 넓이 0(한 줄에 선 세 점) 삼각형은 보이지 않는다 — 틈 셈에서 뺀다(115-10). + const [a, b, c] = tri; + const area = (b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2]); + if (Math.abs(area) < 1e-9) continue; out.push({ tri, tin: child.name === "terrain-tin-mesh" }); } }); @@ -277,6 +283,42 @@ function lostVertices(tris, before, out) { const results = {}; +/** 사면(구역 색) 삼각형 가로세로비(가장 긴 변² / 2 · 넓이) — 10 넘는 수 · 가장 큰 값(115-10). */ +function aspectOf(out) { + const mesh = out.root.getObjectByName("terrain-tin-mesh"); + const p = mesh.geometry.getAttribute("position"); + const keep = mesh.userData.keepColor; + let over = 0; + let max = 0; + let count = 0; + for (let t = 0; t < p.count / 3; t += 1) { + if (!keep[t * 3]) continue; + const v = [0, 1, 2].map((k) => new THREE.Vector3().fromBufferAttribute(p, t * 3 + k)); + const l = [0, 1, 2].map((k) => v[k].distanceTo(v[(k + 1) % 3])); + const area = v[1].clone().sub(v[0]).cross(v[2].clone().sub(v[0])).length() / 2; + const ratio = area > 1e-12 ? Math.max(...l) ** 2 / (2 * area) : Infinity; + count += 1; + if (ratio > 10) over += 1; + max = Math.max(max, ratio); + } + return { count, over, max: Math.round(max * 100) / 100 }; +} + +/** 설계 구역 둘레에서 한 측 비탈 끝 선(노면 끝 뺀 차례) 스스로 꼬임 수. */ +function toeCrossings(out) { + const pslg = out.design.pslg; + const tag = new Map(); + for (let i = 0; i < pslg.count; i += 1) tag.set(`${pslg.x[i]},${pslg.z[i]}`, pslg.tag[i]); + const xs = []; + const zs = []; + out.design.outer.forEach(([x, z]) => { + if (tag.get(`${x},${z}`) === PSLG.TAG_ANCHOR) return; + xs.push(x); + zs.push(z); + }); + return selfCrossings(xs, zs); +} + /** * 사면 음영(115-9) — 이웃한 사면(구역 색) 삼각형 짝마다 면 꺾임 각 · 같은 정점의 법선 차(음영 단차). * 꺾임 각 30° 안 짝은 법선이 같아야(부드러움) · 넘는 짝(소단)은 법선이 갈려야(또렷). @@ -418,6 +460,7 @@ function daylightPoints(out, bounds) { const wAt = (x) => widthAt.get(Math.round(x * 100)); const kink = Math.abs((wAt(20) - wAt(19)) - (wAt(21) - wAt(20))); results.straight = { + aspect: aspectOf(out), stats: out.stats, gaps: gap.gaps, overshoot: r6(overshoot), @@ -448,12 +491,17 @@ function daylightPoints(out, bounds) { const pts = daylightPoints(out, bounds); let wrongSide = 0; let offsetErr = 0; + let overshoot = 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 w = Math.abs(p.y) - HALF; + // 측점(0 · 20)에서 B06 오프셋 6 · 4 · 사이는 두 값 안(115-10 비탈 끝 고르기). + if (Math.abs(p.x) < 1e-6 || Math.abs(p.x - 20) < 1e-6) { + offsetErr = Math.max(offsetErr, Math.abs(w - (p.x < 10 ? 6 : 4))); + } + overshoot = Math.max(overshoot, w - 6, 4 - w); }); // 성토 점 ↔ 절토 점을 잇는 제약 변(노면 끝 · 지형 말고) — 끊었으면 횡방향으로도 없어야 한다. const pslg = out.design.pslg; @@ -477,6 +525,7 @@ function daylightPoints(out, bounds) { cuts, crossEdges, offsetErr: r6(offsetErr), + overshoot: r6(overshoot), }; } @@ -532,9 +581,59 @@ function daylightPoints(out, bounds) { } } const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -40, 40, -30, 50)); - results.curve = { stats: out.stats, selfCross, gaps: gap.gaps }; + results.curve = { + stats: out.stats, + selfCross, + toeCross: toeCrossings(out), + aspect: aspectOf(out), + gaps: gap.gaps, + }; } +// ④-2 직선 20 m → 좌 곡선(90°) → 직선 20 m · 높은 성토(8 m · 비탈 12 m) — 곡선 안쪽 두 직선의 비탈 끝이 +// 엇갈리는 자리(제비꼬리) → 포락선(먼 쪽이 만나는 선에서 멈춤) · 꼬임 0 · 틈 0(115-10). +// R 25 m = 흔한 곡선(실오라기 0) · R 10 m = 비탈이 곡률 중심을 넘는 급곡선(꼬임 · 틈 0 · 실오라기 1 % 밑). +// ④-2 직선 20 m → 좌 급곡선(R 10 m · 90°) → 직선 20 m · 높은 성토(8 m · 비탈 12 m) — 곡선 안쪽 두 직선의 +// 비탈 끝이 엇갈려 꼬이는 자리(제비꼬리) → 포락선 · 곡률 상한 뒤 꼬임 0 · 실오라기 0 · 틈 0(115-10). +function corner(R) { + const route = []; + const sections = []; + const arc = (Math.PI / 2) * R; + const at = (c) => { + if (c <= 20) return { x: c, y: 0, h: 0 }; + if (c <= 20 + arc) { + const a = (c - 20) / R; + return { x: 20 + R * Math.sin(a), y: R - R * Math.cos(a), h: a }; + } + return { x: 20 + R, y: R + (c - 20 - arc), h: Math.PI / 2 }; + }; + const length = 40 + arc; + for (let c = 0; c <= length + 1e-9; c += 1) { + const p = at(c); + route.push({ x: p.x, y: p.y }); + } + for (let c = 0; c <= length + 1e-9; c += 10) { + const p = at(c); + sections.push(station(c, p.x, p.y, -Math.sin(p.h), Math.cos(p.h), 92, 1.5)); + } + const top = 2 * R + 45; + const bounds = { x: [-30, top], y: [-35, top], z: [80, 110] }; + const { out } = run({ + sections, + route, + terrainSpec: { bounds, x0: -30, x1: top, y0: -35, y1: top, step: 1, heightAt: () => 92 }, + }); + const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -30, top, -35, top)); + return { + stats: out.stats, + toeCross: toeCrossings(out), + aspect: aspectOf(out), + gaps: gap.gaps, + }; +} +results.bend = corner(25); +results.hairpin = corner(10); + // ⑤ 소단 단면 — 측점 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] }; @@ -578,7 +677,44 @@ function daylightPoints(out, bounds) { } } const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -10, 50, -30, 30)); - results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps, shade: shadeOf(out) }; + results.berm = { + stats: out.stats, + sectionErr: r6(sectionErr), + gaps: gap.gaps, + shade: shadeOf(out), + aspect: aspectOf(out), + }; +} + +// ⑤-2 Taubin 면 평활화 — 고정점(노견 끝 · 비탈 끝 · 꺾임) 그대로 · 잡음 줄어듦 · 평균 높이 안 꺼짐. +{ + const rows = []; + let seed = 7; + const noise = () => ((seed = (seed * 16807) % 2147483647) / 2147483647 - 0.5) * 0.4; + for (let k = 0; k < 30; k += 1) { + const u = Float64Array.from({ length: 11 }, (_, j) => j / 10); + const z = Float64Array.from(u, (x) => 100 - 6 * x + (x > 0 && x < 1 ? noise() : 0)); + const fixed = Uint8Array.from(u, (_, j) => (j === 0 || j === 10 || j === 5 ? 1 : 0)); + rows.push({ u, z, fixed }); + } + const before = rows.map((row) => Float64Array.from(row.z)); + const rough = (zs) => { + let sum = 0; + zs.forEach((z, k) => z.forEach((v, j) => (sum += (v - (100 - 0.6 * j)) ** 2))); + return sum; + }; + taubin(rows); + let fixedMoved = 0; + rows.forEach((row, k) => + row.fixed.forEach((f, j) => (fixedMoved += f && row.z[j] !== before[k][j] ? 1 : 0)), + ); + const mean = (zs) => zs.reduce((a, z) => a + z.reduce((b, v) => b + v, 0), 0); + results.taubin = { + fixedMoved, + roughBefore: r6(rough(before)), + roughAfter: r6(rough(rows.map((row) => row.z))), + meanShift: r6(Math.abs(mean(rows.map((row) => row.z)) - mean(before)) / (30 * 11)), + }; } // ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록. diff --git a/resources/tester/test_115_10_loft_smooth.py b/resources/tester/test_115_10_loft_smooth.py new file mode 100644 index 000000000..708bc228e --- /dev/null +++ b/resources/tester/test_115_10_loft_smooth.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""PLAN 115-10 — B05 3D 제안 ③ 사면 모양 평활화 검사. + +도우미(helper_115_8_corridor_loft.cjs)를 함께 쓴다. 사면 격자(실오라기 없음) · 비탈 끝 선 +꼬임 0(포락선) · Taubin 평활화(고정점 그대로 · 줄어들지 않음) · 측점 단면 = 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=240, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_slope_grid_no_slivers(results): + # 직선 · 소단 — 사면 삼각형 가로세로비 10 넘는 것 0(행마다 점 둘 실오라기 부채꼴 없음) + for name in ("straight", "berm"): + aspect = results[name]["aspect"] + assert aspect["count"] > 1000 and aspect["over"] == 0, (name, aspect) + + +def test_curves_few_slivers(results): + # 곡선 안쪽(행이 모이는 자리) · 두 비탈이 만나는 골 둘레만 조금 — 1.5 % 밑 + for name in ("curve", "bend", "hairpin"): + aspect = results[name]["aspect"] + assert aspect["over"] <= 0.015 * aspect["count"], (name, aspect) + + +def test_toe_line_no_self_crossing(results): + for name in ("curve", "bend", "hairpin"): + assert results[name]["toeCross"] == 0, name + # 급곡선(R 10 m) — 엇갈린 사면 선은 만나는 선(골)에서 멈춤 + assert results["hairpin"]["stats"]["bowtie"] > 0 + + +def test_no_gap_no_failed_constraint(results): + for name in ("straight", "flip", "curve", "bend", "hairpin", "berm"): + assert results[name]["gaps"] == 0, name + assert results[name]["stats"]["failed"] == 0, name + + +def test_fixed_edges_and_station_section(results): + r = results["straight"] + assert r["drapeErr"] < 1e-4 # 비탈 끝 = 지형 위(고정) + assert r["sectionErr"] < 1e-4 and r["offsetErr"] < 1e-4 # 측점 단면 · 비탈 끝 = B06 + assert results["berm"]["sectionErr"] < 1e-4 + + +def test_taubin_keeps_fixed_and_volume(results): + r = results["taubin"] + assert r["fixedMoved"] == 0 # 노견 끝 · 비탈 끝 · 꺾임 그대로 + assert r["roughAfter"] < 0.5 * r["roughBefore"] # 잡음 반 넘게 줄어듦 + assert r["meanShift"] < 0.01 # 평균 높이 안 꺼짐(Taubin) diff --git a/resources/tester/test_115_8_corridor_loft.py b/resources/tester/test_115_8_corridor_loft.py index 6215a09c3..1ca4263dd 100644 --- a/resources/tester/test_115_8_corridor_loft.py +++ b/resources/tester/test_115_8_corridor_loft.py @@ -30,10 +30,10 @@ def results(): def test_between_stations_daylight_monotone_cubic(results): r = results["straight"] - assert r["rowsChecked"] > 300 + assert r["rowsChecked"] > 200 # 비탈 끝 점(0.3 m 밑은 솎음 · 115-10) assert r["offsetErr"] < 1e-4 # 측점(0 · 20 · 40)에서 비탈 끝 = B06 오프셋 6 · 9 · 6 assert r["overshoot"] < 1e-6 # 사이는 단조 3차 — 두 측점 값 밖으로 안 넘침(115-9) - assert r["kink"] < 0.05 # 꼭대기(측점 20) 양옆 기울기 차 — 직선 보간이면 0.3 + assert r["kink"] < 0.1 # 꼭대기(측점 20) 양옆 기울기 차 — 직선 보간이면 0.3 def test_station_section_matches_b06(results): @@ -63,7 +63,8 @@ def test_fill_cut_change_breaks_like_plan(results): def test_curve_bowtie_cleared(results): r = results["curve"] - assert r["stats"]["bowtie"] > 0 and r["selfCross"] == 0 + # 115-10 — 곡선 안쪽은 곡률 상한 · 포락선으로(자르지 않음) → 둘레 · 비탈 끝 선 꼬임 0 + assert r["selfCross"] == 0 and r["toeCross"] == 0 def test_pairing_bends(results):