From 106536b14511a822a6bc8bad0c251af3344ad901 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 27 Aug 2026 12:55:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=B8=A1=EB=B2=BD=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B8=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=C2=B7=EB=82=A0=EA=B0=9C?= =?UTF-8?q?=20**=EB=A8=B8=EC=A7=80=20=EC=98=81=EC=97=AD=EC=9D=98=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84**=EB=A1=9C=EB=A7=8C=20=EC=84=B8=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**인데, 벽은 두 스케치의 변을 따로 세우고 있었다. 그래서 한쪽 영역의 변이 다른 영역 **안쪽**을 지나는 구간 (속살)에도 벽이 섰다 — 세월교 좌측 실측: 날개 광선 1.87m·1.77m 구간이 스트립 안에 통째로 잠겨 있었다(2026-08-27 사용자 지적). 직전 커밋(86d1d6ac)은 날개가 있는 측의 끝 횡단선을 통째로 뺐는데, 그러면 날개가 못 덮는 도로 쪽 구간이 비어 반대로 모자랐다. 되돌리고 **경계 트림**으로 바꾼다. - Plan: `trimToUnionBoundary()` 신설 — 벽 후보 변을 다른 영역 테두리와의 교점에서 쪼개고, 조각 가운데가 다른 영역 안이면 버린다. 2cm 미만 부스러기도 버린다. 세트마다 벽 후보(스트립 끝 횡단선 2개 + 날개 [끝선→날개→끝선])와 근거 영역을 모아 두었다가 세트가 끝날 때 한 번에 `cut-wall`로 낸다. - BUILD_VERSION 59 → 60. 검증(실빌드 데이터, 구간 중점 양옆 2cm 판정): - 8개 세트·측 **전부 경계 100%** — 속살(양옆 다 안쪽) 0, 영역 밖 0. - 구간 수 세트당 2~8(84.3 2 · 149.73 6 · 200.92 8 · 275.71 2), 벽 판 16장/측, 삼각형 32개/측, 판 길이 0.17~11.52m, 최대 높이 3.15m. - 노견선·성토 끝선 위 판 0 유지. - ISO 근접 캡처: BOX·세월교 절취부 옆면이 큰 판으로 이어지고 구멍 없음. - npm run typecheck 통과, prettier 적용. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 2 +- B05_Profile/B05_Profile_UI_Corridor_Plan.ts | 115 ++++++++++++++++++-- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index c33a07f6..a1dc32ea 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -108,7 +108,7 @@ function fnv1a(text: string): string { // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. // 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 // 아니라 리본이 끝난 자리다(2026-08-27 사용자). -const BUILD_VERSION = 59; +const BUILD_VERSION = 60; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index 7aa2cfa8..2909a034 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -306,6 +306,77 @@ const VERTEX_SNAP_M = 0.001; /** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */ const PROJECT_LIFT_M = 0.02; +/** XY 다각형 안팎 판정(광선 투사). */ +function insideRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { + let hit = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +/** + * 벽 자리를 **머지 영역의 경계**만 남긴다(2026-08-27 사용자). + * + * 절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**이라, 한쪽 영역의 변이 + * 다른 영역 **안쪽**을 지나면 그건 경계가 아니라 속살이다. 변을 다른 영역 테두리와의 + * 교점에서 쪼갠 뒤, 조각 가운데가 다른 영역 안이면 버린다. + */ +function trimToUnionBoundary( + parts: ReadonlyArray<{ points: ReadonlyArray<[number, number, number]>; owner: number }>, + areas: ReadonlyArray>, +): Array> { + const lines: Array> = []; + for (const part of parts) { + for (let i = 0; i < part.points.length - 1; i += 1) { + const from = part.points[i]; + const to = part.points[i + 1]; + const rx = to[0] - from[0]; + const ry = to[1] - from[1]; + if (Math.hypot(rx, ry) < 1e-9) continue; + const cuts = [0, 1]; + areas.forEach((ring, index) => { + if (index === part.owner) return; + for (let k = 0, j = ring.length - 1; k < ring.length; j = k, k += 1) { + const sx = ring[k][0] - ring[j][0]; + const sy = ring[k][1] - ring[j][1]; + const denominator = rx * sy - ry * sx; + if (Math.abs(denominator) < 1e-12) continue; + const dx = ring[j][0] - from[0]; + const dy = ring[j][1] - from[1]; + const t = (dx * sy - dy * sx) / denominator; + const u = (dx * ry - dy * rx) / denominator; + if (t > 1e-9 && t < 1 - 1e-9 && u >= 0 && u <= 1) cuts.push(t); + } + }); + cuts.sort((a, b) => a - b); + const length = Math.hypot(rx, ry); + for (let c = 0; c < cuts.length - 1; c += 1) { + const t0 = cuts[c]; + const t1 = cuts[c + 1]; + // 2cm 미만 조각은 버린다 — 교점이 겹쳐 생기는 부스러기다. + if ((t1 - t0) * length < 0.02) continue; + const mid = (t0 + t1) / 2; + const mx = from[0] + rx * mid; + const my = from[1] + ry * mid; + const buried = areas.some( + (ring, index) => index !== part.owner && insideRing(ring, mx, my), + ); + if (buried) continue; + const at = (t: number): [number, number, number] => [ + from[0] + rx * t, + from[1] + ry * t, + from[2] + (to[2] - from[2]) * t, + ]; + lines.push([at(t0), at(t1)]); + } + } + } + return lines; +} + /** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */ function projectedLoops( rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, @@ -387,8 +458,23 @@ export function buildPlanCurves( leftX: section.frame.left_xy[0], leftY: section.frame.left_xy[1], }; - /** 날개벽 벽면을 낸 측 — 그 측 스트립 끝 횡단선은 겹치므로 안 낸다(2026-08-27 사용자). */ - const wingWallSides = new Set(); + // 벽 자리 후보와 그 근거 영역 — 세트가 끝나면 **머지 경계**만 남겨 한 번에 낸다. + const wallParts = new Map< + Side, + Array<{ points: Array<[number, number, number]>; owner: number }> + >(); + const wallAreas = new Map>>(); + const addArea = (side: Side, loop: ReadonlyArray<[number, number, number]>): number => { + const bucket = wallAreas.get(side) ?? []; + bucket.push(loop.map((point) => [point[0], point[1]] as [number, number])); + wallAreas.set(side, bucket); + return bucket.length - 1; + }; + const addWall = (side: Side, points: Array<[number, number, number]>, owner: number): void => { + const bucket = wallParts.get(side) ?? []; + bucket.push({ points, owner }); + wallParts.set(side, bucket); + }; (["left", "right"] as const).forEach((side) => { push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ, axis)); // 날개벽만 — 성토 끝선까지 이어진 사다리꼴(2026-08-26 사용자). 성토 끝선은 @@ -398,8 +484,11 @@ export function buildPlanCurves( const wing = wingBoxLoops(set, side, planZ, axis, toe); push("wing-box", side, wing.loops); // 날개 사다리꼴의 벽 자리 — 성토 끝선 변은 빠져 있다(2026-08-27 사용자). - push("cut-wall", side, wing.walls); - if (wing.walls.length) wingWallSides.add(side); + wing.loops.forEach((loop, index) => { + const owner = addArea(side, loop); + const line = wing.walls[index]; + if (line) addWall(side, line, owner); + }); }); for (const silhouette of structureSilhouettes(section)) { @@ -507,20 +596,24 @@ export function buildPlanCurves( ); }); push("slope-original", side, stripLoops(rows, planZ)); - push("slope-projected", side, projectedLoops(projected)); + const stripLoopsXY = projectedLoops(projected); + push("slope-projected", side, stripLoopsXY); // 측벽이 설 자리 = 스트립의 **양 끝 횡단선**뿐. 긴 변(노견선·성토 끝선)은 뺀다 // (거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). const spans = projected.filter( (pair): pair is [[number, number, number], [number, number, number]] => pair !== null, ); - // 날개벽이 있는 측은 그 벽면이 같은 자리를 이미 막는다 — 끝 횡단선은 중복이다. - if (spans.length >= 2 && !wingWallSides.has(side)) { - push("cut-wall", side, [ - [spans[0][0], spans[0][1]], - [spans[spans.length - 1][0], spans[spans.length - 1][1]], - ]); + if (spans.length >= 2 && stripLoopsXY.length) { + const owner = addArea(side, stripLoopsXY[0]); + addWall(side, [spans[0][0], spans[0][1]], owner); + addWall(side, [spans[spans.length - 1][0], spans[spans.length - 1][1]], owner); } } + // 본 구조물 영역 ∪ 날개 영역의 **경계만** 벽으로 낸다 — 다른 영역 안에 잠긴 + // 구간은 속살이라 버린다(2026-08-27 사용자). + wallParts.forEach((parts, side) => { + push("cut-wall", side, trimToUnionBoundary(parts, wallAreas.get(side) ?? [])); + }); } return curves; }