feat(B05·B06): 배수관 측점 정본화 · 코리도 단차 제거 · 인접 측점 기슭막이 링크

3D 예상형상은 종단·횡단 확정 뒤의 최종 산출물이고 3D 쪽 편집 경로는 없다
(2026-08-24 사용자 확정). 그런데 구조물(비정규) 측점은 횡단 정본 자체가 없어
조회할 때마다 프리뷰 기본값이 재계산됐고, 조정창 조작값은 세션에서 사라졌다.
정본 경로를 열고, 그 위에서 코리도 단차를 걷어냈다.

① 구조물 측점 정본화 — 확정·임시저장이 종단 정본 stations 중 DB 행이 없는
   측점을 대상에 넣어 upsert. cross_sections 19 → 23행.
② 조정창 조작값 정본화 — 기슭막이 4축(x·d·h·m)·다단 단 수를 design.revet_adjust
   / extra_wall_counts로 저장하고, 3D culvertLayoutOf가 그 값을 기하 입력으로 쓴다.
   카드 우선순위는 세션 → 정본 → 자동.
③ 노폭 연장의 노견 편입 — PIECE_COLS.shoulder 2→3으로 원 노견 끝 꺾임을 남기고,
   조기 return을 없애 확폭이 구조물 구간 내내 유지되게 했다.
④ 종단 기울기 반영 — 구조물 구간 복사 단면에 계획고 차이를 전량 얹는다.
   노견 표고 단차 0.49~0.61m 제거.
⑤ 접속 테이퍼 — 구간 경계를 제어점으로 세워 "복사 vs 보간" 두 규칙을 하나로
   통합. 노폭 3.0m·성토비탈 8.1m 단차 제거. 접속 길이 = 인접 측점까지.
   제어점 생성은 B05_Profile_UI_Corridor_Frames.ts로 분리(Build 699 → 635줄).
⑥ 인접 측점 링크 — 기슭막이 연장(기본 10m)이 덮는 옆 측점 횡단도에도 소유 측점
   단면을 계획고 차이만 얹어 그린다. 요소별 연장 판정(관·집수정 제외), 표시 전용.

BUILD_VERSION 11 → 14 (저장 코리도 만료).
tsc/ruff/prettier 통과, pytest 192 passed (기존 실패 1건 유지).
작업이력·자체검증 기록: docs/raw/PLAN.md §4-8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 17:11:21 +09:00
co-authored by Claude Opus 5
parent 22d4df5aff
commit 83eb58a251
16 changed files with 585 additions and 67 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 10; // 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드 추가(2026-08-23).
const BUILD_VERSION = 14; // 절·성토 비탈도 제어점 보간 — 확폭 끝 단차 제거(2026-08-24).
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+93 -43
View File
@@ -29,6 +29,11 @@ import {
} from "./B05_Profile_UI_Corridor_Station";
export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station";
import {
bracketControls,
buildPieceControls,
type PieceControl,
} from "./B05_Profile_UI_Corridor_Frames";
import {
buildCorridorStructures,
type CorridorStructure,
@@ -260,31 +265,32 @@ export function buildCorridor(
}> = [];
/**
* 구조물(기슭막이·집수정)이 종방향으로 점유하는 측점 찾기 — **측점 경계를 넘어도**
* 그 구간 전체가 같은 단면을 쓴다(2026-08-23 사용자: 전/후 5m가 다음 측점에 걸리면
* 그 측점에도 같은 기슭막이가 이어져야 3D 길이가 맞다). 겹치면 가장 가까운 측점.
* 조각별 종방향 제어점(2026-08-24 사용자: 구조물 구간 끝을 스무스하게).
* · 노견은 **계획고 기준 상대** 단면 — 행 계획고에 얹어 종단을 그대로 탄다.
* · 절·성토는 **앵커(도로부 바깥 끝) 기준 상대** 단면 — 아래 공통 블록이 얹는다.
*/
const structureSources = stations.filter((station) => station.wallSpans);
const structureAt = (chainageM: number, side: "left" | "right"): StationPieces | null => {
let best: StationPieces | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const station of structureSources) {
const span = station.wallSpans?.[side];
if (!span) continue;
if (
chainageM < station.chainage_m - span.beforeM - 1e-9 ||
chainageM > station.chainage_m + span.afterM + 1e-9
) {
continue;
}
const distance = Math.abs(chainageM - station.chainage_m);
if (distance < bestDistance) {
best = station;
bestDistance = distance;
}
}
return best;
};
const designZ = (chainageM: number): number => profileZ?.(chainageM) ?? 0;
const controlsByKey = new Map<string, PieceControl[]>();
(["left", "right"] as const).forEach((side) => {
const shoulderKey = pieceKey("shoulder", side);
const shoulder = buildPieceControls(stations, side, (station) => {
const piece = station.pieces.get(shoulderKey);
if (!piece) return undefined;
const base = designZ(station.chainage_m);
return piece.map((point) => ({
offset_m: point.offset_m,
elevation_m: point.elevation_m - base,
}));
});
if (shoulder) controlsByKey.set(shoulderKey, shoulder);
(["cut", "fill"] as const).forEach((kind) => {
const slopeKey = pieceKey(kind, side);
const slope = buildPieceControls(stations, side, (station) =>
station.slopeRelative.get(slopeKey),
);
if (slope) controlsByKey.set(slopeKey, slope);
});
});
for (let i = 0; i < stations.length - 1; i += 1) {
const s0 = stations[i];
@@ -323,6 +329,10 @@ export function buildCorridor(
const sections = new Map<string, OffsetPoint[]>();
/** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */
const fixedSlopeKeys = new Set<string>();
/** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */
const fixedSource = new Map<string, number>();
/** 제어점 보간으로 이미 계획고에 얹은 조각 — 종단 보정을 두 번 하지 않게 표시. */
const controlPlaced = new Set<string>();
keys.forEach((key) => {
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
const hasA = s0.pieces.has(key);
@@ -355,17 +365,22 @@ export function buildCorridor(
elevation_m: edge.elevation_m + point.elevation_m,
}));
} else if (kind === "cut" || kind === "fill") {
// 절·성토는 상대 측점의 축퇴점으로 모핑하지 않는다(2026-08-23 사용자:
// 완전 분리). 비탈 시작점 기준 **상대 좌표**를 보간해 두고, 아래 공통
// 블록에서 행 앵커(측구 끝/노견 끝)에 얹은 뒤 행 지반과 교차시켜 종결한다.
// 구조물 점유 구간(2026-08-23 사용자)은 그 측점의 트림 사면(노견 연장
// + 구조물 상단 종결)을 **그대로** 쓰고 지반 트림을 생략한다.
const fixedStation = structureAt(chainage, side as "left" | "right");
if (fixedStation) {
const fixed = fixedStation.slopeRelative.get(key);
if (!fixed || fixed.length < 2) return;
points = fixed.map((point) => ({ ...point }));
fixedSlopeKeys.add(key);
// 절·성토도 **제어점 보간**이다(2026-08-24 사용자: 확장된 노폭 끝을 스무스하게).
// 상대 좌표라 아래 공통 블록이 행 앵커(측구 끝/노견 끝)에 얹는다. 구조물
// 구간 안(같은 구간의 두 경계 사이)은 단면이 고정되고 지반 트림을 건너뛴다 —
// 사면이 구조물 상단에서 끝나기 때문. 구간 밖은 이웃 측점까지 테이퍼.
const controls = controlsByKey.get(key);
if (controls) {
const { a, b, t: tc } = bracketControls(controls, chainage);
points =
a === b
? a.points.map((point) => ({ ...point }))
: lerpPoints(a.points, b.points, tc);
// 구조물 구간 안(같은 구간의 두 경계 사이)이든 경계와 이웃 측점 사이
// 테이퍼든, 구조물 단면이 섞인 행은 지반 트림을 건너뛴다 — 트림이 성토부선
// (구조물이 만든 긴 앞치마)을 지반 교차점까지 잘라내 8.1m 절벽이 남았다
// (2026-08-24 실측 2.23 → 9.88m).
if (a.spanId !== null || b.spanId !== null) fixedSlopeKeys.add(key);
} else {
const relA = s0.slopeRelative.get(key);
const relB = s1.slopeRelative.get(key);
@@ -377,13 +392,22 @@ export function buildCorridor(
points = relative;
}
} else {
// 노견은 구조물 구간에서 **넓어진 폭 그대로** 고정한다(2026-08-23 사용자:
// 성토로 늘어난 노폭 반영). 구조물이 없는 구간은 종전대로 보간.
const widened =
kind === "shoulder" ? structureAt(chainage, side as "left" | "right") : null;
const fixed = widened?.pieces.get(key);
if (fixed) {
points = fixed.map((point) => ({ ...point }));
// 노견은 **제어점 보간**으로 그린다(2026-08-24 사용자: 스무스 연결).
// 구조물 구간 안은 두 제어점이 같은 단면이라 그대로 고정되고, 구간 밖은
// 경계 ↔ 이웃 측점 사이에서 테이퍼가 생긴다. 계획고는 아래에서 얹는다.
const controls = kind === "shoulder" ? controlsByKey.get(key) : undefined;
if (controls) {
const { a, b, t: tc } = bracketControls(controls, chainage);
const relative =
a === b
? a.points.map((point) => ({ ...point }))
: lerpPoints(a.points, b.points, tc);
const base = designZ(chainage);
points = relative.map((point) => ({
offset_m: point.offset_m,
elevation_m: point.elevation_m + base,
}));
controlPlaced.add(key);
} else {
const pa = s0.pieces.get(key);
const pb = s1.pieces.get(key);
@@ -391,9 +415,35 @@ export function buildCorridor(
points = lerpPoints(pa, pb, t);
}
}
if (shift !== 0 && kind !== "cut" && kind !== "fill") applyProfileShift(points, shift);
// 종단 보정 — 보간 조각은 직선 보간 대비 **곡선 차이만**(shift) 얹으면 되지만,
// 구조물 측점 단면을 그대로 쓴 조각은 그 측점 표고에 얼어붙어 있으므로
// **원 측점과의 계획고 차이 전량**을 얹어야 한다. 안 그러면 구조물 구간에서
// 노견만 종단을 안 타고 양 끝에 단이 생긴다(2026-08-24 사용자 지적, 실측
// 0.49~0.61m 단 / 종단 기울기 10.1%).
const source = fixedSource.get(key);
const rowShift = controlPlaced.has(key)
? 0 // 제어점 보간은 상대 단면을 행 계획고에 직접 얹는다 — 추가 보정 불필요.
: source !== undefined && profileZ
? (profileZ(chainage) ?? 0) - (profileZ(source) ?? 0)
: shift;
if (rowShift !== 0 && kind !== "cut" && kind !== "fill") {
applyProfileShift(points, rowShift);
}
sections.set(key, points);
});
// 노견 안쪽 끝은 차도 끝과 **한 점**이어야 한다 — 노견은 제어점 보간, 차도는
// 측점 보간이라 테이퍼 구간에서 최대 0.023m 벌어졌다(2026-08-24 실측). 차도에 맞춘다.
const roadTop = sections.get(pieceKey("carriageway", "center"));
if (roadTop && roadTop.length >= 2) {
const ends = { left: roadTop[roadTop.length - 1], right: roadTop[0] };
(["left", "right"] as const).forEach((side) => {
const piece = sections.get(pieceKey("shoulder", side));
if (!piece || piece.length < 2) return;
const inner = side === "left" ? piece[0] : piece[piece.length - 1];
inner.offset_m = ends[side].offset_m;
inner.elevation_m = ends[side].elevation_m;
});
}
// 비탈(상대 좌표)을 그 행의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견
// 끝) 앵커에 얹고, 행 지반선과 직접 교차시켜 종결한다(2026-08-23 완전 분리).
const groundRow = mixedGround(s0.ground, s1.ground, t);
@@ -0,0 +1,85 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Frames.ts
* 코리도 조각의 **종방향 제어점** — 구조물 구간 경계와 구간 밖 측점(Build에서 분리,
* 700줄 제한).
*
* 왜 필요한가(2026-08-24 사용자): 구조물 구간은 그 측점 단면을 그대로 쓰고 구간 밖은
* 측점 사이를 보간했다. 두 규칙이 경계에서 맞부딪쳐 노폭 3.0m·성토비탈 8.1m 단차가
* 났다(실측). 경계를 제어점으로 세워 **한 규칙(제어점 사이 선형 보간)**으로 합치면
* 구간 안은 두 제어점이 같은 단면이라 자동으로 고정되고, 구간 밖은 이웃 측점까지
* 테이퍼가 생긴다. 접속 길이 기준 = **인접 측점까지**(2026-08-24 사용자 확정).
* ========================================================================== */
import type { OffsetPoint, StationPieces } from "./B05_Profile_UI_Corridor_Station";
/** 제어점 하나 — 그 누가거리에서 쓸 단면과 출처. */
export interface PieceControl {
chainage_m: number;
points: OffsetPoint[];
/** 구조물 구간 경계에서 온 제어점인지. 같은 구간의 두 경계 사이 = 완전 고정 구간. */
spanId: number | null;
}
/**
* 조각 하나의 제어점 목록. 구간에 삼켜진 측점은 제외한다 — 그 측점 단면은 구조물
* 단면에 덮이므로 제어점으로 세우면 구간 안에서 형상이 흔들린다.
*
* `pick`이 단면을 돌려주지 못하는 측점은 조용히 건너뛴다(그 측에 조각이 없는 측점).
* 제어점이 2개 미만이면 null — 호출자가 종전 보간으로 떨어진다.
*/
export function buildPieceControls(
stations: StationPieces[],
side: "left" | "right",
pick: (station: StationPieces) => OffsetPoint[] | undefined,
): PieceControl[] | null {
const spans = stations
.map((station) => {
const span = station.wallSpans?.[side];
return span
? {
fromM: station.chainage_m - span.beforeM,
toM: station.chainage_m + span.afterM,
station,
}
: null;
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.sort((a, b) => a.fromM - b.fromM);
const inSpan = (chainageM: number): boolean =>
spans.some((span) => chainageM >= span.fromM - 1e-9 && chainageM <= span.toM + 1e-9);
const controls: PieceControl[] = [];
stations.forEach((station) => {
if (inSpan(station.chainage_m)) return;
const points = pick(station);
if (points && points.length >= 2) {
controls.push({ chainage_m: station.chainage_m, points, spanId: null });
}
});
spans.forEach((span, index) => {
const points = pick(span.station);
if (!points || points.length < 2) return;
controls.push({ chainage_m: span.fromM, points, spanId: index });
controls.push({ chainage_m: span.toM, points, spanId: index });
});
controls.sort((a, b) => a.chainage_m - b.chainage_m);
return controls.length >= 2 ? controls : null;
}
/** 제어점 배열에서 chainage를 감싸는 두 점과 그 사이 비율. 범위 밖은 끝 제어점 고정. */
export function bracketControls(
controls: PieceControl[],
chainageM: number,
): { a: PieceControl; b: PieceControl; t: number } {
if (chainageM <= controls[0].chainage_m) return { a: controls[0], b: controls[0], t: 0 };
for (let index = 1; index < controls.length; index += 1) {
if (chainageM <= controls[index].chainage_m + 1e-9) {
const a = controls[index - 1];
const b = controls[index];
const span = b.chainage_m - a.chainage_m;
return { a, b, t: span <= 1e-9 ? 0 : (chainageM - a.chainage_m) / span };
}
}
const last = controls[controls.length - 1];
return { a: last, b: last, t: 0 };
}
+16 -4
View File
@@ -21,7 +21,9 @@ export type CorridorSide = "left" | "right" | "center";
* 하단 성토사면·추가 기슭막이 반영). */
export const PIECE_COLS: Record<CorridorKind, number> = {
carriageway: 3,
shoulder: 2,
// 노견 3열 = [차도끝, 원 노견끝, 바깥끝]. 2열이면 배수관 측점의 노폭 연장을 담을 때
// 원 노견 끝의 꺾임이 사라져 노견 물매(3%)가 연장분까지 퍼진다(2026-08-24 사용자).
shoulder: 3,
ditch: 5,
cut: 20,
fill: 20,
@@ -367,14 +369,24 @@ export function classifyStation(section: CrossSection): StationPieces | null {
if (widening) {
const shoulder = pieces.get(pieceKey("shoulder", side));
if (shoulder && shoulder.length >= 2) {
const outer = side === "left" ? shoulder[shoulder.length - 1] : shoulder[0];
outer.offset_m = widening.offset_m;
outer.elevation_m = widening.elevation_m;
// 원 노견 끝을 꼭짓점으로 남긴다 — 바깥 정점만 밀면 [차도끝→연장끝] 직선
// 하나가 되어 노견 물매가 연장분 전체로 퍼진다(2026-08-24 사용자 지적).
const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1];
const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m };
const outer = { offset_m: widening.offset_m, elevation_m: widening.elevation_m };
pieces.set(
pieceKey("shoulder", side),
side === "left" ? [inner, knee, outer] : [outer, knee, inner],
);
}
const cutAt = widening;
silhouette = silhouette.filter(
(point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9,
);
// 연장분을 노견이 가져갔으면 비탈은 **연장 끝에서** 시작해야 한다. 남는 점이
// 없다고 그냥 빠지면 원 노견 끝에서 시작하는 옛 비탈이 살아남아 노견 밑을
// 파고든다(2026-08-24 실측: 성토비탈이 연장부와 0.9m 겹침).
if (silhouette.length < 2) silhouette = [widening, widening];
}
}
if (silhouette.length < 2) return;
@@ -11,11 +11,15 @@
* · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23
* 사용자). 연장 = basin_length_m(기본 2m), 전후 동일.
* · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경.
* B06 조정창의 세션 조작값(4축 x·d·h·m)은 세션 전용이라 여기서는 자동 배치
* 기준이다 — 영속값(design.inlet_structure·basin_adjust)만 반영한다.
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust·
* extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는
* 종단·횡단 확정 뒤의 최종 산출물이며 3D 쪽 편집은 없다). 확정 전 세션 값은 반영하지
* 않는다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
@@ -67,6 +71,27 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu
* 같은 계산을 나눠 쓴다. 실패(null)도 캐시해 재계산을 막는다. */
const layoutCache = new WeakMap<CrossSection, CulvertLayout | null>();
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
function storedAdjusts(
section: CrossSection,
):
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined {
const stored = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
if (!stored && !counts) return undefined;
const at = (role: string): WallAdjust =>
stored?.[role]
? { ...ZERO_ADJUST, ...(stored[role] as Partial<WallAdjust>) }
: { ...ZERO_ADJUST };
return {
inlet: at("inlet"),
outlet: at("outlet"),
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)),
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)),
};
}
export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
if (!section.culvert || !section.design) return null;
if (layoutCache.has(section)) return layoutCache.get(section) ?? null;
@@ -75,7 +100,7 @@ export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
layout = computeCulvertLayout(
section,
section.samples,
undefined,
storedAdjusts(section),
section.design.inlet_structure ?? "auto",
section.design.basin_adjust,
);
@@ -240,6 +265,8 @@ export function structureHashParts(section: CrossSection): Array<string | number
spec.basin_length_m ?? "",
].join(",");
const basin = section.design?.basin_adjust;
const revet = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
return [
"cv",
culvert.diameter_m,
@@ -248,5 +275,16 @@ export function structureHashParts(section: CrossSection): Array<string | number
side(culvert.outlet),
section.design?.inlet_structure ?? "",
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
revet
? Object.keys(revet)
.sort()
.map((role) => {
const value = revet[role];
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
})
.join("|")
: "",
counts ? `${counts.outlet},${counts.basin}` : "",
];
}