From 06d9754d6df909086f388f4fab3400a87719bb9e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 09:28:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=203D=20=EC=A0=9C=EC=95=88=20?= =?UTF-8?q?=E2=91=A2=20=EC=A0=88=EC=84=B1=ED=86=A0=20=EC=84=9C=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EB=B6=80=EB=93=9C=EB=9F=BD=EA=B2=8C=20=E2=80=94=20?= =?UTF-8?q?=EC=82=AC=EB=A9=B4=20=EC=82=BC=EA=B0=81=ED=98=95=EB=A7=8C=20?= =?UTF-8?q?=EA=BA=BE=EC=9E=84=20=EB=B2=95=EC=84=A0(30=C2=B0=20=EB=84=98?= =?UTF-8?q?=EB=8A=94=20=EC=86=8C=EB=8B=A8=20=EA=BA=BE=EC=9E=84=EC=9D=80=20?= =?UTF-8?q?=EB=98=90=EB=A0=B7)=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=20=EB=8B=A8=EC=A1=B0=203=EC=B0=A8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=84(=EC=B8=A1=EC=A0=90=20=3D=20B06)=20=C2=B7=20=EB=93=9C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=94=84=20=EB=B3=B4=EC=A0=95=EB=9F=89=20?= =?UTF-8?q?=C2=B12=20m=20=EA=B3=A0=EB=A5=B4=EA=B8=B0(115-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 _Corridor_Loft_Shade.ts(three toCreasedNormals · 부드러운 음영 재질 사본 한 벌) - _Tin.ts 망 손질 자리(finish) 하나 — 제안 ② 는 안 넘겨 그대로 - 시험 도우미 셋(115-5 · 115-6 · 115-8)이 three BufferGeometryUtils 를 config/node_modules 에서 풂 - test_115_8 직선 보간 단언 → 측점 = B06 · 넘침 없음 · 꼭대기 꺾임 없음 · 사면 음영 단언 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_UI_Corridor_Loft.ts | 112 +++++++++++++++++- .../B05_Profile_UI_Corridor_Loft_Profile.ts | 55 +++++++++ .../B05_Profile_UI_Corridor_Loft_Shade.ts | 62 ++++++++++ B05_Profile/B05_Profile_UI_Corridor_Tin.ts | 4 + .../tester/helper_115_5_corridor_improve.cjs | 2 + .../tester/helper_115_6_corridor_tin.cjs | 2 + .../tester/helper_115_8_corridor_loft.cjs | 86 +++++++++++++- resources/tester/test_115_8_corridor_loft.py | 19 ++- 8 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Loft_Shade.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts index e22eb97a3..737897588 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -37,12 +37,24 @@ import { TAG_FILL, TAG_HOLE, } from "./B05_Profile_UI_Corridor_Tin_Pslg"; -import { endOf, pairProfiles, stationLofts } from "./B05_Profile_UI_Corridor_Loft_Profile"; +import { + arcFractions, + atFraction, + endOf, + hermite, + pairProfiles, + pchipSlope, + stationLofts, +} from "./B05_Profile_UI_Corridor_Loft_Profile"; +import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade"; /** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ 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; /** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ const HOLE_REACH_M = 80; @@ -110,6 +122,49 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { return [lo, span > 1e-9 ? (c - stations[lo].chainage_m) / span : 0]; }; const pairs = new Map(); + /** + * 측점 사이 단조 3차 보간(115-9) — 짝 맞춘 꼭짓점의 바깥거리 · 높이를 누가거리로. 끝 기울기는 앞뒤 + * 측점(같은 종류 · 또는 비탈 없음) 단면의 같은 호길이 비율 자리와의 할선으로(PCHIP) · 측점에선 B06 그대로. + */ + const cubics = new Map(); + const cubicOf = (i: number, si: number, kind: number, pa: Float64Array, pb: Float64Array) => { + const j = Math.min(i + 1, last); + const h = stations[j].chainage_m - stations[i].chainage_m; + const neighbour = (q: number, from: number) => { + if (q < 0 || q > last) return null; + const profile = stations[q].sides[si]; + if (profile && profile.kind !== kind) return null; + const gap = Math.abs(stations[q].chainage_m - stations[from].chainage_m); + return gap > 1e-6 ? { profile, gap } : null; + }; + const before = neighbour(i - 1, i); + const after = j > i ? neighbour(j + 1, j) : null; + const fa = arcFractions(pa); + const fb = arcFractions(pb); + const m0 = new Float64Array(pa.length); + const m1 = new Float64Array(pa.length); + for (let k = 0; k < pa.length; k += 1) { + const d1 = h > 1e-9 ? (pb[k] - pa[k]) / h : 0; + const at = k >> 1; + m0[k] = before + ? pchipSlope( + before.gap, + (pa[k] - atFraction(before.profile, fa[at])[k & 1]) / before.gap, + h, + d1, + ) + : d1; + m1[k] = after + ? pchipSlope( + h, + d1, + after.gap, + (atFraction(after.profile, fb[at])[k & 1] - pb[k]) / after.gap, + ) + : d1; + } + return { h, m0, m1 }; + }; 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]; @@ -129,9 +184,20 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { } 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) }; + let cubic = cubics.get(slot); + if (!cubic) { + cubic = cubicOf(i, si, kind, pa, pb); + cubics.set(slot, cubic); + } + const { h, m0, m1 } = cubic; + const pts = pa.map((v, k) => hermite(v, pb[k], m0[k], m1[k], h, t)); + // 꼭짓점끼리 곡선이 엇갈려 바깥거리가 뒤집히지 않게. + for (let k = 2; k < pts.length; k += 2) pts[k] = Math.max(pts[k], pts[k - 2]); + return { key: i * 2, kind, pts }; }; - /** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 s / 끝 비율. */ + /** 고른 드레이프 보정량(행 · 측) — 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]; @@ -144,7 +210,10 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { 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; + // 고른 몫은 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 }; }; @@ -181,6 +250,37 @@ 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 keyOf = [0, 1].map(() => new Int32Array(n).fill(-1)); const reachOf = [0, 1].map(() => new Float64Array(n)); @@ -382,5 +482,7 @@ export function loftTerrain( bounds: ModelBounds, heightIndex: TerrainHeightIndex, ): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { - return tinTerrain(split, build, bounds, heightIndex, loftDesign); + return tinTerrain(split, build, bounds, heightIndex, loftDesign, (mesh) => + smoothSlopeNormals(mesh), + ); } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts index befb18a12..ab2d759d7 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Profile.ts @@ -169,3 +169,58 @@ export function pairProfiles( } return [Float64Array.from(outA), Float64Array.from(outB)]; } + +/** 꼭짓점마다 단면 전체 호길이 비율(0~1) — 길이가 없으면 차례 비율. */ +export function arcFractions(pts: Float64Array): Float64Array { + const m = pts.length / 2; + const out = new Float64Array(m); + for (let k = 1; k < m; k += 1) { + const ds = pts[k * 2] - pts[k * 2 - 2]; + const dz = pts[k * 2 + 1] - pts[k * 2 - 1]; + out[k] = out[k - 1] + Math.hypot(ds, dz); + } + const total = out[m - 1]; + for (let k = 0; k < m; k += 1) out[k] = total > 1e-9 ? out[k] / total : m > 1 ? k / (m - 1) : 0; + return out; +} + +/** 단면 위 호길이 비율 g 자리 [s, dz] — 비탈 없음(null)은 [0, 0]. */ +export function atFraction(profile: LoftProfile | null, g: number): [number, number] { + if (!profile) return [0, 0]; + const pts = profile.pts; + const f = arcFractions(pts); + for (let k = 1; k < f.length; k += 1) { + if (g <= f[k] || k === f.length - 1) { + const span = f[k] - f[k - 1]; + const t = span <= 1e-12 ? 1 : Math.max(0, Math.min(1, (g - f[k - 1]) / span)); + return [ + pts[k * 2 - 2] + (pts[k * 2] - pts[k * 2 - 2]) * t, + pts[k * 2 - 1] + (pts[k * 2 + 1] - pts[k * 2 - 1]) * t, + ]; + } + } + return [pts[0], pts[1]]; +} + +/** + * 단조 3차(PCHIP · Fritsch–Butland) 마디 끝 기울기 — 앞 마디(h0, d0) · 뒤 마디(h1, d1) 할선. + * 부호가 다르거나 0 이면 0(넘침 없음) · 가중 조화 평균은 3 × 할선을 넘지 않는다. + */ +export function pchipSlope(h0: number, d0: number, h1: number, d1: number): number { + if (d0 * d1 <= 0) return 0; + const w1 = 2 * h1 + h0; + const w2 = h1 + 2 * h0; + return (w1 + w2) / (w1 / d0 + w2 / d1); +} + +/** 3차 Hermite — 마디 [0, h] 의 t(0~1) 자리 · 끝값 y0 · y1 · 끝 기울기 m0 · m1. */ +export function hermite(y0: number, y1: number, m0: number, m1: number, h: number, t: number) { + const t2 = t * t; + const t3 = t2 * t; + return ( + (2 * t3 - 3 * t2 + 1) * y0 + + (t3 - 2 * t2 + t) * h * m0 + + (-2 * t3 + 3 * t2) * y1 + + (t3 - t2) * h * m1 + ); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Loft_Shade.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft_Shade.ts new file mode 100644 index 000000000..760d2035a --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft_Shade.ts @@ -0,0 +1,62 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Loft_Shade.ts + * 3D 제안 ③ 사면 **부드러운 음영**(계획서 115-9) — TIN 망에서 절토 · 성토 구역 삼각형만 정점 + * 법선을 같은 자리끼리 평균(꺾임 각 넘는 곳은 나눔 → 소단 · 노견 끝 · 암/토사 꺾임은 또렷). + * 지형 · 구멍 둘레 삼각형은 면 법선 그대로(flatShading 과 같은 모양). 구역 색은 손대지 않는다. + * ========================================================================== */ + +import * as THREE from "three"; +import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js"; + +// ponytail: 꺾임 각 30° — 측점 사이 비틀림 · 1:1.0 ↔ 1:1.5(약 11°)는 고르게, 소단(1:1.5 ↔ 수평 34°)은 +// 또렷하게. 암/토사(1:0.5 ↔ 1:1.2 약 24°)도 또렷이 남기려면 20° 로 낮춤 · 뭉개짐 대 각짐 맞바꿈은 이 값 하나. +export const CREASE_DEG = 30; + +/** 같은 지형 재질에서 만든 부드러운 음영 사본 — 다시 그릴 때마다 새로 만들지 않는다. */ +const smoothCopies = new WeakMap(); + +function smoothMaterial(material: THREE.Material): THREE.Material { + let copy = smoothCopies.get(material); + if (!copy) { + copy = material.clone(); + (copy as THREE.MeshStandardMaterial).flatShading = false; + copy.needsUpdate = true; + smoothCopies.set(material, copy); + } + return copy; +} + +/** TIN 망(비색인 · `userData.keepColor` = 구역 색 정점) — 사면 삼각형만 꺾임 법선 · 나머지 면 법선. */ +export function smoothSlopeNormals(mesh: THREE.Mesh, creaseDeg = CREASE_DEG): void { + const geometry = mesh.geometry; + const position = geometry.getAttribute("position") as THREE.BufferAttribute; + const keep = mesh.userData.keepColor as Uint8Array | undefined; + const count = position.count; + const normals = new Float32Array(count * 3); + const slope: number[] = []; + const a = new THREE.Vector3(); + const b = new THREE.Vector3(); + const c = new THREE.Vector3(); + for (let t = 0; t < count / 3; t += 1) { + a.fromBufferAttribute(position, t * 3); + b.fromBufferAttribute(position, t * 3 + 1); + c.fromBufferAttribute(position, t * 3 + 2); + // three computeVertexNormals 와 같은 감기 — (c − b) × (a − b). + c.sub(b).cross(a.sub(b)).normalize(); + for (let k = 0; k < 3; k += 1) normals.set([c.x, c.y, c.z], (t * 3 + k) * 3); + if (keep?.[t * 3]) slope.push(t); + } + if (slope.length) { + const part = new THREE.BufferGeometry(); + const sub = new Float32Array(slope.length * 9); + const flat = position.array as Float32Array; + slope.forEach((t, i) => sub.set(flat.subarray(t * 9, t * 9 + 9), i * 9)); + part.setAttribute("position", new THREE.BufferAttribute(sub, 3)); + const creased = toCreasedNormals(part, (creaseDeg * Math.PI) / 180).getAttribute("normal"); + const smooth = creased.array as Float32Array; + slope.forEach((t, i) => normals.set(smooth.subarray(i * 9, i * 9 + 9), t * 9)); + part.dispose(); + } + geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + if (!Array.isArray(mesh.material)) mesh.material = smoothMaterial(mesh.material); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts index 43db3be55..6a1c3afd3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -196,6 +196,7 @@ const deformDesign: TinDesigner = (build, groundAt, bounds) => { * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못 * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. * `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③). + * `finish` = 다 만든 TIN 망 손질(제안 ③ 사면 부드러운 법선). */ export function tinTerrain( split: TerrainBandSplit, @@ -203,6 +204,8 @@ export function tinTerrain( bounds: ModelBounds, heightIndex: TerrainHeightIndex, designer: TinDesigner = deformDesign, + /** 망을 다 만든 뒤 손질(제안 ③ 사면 법선) — 없으면 그대로. */ + finish?: (mesh: THREE.Mesh) => void, ): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { const started = performance.now(); if (!build.sections?.length) return null; @@ -352,6 +355,7 @@ export function tinTerrain( mesh.name = "terrain-tin-mesh"; mesh.userData.owned = true; mesh.userData.keepColor = Uint8Array.from(keep); + finish?.(mesh); root.add(mesh); split.far.forEach((part) => { const far = new THREE.Mesh(part.geometry, part.material); diff --git a/resources/tester/helper_115_5_corridor_improve.cjs b/resources/tester/helper_115_5_corridor_improve.cjs index 4905f312f..e25c5201c 100644 --- a/resources/tester/helper_115_5_corridor_improve.cjs +++ b/resources/tester/helper_115_5_corridor_improve.cjs @@ -14,6 +14,8 @@ const NM = path.join(ROOT, "config", "node_modules"); const LIBS = { // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). delaunator: require(path.join(NM, "delaunator", "index.js")), + // three 부록(ESM 전용) — Node 22 require(esm). + "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), "@kninnug/constrainautor": { __esModule: true, default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), diff --git a/resources/tester/helper_115_6_corridor_tin.cjs b/resources/tester/helper_115_6_corridor_tin.cjs index 13d11319e..a3c19406a 100644 --- a/resources/tester/helper_115_6_corridor_tin.cjs +++ b/resources/tester/helper_115_6_corridor_tin.cjs @@ -13,6 +13,8 @@ const LIBS = { // 기본 내보내기(ESM default) 꼴로 감싼다. // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). delaunator: require(path.join(NM, "delaunator", "index.js")), + // three 부록(ESM 전용) — Node 22 require(esm). + "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), "@kninnug/constrainautor": { __esModule: true, default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), diff --git a/resources/tester/helper_115_8_corridor_loft.cjs b/resources/tester/helper_115_8_corridor_loft.cjs index a3c8ef1ef..296513c8d 100644 --- a/resources/tester/helper_115_8_corridor_loft.cjs +++ b/resources/tester/helper_115_8_corridor_loft.cjs @@ -13,6 +13,8 @@ const LIBS = { // 기본 내보내기(ESM default) 꼴로 감싼다. // delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함). delaunator: require(path.join(NM, "delaunator", "index.js")), + // three 부록(ESM 전용) — Node 22 require(esm). + "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), "@kninnug/constrainautor": { __esModule: true, default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), @@ -274,6 +276,70 @@ function lostVertices(tris, before, out) { const results = {}; + +/** + * 사면 음영(115-9) — 이웃한 사면(구역 색) 삼각형 짝마다 면 꺾임 각 · 같은 정점의 법선 차(음영 단차). + * 꺾임 각 30° 안 짝은 법선이 같아야(부드러움) · 넘는 짝(소단)은 법선이 갈려야(또렷). + */ +function shadeOf(out) { + const mesh = out.root.getObjectByName("terrain-tin-mesh"); + const p = mesh.geometry.getAttribute("position"); + const nrm = mesh.geometry.getAttribute("normal"); + const keep = mesh.userData.keepColor; + const vkey = (v) => `${Math.round(p.getX(v) * 1e3)},${Math.round(p.getY(v) * 1e3)},${Math.round(p.getZ(v) * 1e3)}`; + const face = (t) => { + const a = new THREE.Vector3().fromBufferAttribute(p, t * 3); + const b = new THREE.Vector3().fromBufferAttribute(p, t * 3 + 1); + const c = new THREE.Vector3().fromBufferAttribute(p, t * 3 + 2); + return c.sub(b).cross(a.sub(b)).normalize(); + }; + const deg = (a, b) => (Math.acos(Math.max(-1, Math.min(1, a.dot(b)))) * 180) / Math.PI; + const edges = new Map(); + for (let t = 0; t < p.count / 3; t += 1) { + if (!keep[t * 3]) continue; + for (let k = 0; k < 3; k += 1) { + const a = t * 3 + k; + const b = t * 3 + ((k + 1) % 3); + const ka = vkey(a); + const kb = vkey(b); + const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + const list = edges.get(e) ?? []; + list.push({ t, a: ka, va: a, vb: b, kb }); + edges.set(e, list); + } + } + const jumps = []; + let creasePairs = 0; + let creaseJumpMin = 180; + for (const list of edges.values()) { + if (list.length !== 2) continue; + const [x, y] = list; + const fx = face(x.t); + const fy = face(y.t); + if (fx.length() < 0.5 || fy.length() < 0.5) continue; + const bend = deg(fx, fy); + // 같은 자리 정점(두 삼각형 쪽) 법선 차. + const other = (key) => (y.a === key ? y.va : y.vb); + const jump = Math.max( + deg(new THREE.Vector3().fromBufferAttribute(nrm, x.va), new THREE.Vector3().fromBufferAttribute(nrm, other(x.a))), + deg(new THREE.Vector3().fromBufferAttribute(nrm, x.vb), new THREE.Vector3().fromBufferAttribute(nrm, other(x.kb))), + ); + if (bend < 25) { + jumps.push(jump); + } else if (bend > 33) { + creasePairs += 1; + creaseJumpMin = Math.min(creaseJumpMin, jump); + } + } + return { + smoothPairs: jumps.length, + smoothJumpQ50: r6(jumps.sort((a, b) => a - b)[Math.floor(jumps.length / 2)]), + smoothJumpQ90: r6(jumps[Math.floor(jumps.length * 0.9)]), + creasePairs, + creaseJumpMin: r6(creaseJumpMin), + smoothMaterial: mesh.material.flatShading === false, + }; +} const r6 = (v) => Math.round(v * 1e6) / 1e6; const STRAIGHT = [ { x: 0, y: 0 }, @@ -318,14 +384,21 @@ function daylightPoints(out, bounds) { 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(+ 노면 반폭) 직선 보간. + // 비탈 끝 오프셋 — 측점(0 · 20 · 40)에서 6 · 9 · 6(+ 노면 반폭) 그대로 · 사이는 단조 3차(115-9): + // 두 측점 값 밖으로 안 넘침 · 꼭대기(측점 20)에서 꺾이지 않음(양옆 기울기 차 작음). let offsetErr = 0; + let overshoot = 0; let drapeErr = 0; let rowsChecked = 0; + const widthAt = new Map(); 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 w = Math.abs(p.y) - HALF; + if (p.y > 0) widthAt.set(Math.round(p.x * 100), w); + if ([0, 20, 40].some((x) => Math.abs(p.x - x) < 1e-6)) { + offsetErr = Math.max(offsetErr, Math.abs(w - (Math.abs(p.x - 20) < 1e-6 ? 9 : 6))); + } + overshoot = Math.max(overshoot, w - 9, 6 - w); const mesh = heightIndex.heightAt(p.x - 20, -p.y) + 100; // 망 지형(격자 삼각형) 높이 drapeErr = Math.max(drapeErr, Math.abs(p.z - mesh)); rowsChecked += 1; @@ -341,9 +414,14 @@ function daylightPoints(out, bounds) { } } // 측점 사이(x = 10) 단면은 지형에 맞춰 다시 그리지 않는다 — 노면 끝 0 → 비탈 끝 1 비율 보정. + // 꼭대기 양옆 기울기 — 직선 보간이면 +0.15 · −0.15(차 0.3) · 3차면 둘 다 0 근처. + const wAt = (x) => widthAt.get(Math.round(x * 100)); + const kink = Math.abs((wAt(20) - wAt(19)) - (wAt(21) - wAt(20))); results.straight = { stats: out.stats, gaps: gap.gaps, + overshoot: r6(overshoot), + kink: r6(kink), rowsChecked, offsetErr: r6(offsetErr), drapeErr: r6(drapeErr), @@ -500,7 +578,7 @@ 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 }; + results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps, shade: shadeOf(out) }; } // ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록. diff --git a/resources/tester/test_115_8_corridor_loft.py b/resources/tester/test_115_8_corridor_loft.py index be4fce7ac..6215a09c3 100644 --- a/resources/tester/test_115_8_corridor_loft.py +++ b/resources/tester/test_115_8_corridor_loft.py @@ -2,8 +2,9 @@ """PLAN 115-8 — B05 3D 제안 ③ 측점 횡단 기반 사면 검사. Node 도우미(helper_115_8_corridor_loft.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 사이 비탈 끝 = 오프셋 직선 보간 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 · -드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부. +결과(JSON)를 단언한다. 측점 비탈 끝 = B06 오프셋 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 · +드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부 · +단조 3차 보간 넘침 없음 · 사면 음영(115-9). """ import json @@ -27,10 +28,12 @@ def results(): return json.loads(proc.stdout) -def test_between_stations_daylight_is_offset_lerp(results): +def test_between_stations_daylight_monotone_cubic(results): r = results["straight"] assert r["rowsChecked"] > 300 - assert r["offsetErr"] < 1e-4 # 비탈 끝 = 측점 오프셋 6 → 9 → 6 직선 보간(평면도 외곽선) + 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 def test_station_section_matches_b06(results): @@ -69,6 +72,14 @@ def test_pairing_bends(results): assert results["berm"]["stats"]["bendLines"] > 0 # 같은 번째 꼭짓점 종방향 선 +def test_slope_shading_smooth_and_berm_crisp(results): + r = results["berm"]["shade"] + assert r["smoothMaterial"] is True # 사면 망만 부드러운 음영 재질 사본 + assert r["smoothPairs"] > 500 + assert r["smoothJumpQ50"] < 0.5 and r["smoothJumpQ90"] < 1 # 꺾임 각 안 이웃 면 = 같은 법선 + assert r["creasePairs"] > 0 and r["creaseJumpMin"] > 25 # 소단 꺾임(34°)은 또렷이 남음 + + def test_registered_as_proposal3(results): r = results["registry"] assert r["slot"] is True and r["registered"] is True and r["hasTerrain"] is True