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:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}` : "",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -247,6 +247,21 @@ export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
|
||||
export type DitchSide = "left" | "right";
|
||||
export type DitchType = "standard" | "l_type";
|
||||
|
||||
/** 기슭막이 한 벽의 4축 조작값(좌우 x·상하 d·높이 h·재질 m). null = 자동. */
|
||||
export interface StoredWallAdjust {
|
||||
x: number;
|
||||
d: number | null;
|
||||
h: number | null;
|
||||
/** 재질 — B06_Section_UI_Cross_Culvert_Const의 RevetMaterial과 같은 값. */
|
||||
m: "dry" | "wet" | "concrete" | null;
|
||||
}
|
||||
|
||||
/** 다단 기슭막이 단 수(유출 성토부 / 집수정 계류측). */
|
||||
export interface StoredExtraWallCounts {
|
||||
outlet: number;
|
||||
basin: number;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
|
||||
export interface CrossDesign {
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
@@ -256,6 +271,11 @@ export interface CrossDesign {
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
/** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
ground_type: GroundType;
|
||||
geometry_preset: "soil" | "rock";
|
||||
section_mode: SectionMode;
|
||||
@@ -352,6 +372,8 @@ export interface CrossSectionPatch {
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
}
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
|
||||
@@ -518,6 +518,19 @@ async def get_cross_sections_missing_design_chainages(
|
||||
return chainages
|
||||
|
||||
|
||||
async def get_cross_section_chainages(
|
||||
connection: aiomysql.Connection, route_id: int
|
||||
) -> list[float]:
|
||||
"""경로에 **행이 존재하는** 측점의 chainage(m) 목록. 구조물(비정규) 측점처럼
|
||||
행 자체가 없는 자리를 가려내 확정 때 정본으로 채우기 위해 쓴다(2026-08-24)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [float(row[0]) for row in rows]
|
||||
|
||||
|
||||
async def merge_cross_section_design_patch(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
|
||||
@@ -659,7 +659,13 @@ async def compute_cross_section_design(
|
||||
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
|
||||
stored_design = record.get("design")
|
||||
if isinstance(stored_design, dict):
|
||||
for key in ("display_half_width_m", "inlet_structure", "basin_adjust"):
|
||||
for key in (
|
||||
"display_half_width_m",
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"extra_wall_counts",
|
||||
):
|
||||
if stored_design.get(key) is not None:
|
||||
design[key] = stored_design[key]
|
||||
break
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -23,6 +24,7 @@ from B05_Profile.B05_Profile_Repository import confirm_route as confirm_route_st
|
||||
from B05_Profile.B05_Profile_Router_Confirm import _merge_uphill_overrides_into_longitudinal
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
confirm_sections_for_route,
|
||||
get_cross_section_chainages,
|
||||
get_cross_section_designs,
|
||||
get_cross_sections_missing_design_chainages,
|
||||
get_longitudinal_section,
|
||||
@@ -44,6 +46,42 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
|
||||
def _rowless_station_chainages(
|
||||
project_root: Path, longitudinal_file_path: str, known: list[float]
|
||||
) -> list[float]:
|
||||
"""종단 정본 stations 중 **cross_sections 행이 없는** 측점의 chainage 목록.
|
||||
|
||||
구조물(비정규) 측점은 B05 확정이 횡단 파일만 쓰고 DB 행을 만들지 않는다
|
||||
(`generate_irregular_sections`). 그래서 확정해도 설계가 정본으로 남지 않고 조회할
|
||||
때마다 프리뷰 기본값이 다시 계산됐다 — 3D·수량이 확정 결과가 아니게 된다
|
||||
(2026-08-24 사용자 확정: 3D는 종단·횡단 확정 뒤의 최종 산출물). 여기서 골라내
|
||||
확정 대상에 넣으면 `update_cross_section_design`의 upsert가 행을 만든다.
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
if root not in path.parents or not path.is_file():
|
||||
return []
|
||||
try:
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
|
||||
if not isinstance(stations, list):
|
||||
return []
|
||||
result: list[float] = []
|
||||
for station in stations:
|
||||
if not isinstance(station, dict):
|
||||
continue
|
||||
try:
|
||||
chainage = float(station.get("chainage_m"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if any(abs(chainage - value) < 0.01 for value in known):
|
||||
continue
|
||||
result.append(chainage)
|
||||
return result
|
||||
|
||||
|
||||
async def _apply_section_edits(
|
||||
connection: aiomysql.Connection,
|
||||
route_id: int,
|
||||
@@ -83,6 +121,13 @@ async def _apply_section_edits(
|
||||
patch["inlet_structure"] = patch_item.inlet_structure
|
||||
if patch_item.basin_adjust is not None:
|
||||
patch["basin_adjust"] = patch_item.basin_adjust.model_dump()
|
||||
# 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 남긴다(2026-08-24).
|
||||
if patch_item.revet_adjust is not None:
|
||||
patch["revet_adjust"] = {
|
||||
role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items()
|
||||
}
|
||||
if patch_item.extra_wall_counts is not None:
|
||||
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
|
||||
if patch:
|
||||
await merge_cross_section_design_patch(
|
||||
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
|
||||
@@ -97,20 +142,48 @@ async def save_sections(
|
||||
) -> SectionConfirmResponse | JSONResponse:
|
||||
"""편집 중인 종횡단을 **확정하지 않고** 영구저장소에만 남긴다(임시 저장).
|
||||
|
||||
저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않는다. 미지정 측점을
|
||||
기본값으로 채우지도 않는다 — 임시 저장은 **사용자가 실제로 손댄 것만** 남기는 게 맞다.
|
||||
저장 내용은 확정과 같지만 경로 상태·워크플로 단계를 건드리지 않는다. 지반유형 미지정
|
||||
측점을 기본값으로 채우지도 않는다 — 임시 저장은 **사용자가 실제로 손댄 것만** 남긴다.
|
||||
|
||||
다만 **행 자체가 없는 측점**(구조물 등 비정규)은 예외다. 임시저장이 곧 캐시를
|
||||
영구저장소에 내리는 시점인데(2026-08-24 사용자 확정), 행이 없으면 그 측점 패치가
|
||||
통째로 버려져 조작값이 사라진다. 그래서 여기서 정본 행을 만든다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
if not await get_longitudinal_section(connection, project_id, route_id):
|
||||
existing = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not existing:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "저장할 종횡단이 없습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
rowless = await asyncio.to_thread(
|
||||
_rowless_station_chainages,
|
||||
project_root,
|
||||
str(existing["longitudinal_file_path"]),
|
||||
known,
|
||||
)
|
||||
default_designs: list[tuple[float, dict[str, Any]]] = []
|
||||
if rowless:
|
||||
default_designs = await asyncio.to_thread(
|
||||
_compute_default_designs,
|
||||
project_root,
|
||||
str(existing["longitudinal_file_path"]),
|
||||
rowless,
|
||||
request.standard_cross_section if request else None,
|
||||
)
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await _apply_section_edits(connection, route_id, request, [])
|
||||
await _apply_section_edits(
|
||||
connection, route_id, request, default_designs, project_id
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -155,11 +228,21 @@ async def confirm_sections(
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
|
||||
known = await get_cross_section_chainages(connection, route_id)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
|
||||
# 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24).
|
||||
missing = missing + await asyncio.to_thread(
|
||||
_rowless_station_chainages,
|
||||
project_root,
|
||||
str(existing["longitudinal_file_path"]),
|
||||
known,
|
||||
)
|
||||
|
||||
# 미지정 측점을 기본값으로 계산해 채운다 (계산 불가 측점은 조용히 건너뜀).
|
||||
default_designs: list[tuple[float, dict[str, Any]]] = []
|
||||
if missing:
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
default_designs = await asyncio.to_thread(
|
||||
_compute_default_designs,
|
||||
project_root,
|
||||
|
||||
@@ -75,6 +75,22 @@ class BasinAdjustPatch(BaseModel):
|
||||
slopeM: float = Field(..., ge=0.0, le=10.0)
|
||||
|
||||
|
||||
class WallAdjustPatch(BaseModel):
|
||||
"""기슭막이 4축 조작값(좌우 x·상하 d·높이 h·재질 m). null = 자동."""
|
||||
|
||||
x: float = Field(default=0.0, ge=-10.0, le=10.0)
|
||||
d: float | None = Field(default=None, ge=-10.0, le=10.0)
|
||||
h: float | None = Field(default=None, ge=0.0, le=10.0)
|
||||
m: str | None = None
|
||||
|
||||
|
||||
class ExtraWallCountsPatch(BaseModel):
|
||||
"""다단 기슭막이 단 수 — 유출 성토부(outlet)·집수정 계류측(basin)."""
|
||||
|
||||
outlet: int = Field(default=0, ge=0, le=9)
|
||||
basin: int = Field(default=0, ge=0, le=9)
|
||||
|
||||
|
||||
class CrossSectionPatch(BaseModel):
|
||||
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
|
||||
|
||||
@@ -85,6 +101,10 @@ class CrossSectionPatch(BaseModel):
|
||||
display_half_width_m: float | None = Field(default=None, gt=0)
|
||||
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
|
||||
basin_adjust: BasinAdjustPatch | None = None
|
||||
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
# 세션 전용이던 값을 정본으로 올린다(2026-08-24 사용자: 3D는 확정 결과물).
|
||||
revet_adjust: dict[str, WallAdjustPatch] | None = None
|
||||
extra_wall_counts: ExtraWallCountsPatch | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -71,6 +71,9 @@ export function appendCulvertOverlay(
|
||||
x: (offset: number) => number,
|
||||
toDisplayY: (elevation: number) => number,
|
||||
onSelectRevet?: (key: RevetKey) => void,
|
||||
/** 관을 그리지 않는다 — 관은 한 측점에만 있고, 연장이 넘어온 옆 측점 카드에는
|
||||
* 기슭막이만 링크로 보인다(2026-08-24 사용자). */
|
||||
hidePipe?: boolean,
|
||||
): RevetHighlightSetter {
|
||||
const { culvert, pipe, pipeCorners } = layout;
|
||||
const diameter = culvert.diameter_m;
|
||||
@@ -347,10 +350,13 @@ export function appendCulvertOverlay(
|
||||
elevation: corner.top.elevation + normal.elevation * delta,
|
||||
},
|
||||
});
|
||||
for (const [delta, cls] of [
|
||||
[wallThickness, "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer"],
|
||||
[0, "b06-chart__culvert-pipe"],
|
||||
] as const) {
|
||||
const pipeLayers = hidePipe
|
||||
? []
|
||||
: ([
|
||||
[wallThickness, "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer"],
|
||||
[0, "b06-chart__culvert-pipe"],
|
||||
] as const);
|
||||
for (const [delta, cls] of pipeLayers) {
|
||||
const a = shifted(inletFinal, delta);
|
||||
const b = shifted(outletFinal, delta);
|
||||
layer.append(
|
||||
|
||||
@@ -71,6 +71,98 @@ import { L } from "./B06_Section_UI_Section_Common";
|
||||
* 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). 조작 중(그 벽이 선택된
|
||||
* 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵.
|
||||
*/
|
||||
/**
|
||||
* 구조물이 **인접 측점까지 넘어온** 경우의 링크(2026-08-24 사용자).
|
||||
*
|
||||
* 기슭막이 기본 연장이 10m(전 5·후 5)라 배수관 측점 하나만으로는 끝나지 않고 옆
|
||||
* 측점을 덮는다. 그 측점 횡단도에도 같은 기슭막이가 보여야 하고, 3D 코리도가 이미
|
||||
* 그 구간을 소유 측점 단면으로 채우고 있으므로 **2D도 같은 규칙(복사 + 종단 보정)**
|
||||
* 이라야 어긋나지 않는다. 조작·저장은 소유 측점에서만 한다(링크 카드는 표시 전용).
|
||||
*/
|
||||
export interface CulvertLink {
|
||||
/** 구조물이 실제로 선 측점. */
|
||||
source: CrossSection;
|
||||
/** 이 측점 계획고 − 소유 측점 계획고. 벽을 종단 기울기만큼 올려/내려 얹는다. */
|
||||
dz: number;
|
||||
/** 소유 측점과의 거리(m) — 요소별 연장 안에 드는지 판정에 쓴다. */
|
||||
distanceM: number;
|
||||
}
|
||||
|
||||
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이 연장·집수정 길이 중 큰 값. */
|
||||
function culvertReach(section: CrossSection): { beforeM: number; afterM: number } | null {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return null;
|
||||
let beforeM = 0;
|
||||
let afterM = 0;
|
||||
for (const spec of [culvert.inlet, culvert.outlet]) {
|
||||
if (!spec) continue;
|
||||
const length = spec.revet_length_m ?? 10;
|
||||
const before = spec.revet_before_m ?? length / 2;
|
||||
const after = spec.revet_after_m ?? Math.max(length - before, 0);
|
||||
const basinHalf = (spec.basin_length_m ?? 0) / 2;
|
||||
beforeM = Math.max(beforeM, before, basinHalf);
|
||||
afterM = Math.max(afterM, after, basinHalf);
|
||||
}
|
||||
return beforeM > 0 || afterM > 0 ? { beforeM, afterM } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 측점을 덮는 구조물 측점을 찾는다. 자기 구조물이 있으면 링크 아님(소유 측점).
|
||||
* 여러 구조물이 겹치면 가장 가까운 측점 — 3D `structureAt`과 같은 규칙.
|
||||
*/
|
||||
export function culvertLinkFor(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
designZAt: (chainageM: number) => number | null,
|
||||
): CulvertLink | undefined {
|
||||
if (section.culvert) return undefined;
|
||||
let best: CrossSection | null = null;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of sections) {
|
||||
const reach = culvertReach(candidate);
|
||||
if (!reach) continue;
|
||||
if (
|
||||
section.chainage_m < candidate.chainage_m - reach.beforeM - 1e-9 ||
|
||||
section.chainage_m > candidate.chainage_m + reach.afterM + 1e-9
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const distance = Math.abs(section.chainage_m - candidate.chainage_m);
|
||||
if (distance < bestDistance) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (!best) return undefined;
|
||||
const here = designZAt(section.chainage_m);
|
||||
const there = designZAt(best.chainage_m);
|
||||
return {
|
||||
source: best,
|
||||
dz: here != null && there != null ? here - there : 0,
|
||||
distanceM: bestDistance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기하 전체를 종단 차이만큼 올려/내린다 — offset(횡방향)은 그대로다. 표고 필드는
|
||||
* 이름이 `elevation`·`minElevation`·`maxElevation`처럼 끝나므로 키로 찾아 더한다.
|
||||
*/
|
||||
function shiftLayoutElevation<T>(value: T, dz: number): T {
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => shiftLayoutElevation(item, dz)) as unknown as T;
|
||||
if (value && typeof value === "object") {
|
||||
const clone: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
clone[key] =
|
||||
typeof entry === "number" && /elevation$/i.test(key)
|
||||
? entry + dz
|
||||
: shiftLayoutElevation(entry, dz);
|
||||
}
|
||||
return clone as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function computeCardCulvert(
|
||||
section: CrossSection,
|
||||
sourceSamples: SectionSample[],
|
||||
@@ -78,7 +170,25 @@ export function computeCardCulvert(
|
||||
revetOffset?: RevetOffsetControl,
|
||||
inletStructure?: InletStructureControl,
|
||||
extraWalls?: ExtraWallControl,
|
||||
link?: CulvertLink,
|
||||
): CulvertLayout | null {
|
||||
// 링크 측점 — 소유 측점 단면을 그대로 계산해 종단 차이만 얹는다. 한계 토스트·
|
||||
// 조작값 되받기는 하지 않는다(소유 측점 카드가 이미 한다).
|
||||
if (!section.culvert && link) {
|
||||
const owner = computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
adjustsInput(link.source, revetOffset, extraWalls),
|
||||
inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
);
|
||||
if (!owner) return null;
|
||||
const shifted = shiftLayoutElevation(owner, link.dz);
|
||||
// 집수정은 자기 길이(기본 2m)만큼만 이어진다 — 기슭막이 연장(10m)에 얹혀
|
||||
// 따라오면 있지도 않은 자리에 집수정이 생긴다. 관도 마찬가지(한 측점 전용).
|
||||
const basinHalf = (link.source.culvert?.inlet.basin_length_m ?? 0) / 2;
|
||||
return link.distanceM > basinHalf + 1e-9 ? { ...shifted, basin: null } : shifted;
|
||||
}
|
||||
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
|
||||
const layout = computeCulvertLayout(
|
||||
section,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
@@ -119,7 +120,12 @@ export function createCrossSectionCard(
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
/** 옆 측점 구조물이 이 측점까지 넘어온 경우의 링크(2026-08-24). 표시 전용. */
|
||||
culvertLink?: CulvertLink,
|
||||
): CrossCardElement {
|
||||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 그림만 얹고
|
||||
// 선택·조정은 막는다 — 조작값은 소유 측점 하나에서만 관리해야 한다.
|
||||
const isLinkedCulvert = !section.culvert && !!culvertLink;
|
||||
// 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점
|
||||
// (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편
|
||||
// 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점 = toeFitHalfWidth).
|
||||
@@ -385,6 +391,7 @@ export function createCrossSectionCard(
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
culvertLink,
|
||||
);
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
@@ -407,9 +414,9 @@ export function createCrossSectionCard(
|
||||
);
|
||||
}
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
|
||||
culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null;
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
if (culvertLayout) {
|
||||
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
|
||||
culvertInletIsBasin = !isLinkedCulvert && !!culvertLayout?.basin;
|
||||
if (culvertLayout && !isLinkedCulvert) {
|
||||
culvertInletOptions = culvertLayout.inletOptions;
|
||||
culvertExtraState = {
|
||||
canAdd: culvertLayout.outletFill.addable,
|
||||
@@ -445,7 +452,8 @@ export function createCrossSectionCard(
|
||||
culvertLayout,
|
||||
x,
|
||||
toDisplayY,
|
||||
revetOffset ? toggleRevet : undefined,
|
||||
revetOffset && !isLinkedCulvert ? toggleRevet : undefined,
|
||||
isLinkedCulvert,
|
||||
);
|
||||
if (activeRevet) setRevetActive(activeRevet);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
...response.design,
|
||||
inlet_structure: target.design?.inlet_structure,
|
||||
basin_adjust: target.design?.basin_adjust,
|
||||
revet_adjust: target.design?.revet_adjust,
|
||||
extra_wall_counts: target.design?.extra_wall_counts,
|
||||
};
|
||||
sectionView.refreshCard(chainageM);
|
||||
} catch (error) {
|
||||
@@ -257,6 +259,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
...(next as NonNullable<typeof section.design>),
|
||||
inlet_structure: section.design?.inlet_structure,
|
||||
basin_adjust: section.design?.basin_adjust,
|
||||
revet_adjust: section.design?.revet_adjust,
|
||||
extra_wall_counts: section.design?.extra_wall_counts,
|
||||
};
|
||||
sectionView.refreshCard(section.chainage_m);
|
||||
}
|
||||
@@ -519,6 +523,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
basinAdjustments.forEach((adjust, chainage) => {
|
||||
patchFor(Number(chainage)).basin_adjust = adjust;
|
||||
});
|
||||
// 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 실어 확정한다(2026-08-24).
|
||||
stationControls.revetAdjustsByChainage().forEach((adjusts, chainage) => {
|
||||
patchFor(chainage).revet_adjust = adjusts;
|
||||
});
|
||||
stationControls.extraCountsByChainage().forEach((counts, chainage) => {
|
||||
patchFor(chainage).extra_wall_counts = counts;
|
||||
});
|
||||
const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()];
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const result =
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface StationControls {
|
||||
widths: Map<string, number>;
|
||||
inletStructures: Map<string, InletStructureChoice>;
|
||||
basinAdjustments: Map<string, BasinAdjust>;
|
||||
/** 확정 payload용 — 측점별 기슭막이 4축 조작값(역할 → 값). */
|
||||
revetAdjustsByChainage: () => Map<number, Record<string, WallAdjust>>;
|
||||
/** 확정 payload용 — 측점별 다단 단 수. */
|
||||
extraCountsByChainage: () => Map<number, { outlet: number; basin: number }>;
|
||||
load: () => void;
|
||||
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
|
||||
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
|
||||
@@ -174,8 +178,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
(a.m ?? null) === (b.m ?? null);
|
||||
|
||||
const revetOffsetControl: RevetOffsetControl = {
|
||||
// 세션 → 정본(design.revet_adjust) → 자동. 확정된 조작값이 재접근·3D에서 살아난다
|
||||
// (2026-08-24 사용자: 3D는 종단·횡단 확정 뒤의 최종 산출물).
|
||||
adjustFor: (section, role) =>
|
||||
revetShifts.get(revetKey(section.chainage_m, role)) ?? { ...ZERO_ADJUST },
|
||||
revetShifts.get(revetKey(section.chainage_m, role)) ??
|
||||
(section.design?.revet_adjust?.[role]
|
||||
? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] }
|
||||
: { ...ZERO_ADJUST }),
|
||||
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
|
||||
select: (chainageM, key) => {
|
||||
if (key) revetSelected.set(chainageM.toFixed(2), key);
|
||||
@@ -305,7 +314,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
|
||||
const extraWallControl: ExtraWallControl = {
|
||||
countFor: (section, side = "outlet") =>
|
||||
extraCounts.get(extraKey(section.chainage_m, side)) ?? 0,
|
||||
extraCounts.get(extraKey(section.chainage_m, side)) ??
|
||||
section.design?.extra_wall_counts?.[side] ??
|
||||
0,
|
||||
equalize: (chainageM) => {
|
||||
if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미
|
||||
pendingEqualize.add(chainageM.toFixed(2));
|
||||
@@ -385,6 +396,31 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
widths: stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
// 확정·임시저장 payload용 — 세션 키(누가거리:역할)를 측점별로 되접는다.
|
||||
revetAdjustsByChainage: () => {
|
||||
const result = new Map<number, Record<string, WallAdjust>>();
|
||||
revetShifts.forEach((adjust, key) => {
|
||||
const [chainage, role] = key.split(":");
|
||||
const value = Number(chainage);
|
||||
if (!Number.isFinite(value) || !role) return;
|
||||
const bucket = result.get(value) ?? {};
|
||||
bucket[role] = adjust;
|
||||
result.set(value, bucket);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
extraCountsByChainage: () => {
|
||||
const result = new Map<number, { outlet: number; basin: number }>();
|
||||
extraCounts.forEach((count, key) => {
|
||||
const basin = key.startsWith("b");
|
||||
const value = Number(basin ? key.slice(1) : key);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const bucket = result.get(value) ?? { outlet: 0, basin: 0 };
|
||||
bucket[basin ? "basin" : "outlet"] = count;
|
||||
result.set(value, bucket);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
load: () => {
|
||||
loadStationWidths();
|
||||
loadRevetShifts();
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
type CrossCardElement,
|
||||
type StationWidthControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
applyLegendToggle,
|
||||
@@ -394,8 +396,24 @@ export function createSectionView(
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
culvertLinkFor(section),
|
||||
);
|
||||
|
||||
/**
|
||||
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
|
||||
* 배수관 측점 하나로 끝나지 않는다(2026-08-24 사용자). 3D는 이미 그 구간을 소유
|
||||
* 측점 단면으로 채우므로 횡단도도 같은 규칙으로 링크한다.
|
||||
*/
|
||||
const culvertLinkFor = (section: CrossSection): CulvertLink | undefined => {
|
||||
const detail = currentDetail;
|
||||
if (!detail) return undefined;
|
||||
return culvertLink(
|
||||
section,
|
||||
detail.cross_sections,
|
||||
(chainageM) => designElevationAt(detail.longitudinal.design_profiles, chainageM) ?? null,
|
||||
);
|
||||
};
|
||||
|
||||
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
|
||||
const toggleSeries = (key: string): void => {
|
||||
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다.
|
||||
|
||||
Reference in New Issue
Block a user