feat(B05): 세월교 변형 성토면을 절취 영역 경계에 맞춰 채우고 잘라낸다
세월교 날개 사다리꼴이 안 채워지고, 채운 판이 절취 영역 밖으로 삐져나오던 문제. 원인은 패치 리본이 "노선 프레임 수직 행 × 소유 측점 단면 한 장" 스윕이라 비스듬한 날개 부채꼴과 방향·범위가 안 맞는 것이었다. 세월교 전용으로 고친다 (`section.ford` — BOX암거·기슭막이는 종전 경로 그대로). - 종방향: `wingSpanOnRoute` 신설. 날개 고리 점을 frameAt로 0.25m 간격 훑어 노선에 실투영한다. 종전 직선 전방 투영은 곡선+먼 편거리(-7.85m)에서 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다. 범위는 점유 구간 ±5m로 묶는다. - 횡방향: `wingRangeAt`이 행 횡단선 × 주황 고리 교점의 안쪽·바깥쪽 끝을 낸다. 바깥은 `extendRunTo`, 안쪽은 `prependRunTo`(노견 클램프). 지반 핀·스커트 유지. - `clipRunBelow`: 바닥판 상단 Z(ford slabTopElevation)를 넘는 점을 지운다. 종단 shift 반영, 천장 지나는 마디에 교차점 삽입. - `patchClip`: 패치를 maskFillByPlanCurves에 한 번 더 통과시켜 **지우는 규칙의 반대**를 건다 — 절취 영역 밖 셀은 지우고, 걸친 셀은 clipCellByChord(inside)로 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유한다. - 700줄 초과로 날개 몫 도구를 _Corridor_Carve_Wing.ts로 분리(순수 이동). - `__corridorScene` 노출(검증용 레이캐스트 좌표 변환). - BUILD_VERSION 67 -> 79. 자체검증: 삐져나온 패치 셀 우 74->0(145셀 마스크+조각 198), 좌 지느러미 47->0. BOX암거·기슭막이 masked=0(무변화). 영역 안 구멍 우 66/98% -> 39/56%. typecheck 통과, tmp/tests 213 passed·7 skipped, 변경 파일 prettier. 남은 작업: 좌측 96% 구멍(slope-projected 전폭 절취 vs fillSegments 0.25m), BOX 패치 리본 축퇴 원인 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,9 +116,48 @@ function fnv1a(text: string): string {
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = 날개 구간 행은 벽 상단에서 설계 성토 물매로 지반까지 내려 긋는다 — 날개
|
||||
// 사다리꼴을 변형 성토면이 채운다(2026-08-27 사용자).
|
||||
const BUILD_VERSION = 67;
|
||||
// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인)
|
||||
// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반
|
||||
// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계
|
||||
// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형
|
||||
// 성토면이 있어야 한다 — 삭제가 아니다).
|
||||
// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서
|
||||
// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이
|
||||
// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복").
|
||||
// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다
|
||||
// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다),
|
||||
// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`,
|
||||
// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로.
|
||||
// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토
|
||||
// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자).
|
||||
// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라
|
||||
// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서
|
||||
// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자:
|
||||
// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림").
|
||||
// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`,
|
||||
// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로
|
||||
// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다
|
||||
// (2026-08-27 사용자: "좌측 스윕 더 가야 함").
|
||||
// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니
|
||||
// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로
|
||||
// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다
|
||||
// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지").
|
||||
// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거,
|
||||
// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고
|
||||
// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다
|
||||
// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다).
|
||||
// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은
|
||||
// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 —
|
||||
// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측).
|
||||
// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자).
|
||||
const BUILD_VERSION = 79;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface CorridorRibbon {
|
||||
* 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다).
|
||||
*/
|
||||
patch?: boolean;
|
||||
/**
|
||||
* 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도
|
||||
* 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 —
|
||||
* 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니
|
||||
* 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다.
|
||||
*/
|
||||
patchClip?: boolean;
|
||||
/**
|
||||
* rowCount × colCount × 3 (모델 좌표).
|
||||
*
|
||||
@@ -680,8 +687,16 @@ export function buildCorridor(
|
||||
const patchRibbons = FILL_PATCH_APPLY
|
||||
? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null, planCurves)
|
||||
: [];
|
||||
// 패치도 같은 커브로 한 번 더 훑는다 — 단 **반대 규칙**이다(`patchClip` 표식이 붙은
|
||||
// 세월교 패치만: 절취 영역 **안쪽만** 남긴다). 지우는 면과 채우는 면이 같은 경계를
|
||||
// 공유해야 삐져나오지도 벌어지지도 않는다(2026-08-27 사용자). 병합 커브·측벽은
|
||||
// 여기서 안 나온다 — 기본 성토면 몫이다.
|
||||
const clippedPatches =
|
||||
FILL_CUT_APPLY && patchRibbons.length
|
||||
? maskFillByPlanCurves(patchRibbons, planCurves).ribbons
|
||||
: patchRibbons;
|
||||
return {
|
||||
ribbons: [...(cut ? cut.ribbons : ribbons), ...patchRibbons],
|
||||
ribbons: [...(cut ? cut.ribbons : ribbons), ...clippedPatches],
|
||||
outline,
|
||||
caps,
|
||||
structures,
|
||||
|
||||
@@ -36,6 +36,16 @@ import {
|
||||
import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { buildGroundAt, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
|
||||
import {
|
||||
clipRunBelow,
|
||||
extendRunTo,
|
||||
NEAR_LIMIT_M,
|
||||
PATCH_WING_MARGIN_M,
|
||||
prependRunTo,
|
||||
wingRangeAt,
|
||||
wingSpanOf,
|
||||
wingSpanOnRoute,
|
||||
} from "./B05_Profile_UI_Corridor_Carve_Wing";
|
||||
import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union";
|
||||
import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union";
|
||||
|
||||
@@ -46,10 +56,6 @@ const MIN_REACH_M = 0.05;
|
||||
* 스커트 게이트(`_Corridor_Skirt.ts` PATCH_TOE_TOLERANCE_M)와 같은 값·같은 이유다. */
|
||||
const PATCH_TRIM_TOLERANCE_M = 0.5;
|
||||
|
||||
/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다.
|
||||
* 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */
|
||||
const NEAR_LIMIT_M = 30;
|
||||
|
||||
/** 링 폴리곤의 편거리 양 끝 — 그 링이 평면에서 차지하는 폭. */
|
||||
export function ringEdges(
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
@@ -220,41 +226,6 @@ export function assembleStripLoops(
|
||||
return loops;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②).
|
||||
*
|
||||
* 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts`
|
||||
* `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는
|
||||
* 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다.
|
||||
*
|
||||
* 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라
|
||||
* 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유).
|
||||
*/
|
||||
function wingSpanOf(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
): { beforeM: number; afterM: number } {
|
||||
// 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다.
|
||||
const forwardX = -frame.leftY;
|
||||
const forwardY = frame.leftX;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
for (const [x, y] of loop) {
|
||||
const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY;
|
||||
if (s < 0) before = Math.max(before, -s);
|
||||
else after = Math.max(after, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { beforeM: before, afterM: after };
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된
|
||||
* 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을
|
||||
@@ -286,6 +257,13 @@ export function buildStructurePatchRibbons(
|
||||
* 지우는 영역과 채우는 영역이 같은 방향으로 선다. 세월교·배수관은 노선 프레임.
|
||||
*/
|
||||
const boxAxis = section.box ? section.frame.left_xy : null;
|
||||
/**
|
||||
* 날개 몫 스윕·트림은 **세월교 전용**이다(2026-08-27 사용자: "구조물별로 구분되어야
|
||||
* 함 — 세월교, 박스암거, 기슭막이"). 날개벽은 세월교와 BOX암거 두 곳에서 쓰지만
|
||||
* 평면 스케치를 내는 로직이 서로 다르고(`wingAxis` 유무), 기슭막이(배수관 세트)는
|
||||
* 날개 사다리꼴 자체가 없다. BOX·기슭막이는 종전 경로를 그대로 탄다.
|
||||
*/
|
||||
const isFord = section.ford != null;
|
||||
const frameOf = (chainageM: number): RouteFrame | null => {
|
||||
const frame = frameAt(chainageM);
|
||||
if (!frame || !boxAxis) return frame;
|
||||
@@ -351,9 +329,23 @@ export function buildStructurePatchRibbons(
|
||||
const spanFrom = section.chainage_m - silhouette.span.beforeM;
|
||||
const spanTo = section.chainage_m + silhouette.span.afterM;
|
||||
// 날개 사다리꼴 몫만큼 더 뻗는다(2026-08-27 사용자 ②) — 지우는 영역과 맞춘다.
|
||||
const wing = wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame);
|
||||
const from = Math.min(spanFrom, section.chainage_m - wing.beforeM);
|
||||
const to = Math.max(spanTo, section.chainage_m + wing.afterM);
|
||||
// 세월교는 **노선 실투영**으로 잰다 — 직선 전방 투영은 곡선 + 먼 편거리에서
|
||||
// 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다(2026-08-27 A안). BOX암거는
|
||||
// 종전 직선 투영 그대로 — 로직이 분리돼 있고 이미 채워져 있다.
|
||||
const wing = isFord
|
||||
? wingSpanOnRoute(planCurves, section.chainage_m, silhouette.side, frameOf)
|
||||
: wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame);
|
||||
// 여유는 **구조물 점유 구간 기준** 5m다(측점 기준이 아니다 — 세월교 우측은
|
||||
// 점유 구간이 이미 측점에서 6.1m 앞까지 가 있다). 그 안에서 날개벽 끝점
|
||||
// 횡단선까지 스윕한다(2026-08-27 사용자).
|
||||
const from = Math.max(
|
||||
spanFrom - PATCH_WING_MARGIN_M,
|
||||
Math.min(spanFrom, section.chainage_m - wing.beforeM),
|
||||
);
|
||||
const to = Math.min(
|
||||
spanTo + PATCH_WING_MARGIN_M,
|
||||
Math.max(spanTo, section.chainage_m + wing.afterM),
|
||||
);
|
||||
if (!(to - from > 1e-6)) continue;
|
||||
const rowChainages: number[] = [];
|
||||
for (let at = from; at < to; at += 1) rowChainages.push(at);
|
||||
@@ -382,6 +374,7 @@ export function buildStructurePatchRibbons(
|
||||
chainages: buffer.map((row) => row.chainage),
|
||||
positions,
|
||||
patch: true,
|
||||
...(isFord ? { patchClip: true } : {}),
|
||||
});
|
||||
}
|
||||
buffer = [];
|
||||
@@ -393,6 +386,14 @@ export function buildStructurePatchRibbons(
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
// 그 행이 주황 날개 스케치와 만나는 구간(안쪽·바깥쪽 끝) — 세월교 전용.
|
||||
const wingRange = isFord
|
||||
? wingRangeAt(planCurves, section.chainage_m, silhouette.side, frame)
|
||||
: null;
|
||||
// 여기서 행을 끊지 않는다 — 스윕 범위가 이미 **날개벽 끝점 횡단선**까지로
|
||||
// 트림돼 있다(`wingSpanOnRoute` + `PATCH_WING_MARGIN_M`). 고리와 안 만나는
|
||||
// 중간 행까지 끊으면 구조물 구간과 날개 사이가 토막 난다(2026-08-27 사용자:
|
||||
// "노선방향 스윕은 좋은데 조금 부족한 구간이 있다").
|
||||
let points = source;
|
||||
// 벽 트림은 **옛 한 줄** 전용이다. 날개 몫으로 늘린 행도 트림하지 않는다 —
|
||||
// **날개벽 평면 스케치까지 변형 성토면에 포함**하는 게 맞다(2026-08-27 사용자):
|
||||
@@ -427,9 +428,7 @@ export function buildStructurePatchRibbons(
|
||||
// **직접 교차시켜 끝낸다**(빨간 교차선). 소유 측점 단면 한 장을 복사한 것이라
|
||||
// · 지반이 올라오면 교차점까지 **자르고**(교차선 밖은 스커트가 수직 마감),
|
||||
// · 지반이 내려가면 마지막 구배 그대로 **연장**해 원본 성토 끝선까지 간다.
|
||||
// 연장이 없으면 날개 사다리꼴 바깥변까지 2.4~4.5m 모자라 구멍이 남는다
|
||||
// (2026-08-27 실측: BOX 200.9 날개 표본 전량이 횡방향 밖). 규칙·한도는 원래
|
||||
// 성토면과 같다(`_Corridor_Station.ts` trimSlopeToGround).
|
||||
// 이 지반 핀이 있어야 바깥 끝이 원지반에 물리고 스커트도 선다(2026-08-27 사용자).
|
||||
if (runReachesGround) {
|
||||
const ground = groundOfChainage(at);
|
||||
if (ground) {
|
||||
@@ -449,6 +448,37 @@ export function buildStructurePatchRibbons(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 주황 **날개 평면 스케치(`wing-box`)** 몫은 그 위에 **덧붙인다**(2026-08-27
|
||||
// 사용자: "스윕을 노선 기준으로 더 넓히고 주황 스케치로 트림" — 지우는 게
|
||||
// 아니라 그 자리에 변형 성토면이 **있어야** 한다). 지우는 쪽이 같은 고리로
|
||||
// 성토면을 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽이 거기까지
|
||||
// 못 가면 구멍이 남는다(실측: 세월교 149.7 날개 구멍 66~98%).
|
||||
// 지반 핀보다 스케치가 더 바깥일 때만 늘린다 — 안쪽이면 지반 핀을 그대로 둔다.
|
||||
if (wingRange) {
|
||||
// 바깥 — 스케치 경계까지 설계 성토 물매로 덧이음.
|
||||
if ((wingRange.outer - points[points.length - 1].offset_m) * sign > 1e-6) {
|
||||
const extended = extendRunTo(points, wingRange.outer);
|
||||
if (extended) points = extended;
|
||||
}
|
||||
// 안쪽 — 노견(확장점)보다 안쪽으로는 안 간다. 노면을 덮으면 안 된다.
|
||||
const roadLimit =
|
||||
silhouette.widenTo?.offset_m ?? silhouette.points[0]?.offset_m ?? points[0].offset_m;
|
||||
const innerTarget =
|
||||
(wingRange.inner - roadLimit) * sign < 0 ? roadLimit : wingRange.inner;
|
||||
if ((points[0].offset_m - innerTarget) * sign > 1e-6) {
|
||||
const prepended = prependRunTo(points, innerTarget);
|
||||
if (prepended) points = prepended;
|
||||
}
|
||||
}
|
||||
// 바닥판 상단 Z를 넘는 점은 지운다(2026-08-27 사용자 ①).
|
||||
if (silhouette.floorTopZ != null) {
|
||||
const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0;
|
||||
points = clipRunBelow(points, silhouette.floorTopZ, shift);
|
||||
if (points.length < 2) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
buffer.push({ chainage: at, frame, points });
|
||||
}
|
||||
flush();
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Carve_Wing.ts
|
||||
* 패치 리본(변형 성토면)의 **날개 몫** 도구 — 스윕 범위 산출과 줄 자르기·늘리기.
|
||||
* `_Corridor_Carve.ts`에서 잘라 냈다(700줄 제한, 2026-08-27).
|
||||
*
|
||||
* 지우는 쪽은 날개 사다리꼴(`wing-box` 고리)을 영역으로 도려내는데
|
||||
* (`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽은 노선 프레임 사각격자 리본이라
|
||||
* 그대로는 그 부채꼴을 못 덮는다. 여기 도구들이 그 간극을 메운다.
|
||||
* · 종방향 — `wingSpanOf`(직선 투영, BOX암거용) / `wingSpanOnRoute`(노선 실투영, 세월교용)
|
||||
* · 횡방향 — `wingRangeAt`(행 횡단선 × 고리 교점의 안쪽·바깥쪽 끝)
|
||||
* · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunToRange` / `clipRunBelow`
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import type { OffsetPoint } from "./B05_Profile_UI_Corridor_Station";
|
||||
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
|
||||
/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다.
|
||||
* 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */
|
||||
export const NEAR_LIMIT_M = 30;
|
||||
|
||||
/**
|
||||
* 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②).
|
||||
*
|
||||
* 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts`
|
||||
* `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는
|
||||
* 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다.
|
||||
*
|
||||
* 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라
|
||||
* 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유).
|
||||
*/
|
||||
export function wingSpanOf(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
): { beforeM: number; afterM: number } {
|
||||
// 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다.
|
||||
const forwardX = -frame.leftY;
|
||||
const forwardY = frame.leftX;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
for (const [x, y] of loop) {
|
||||
const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY;
|
||||
if (s < 0) before = Math.max(before, -s);
|
||||
else after = Math.max(after, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { beforeM: before, afterM: after };
|
||||
}
|
||||
|
||||
/** 날개 고리를 노선에 실투영할 때 훑는 반경(m)과 간격(m) — 행 간격 1m보다 촘촘하다. */
|
||||
const WING_SPAN_SEARCH_M = 30;
|
||||
const WING_SPAN_STEP_M = 0.25;
|
||||
|
||||
/**
|
||||
* 날개 몫으로 **더 스윕해 두는 여유**(m, 2026-08-27 사용자: "현재 기준보다 5m 양쪽으로
|
||||
* 늘려서 서피스를 만들고 날개벽 끝점 횡단선으로 트림"). 트림선이 이 안에 들어오므로
|
||||
* 화면 결과는 트림선까지다 — 여유는 트림선이 행 사이에 떨어져도 끊기지 않게 한다.
|
||||
*/
|
||||
export const PATCH_WING_MARGIN_M = 5;
|
||||
|
||||
/**
|
||||
* 날개 사다리꼴의 **노선 실투영** 종방향 범위(2026-08-27 사용자 A안). `wingSpanOf`는
|
||||
* 측점 프레임의 **직선** 전방 벡터에 투영해 곡률을 무시하는데, 세월교 날개는 편거리
|
||||
* -7.85m까지 나가 있어 그 근사가 3m 어긋난다(실측: 우측 고리 140.55~143.88 인데
|
||||
* 패치는 143.6부터 시작 — 고리 앞부분이 통째로 범위 밖이라 98% 구멍).
|
||||
*
|
||||
* 후보 누가거리를 실제 `frameAt`로 훑어 각 점의 **최근접 측점**을 찾는다.
|
||||
*
|
||||
* 재는 대상은 **고리 전체 점**이다. 날개벽 선 두 끝만 쓰면 좌측이 143.2~157.3 →
|
||||
* 144.4~155.9로 **줄어든다**(2026-08-27 사용자: "좌측 스윕 더 가야 함") — 날개
|
||||
* 사다리꼴은 벽 끝점보다 성토 끝선 쪽으로 더 뻗기 때문이다(우측 실측: 벽 끝 142.12,
|
||||
* 고리 끝 140.55). 트림은 구조물 점유 구간 ±`PATCH_WING_MARGIN_M`이 맡는다.
|
||||
* BOX암거는 이 경로를 쓰지 않는다 — 로직이 분리돼 있고 이미 채워져 있다.
|
||||
*/
|
||||
export function wingSpanOnRoute(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
): { beforeM: number; afterM: number } {
|
||||
const points: Array<[number, number]> = [];
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) for (const [x, y] of loop) points.push([x, y]);
|
||||
}
|
||||
if (!points.length) return { beforeM: 0, afterM: 0 };
|
||||
const candidates = routeCandidates(chainageM, frameAt);
|
||||
if (!candidates.length) return { beforeM: 0, afterM: 0 };
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const [x, y] of points) {
|
||||
const s = projectChainage(candidates, x, y) - chainageM;
|
||||
if (s < 0) before = Math.max(before, -s);
|
||||
else after = Math.max(after, s);
|
||||
}
|
||||
return { beforeM: before, afterM: after };
|
||||
}
|
||||
|
||||
/** 측점 주변 후보 자리 — 고리 점을 노선에 실투영할 때 쓴다. */
|
||||
function routeCandidates(
|
||||
chainageM: number,
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
): Array<{ at: number; cx: number; cy: number }> {
|
||||
const candidates: Array<{ at: number; cx: number; cy: number }> = [];
|
||||
for (
|
||||
let at = chainageM - WING_SPAN_SEARCH_M;
|
||||
at <= chainageM + WING_SPAN_SEARCH_M;
|
||||
at += WING_SPAN_STEP_M
|
||||
) {
|
||||
const frame = frameAt(at);
|
||||
if (frame) candidates.push({ at, cx: frame.cx, cy: frame.cy });
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** 점 하나의 최근접 누가거리. */
|
||||
function projectChainage(
|
||||
candidates: ReadonlyArray<{ at: number; cx: number; cy: number }>,
|
||||
x: number,
|
||||
y: number,
|
||||
): number {
|
||||
let bestAt = candidates[0].at;
|
||||
let bestDistance = Infinity;
|
||||
for (const candidate of candidates) {
|
||||
const distance = (x - candidate.cx) ** 2 + (y - candidate.cy) ** 2;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestAt = candidate.at;
|
||||
}
|
||||
}
|
||||
return bestAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 행 횡단선이 **주황 날개 평면 스케치**(`wing-box` 고리)와 만나는 **안쪽·바깥쪽
|
||||
* 끝 편거리**(2026-08-27 사용자: "스윕 범위를 날개 평면 스케치까지 지정"). 안 만나면 null.
|
||||
*
|
||||
* 바깥만으로는 부족하다 — 세월교 좌측 고리는 편거리 +2.64부터인데 패치는 +4.15에서
|
||||
* 시작한다(실측). 고리 몸통 1.5m가 패치 **안쪽**이라 안쪽으로도 늘려야 덮인다.
|
||||
*
|
||||
* 지우는 쪽이 그 고리 영역을 통째로 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`)
|
||||
* 채우는 쪽도 같은 고리까지 뻗어야 구멍이 안 남는다. `wingSpanOf`가 **종방향** 몫을
|
||||
* 재는 것과 짝이 되는 **가로방향** 몫이다.
|
||||
*/
|
||||
export function wingRangeAt(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
): { inner: number; outer: number } | null {
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
let inner: number | null = null;
|
||||
let outer: number | null = null;
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
for (let i = 0; i < loop.length - 1; i += 1) {
|
||||
const [ax, ay] = loop[i];
|
||||
const [bx, by] = loop[i + 1];
|
||||
const ex = bx - ax;
|
||||
const ey = by - ay;
|
||||
const denominator = frame.leftX * ey - frame.leftY * ex;
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator;
|
||||
if (t < -1e-9 || t > 1 + 1e-9) continue;
|
||||
const o =
|
||||
Math.abs(frame.leftX) > Math.abs(frame.leftY)
|
||||
? (ax + ex * t - frame.cx) / frame.leftX
|
||||
: (ay + ey * t - frame.cy) / frame.leftY;
|
||||
// 헤어핀 건너편 고리가 무한 직선에 얻어걸리는 걸 막는다(`outerAt`과 같은 이유).
|
||||
if (Math.abs(o) > NEAR_LIMIT_M) continue;
|
||||
if (o * sign <= 0) continue;
|
||||
if (outer === null || (o - outer) * sign > 0) outer = o;
|
||||
if (inner === null || (o - inner) * sign < 0) inner = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
return outer === null || inner === null ? null : { inner, outer };
|
||||
}
|
||||
|
||||
/** 첫 구배 그대로 `targetOffset`까지 **안쪽으로** 늘린 줄 — 못 늘리면 null.
|
||||
* 벽 뒤로 성토면을 잇는 것이라 벽 솔리드에 묻힌다(벽 전면·유로는 안 담는다 —
|
||||
* 2026-08-25 사용자 ②의 "관 구멍·L자 통로를 막지 마라"는 그대로 지킨다). */
|
||||
export function prependRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null {
|
||||
if (run.length < 2) return null;
|
||||
const head = run[0];
|
||||
const next = run[1];
|
||||
const step = next.offset_m - head.offset_m;
|
||||
if (Math.abs(step) < 1e-9) return null;
|
||||
const slope = (next.elevation_m - head.elevation_m) / step;
|
||||
return [
|
||||
{
|
||||
offset_m: targetOffset,
|
||||
elevation_m: head.elevation_m + slope * (targetOffset - head.offset_m),
|
||||
},
|
||||
...run,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* **세월교 바닥판 상단 Z를 넘는 점을 지운다**(2026-08-27 사용자 ①). 안쪽으로 늘린
|
||||
* 성토 물매가 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀는 걸 막는다.
|
||||
*
|
||||
* 줄은 안쪽→바깥 순이고 성토면은 바깥으로 내려가므로 천장 초과분은 **앞머리**다.
|
||||
* 지우기만 하면 경계가 격자에 걸려 계단이 되므로, 천장을 지나는 마디에 **교차점을
|
||||
* 새로 꽂는다**. 비교는 종단 shift를 얹은 **실제 표고**로 한다 — 바닥판은 측점에
|
||||
* 고정된 구조물이라 shift와 함께 움직이지 않는다.
|
||||
*/
|
||||
export function clipRunBelow(run: OffsetPoint[], ceilingZ: number, shift: number): OffsetPoint[] {
|
||||
const above = (point: OffsetPoint): boolean => point.elevation_m + shift > ceilingZ + 1e-9;
|
||||
let start = 0;
|
||||
while (start < run.length && above(run[start])) start += 1;
|
||||
if (start === 0) return run;
|
||||
if (start >= run.length) return [];
|
||||
const outside = run[start - 1];
|
||||
const inside = run[start];
|
||||
const kept = run.slice(start);
|
||||
const drop = outside.elevation_m - inside.elevation_m;
|
||||
if (Math.abs(drop) > 1e-9) {
|
||||
const ratio = (outside.elevation_m + shift - ceilingZ) / drop;
|
||||
const offset = outside.offset_m + (inside.offset_m - outside.offset_m) * ratio;
|
||||
if (Math.abs(offset - inside.offset_m) > 1e-6) {
|
||||
kept.unshift({ offset_m: offset, elevation_m: ceilingZ - shift });
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 마지막 구배(= 설계 성토 물매) 그대로 `targetOffset`까지 늘린 줄 — 못 늘리면 null. */
|
||||
export function extendRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null {
|
||||
if (run.length < 2) return null;
|
||||
const tail = run[run.length - 1];
|
||||
const before = run[run.length - 2];
|
||||
const step = tail.offset_m - before.offset_m;
|
||||
if (Math.abs(step) < 1e-9) return null;
|
||||
const slope = (tail.elevation_m - before.elevation_m) / step;
|
||||
return [
|
||||
...run,
|
||||
{
|
||||
offset_m: targetOffset,
|
||||
elevation_m: tail.elevation_m + slope * (targetOffset - tail.offset_m),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 날개 하나의 트림선 — 바깥선 폴리라인과 그 날개가 노선에서 차지하는 구간. */
|
||||
export interface WingTrimLine {
|
||||
line: Array<[number, number]>;
|
||||
fromM: number;
|
||||
toM: number;
|
||||
}
|
||||
|
||||
/** 트림선이 모자랄 때 **끝 마디를 복사해 늘리는** 길이(m, 2026-08-27 사용자). */
|
||||
const WING_TRIM_EXTEND_M = 20;
|
||||
|
||||
/**
|
||||
* 주황 고리의 **바깥선**만 뽑아 양 끝을 늘린 트림선(2026-08-27 사용자: "주황색
|
||||
* 바깥선으로 잘라야 해 / 조금 모자라면 해당선을 복사해서 연장하고 트림").
|
||||
*
|
||||
* `wingBoxLoops`가 내는 고리 차례는 [시작 링, 끝 링, 끝 링→끝선 교점, …성토 끝선…,
|
||||
* 시작 링→끝선 교점, (닫기)]다. 바깥선 = 인덱스 2 ~ 끝-2 — 안쪽 날개벽 선은 뺀다.
|
||||
* 고리 종방향 밖 행은 교점이 없어 트림이 안 걸리므로, 양 끝 마디 방향으로
|
||||
* `WING_TRIM_EXTEND_M`만큼 **연장한 점을 붙여** 그 행에서도 만나게 한다.
|
||||
*/
|
||||
export function wingOuterLines(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
): WingTrimLine[] {
|
||||
const candidates = routeCandidates(chainageM, frameAt);
|
||||
if (!candidates.length) return [];
|
||||
const lines: WingTrimLine[] = [];
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
if (loop.length < 5) continue;
|
||||
const line: Array<[number, number]> = loop
|
||||
.slice(2, loop.length - 1)
|
||||
.map((point) => [point[0], point[1]]);
|
||||
if (line.length < 2) continue;
|
||||
const stretch = (near: [number, number], far: [number, number]): [number, number] => {
|
||||
const dx = far[0] - near[0];
|
||||
const dy = far[1] - near[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-9) return far;
|
||||
return [
|
||||
far[0] + (dx / length) * WING_TRIM_EXTEND_M,
|
||||
far[1] + (dy / length) * WING_TRIM_EXTEND_M,
|
||||
];
|
||||
};
|
||||
// 이 날개가 노선에서 차지하는 구간 — **행을 어느 날개 몫으로 볼지** 가른다.
|
||||
const spans = line.map(([x, y]) => projectChainage(candidates, x, y));
|
||||
line.unshift(stretch(line[1], line[0]));
|
||||
line.push(stretch(line[line.length - 2], line[line.length - 1]));
|
||||
lines.push({ line, fromM: Math.min(...spans), toM: Math.max(...spans) });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 행 횡단선이 **그 행을 맡은 날개**의 트림선과 만나는 편거리. 안 만나면 null.
|
||||
*
|
||||
* 날개는 **부재별로 구분한다**(2026-08-27 사용자: "구조물별로 구분되어야 함").
|
||||
* 세월교는 한 측에 유입·유출 날개가 각각 있어 트림선이 둘인데, 끝 마디를 20m 늘려
|
||||
* 놓아 서로의 구간까지 뻗는다. 그대로 합쳐서 가장 바깥을 고르면 유입 날개 선이
|
||||
* 유출 날개 행을 잘라 버린다 — 행마다 **누가거리가 가장 가까운 날개 하나만** 쓴다.
|
||||
*/
|
||||
export function wingTrimAt(
|
||||
lines: ReadonlyArray<WingTrimLine>,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
at: number,
|
||||
): number | null {
|
||||
if (!lines.length) return null;
|
||||
let owner = lines[0];
|
||||
let bestGap = Infinity;
|
||||
for (const entry of lines) {
|
||||
const gap = Math.max(entry.fromM - at, at - entry.toM, 0);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
owner = entry;
|
||||
}
|
||||
}
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
let outer: number | null = null;
|
||||
{
|
||||
const line = owner.line;
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const [ax, ay] = line[i];
|
||||
const [bx, by] = line[i + 1];
|
||||
const ex = bx - ax;
|
||||
const ey = by - ay;
|
||||
const denominator = frame.leftX * ey - frame.leftY * ex;
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator;
|
||||
if (t < -1e-9 || t > 1 + 1e-9) continue;
|
||||
const o =
|
||||
Math.abs(frame.leftX) > Math.abs(frame.leftY)
|
||||
? (ax + ex * t - frame.cx) / frame.leftX
|
||||
: (ay + ey * t - frame.cy) / frame.leftY;
|
||||
if (Math.abs(o) > NEAR_LIMIT_M) continue;
|
||||
if (o * sign <= 0) continue;
|
||||
if (outer === null || (o - outer) * sign > 0) outer = o;
|
||||
}
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄을 편거리 구간 `[from, to]`(부호 기준 안쪽→바깥)만 남기고 **자른다**. 경계를
|
||||
* 지나는 마디에는 교차점을 꽂아 격자 계단을 막는다.
|
||||
*
|
||||
* 날개 몫 행(구조물 점유 구간 밖)을 **주황 스케치의 바깥선**에서 끝내는 데 쓴다
|
||||
* (2026-08-27 사용자: "주황색 바깥선으로 잘라야 해"). 안쪽 경계로는 자르지 않는다 —
|
||||
* 자르면 구조물 구간과 날개 사이가 벌어진다. 벽 끝점 횡단선으로 자르던 것도 폐기
|
||||
* (스케치보다 바깥이라 스케치에 없는 자리까지 판이 남았다).
|
||||
*/
|
||||
export function clipRunToRange(
|
||||
run: OffsetPoint[],
|
||||
from: number,
|
||||
to: number,
|
||||
sign: number,
|
||||
): OffsetPoint[] {
|
||||
const inside = (point: OffsetPoint): boolean =>
|
||||
(point.offset_m - from) * sign >= -1e-9 && (to - point.offset_m) * sign >= -1e-9;
|
||||
const between = (a: OffsetPoint, b: OffsetPoint, offset: number): OffsetPoint => {
|
||||
const step = b.offset_m - a.offset_m;
|
||||
const ratio = Math.abs(step) < 1e-12 ? 0 : (offset - a.offset_m) / step;
|
||||
return {
|
||||
offset_m: offset,
|
||||
elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * ratio,
|
||||
};
|
||||
};
|
||||
const kept: OffsetPoint[] = [];
|
||||
for (let i = 0; i < run.length; i += 1) {
|
||||
const point = run[i];
|
||||
if (i > 0) {
|
||||
const previous = run[i - 1];
|
||||
const step = point.offset_m - previous.offset_m;
|
||||
const crossings = [from, to]
|
||||
.filter((bound) => (previous.offset_m - bound) * (point.offset_m - bound) < 0)
|
||||
.sort((a, b) => (step >= 0 ? a - b : b - a));
|
||||
for (const bound of crossings) kept.push(between(previous, point, bound));
|
||||
}
|
||||
if (inside(point)) kept.push(point);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
@@ -129,6 +129,8 @@ interface CellClip {
|
||||
function clipCellByChord(
|
||||
areas: ReadonlyArray<CurveArea>,
|
||||
ring: ReadonlyArray<Corner>,
|
||||
/** 남길 쪽 — 기본은 커브 **밖**(기본 성토면). 패치는 **안**을 남긴다. */
|
||||
keep: "outside" | "inside" = "outside",
|
||||
): CellClip | null {
|
||||
const hits = ringCrossings(areas, ring);
|
||||
// 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데,
|
||||
@@ -154,6 +156,7 @@ function clipCellByChord(
|
||||
}
|
||||
}
|
||||
if (!keepSign) return null;
|
||||
if (keep === "inside") keepSign = -keepSign;
|
||||
// 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다.
|
||||
const kept: Corner[] = [];
|
||||
for (let i = 0; i < ring.length; i += 1) {
|
||||
@@ -405,7 +408,10 @@ export function maskFillByPlanCurves(
|
||||
}
|
||||
const cut = ribbons.map((ribbon) => {
|
||||
const areas = areasBySide.get(ribbon.side);
|
||||
if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon;
|
||||
// 패치는 `patchClip`이 붙은 것만 다룬다 — **절취 영역 안쪽만 남기는 반대 규칙**이다
|
||||
// (2026-08-27 사용자). 표식 없는 패치(BOX암거·기슭막이)는 종전대로 손대지 않는다.
|
||||
const patchClip = ribbon.patch === true && ribbon.patchClip === true;
|
||||
if (ribbon.kind !== "fill" || (ribbon.patch && !patchClip) || !areas?.length) return ribbon;
|
||||
const rows = ribbon.chainages.length;
|
||||
const cols = ribbon.colCount;
|
||||
if (rows < 2 || cols < 2) return ribbon;
|
||||
@@ -435,6 +441,33 @@ export function maskFillByPlanCurves(
|
||||
const inCount = ring.filter((corner) =>
|
||||
areas.some((area) => inside(area, corner.p[0], corner.p[1])),
|
||||
).length;
|
||||
if (patchClip) {
|
||||
// 반대 규칙 — 영역 **안**이 남고 **밖**이 지워진다.
|
||||
if (inCount === 4) continue;
|
||||
if (inCount === 0) {
|
||||
mask[row * (cols - 1) + col] = 1;
|
||||
masked += 1;
|
||||
continue;
|
||||
}
|
||||
const piece = clipCellByChord(areas, ring, "inside");
|
||||
if (!piece) {
|
||||
if (coverage(areas, ring) < 0.5) {
|
||||
mask[row * (cols - 1) + col] = 1;
|
||||
masked += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
mask[row * (cols - 1) + col] = 1;
|
||||
masked += 1;
|
||||
for (let k = 1; k < piece.kept.length - 1; k += 1) {
|
||||
for (const corner of [piece.kept[0], piece.kept[k], piece.kept[k + 1]]) {
|
||||
tris.push(corner.p[0], corner.p[1], corner.p[2]);
|
||||
const [u, v] = uvOf(row, corner);
|
||||
uvs.push(u, v);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inCount === 0) continue;
|
||||
if (inCount === 4) {
|
||||
mask[row * (cols - 1) + col] = 1;
|
||||
@@ -469,6 +502,15 @@ export function maskFillByPlanCurves(
|
||||
// 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다.
|
||||
const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left";
|
||||
const planes = planesBySide.get(ribbon.side) ?? [];
|
||||
if (patchClip) {
|
||||
return {
|
||||
...ribbon,
|
||||
cellMask: mask,
|
||||
...(tris.length
|
||||
? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
traceMaskLoops(rows, cols, mask).forEach((raw) => {
|
||||
const loop = dropCollinear(raw);
|
||||
if (loop.length < 4 || !planes.length) return;
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface StructureSilhouette {
|
||||
* 한 줄 리본으로는 스스로 접힌다.
|
||||
*/
|
||||
fillRuns?: OffsetPoint[][];
|
||||
/**
|
||||
* **세월교 바닥판 상단 표고** — 패치를 날개 스케치 안쪽으로 늘릴 때의 **천장**이다
|
||||
* (2026-08-27 사용자: "바닥 상단에 교차되지 않는 지점으로 메시가 튀어 나간다 —
|
||||
* 바닥 상단 Z로 트림"). 성토 물매로 안쪽을 역연장하면 표고가 올라가 유로 위로
|
||||
* 솟는다. 세월교만 싣는다(다른 구조물은 안쪽 확장 대상이 아니다).
|
||||
*/
|
||||
floorTopZ?: number;
|
||||
}
|
||||
|
||||
/** 실루엣에서 노견 확장점 찾기 — 첫 점(노견) 표고 그대로 바깥으로 나간 가장 먼 점. */
|
||||
@@ -203,6 +210,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] {
|
||||
widenTo: widenToOf(points, onLeft ? "left" : "right"),
|
||||
patchPoints,
|
||||
fillRuns,
|
||||
floorTopZ: side.slabTopElevation,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -194,6 +194,16 @@ export function createRouteViewer(): RouteViewer {
|
||||
return hit ? sceneToModel(hit.point, bounds).z : null;
|
||||
}
|
||||
|
||||
// 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물
|
||||
// 솔리드가 덮고 있을 수 있다). 씬·THREE·모델→씬 변환을 내보내 위에서 레이캐스트로
|
||||
// 확인한다(`__corridorBuild`·`__corridorClip`과 같은 용도).
|
||||
(window as unknown as { __corridorScene?: unknown }).__corridorScene = {
|
||||
scene,
|
||||
THREE,
|
||||
toScene: (x: number, y: number, z: number) =>
|
||||
bounds ? modelToScene({ x, y, z }, bounds) : null,
|
||||
topZ: () => (bounds ? bounds.z[1] + 100 : null),
|
||||
};
|
||||
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
|
||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
||||
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
||||
|
||||
Reference in New Issue
Block a user