From a92e1768ca96961b4579eb45edd4231e102da61d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 20:20:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=203D=20=EC=83=88=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20=E2=80=94=20=ED=92=8B=ED=94=84=EB=A6=B0=ED=8A=B8=20?= =?UTF-8?q?=C2=B7=20=EC=95=84=EB=A0=B9=20=EC=99=B8=EA=B3=BD=20=EA=B5=AC?= =?UTF-8?q?=EB=A9=8D=20=C2=B7=20=EB=82=A0=EA=B0=9C=20=EC=95=88=EC=AA=BD=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=20=EA=B8=88=EC=A7=80=20=C2=B7=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EB=8B=A8=EB=A9=B4=20=EC=83=81=EB=B6=80=20=ED=8F=AC?= =?UTF-8?q?=EB=9D=BD=EC=84=A0=20+=20=EC=9D=B4=EC=9B=83=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=ED=85=8C=EC=9D=B4=ED=8D=BC(115-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 구간 단면 = 소유 측점 실루엣 상부 포락선(앵커 기준) · 구간 안 고정 · 끝 → 이웃 측점 테이퍼 - BOX(구조물에서 끝나는 실루엣) 구간은 단면 끝 너머 연장 없음 = 본체 밖 원지반 - 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(솔리드가 메움 · 솔리드 코드는 그대로) - 날개 안쪽 유로 · 에이프런 성토 금지 고리 — 원지반(세월교는 바닥판 상단)까지만 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B05_Profile/B05_Profile_UI_Corridor_Deform.ts | 89 ++++++- ...05_Profile_UI_Corridor_Deform_Structure.ts | 243 ++++++++++++++++++ .../B05_Profile_UI_Corridor_Deform_Terrain.ts | 28 +- .../tester/helper_115_corridor_deform.cjs | 178 +++++++++++++ resources/tester/test_115_corridor_deform.py | 38 +++ 5 files changed, 562 insertions(+), 14 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts index 38170e5ef..0a8311995 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts @@ -65,9 +65,31 @@ export interface RowProfiles { noExtend?: boolean; } -/** 행 · 측마다 단면을 내주는 자리 — 115-2 구조물 단면이 이 자리를 갈아 끼운다. */ +/** 행 · 측마다 단면을 내주는 자리 — 측점 단면 + 구조물 구간 단면(115-2). */ export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles; +/** + * 구조물 구간 단면(115-2) — 소유 측점 실루엣의 상부 포락선(앵커 기준 상대 · 측점 단면과 + * 같은 꼴 · 같은 열 수). 구간(전/후 몫) 안은 이 단면 그대로, 밖은 이웃 측점까지 테이퍼. + */ +export interface StructureSlope { + chainage_m: number; + side: DeformSide; + kind: DeformKind; + span: { beforeM: number; afterM: number }; + points: OffsetPoint[]; + /** 실루엣이 구조물에서 끝난다(BOX) — 구간 안은 단면 끝 너머를 원지반으로 둔다. */ + endsAtStructure: boolean; +} + +/** 성토 금지 고리(115-2 · 날개 안쪽 유로 · 에이프런) — 안쪽 정점은 원지반(또는 천장) 위로 못 올린다. */ +export interface DeformLimit { + /** 모델 좌표 닫힌 고리. */ + loop: Array<[number, number]>; + /** 바닥판 상단 등 — 원지반이 이보다 낮으면 여기까지는 올린다. null 이면 원지반. */ + ceiling: number | null; +} + /** 정점 하나의 역투영 결과. */ export interface DeformLocation { row: number; @@ -172,19 +194,38 @@ export function roadRows(build: CorridorBuildResult): DeformRow[] { ); } -/** 측점 비탈 단면으로 내는 기본 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). */ -export function stationProfileSource(sections: ReadonlyArray): ProfileSource { +/** + * 측점 비탈 단면으로 내는 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). 구조물 단면을 주면 + * 그 측점의 그 측 · 그 종류는 구조물 단면으로 갈아 끼우고 전/후 몫을 고정 구간으로 세운다 + * (구간 끝 → 이웃 측점 테이퍼). 고정 구간 안은 실루엣 종류만 남긴다. + */ +export function stationProfileSource( + sections: ReadonlyArray, + slopes: ReadonlyArray = [], +): ProfileSource { const stations = sections .map(classifyStation) .filter((s): s is StationPieces => s !== null) .sort((a, b) => a.chainage_m - b.chainage_m); + const slopeOf = (station: StationPieces, side: DeformSide, kind: DeformKind) => + slopes.find( + (slope) => + slope.side === side && + slope.kind === kind && + Math.abs(slope.chainage_m - station.chainage_m) < 1e-6, + ); const controls = new Map(); SIDES.forEach((side) => KINDS.forEach((kind) => { const key = pieceKey(kind, side); controls.set( key, - buildPieceControls(stations, side, (station) => station.slopeRelative.get(key)), + buildPieceControls( + stations, + side, + (station) => slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(key), + (station) => slopeOf(station, side, kind)?.span, + ), ); }), ); @@ -198,10 +239,33 @@ export function stationProfileSource(sections: ReadonlyArray): Pro const points = a === b ? a.points : lerpPoints(a.points, b.points, t); return outwardProfile(points, own ? side : other); }; - return (chainageM, side) => ({ - fill: at(side, "fill", chainageM), - cut: at(side, "cut", chainageM), - }); + return (chainageM, side) => { + const fill = at(side, "fill", chainageM); + const cut = at(side, "cut", chainageM); + const fixed = slopes.find( + (slope) => + slope.side === side && + chainageM >= slope.chainage_m - slope.span.beforeM - 1e-9 && + chainageM <= slope.chainage_m + slope.span.afterM + 1e-9, + ); + if (!fixed) return { fill, cut }; + return { + fill: fixed.kind === "fill" ? fill : null, + cut: fixed.kind === "cut" ? cut : null, + noExtend: fixed.endsAtStructure, + }; + }; +} + +/** 점이 고리 안인가(광선 투사). */ +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]; + const [xj, yj] = loop[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; } /** @@ -212,6 +276,7 @@ export function buildDeformField( build: CorridorBuildResult, source: ProfileSource, groundAt: (x: number, y: number) => number | null, + limits: ReadonlyArray = [], ): DeformField | null { const rows = roadRows(build); const n = rows.length; @@ -378,7 +443,13 @@ export function buildDeformField( if (!at || at.outward > reachMax) return z; const si = at.side === "left" ? 0 : 1; const s = Math.max(0, at.outward); - return rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t; + let out = rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t; + for (const limit of limits) { + if (out > z && insideLoop(limit.loop, x, y)) { + out = Math.min(out, Math.max(z, limit.ceiling ?? z)); + } + } + return out; } return { rows, locate, deformZ }; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts new file mode 100644 index 000000000..65b042aae --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts @@ -0,0 +1,243 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Deform_Structure.ts + * 새 방식 지형 변형의 **구조물 마스크**(계획서 115-2) — 순수 기하. + * + * · 구조물 구간 단면 = 소유 측점 실루엣(`_Station_Structure.ts`)의 상부 포락선. + * 구간 안은 고정, 구간 끝 → 이웃 측점까지 제어점 테이퍼(`_Frames.ts`). + * · 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(`_Plan_Footprint.ts`). 솔리드가 메운다. + * · 성토 금지 고리 = 날개 안쪽 유로 · 에이프런 — 원지반(세월교는 바닥판 상단) 위로 안 올린다. + * + * 구조물 솔리드(`_Structures*.ts`)는 읽기만 한다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; +import { + classifyStation, + PIECE_COLS, + pieceKey, + resample, + type OffsetPoint, + type StationPieces, +} from "./B05_Profile_UI_Corridor_Station"; +import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; +import { + structureFootprintLoops, + type StationAxis, +} from "./B05_Profile_UI_Corridor_Plan_Footprint"; +import type { + DeformKind, + DeformLimit, + DeformSide, + StructureSlope, +} from "./B05_Profile_UI_Corridor_Deform"; + +/** + * 상부 포락선 — 바깥거리(`offset_m` = s ≥ 0)마다 실루엣 마디 중 가장 높은 표고. + * 벽 앞면(거의 수직) · 다단 계단처럼 바깥으로 갔다 돌아오는 줄을 높이장 한 줄로 편다. + */ +export function upperEnvelope(points: ReadonlyArray): OffsetPoint[] { + if (points.length < 2) return points.map((point) => ({ ...point })); + const xs = [...new Set(points.map((point) => point.offset_m))].sort((a, b) => a - b); + return xs.map((x) => { + let best = -Infinity; + for (let i = 0; i < points.length - 1; i += 1) { + const a = points[i]; + const b = points[i + 1]; + const lo = Math.min(a.offset_m, b.offset_m); + const hi = Math.max(a.offset_m, b.offset_m); + if (x < lo - 1e-9 || x > hi + 1e-9) continue; + const z = + hi - lo < 1e-9 + ? Math.max(a.elevation_m, b.elevation_m) + : a.elevation_m + + ((b.elevation_m - a.elevation_m) * (x - a.offset_m)) / (b.offset_m - a.offset_m); + if (z > best) best = z; + } + return { offset_m: x, elevation_m: best }; + }); +} + +/** 측점의 노면부 바깥 끝(측구 > 노견) — 행 앵커와 같은 자리. */ +function anchorOf(station: StationPieces, side: DeformSide): OffsetPoint | null { + const piece = + station.pieces.get(pieceKey("ditch", side)) ?? station.pieces.get(pieceKey("shoulder", side)); + if (!piece || piece.length < 2) return null; + return side === "left" ? piece[piece.length - 1] : piece[0]; +} + +/** + * 실루엣 → 앵커 기준 상대 단면(측점 단면 `slopeRelative` 와 같은 꼴 · 같은 열 수). + * 앵커 안쪽(노면부)은 잘라 내고, 앵커 자리 교차점을 첫 점으로 세운다. + */ +function silhouetteSlope( + points: ReadonlyArray, + side: DeformSide, + anchor: OffsetPoint, + kind: DeformKind, +): OffsetPoint[] | null { + const sign = side === "left" ? 1 : -1; + const outward = points.map((point) => ({ + offset_m: (point.offset_m - anchor.offset_m) * sign, + elevation_m: point.elevation_m - anchor.elevation_m, + })); + const kept: OffsetPoint[] = []; + outward.forEach((point, i) => { + if (point.offset_m < 0) return; + const previous = outward[i - 1]; + if (!kept.length && previous && previous.offset_m < 0) { + const t = -previous.offset_m / (point.offset_m - previous.offset_m); + kept.push({ + offset_m: 0, + elevation_m: previous.elevation_m + (point.elevation_m - previous.elevation_m) * t, + }); + } + kept.push(point); + }); + if (kept.length && kept[0].offset_m > 1e-9) kept.unshift({ offset_m: 0, elevation_m: 0 }); + const envelope = upperEnvelope(kept); + if (envelope.length < 2 || envelope[envelope.length - 1].offset_m < 1e-3) return null; + const sampled = resample(envelope, PIECE_COLS[kind]); + // 측점 단면 꼴로 — 좌측은 바깥거리 그대로, 우측은 부호를 뒤집어 오름차순(도로측 원점이 끝). + return side === "left" + ? sampled + : sampled + .map((point) => ({ offset_m: -point.offset_m, elevation_m: point.elevation_m })) + .reverse(); +} + +/** 구조물 측점마다 그 측 실루엣을 구간 단면으로 — `stationProfileSource` 두 번째 인자. */ +export function structureSlopes(sections: ReadonlyArray): StructureSlope[] { + const slopes: StructureSlope[] = []; + for (const section of sections) { + const silhouettes = structureSilhouettes(section); + if (!silhouettes.length) continue; + const station = classifyStation(section); + const mode = section.design?.section_mode; + if (!station || !mode) continue; + for (const silhouette of silhouettes) { + const side = silhouette.side; + const role: DeformKind = + mode === "both_cut" || mode === (side === "left" ? "left_cut" : "right_cut") + ? "cut" + : "fill"; + const kind = silhouette.kind === "auto" ? role : silhouette.kind; + const anchor = anchorOf(station, side); + if (!anchor) continue; + const points = silhouetteSlope(silhouette.points, side, anchor, kind); + if (!points) continue; + slopes.push({ + chainage_m: section.chainage_m, + side, + kind, + span: silhouette.span, + points, + endsAtStructure: silhouette.endsAtStructure === true, + }); + } + } + return slopes; +} + +/** 측점 프레임 축. */ +function axisOf(section: CrossSection): StationAxis { + return { + cx: section.center_x, + cy: section.center_y, + leftX: section.frame.left_xy[0], + leftY: section.frame.left_xy[1], + }; +} + +/** 그 측점에 선 구조물 한 벌. */ +function setOf( + section: CrossSection, + structures: ReadonlyArray, +): CorridorStructure[] { + return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6); +} + +/** 지형 구멍 고리(모델 좌표) — 구조물 풋프린트 · BOX 아령 외곽. 도로부 안쪽은 뺀다. */ +export function structureHoleLoops( + sections: ReadonlyArray, + structures: ReadonlyArray, +): Array> { + const loops: Array> = []; + for (const section of sections) { + const set = setOf(section, structures); + const edges = section.design?.road_edges; + if (!set.length || !edges) continue; + (["left", "right"] as const).forEach((side) => { + const roadEdge = side === "left" ? edges.left.offset_m : edges.right.offset_m; + structureFootprintLoops(set, side, roadEdge, 0, axisOf(section)).forEach((loop) => + loops.push(loop.map(([x, y]) => [x, y] as [number, number])), + ); + }); + } + return loops; +} + +/** 볼록 껍질(단조 사슬) — 날개 두 장의 링 중심을 감싸 날개 안쪽 유로 자리를 낸다. */ +function convexHull(points: Array<[number, number]>): Array<[number, number]> { + const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (sorted.length < 3) return sorted; + const cross = (o: [number, number], a: [number, number], b: [number, number]): number => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + const lower: Array<[number, number]> = []; + for (const point of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) + lower.pop(); + lower.push(point); + } + const upper: Array<[number, number]> = []; + for (let i = sorted.length - 1; i >= 0; i -= 1) { + const point = sorted[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) + upper.pop(); + upper.push(point); + } + return [...lower.slice(0, -1), ...upper.slice(0, -1)]; +} + +function polygonArea(loop: ReadonlyArray<[number, number]>): number { + let area = 0; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + area += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1]; + } + return Math.abs(area) / 2; +} + +/** + * 성토 금지 고리 — 측마다 날개(날개벽 · 날개 에이프런) 링 중심을 감싼 볼록 껍질. + * 천장 = 세월교 바닥판 상단(그 측 실루엣) · BOX 는 원지반. + */ +export function noFillLimits( + sections: ReadonlyArray, + structures: ReadonlyArray, +): DeformLimit[] { + const limits: DeformLimit[] = []; + for (const section of sections) { + const set = setOf(section, structures); + if (!set.length) continue; + const axis = axisOf(section); + const floors = new Map(); + structureSilhouettes(section).forEach((silhouette) => { + if (silhouette.floorTopZ !== undefined) floors.set(silhouette.side, silhouette.floorTopZ); + }); + (["left", "right"] as const).forEach((side) => { + const centers: Array<[number, number]> = []; + for (const structure of set) { + const rings = structure.rings; + if (!structure.wing || structure.kind === "pipe" || !rings || rings.length < 2) continue; + const middle = rings[Math.floor(rings.length / 2)]; + const offset = (middle.cx - axis.cx) * axis.leftX + (middle.cy - axis.cy) * axis.leftY; + if ((offset >= 0 ? "left" : "right") !== side) continue; + rings.forEach((ring) => centers.push([ring.cx, ring.cy])); + } + const loop = convexHull(centers); + if (loop.length < 3 || polygonArea(loop) < 0.01) return; + limits.push({ loop, ceiling: floors.get(side) ?? null }); + }); + } + return limits; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts index 0d185451d..b1cda72cf 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts @@ -15,13 +15,23 @@ 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 { clipGeometry } from "./B05_Profile_UI_Corridor_Clip"; -import { CellRegion, scenePlanMapper, type PlanRegion } from "./B05_Profile_UI_Corridor_Region"; +import { + CellRegion, + PlanLoopRegion, + scenePlanMapper, + type PlanRegion, +} from "./B05_Profile_UI_Corridor_Region"; import { buildDeformField, stationProfileSource, type DeformField, type DeformRow, } from "./B05_Profile_UI_Corridor_Deform"; +import { + noFillLimits, + structureHoleLoops, + structureSlopes, +} from "./B05_Profile_UI_Corridor_Deform_Structure"; /** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */ const CUT_COLOR = new THREE.Color(0xb5179e); @@ -50,6 +60,7 @@ class RoadStrip extends CellRegion { /** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */ export interface DeformStats { rows: number; + holes: number; moved: number; raised: number; lowered: number; @@ -110,9 +121,10 @@ export function deformTerrain( build: CorridorBuildResult, bounds: ModelBounds, heightIndex: TerrainHeightIndex, - extraRegions: ReadonlyArray = [], ): { root: THREE.Object3D; stats: DeformStats } | null { - if (!build.sections?.length) return null; + const sections = build.sections; + if (!sections?.length) return null; + 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; @@ -120,11 +132,17 @@ export function deformTerrain( const height = heightIndex.heightAt(x - ox, -(y - oy)); return height === null ? null : height + oz; }; - const field = buildDeformField(build, stationProfileSource(build.sections), groundAt); + // 구조물 구간 = 실루엣 상부 포락선 + 이웃 측점 테이퍼 · 날개 안쪽 성토 금지(115-2). + const source = stationProfileSource(sections, structureSlopes(sections)); + const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures)); if (!field) return null; - const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds), ...extraRegions]; + // 지형 구멍 = 노면부 띠 + 구조물 풋프린트 · BOX 아령 외곽 — 솔리드가 그 자리를 메운다. + const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds)]; + const holes = new PlanLoopRegion(structureHoleLoops(sections, structures), bounds); + if (!holes.isEmpty) regions.push(holes); const stats: DeformStats = { rows: field.rows.length, + holes: regions.length - 1, moved: 0, raised: 0, lowered: 0, diff --git a/resources/tester/helper_115_corridor_deform.cjs b/resources/tester/helper_115_corridor_deform.cjs index f9f50c525..e8aa12a6b 100644 --- a/resources/tester/helper_115_corridor_deform.cjs +++ b/resources/tester/helper_115_corridor_deform.cjs @@ -243,4 +243,182 @@ const results = {}; }; } +// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다. +{ + const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure")); + const env = upperEnvelope( + [ + [0, 0], + [2, 0], + [2.1, -3], + [1.8, -3.2], + [5, -4], + ].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ); + const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m; + results.envelope = { + monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m), + at18: r4(at(1.8)), + at21: r4(at(2.1)), + at5: r4(at(5)), + }; +} + +/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */ +function slopeOf(points, opts = {}) { + const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station")); + return { + chainage_m: 20, + side: "left", + kind: "fill", + span: { beforeM: 2, afterM: 2 }, + points: resample( + points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + 48, + ), + endsAtStructure: false, + ...opts, + }; +} + +// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5). +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const build = buildCorridor(sections, ROUTE); + const shelf = slopeOf([ + [0, 0], + [4, 0], + [6, -4], + ]); + const source = deform.stationProfileSource(sections, [shelf]); + const field = deform.buildDeformField(build, source, () => 96); + results.span = { + in20: r4(field.deformZ(20, HALF + 2, 96)), + in19: r4(field.deformZ(19, HALF + 2, 96)), + taper10: r4(field.deformZ(10, HALF + 2, 96)), + taper30: r4(field.deformZ(30, HALF + 2, 96)), + right20: r4(field.deformZ(20, -HALF - 2, 96)), + }; +} + +// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const build = buildCorridor(sections, ROUTE); + const box = slopeOf( + [ + [0, 0], + [3, -1], + ], + { endsAtStructure: true }, + ); + const field = deform.buildDeformField( + build, + deform.stationProfileSource(sections, [box]), + () => 96, + ); + results.box = { + inside: r4(field.deformZ(20, HALF + 2, 96)), + outside: r4(field.deformZ(20, HALF + 5, 96)), + normal: r4(field.deformZ(5, HALF + 5, 96)), + }; +} + +// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const build = buildCorridor(sections, ROUTE); + const source = deform.stationProfileSource(sections); + const loop = [ + [8, 4], + [12, 4], + [12, 8], + [8, 8], + ]; + const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]); + const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]); + const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure")); + const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 }); + const wings = [ + { chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] }, + { chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] }, + ]; + const limits = noFillLimits(sections, wings); + results.noFill = { + blocked: r4(none.deformZ(10, HALF + 2, 96)), + ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)), + outsideLoop: r4(none.deformZ(20, HALF + 2, 96)), + limits: limits.length, + hullArea: limits.length ? limits[0].loop.length : 0, + }; +} + +// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const build = buildCorridor(sections, ROUTE); + build.sections = sections; + const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); + build.structures = [ + { + chainage_m: 20, + kind: "revet", + polygon: [ + [5, 95], + [6, 95], + [6, 100], + [5, 100], + ], + rings: [ring(19), ring(20), ring(21)], + }, + ]; + const step = 0.5; + const cx = 20; + const cz = 100; + const positions = []; + const nx = 121; + const ny = 81; + for (let j = 0; j < ny; j += 1) { + for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step)); + } + 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; + index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(index); + const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial()); + const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 }); + const out = deformTerrain( + split, + build, + { x: [-10, 50], y: [-20, 20], z: [90, 110] }, + new TerrainHeightIndex(terrain), + ); + let inHole = 0; + let nearHole = 0; + out.root.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; + const p = child.geometry.getAttribute("position"); + const idx = child.geometry.getIndex(); + for (let t = 0; t < idx.count; t += 3) { + let sx = 0; + let sy = 0; + for (let k = 0; k < 3; k += 1) { + sx += p.getX(idx.getX(t + k)) + cx; + sy += -p.getZ(idx.getX(t + k)); + } + sx /= 3; + sy /= 3; + if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1; + if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1; + } + }); + results.hole = { holes: out.stats.holes, inHole, nearHole }; +} + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_corridor_deform.py b/resources/tester/test_115_corridor_deform.py index e3fac0cb8..f163d9286 100644 --- a/resources/tester/test_115_corridor_deform.py +++ b/resources/tester/test_115_corridor_deform.py @@ -74,3 +74,41 @@ def test_three_copy_only_xy_fixed_hole_and_tint(results): assert r["probe"] == FILL_2M assert r["moved"] > 0 and r["raised"] == r["moved"] assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"] + + +def test_upper_envelope_keeps_top_of_wall_steps(results): + r = results["envelope"] + assert r["monotone"] is True + assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0) + assert r["at21"] == -3 + assert r["at5"] == -4 + + +def test_structure_span_fixed_and_tapers_to_neighbours(results): + r = results["span"] + assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정 + assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼 + assert r["taper30"] == r["taper10"] + assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로 + + +def test_box_outside_body_stays_original_ground(results): + r = results["box"] + assert r["inside"] == round(100 - 2 / 3, 4) + assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함) + assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형 + + +def test_no_fill_ring_inside_wings(results): + r = results["noFill"] + assert r["blocked"] == 96 + assert r["ceiling"] == 97 + assert r["outsideLoop"] == FILL_2M + assert r["limits"] == 1 and r["hullArea"] == 4 + + +def test_structure_footprint_hole(results): + r = results["hole"] + assert r["holes"] == 1 + assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음 + assert r["nearHole"] > 0 # 바로 바깥은 남는다