fix(B05·B06): 기슭막이 조작의 3D 즉시 반영 · 성토 비탈 실루엣 순서 복구
사용자 보고: 기슭막이 위치(본·연동)를 바꿔도, 추가(다단) 기슭막이를 세워도 3D가 바뀌지 않는다. 형상은 성토선 > 본 기슭막이 > 추가 성토선 > 추가 기슭막이 순이어야 한다. 원인이 둘이었다. ① 조작값이 캐시에 안 실렸다 — 사용자 확정 흐름은 "영구저장소 → 브라우저 캐시 → 3D는 캐시로 플롯, 편집하면 캐시를 갱신하고 그 캐시로 다시 플롯"이다. 그런데 조정창 조작값(4축·다단 단 수·유입 구조물·집수정·연동 플래그)은 sessionStorage에만 있고 확정·임시저장 때만 design에 실렸다. 이제 편집 즉시 `section.design`(B05·B06이 공유하는 같은 객체)에 함께 기록한다. 세션은 리로드 복원용 사본으로 남는다. ② 기하 캐시가 낡았다 — `culvertLayoutOf`의 WeakMap이 측점 객체만 키로 써서, 캐시를 제자리 고치면 옛 기하가 그대로 살아남는다. 입력 요약 해시를 함께 담아 무효화한다. ③ 비탈 실루엣이 순서를 잃었다 — 수집한 점을 편거리로 정렬한 뒤 재추출했다. 기슭막이 앞면이 거의 수직이라 편거리 정렬은 순서를 보장하지 못하고, 다단 계단은 안팎을 오가 뒤섞인다. 유출측은 수집 순서를 그대로 잇고, 추가 성토선과 추가 기슭막이를 번갈아 밀어 계단 순서를 맞춘다. 유입측(집수정·절토)은 범위 밖이라 종전 정렬 유지. ④ 성토 조각 해상도 20 → 48열 — 호길이 균등 재추출이 수직면을 뭉갠다. 열을 꼭짓점에 맞춰 배분하면 측점마다 열 수가 달라 리본이 꼬이므로, 파라미터화는 그대로 두고 해상도로 해결했다. 절토는 20 유지(다단이 붙지 않는다). 성토 물매 불변: 이번 수정은 trimSlope·outletFill 점을 만들지 않는다 — 잇는 순서와 재추출 열 수만 바꿨고, 물매 산출 경로(layout.fillSlope.ratio)는 손대지 않았다. BUILD_VERSION 17 → 18(저장 코리도 만료). tsc/prettier 통과, pytest 196 passed(기존 실패 1건 유지). 작업이력·자체검증 기록: docs/raw/PLAN.md §4-10. 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 = 17; // 연동 자리 고정 · 3D 절토선 트림 제거(2026-08-24).
|
||||
const BUILD_VERSION = 18; // 조작값 캐시 반영 · 비탈 실루엣 순서 복구(2026-08-24).
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
|
||||
@@ -26,7 +26,10 @@ export const PIECE_COLS: Record<CorridorKind, number> = {
|
||||
shoulder: 3,
|
||||
ditch: 5,
|
||||
cut: 20,
|
||||
fill: 20,
|
||||
// 성토 조각은 다단 기슭막이의 계단(거의 수직인 앞면)을 담아야 한다. 20열이면 호길이
|
||||
// 균등 재추출이 수직면을 뭉갠다(2026-08-24). 열 수는 모든 측점이 같아야 리본이
|
||||
// 꼬이지 않으므로, 꼭짓점 보존 대신 **열을 늘려** 해상도로 해결한다.
|
||||
fill: 48,
|
||||
};
|
||||
|
||||
export interface XY {
|
||||
@@ -318,11 +321,17 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
// 유출부가 지면에 닿은 뒤의 **절토선**(kind="cut")과 유입 기슭막이 이후의 **접속선**
|
||||
// (inletFill)은 넣지 않는다 — 2D 횡단도에서 지운 선이라 3D 지반 트림에도 반영해야
|
||||
// 두 화면이 같은 형상이 된다(2026-08-24 사용자: "3D에 반영이 안 된 것 같다").
|
||||
layout.outletFill.segments.forEach((segment) => {
|
||||
if (segment.kind === "cut") return;
|
||||
push(outletSide, segment.points);
|
||||
});
|
||||
layout.extraWalls.forEach((wall) => push(outletSide, [wall.topJoint, wall.bottomFront]));
|
||||
// 성토선 > 본 기슭막이 > **추가 성토선 > 추가 기슭막이** 순으로 번갈아 잇는다
|
||||
// (2026-08-24 사용자 확정). 세그먼트를 다 밀고 나서 벽을 밀면 계단이 안팎을 오가는
|
||||
// 순서가 깨져 3D 비탈이 엉킨다. 유출부 접지 후 절토선(kind="cut")은 넣지 않는다.
|
||||
const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut");
|
||||
const stepCount = Math.max(outletSegments.length, layout.extraWalls.length);
|
||||
for (let step = 0; step < stepCount; step += 1) {
|
||||
const segment = outletSegments[step];
|
||||
if (segment) push(outletSide, segment.points);
|
||||
const wall = layout.extraWalls[step];
|
||||
if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]);
|
||||
}
|
||||
layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points));
|
||||
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
|
||||
|
||||
@@ -395,7 +404,13 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
}
|
||||
}
|
||||
if (silhouette.length < 2) return;
|
||||
silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m);
|
||||
// 유출측(성토 + 다단)은 **수집 순서 그대로** 잇는다 — 기슭막이 앞면이 거의 수직이라
|
||||
// 편거리 정렬은 순서를 보장하지 못하고, 다단 계단은 안팎을 오가 뒤섞인다
|
||||
// (2026-08-24 사용자: 위치·다단이 3D에 반영 안 됨). 유입측(집수정·절토)은 이번
|
||||
// 범위 밖이라 종전 정렬을 유지한다.
|
||||
if (side !== outletSide) {
|
||||
silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m);
|
||||
}
|
||||
const kind: CorridorKind =
|
||||
basinCut && side === inletSide ? "cut" : side === "left" ? leftRole : rightRole;
|
||||
// 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다.
|
||||
|
||||
@@ -67,9 +67,15 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu
|
||||
return [Math.max(before, 0), Math.max(after, 0)];
|
||||
}
|
||||
|
||||
/** 측점별 배수관 세트 기하 캐시 — classifyStation(성토부 트림)과 구조물 빌더가
|
||||
* 같은 계산을 나눠 쓴다. 실패(null)도 캐시해 재계산을 막는다. */
|
||||
const layoutCache = new WeakMap<CrossSection, CulvertLayout | null>();
|
||||
/**
|
||||
* 측점별 배수관 세트 기하 캐시 — classifyStation(성토부 트림)과 구조물 빌더가 같은
|
||||
* 계산을 나눠 쓴다. 실패(null)도 캐시해 재계산을 막는다.
|
||||
*
|
||||
* 입력 요약(해시)을 함께 담는다 — 편집이 캐시(`section.design`)를 **제자리** 고치므로
|
||||
* 측점 객체만 키로 쓰면 옛 기하가 그대로 살아남아 3D가 안 바뀐다(2026-08-24 사용자
|
||||
* 확정 흐름: 편집 → 캐시 갱신 → 캐시로 플롯).
|
||||
*/
|
||||
const layoutCache = new WeakMap<CrossSection, { hash: string; layout: CulvertLayout | null }>();
|
||||
|
||||
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
|
||||
function storedAdjusts(
|
||||
@@ -94,7 +100,9 @@ function storedAdjusts(
|
||||
|
||||
export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
|
||||
if (!section.culvert || !section.design) return null;
|
||||
if (layoutCache.has(section)) return layoutCache.get(section) ?? null;
|
||||
const hash = structureHashParts(section).join("|");
|
||||
const cached = layoutCache.get(section);
|
||||
if (cached && cached.hash === hash) return cached.layout;
|
||||
let layout: CulvertLayout | null = null;
|
||||
try {
|
||||
layout = computeCulvertLayout(
|
||||
@@ -107,7 +115,7 @@ export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
|
||||
} catch {
|
||||
layout = null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
|
||||
}
|
||||
layoutCache.set(section, layout);
|
||||
layoutCache.set(section, { hash, layout });
|
||||
return layout;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* (`_UI_Page.ts`)에서 700줄 제한으로 분리했다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesign, CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
@@ -68,6 +68,23 @@ export interface StationControls {
|
||||
}
|
||||
|
||||
export function createStationControls(deps: StationControlDeps): StationControls {
|
||||
/* ── 조작값 캐시 write-through(2026-08-24 사용자 확정 데이터 흐름) ────────
|
||||
* 영구저장소 → 브라우저 캐시 → 3D는 **캐시로 플롯**. 편집하면 캐시를 갱신하고 그
|
||||
* 캐시로 다시 그린다. 그래서 조정창 조작값은 세션에만 담지 않고 그 자리에서
|
||||
* `section.design`(B05·B06이 공유하는 같은 객체)에 실어야 한다 — 세션은 리로드
|
||||
* 복원용 사본이다. 코리도 해시가 design을 읽으므로 캐시가 바뀌면 3D가 다시 빌드된다.
|
||||
*/
|
||||
const sectionAt = (chainageM: number): CrossSection | undefined =>
|
||||
deps
|
||||
.detail()
|
||||
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
|
||||
|
||||
/** design이 있는 측점에만 얹는다 — 없는 측점은 아직 설계 전이라 실을 자리가 없다. */
|
||||
const patchCachedDesign = (chainageM: number, patch: Partial<CrossDesign>): void => {
|
||||
const design = sectionAt(chainageM)?.design;
|
||||
if (design) Object.assign(design, patch);
|
||||
};
|
||||
|
||||
/* ── 측점 개별 표시 반폭(2026-08-06 사용자 지시) ──────────────────────
|
||||
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
|
||||
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
|
||||
@@ -185,6 +202,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
if (isDefaultAdjust(value)) revetShifts.delete(key);
|
||||
else revetShifts.set(key, value);
|
||||
persistRevetShifts();
|
||||
// 같은 값을 캐시(design.revet_adjust)에도 실어 3D가 바로 따라오게 한다.
|
||||
const [chainage, role] = key.split(":");
|
||||
const at = Number(chainage);
|
||||
if (!Number.isFinite(at) || !role) return;
|
||||
const design = sectionAt(at)?.design;
|
||||
if (!design) return;
|
||||
const bucket = { ...(design.revet_adjust ?? {}) };
|
||||
if (isDefaultAdjust(value)) delete bucket[role];
|
||||
else bucket[role] = { ...value };
|
||||
design.revet_adjust = bucket;
|
||||
};
|
||||
const axisEq = (a: number | null, b: number | null): boolean =>
|
||||
a == null || b == null ? a === b : Math.abs(a - b) < 1e-9;
|
||||
@@ -374,6 +401,12 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
}
|
||||
persistRevetShifts();
|
||||
persistExtraCounts();
|
||||
patchCachedDesign(chainageM, {
|
||||
extra_wall_counts: {
|
||||
outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0,
|
||||
basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0,
|
||||
},
|
||||
});
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
syncCount: (chainageM, built, side = "outlet") => {
|
||||
@@ -404,6 +437,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
set: (chainageM, value) => {
|
||||
inletStructures.set(chainageM.toFixed(2), value);
|
||||
persistInletStructures();
|
||||
patchCachedDesign(chainageM, { inlet_structure: value });
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
updateAdjust: (chainageM, patch) => {
|
||||
@@ -417,11 +451,13 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
slopeM: Math.max(0, clampMove(next.slopeM)),
|
||||
});
|
||||
persistBasinAdjustments();
|
||||
patchCachedDesign(chainageM, { basin_adjust: basinAdjustments.get(key) });
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
resetAdjust: (chainageM) => {
|
||||
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
|
||||
persistBasinAdjustments();
|
||||
patchCachedDesign(chainageM, { basin_adjust: { ...DEFAULT_BASIN_ADJUST } });
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
};
|
||||
@@ -568,6 +604,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
setLinked: (chainageM, linked) => {
|
||||
linkDetached.set(chainageKey(chainageM), !linked);
|
||||
persistLinkFlags();
|
||||
patchCachedDesign(chainageM, { revet_link_detached: !linked });
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
followGradeFor: (section) => {
|
||||
@@ -578,6 +615,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
setFollowGrade: (section, follow) => {
|
||||
followGrades.set(chainageKey(section.chainage_m), follow);
|
||||
persistLinkFlags();
|
||||
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
|
||||
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
|
||||
// 연장 안의 측점만 다시 그린다.
|
||||
const spec = section.culvert;
|
||||
|
||||
Reference in New Issue
Block a user