From 43804f2f5b3bcd35bfc508b2f41d94a142e986f5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 27 Aug 2026 10:46:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=A0=88=EC=B7=A8=20=EC=B8=A1?= =?UTF-8?q?=EB=B2=BD=EC=9D=84=20=EB=B3=84=EB=8F=84=20=EC=84=9C=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EB=B9=BC=EA=B3=A0=20=EC=9B=90=EC=A7=80?= =?UTF-8?q?=EB=B0=98=20=EC=95=84=EB=9E=98=201m=20=EC=97=AC=EC=9C=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 측벽을 성토 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로 낸다(2026-08-27 사용자). UV·법선이 리본에 묶이지 않아 다음 단계(구조물 안착 바닥면·트림)에서 따로 다룰 수 있다. 밑선은 원지반에서 **1m 더 내린다** — 지반 보간 오차만큼 밑이 뜨는 걸 막고, 지형은 코리도 자리에서 이미 잘려 있어 여유분은 가려진다. - Build: `CorridorCutWall`(삼각형 수프 + UV) 추가, 빌드 결과에 `cutWalls`로 싣는다. - Cut: 벽 삼각형을 `trimTris`에서 분리해 `walls`로 돌려준다. `SKIRT_MARGIN_M = 1`. - Mesh: 벽마다 메쉬를 따로 만든다(`corridor-cut-wall:{측}:{번호}`) — 색·빗금은 성토와 같아 한 몸으로 보이되 객체는 분리. - Corridor: 저장본에 `cutWalls` 담고 ENVELOPE_VERSION 3 → 4, BUILD_VERSION 53 → 54. - Viewer: 검증 훅 `raw`에 `cutWalls` 추가. 검증(실빌드 데이터): - 리본 조각 삼각형 우 814·좌 787(벽 빠짐), 벽 서피스 삼각형 우 360·좌 356. - 벽 높이 최소 1.00m(여유분) / 평균 2.26·2.87 / 최대 3.80·6.25m — 1차 대비 정확히 +1m. - ISO 화면: BOX·세월교 절취부 옆면이 벽으로 막히고 바닥만 열려 있다(다음 작업). - npm run typecheck 통과, prettier 적용. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 25 +++++++++++++--- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 13 +++++++++ B05_Profile/B05_Profile_UI_Corridor_Cut.ts | 30 +++++++++++++++----- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 29 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Viewer.ts | 6 +++- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index e05e86d4..e895fa76 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -35,8 +35,9 @@ export interface CorridorStructureHook { * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. * * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. + * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. */ -const ENVELOPE_VERSION = 3; +const ENVELOPE_VERSION = 4; interface CorridorEnvelope { version: number; @@ -63,6 +64,12 @@ interface CorridorEnvelope { * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. */ planCurves?: CorridorBuildResult["planCurves"]; + /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ + cutWalls?: Array<{ + side: "left" | "right"; + positionsBase64: string; + uvsBase64: string; + }>; } interface CacheEntry { @@ -99,9 +106,9 @@ function fnv1a(text: string): string { // 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). // ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 53 = 잘린 자리 측벽 — 절취 윤곽을 z로 밀어내 성토면↔원지반을 잇는 벽을 세우고 -// 성토 메쉬에 합친다(2026-08-27). -const BUILD_VERSION = 53; +// 54 = 절취 측벽을 리본 메시에서 떼어 **별도 서피스**로 내고, 원지반 아래로 여유 +// 1m를 더 내린다(2026-08-27 사용자). +const BUILD_VERSION = 54; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { @@ -190,6 +197,11 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { caps: build.caps, structures: build.structures, planCurves: build.planCurves, + cutWalls: (build.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positionsBase64: base64FromFloats(wall.positions), + uvsBase64: base64FromBytes(new Uint8Array(wall.uvs.buffer.slice(0))), + })), }; } @@ -213,6 +225,11 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { caps: envelope.caps ?? [], structures: envelope.structures ?? [], planCurves: envelope.planCurves ?? [], + cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positions: floatsFromBase64(wall.positionsBase64), + uvs: new Float32Array(bytesFromBase64(wall.uvsBase64).buffer), + })), }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 90b3cdae..1dc22dbe 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -90,6 +90,16 @@ export interface CorridorRibbon { groundZ?: Float64Array; } +/** + * 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼 + * 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9)와 짝 UV(정점당 2). + */ +export interface CorridorCutWall { + side: "left" | "right"; + positions: Float64Array; + uvs: Float32Array; +} + /** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */ export interface CorridorCap { /** [x, y, z설계, z지반] × N — offset 오름차순. */ @@ -110,6 +120,8 @@ export interface CorridorBuildResult { * 저장 코리도엔 없을 수 있다. */ planCurves?: PlanCurve[]; + /** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */ + cutWalls?: CorridorCutWall[]; } /** @@ -663,5 +675,6 @@ export function buildCorridor( caps, structures, planCurves: cut ? [...planCurves, ...cut.curves] : planCurves, + cutWalls: cut ? cut.walls : [], }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index f718e9ac..5ee2520c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -29,7 +29,7 @@ * 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만). * ========================================================================== */ -import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; +import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; /** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */ @@ -276,9 +276,14 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu return kept; } +/** 측벽을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다. + * 지형은 코리도 자리에서 이미 잘려 있어 더 내려도 가려진다(2026-08-27 사용자). */ +const SKIRT_MARGIN_M = 1; + /** - * 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반까지 - * 세우고, 성토면과 **같은 메쉬**에 담는다(`trimTris`로 함께 그려진다). + * 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반 + * 아래(`SKIRT_MARGIN_M`)까지 세운다. 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로 + * 낸다 — UV·법선이 리본에 묶이지 않아 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. * * ponytail: 벽은 격자 모서리를 타는 윤곽(계단 6cm × 0.2m)을 그대로 쓴다 — 걸친 셀 안에서 * 실제 절단선은 교점 직선이라 최대 한 셀만큼 어긋난다. 화면에서 틈이 보이면 그때 @@ -301,7 +306,7 @@ function pushSkirt( ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2], - ground[row * cols + col], + ground[row * cols + col] - SKIRT_MARGIN_M, (ribbon.chainages[row] - startChainage) / 2, ]; }; @@ -332,7 +337,7 @@ function pushSkirt( export function maskFillByPlanCurves( ribbons: ReadonlyArray, curves: ReadonlyArray, -): { ribbons: CorridorRibbon[]; curves: PlanCurve[] } { +): { ribbons: CorridorRibbon[]; curves: PlanCurve[]; walls: CorridorCutWall[] } { const areasBySide = new Map(); const planesBySide = new Map(); for (const curve of curves) { @@ -350,6 +355,7 @@ export function maskFillByPlanCurves( } const merged: PlanCurve[] = []; + const walls: CorridorCutWall[] = []; const cut = ribbons.map((ribbon) => { const areas = areasBySide.get(ribbon.side); if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon; @@ -360,6 +366,9 @@ export function maskFillByPlanCurves( let masked = 0; const tris: number[] = []; const uvs: number[] = []; + // 측벽은 리본 메시가 아니라 **별도 서피스**로 나간다(2026-08-27 사용자). + const wallTris: number[] = []; + const wallUvs: number[] = []; const startChainage = ribbon.chainages[0]; const point = (row: number, col: number): [number, number, number] => { const i = (row * cols + col) * 3; @@ -418,7 +427,7 @@ export function maskFillByPlanCurves( traceMaskLoops(rows, cols, mask).forEach((raw) => { const loop = dropCollinear(raw); if (loop.length < 4 || !planes.length) return; - pushSkirt(loop, ribbon, tris, uvs); + pushSkirt(loop, ribbon, wallTris, wallUvs); const meanRow = loop.reduce((sum, [row]) => sum + row, 0) / loop.length; const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))]; const plane = planes.reduce((best, item) => @@ -439,11 +448,18 @@ export function maskFillByPlanCurves( ], }); }); + if (wallTris.length) { + walls.push({ + side, + positions: new Float64Array(wallTris), + uvs: new Float32Array(wallUvs), + }); + } return { ...ribbon, cellMask: mask, ...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}), }; }); - return { ribbons: cut, curves: merged }; + return { ribbons: cut, curves: merged, walls }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 9359016d..f39cffb6 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -416,6 +416,35 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou group.add(line); }); }); + // 절취 측벽 — 리본과 분리된 **별도 서피스**(2026-08-27 사용자). 성토면과 같은 색·빗금이라 + // 한 몸으로 보이지만 객체가 달라 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. + (build.cutWalls ?? []).forEach((wall, index) => { + const count = wall.positions.length / 3; + if (!count) return; + const positions = new Float32Array(count * 3); + for (let v = 0; v < count; v += 1) { + positions[v * 3] = wall.positions[v * 3] - origin.cx; + positions[v * 3 + 1] = wall.positions[v * 3 + 2] - origin.cz; + positions[v * 3 + 2] = -(wall.positions[v * 3 + 1] - origin.cy); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.BufferAttribute(wall.uvs.slice(0), 2)); + geometry.computeVertexNormals(); + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshLambertMaterial({ + color: KIND_COLORS.fill, + map: hatchTexture(HATCH_DIRECTION.fill ?? -1), + side: THREE.DoubleSide, + polygonOffset: true, + polygonOffsetFactor: POLYGON_OFFSET_FACTOR, + polygonOffsetUnits: POLYGON_OFFSET_FACTOR, + }), + ); + mesh.name = `corridor-cut-wall:${wall.side}:${index}`; + group.add(mesh); + }); build.caps.forEach((cap, index) => { const geometry = capGeometry(cap, origin); if (!geometry) return; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 9016a33a..e44b1ff8 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -605,7 +605,11 @@ export function createRouteViewer(): RouteViewer { outlineRows: build.outline.chainages.length, outline: build.outline, // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. - raw: { ribbons: build.ribbons, planCurves: build.planCurves ?? [] }, + raw: { + ribbons: build.ribbons, + planCurves: build.planCurves ?? [], + cutWalls: build.cutWalls ?? [], + }, planCurves: (build.planCurves ?? []).map((curve) => ({ source: curve.source, role: curve.role,