diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 9d30bc84..7f211e03 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index c8562acc..d7c288bc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -26,7 +26,10 @@ export const PIECE_COLS: Record = { 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; // 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 99975f49..8fb9808c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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(); +/** + * 측점별 배수관 세트 기하 캐시 — classifyStation(성토부 트림)과 구조물 빌더가 같은 + * 계산을 나눠 쓴다. 실패(null)도 캐시해 재계산을 막는다. + * + * 입력 요약(해시)을 함께 담는다 — 편집이 캐시(`section.design`)를 **제자리** 고치므로 + * 측점 객체만 키로 쓰면 옛 기하가 그대로 살아남아 3D가 안 바뀐다(2026-08-24 사용자 + * 확정 흐름: 편집 → 캐시 갱신 → 캐시로 플롯). + */ +const layoutCache = new WeakMap(); /** 정본(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; } diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 40e049bc..6e744256 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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): 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;