diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 1dd57e58..dc079a94 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -11,15 +11,16 @@ * · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23 * 사용자). 연장 = basin_length/before/after_m(기본 2m·1/1 — 2026-08-24). * · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경. - * 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust· - * extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는 - * 종단·횡단 확정 뒤의 최종 산출물이며 3D 쪽 편집은 없다). 확정 전 세션 값은 반영하지 - * 않는다. + * 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 **캐시**(`section.design`)에 그 자리에서 + * 실린다 — 3D는 그 **캐시를 읽는다**(2026-08-30 사용자 확정. 영구저장소 정본이 아니라 + * 캐시가 지금 화면의 값이다). B06 조정창이 캐시를 고치면 코리도 해시가 바뀌어 다시 빌드된다. + * + * 연동(2026-08-30 사용자 확정): 기슭막이는 길이·전/후로 **이미 하나**다. 연동을 푼 측점은 + * 그 측점 자리에 서므로 벽이 노선과 평행이 아니라 **자기 경로선**을 따라간다 — 링마다 + * 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다. * ========================================================================== */ 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 { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; @@ -39,6 +40,13 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box"; import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing"; +import { + buildWallPath, + detachedControlsOf, + polygonAt, + storedAdjusts, + type WallPath, +} from "./B05_Profile_UI_Corridor_Structures_Path"; import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ @@ -163,27 +171,6 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu */ const layoutCache = new WeakMap(); -/** 정본(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) } - : { ...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}`)), - }; -} - /** BOX암거 정본 조작값 — 확정 전 세션 값은 안 읽는다(2026-08-24 규칙). */ export function boxAdjustOf(section: CrossSection): BoxAdjust { const stored = section.design?.box_adjust; @@ -301,41 +288,38 @@ export function buildCorridorStructures( // 본다. 이 스위치는 3D 전용이다 — 횡단도는 언제나 그 측점 계획고로 그린다. const followGrade = section.design?.revet_follow_grade !== false; const baseZ = followGrade ? (frameAt(chainage)?.designZ ?? null) : null; - const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => { + /** 스윕 링이 설 누가거리 열 — 프레임과 폴리곤 보간이 같은 자리를 쓴다. */ + const ringChainagesOf = (beforeM: number, afterM: number): number[] => { const from = chainage - beforeM; const to = chainage + afterM; const count = Math.max(2, Math.ceil((to - from) / SWEEP_STEP_M) + 1); - const rings: StructureFrame[] = []; - for (let i = 0; i < count; i += 1) { - const at = from + ((to - from) * i) / (count - 1); - const frame = frameAt(at); - if (!frame) { - rings.push(stationFrame); - continue; - } - rings.push({ - cx: frame.cx, - cy: frame.cy, - leftX: frame.leftX, - leftY: frame.leftY, - dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0, - }); - } - return rings; + return Array.from({ length: count }, (_unused, i) => from + ((to - from) * i) / (count - 1)); }; - const pushSwept = ( kind: "revet" | "basin", points: Array<{ offset: number; elevation: number }>, beforeM: number, afterM: number, + /** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */ + path?: WallPath | null, ): void => { if (points.length < 3) return; + const chainages = ringChainagesOf(beforeM, afterM); + const rings = ringsAt(chainages); + if (!path) { + solids.push({ + chainage_m: chainage, + kind, + polygon: points.map((p) => [p.offset, p.elevation]), + rings, + }); + return; + } solids.push({ chainage_m: chainage, kind, - polygon: points.map((p) => [p.offset, p.elevation]), - rings: ringsOf(beforeM, afterM), + polygons: chainages.map((at) => polygonAt(path, at)), + rings, }); }; @@ -378,6 +362,8 @@ export function buildCorridorStructures( }, /** 보어(구멍) 중심 누가거리 목록 — 세월교는 련수만큼(2026-08-25 사용자 ⑥). */ boreChainages: number[] = [chainage], + /** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */ + path?: WallPath | null, ): void => { if (points.length < 3) return; const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); @@ -385,7 +371,7 @@ export function buildCorridorStructures( const right = Math.min(...polygon.map(([offset]) => offset)); // 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다. if (left < bore.minOffset || right > bore.maxOffset) { - pushSwept(kind, points, beforeM, afterM); + pushSwept(kind, points, beforeM, afterM, path); return; } // 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다. @@ -423,7 +409,7 @@ export function buildCorridorStructures( clamped.bottom = center; clamped.top = center; } - const [top, bottom] = splitByBand(polygon, clamped); + const [top, bottom] = splitByBand(path ? polygonAt(path, at) : polygon, clamped); upper.push(top); lower.push(bottom); } @@ -532,21 +518,37 @@ export function buildCorridorStructures( const basinSpan = basinSpanOf(section); const hiddenPipe = section.culvert?.hidden_pipe === true; const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet); + // 연동을 푼 측점이 있으면 벽은 노선과 평행이 아니라 **자기 경로선**을 따라간다 + // (2026-08-30 사용자). 벽 하나에 솔리드 하나인 것은 그대로다 — 링 단면만 바뀐다. + const detachedControls = detachedControlsOf(section, crossSections); + const pathOf = ( + key: string, + points: Array<{ offset: number; elevation: number }>, + ): WallPath | null => + detachedControls.length + ? buildWallPath( + { at: chainage, polygon: points.map((p) => [p.offset, p.elevation]) }, + detachedControls, + key, + ) + : null; for (const wall of layout.walls) { const span = wall.role === "inlet" ? inletSpan : outletSpan; + const path = pathOf(wall.role, wall.points); // 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다. - if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM); - else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); + if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path); + else + pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path); } // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). // 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`). layout.extraWalls.forEach((wall, i) => { const span = tierSpanOf(section, `extra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM); + pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points)); }); layout.basinExtras.forEach((wall, i) => { const span = tierSpanOf(section, `bextra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM); + pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points)); }); if (layout.basin) { @@ -701,5 +703,12 @@ export function structureHashParts(section: CrossSection): Array (a < b ? -1 : 1)) + .map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`) + .join("|"), + section.design?.revet_link_detached === true ? "detached" : "", ]; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts new file mode 100644 index 00000000..e1d71a76 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Path.ts @@ -0,0 +1,203 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Structures_Path.ts + * 기슭막이 **벽 경로선** — 연동을 푼 측점이 있을 때 벽이 노선과 평행하지 않고 자기 + * 경로를 따라가도록, 측점별 단면 폴리곤을 링마다 보간한다(2026-08-30 사용자 확정: + * "연동은 분리가 아니다 — 하나의 기슭막이가 스플라인 형태의 경로를 따라간다"). + * + * 순수 기하만 둔다(Three.js·레이아웃 계산 무의존). 측점 폴리곤을 만드는 쪽은 + * `_Corridor_Structures.ts`이고, 여기서는 **꼭짓점별 Catmull-Rom**으로 잇기만 한다. + * 꼭짓점 수가 다른 측점은 로프트 토폴로지가 깨지므로 제어점에서 뺀다 — 그런 벽은 + * 소유 측점 폴리곤 하나로 고정(종전 동작)된다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +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 { + culvertOwnerFor, + culvertReach, + restrictToSide, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; + +/** 단면 폴리곤 [offset, 표고][] — 코리도 솔리드가 쓰는 그 형식. */ +export type SectionPolygon = Array<[number, number]>; + +/** 경로선 제어점 하나 — 그 측점 누가거리와 거기서 잰 벽 단면. */ +export interface WallPathControl { + at: number; + polygon: SectionPolygon; +} + +/** 누가거리 오름차순 제어점 열. 2개 이상이라야 경로선이 생긴다. */ +export type WallPath = WallPathControl[]; + +/** 조정창 키(`inlet`·`outlet`·`extra0`·`bextra0`…)로 그 벽 단면을 꺼낸다. */ +export function wallPolygonOf(layout: CulvertLayout, key: string): SectionPolygon | null { + const wall = key.startsWith("bextra") + ? layout.basinExtras[Number(key.slice(6))] + : key.startsWith("extra") + ? layout.extraWalls[Number(key.slice(5))] + : layout.walls.find((candidate) => candidate.role === key); + if (!wall || wall.points.length < 3) return null; + return wall.points.map((point) => [point.offset, point.elevation]); +} + +/** + * 소유 측점 단면 + 연동을 푼 옆 측점 단면으로 경로선을 만든다. 제어점이 소유 측점 + * 하나뿐이거나 꼭짓점 수가 어긋나면 null — 호출부는 종전대로 단일 폴리곤을 스윕한다. + */ +export function buildWallPath( + base: WallPathControl, + others: ReadonlyArray<{ at: number; layout: CulvertLayout }>, + key: string, +): WallPath | null { + const path: WallPath = [base]; + for (const other of others) { + const polygon = wallPolygonOf(other.layout, key); + // 꼭짓점 수가 다르면 로프트 링 토폴로지가 깨진다 — 그 측점은 제어점에서 뺀다. + if (!polygon || polygon.length !== base.polygon.length) continue; + path.push({ at: other.at, polygon }); + } + if (path.length < 2) return null; + return path.sort((a, b) => a.at - b.at); +} + +/** 제어값 열의 Catmull-Rom 보간(끝 밖은 끝값 유지). 제어점 2개면 사실상 선형. */ +function splineValue(values: number[], ats: number[], at: number): number { + const last = values.length - 1; + if (last < 1) return values[0]; + if (at <= ats[0]) return values[0]; + if (at >= ats[last]) return values[last]; + let i = 0; + while (i < last - 1 && ats[i + 1] < at) i += 1; + const span = ats[i + 1] - ats[i]; + const t = span <= 1e-9 ? 0 : (at - ats[i]) / span; + const p0 = i > 0 ? values[i - 1] : values[i]; + const p1 = values[i]; + const p2 = values[i + 1]; + const p3 = i + 2 <= last ? values[i + 2] : values[i + 1]; + const t2 = t * t; + const t3 = t2 * t; + return ( + 0.5 * + (2 * p1 + + (-p0 + p2) * t + + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + + (-p0 + 3 * p1 - 3 * p2 + p3) * t3) + ); +} + +/** 그 누가거리의 단면 — 꼭짓점마다 offset·표고를 따로 보간한다. */ +export function polygonAt(path: WallPath, at: number): SectionPolygon { + const ats = path.map((control) => control.at); + return path[0].polygon.map((_vertex, index) => [ + splineValue( + path.map((control) => control.polygon[index][0]), + ats, + at, + ), + splineValue( + path.map((control) => control.polygon[index][1]), + ats, + at, + ), + ]); +} + +/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */ +export 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) } + : { ...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}`)), + }; +} + +/** + * 연동을 푼 측점의 4축 — **소유 측점 값에서 출발**해 이 측점이 손댄 벽만 갈아 끼운다. + * 단 수는 소유 측점 것이다(2026-08-30 사용자: 연동 해제는 분리가 아니다). 횡단도 + * `detachedAdjusts`와 같은 규칙 — 3D가 2D와 다른 벽을 세우면 안 된다. + */ +function detachedAdjusts( + owner: CrossSection, + section: CrossSection, +): ReturnType { + const base = storedAdjusts(owner); + const mine = section.design?.revet_adjust; + const counts = owner.design?.extra_wall_counts; + const pick = (role: string, fallback?: WallAdjust): WallAdjust => + mine?.[role] + ? { ...ZERO_ADJUST, ...(mine[role] as Partial) } + : (fallback ?? { ...ZERO_ADJUST }); + return { + inlet: pick("inlet", base?.inlet), + outlet: pick("outlet", base?.outlet), + extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => + pick(`extra${i}`, base?.extras[i]), + ), + basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => + pick(`bextra${i}`, base?.basinExtras[i]), + ), + }; +} + +/** + * **연동을 푼** 옆 측점의 기하 — 소유 측점 **스펙**을 그 측점 지형·조작값 위에 세운다. + * 횡단도 링크 카드(`computeCardCulvert`)와 같은 규칙이다: 구조물 형식은 언제나 소유 + * 측점 것, 4축은 이 측점 것. 벽 경로선의 제어점으로 쓴다(2026-08-30 사용자). + */ +export function detachedLayoutOf(owner: CrossSection, section: CrossSection): CulvertLayout | null { + if (!owner.culvert || !section.design) return null; + const hosted: CrossSection = { ...section, culvert: owner.culvert }; + try { + const layout = computeCulvertLayout( + hosted, + section.samples, + detachedAdjusts(owner, section), + owner.culvert.hidden_pipe ? "revet" : (owner.design?.inlet_structure ?? "auto"), + section.design.basin_adjust, + ); + if (layout && owner.culvert.hidden_pipe) return restrictToSide(layout, owner.culvert.side); + return layout; + } catch { + return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다. + } +} + +/** + * 이 구조물의 연장 안에서 **연동을 푼** 측점들의 기하. 경사 해제(수평)면 연동을 풀 수 + * 없으므로 빈 배열이다 — 그때는 소유 측점 단면 하나가 Z 그대로 눕는다(2026-08-30). + */ +export function detachedControlsOf( + owner: CrossSection, + sections: ReadonlyArray, +): Array<{ at: number; layout: CulvertLayout }> { + if (owner.design?.revet_follow_grade === false) return []; + const reach = culvertReach(owner); + if (!reach) return []; + const controls: Array<{ at: number; layout: CulvertLayout }> = []; + for (const section of sections) { + if (section === owner || section.design?.revet_link_detached !== true) continue; + const deltaM = section.chainage_m - owner.chainage_m; + if (deltaM < -reach.beforeM - 1e-9 || deltaM > reach.afterM + 1e-9) continue; + // 겹치는 구조물이 있으면 가장 가까운 측점이 주인이다 — 남의 벽을 끌어오지 않는다. + if (culvertOwnerFor(section, sections) !== owner) continue; + const layout = detachedLayoutOf(owner, section); + if (layout) controls.push({ at: section.chainage_m, layout }); + } + return controls; +} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 1f498914..6464073f 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -105,6 +105,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti put("ford_adjust", design.ford_adjust); put("box_adjust", design.box_adjust); put("extra_wall_counts", design.extra_wall_counts); + put("extra_spans", design.extra_spans); put("revet_link_detached", design.revet_link_detached); put("revet_follow_grade", design.revet_follow_grade); if (touched) patches.push(patch); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 35b1261b..7c66da84 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -10,6 +10,7 @@ import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Sectio import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types"; import { basinSpanOfSpec, + FILL_SLOPE_RATIO_MIN, materialLabel, materialLimit, revetSpanOfSpec, @@ -29,8 +30,16 @@ import { */ export interface RevetOffsetControl { adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust; + /** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */ + storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null; selectedFor: (section: CrossSection) => RevetKey | null; + /** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다 + * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). + * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ + highlightFor: (section: CrossSection) => RevetKey | null; select: (chainageM: number, key: RevetKey | null) => void; + /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ + onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void; /** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */ @@ -154,7 +163,7 @@ export function tierSpanOf(section: CrossSection, key: string): StructureSpan { } /** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */ -function culvertReach(section: CrossSection): StructureSpan | null { +export function culvertReach(section: CrossSection): StructureSpan | null { const culvert = section.culvert; if (!culvert) return null; let beforeM = 0; @@ -205,6 +214,26 @@ export function culvertOwnerFor( return best; } +/** + * 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용 + * (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다: + * 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`). + */ +export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean { + const deltaM = section.chainage_m - owner.chainage_m; + if (Math.abs(deltaM) < 0.01) return true; + const spec = owner.culvert; + if (!spec) return false; + if (key.startsWith("extra") || key.startsWith("bextra")) { + return spanCovers(tierSpanOf(owner, key), deltaM); + } + const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null; + if (!side) return false; + // 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다. + if (side.structure === "집수정" && !spec.hidden_pipe) return false; + return spanCovers(revetSpanOfSpec(side), deltaM); +} + export function culvertLinkFor( section: CrossSection, sections: readonly CrossSection[], @@ -352,23 +381,41 @@ export function computeCardCulvert( // 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다. // 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시 // 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자). - const ownerAdjusts = detached - ? adjustsInput(section, revetOffset, extraWalls) - : (computeCulvertLayout( - link.source, - link.source.samples, - adjustsInput(link.source, revetOffset, extraWalls), - link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), - inletStructure?.adjustFor(link.source), - )?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls)); + const ownerInput = adjustsInput(link.source, revetOffset, extraWalls); + const ownerApplied = + computeCulvertLayout( + link.source, + link.source.samples, + ownerInput, + link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), + inletStructure?.adjustFor(link.source), + )?.revetShift ?? ownerInput; + // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 + // 벽만 자기 값으로 선다(2026-08-30 사용자). + const ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; + // 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽 + // 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용" + // 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점 + // 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피). + const followGrade = + revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false; const layout = computeCulvertLayout( hosted, sourceSamples, - ownerAdjusts, + followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz), link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(detached ? section : link.source), ); if (!layout) return null; + // 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다. + if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) { + const requested = levelToOwner(ownerAdjusts, link.dz); + const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet; + const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet; + if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) { + showToast(L("B06_Cross_Revet_Limit_Up"), "info"); + } + } // 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나 // 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서 // 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적). @@ -492,6 +539,51 @@ export function computeCardCulvert( return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout; } +/** + * **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지 + * 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 − d/1.2`(`placePipeWall`)이므로 이 측점 + * d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는 + * **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다. + * + * 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다. + * d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다. + */ +function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet { + if (!adjusts || Math.abs(dz) < 1e-9) return adjusts; + const shift = (adjust: WallAdjust): WallAdjust => ({ + ...adjust, + d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz, + }); + return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) }; +} + +/** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */ +type WallAdjustSet = + | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } + | undefined; + +/** + * **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다"). + * 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다. + * 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다 + * (2026-08-30 화면 실측: 벽 4 → 2). + */ +function detachedAdjusts( + section: CrossSection, + base: WallAdjustSet, + revetOffset?: RevetOffsetControl, +): WallAdjustSet { + if (!base) return base; + const pick = (role: string, fallback: WallAdjust): WallAdjust => + revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback; + return { + inlet: pick("inlet", base.inlet), + outlet: pick("outlet", base.outlet), + extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)), + basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)), + }; +} + function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 9fdffd0c..dc00bb8b 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -408,11 +408,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan optionParts.controls.append(button); return { button, set }; }; - const linkToggle = makeToggle( - "연동", - "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.", - (on) => deps.setLinked(on), - ); + const LINK_TITLE = + "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다."; + /** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */ + const LINK_LOCKED_TITLE = + "경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다."; + const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on)); const gradeToggle = makeToggle( "경사", "종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.", @@ -575,8 +576,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan const linkState = deps.linkState(); linkToggle.button.classList.toggle("is-hidden", linkState === null); linkToggle.set(linkState?.linked ?? true); + // 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다. + const levelled = !deps.followGrade(); + linkToggle.button.disabled = levelled; + linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE; gradeToggle.button.classList.toggle("is-hidden", currentSpan === null); - gradeToggle.set(deps.followGrade()); + gradeToggle.set(!levelled); optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null); const adjust = deps.adjustFor(key); // 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥, diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index e4b20ca1..df4f6938 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -70,6 +70,9 @@ export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics"; */ export interface CrossCardElement extends HTMLElement { applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void; + /** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자). + * 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */ + applyRevetHighlight?: (key: RevetKey | null) => void; } /** @@ -181,6 +184,9 @@ export function createCrossSectionCard( let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; + /** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 — + * 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */ + let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 @@ -222,9 +228,11 @@ export function createCrossSectionCard( const toggleRevet = (key: RevetKey): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; - setRevetActive(activeRevet); showRevetControl(activeRevet !== null); + // 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를 + // 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다. revetOffset?.select(section.chainage_m, activeRevet); + card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet); if (activeRevet !== null && activeArea !== null) { activeArea = null; setBandActive(null); @@ -247,13 +255,20 @@ export function createCrossSectionCard( setChipActive(activeArea); // 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨). if ((activeArea !== null || !nextSelected) && activeRevet !== null) { + // 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지 + // 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조). + const owns = revetOffset?.selectedFor(section) === activeRevet; activeRevet = null; - setRevetActive(null); showRevetControl(false); - revetOffset?.select(section.chainage_m, null); + if (owns) revetOffset?.select(section.chainage_m, null); + else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null); } if (activeArea !== null || !nextSelected) bodies.clearSelection(); }; + card.applyRevetHighlight = (key) => { + highlightRevet = key; + setRevetActive(key); + }; const toggleArea = (key: CrossAreaKey): void => { if (!isSelected) { // 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다. @@ -479,7 +494,7 @@ export function createCrossSectionCard( baseSetActive(key); setOwnActive(key === "own"); }; - if (activeRevet === "own") setOwnActive(true); + if (highlightRevet === "own") setOwnActive(true); } } appendCrossDesignOverlay( @@ -548,7 +563,7 @@ export function createCrossSectionCard( // 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙. isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true, ); - if (activeRevet) setRevetActive(activeRevet); + if (highlightRevet) setRevetActive(highlightRevet); } } @@ -662,8 +677,14 @@ export function createCrossSectionCard( isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) ? culvertLink.source : section; - const adjustOf = (key: RevetKey): WallAdjust => - revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null }; + // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 + // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). + const adjustOf = (key: RevetKey): WallAdjust => { + const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); + if (stored) return stored; + const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; + return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; + }; const panel = buildStructurePanel( structurePanelDeps({ section, diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 94a527ab..2decf785 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -24,7 +24,12 @@ import type { StructureSpanControl, } from "./B06_Section_UI_Cross_View"; import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const"; -import { culvertOwnerFor, tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { + culvertOwnerFor, + culvertReach, + tierSpanOf, + wallStandsAt, +} from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session"; @@ -184,13 +189,15 @@ export function createStationControls(deps: StationControlDeps): StationControls * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ const revetShifts = new Map(); - const revetSelected = new Map(); + /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: + * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */ + let revetSelection: { at: string; key: RevetKey } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; const revetSessionKey = (): string | null => deps.sessionKey("revetx"); function loadRevetShifts(): void { revetShifts.clear(); - revetSelected.clear(); + revetSelection = null; const key = revetSessionKey(); if (!key) return; try { @@ -255,17 +262,11 @@ export function createStationControls(deps: StationControlDeps): StationControls deps.refreshCard(chainageM); const sections = deps.detail()?.cross_sections ?? []; const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01); - const spec = owner?.culvert; - if (!spec) return; - const reach = Math.max( - ...[spec.inlet, spec.outlet].flatMap((side) => { - const span = - side?.structure === "집수정" - ? CulvertConst.basinSpanOfSpec(side) - : CulvertConst.revetSpanOfSpec(side); - return [span.beforeM, span.afterM]; - }), - ); + if (!owner) return; + // 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다. + const span = owner.culvert ? culvertReach(owner) : null; + if (!span) return; + const reach = Math.max(span.beforeM, span.afterM); for (const other of sections) { if (other === owner) continue; if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9) @@ -281,10 +282,29 @@ export function createStationControls(deps: StationControlDeps): StationControls (section.design?.revet_adjust?.[role] ? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] } : { ...ZERO_ADJUST }), - selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null, + storedAdjustFor: (section, role) => { + const session = revetShifts.get(revetKey(section.chainage_m, role)); + if (session) return session; + const stored = section.design?.revet_adjust?.[role]; + return stored ? { ...ZERO_ADJUST, ...stored } : null; + }, + selectedFor: (section) => + revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null, + // 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의 + // 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자). + highlightFor: (section) => { + if (!revetSelection) return null; + if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key; + const picked = sectionAt(Number(revetSelection.at)); + if (!picked) return null; + const owner = picked.culvert ? picked : ownerOf(picked); + const here = section.culvert ? section : ownerOf(section); + if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null; + return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null; + }, select: (chainageM, key) => { - if (key) revetSelected.set(chainageM.toFixed(2), key); - else revetSelected.delete(chainageM.toFixed(2)); + revetSelection = key ? { at: chainageM.toFixed(2), key } : null; + revetOffsetControl.onSelectionChange?.(); }, syncApplied: (chainageM, role, applied) => { // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. @@ -650,8 +670,36 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }; + /** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */ + const followGradeOf = (section: CrossSection): boolean => { + const session = followGrades.get(chainageKey(section.chainage_m)); + if (session !== undefined) return session; + return section.design?.revet_follow_grade !== false; + }; + + /** + * 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때 + * 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은 + * 이 구조물 것이 아니라 남긴다. + */ + const dropLinkedAdjusts = (chainageM: number): void => { + const prefix = `${chainageM.toFixed(2)}:`; + for (const key of [...revetShifts.keys()]) { + if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue; + revetShifts.delete(key); + } + persistRevetShifts(); + const design = sectionAt(chainageM)?.design; + if (!design?.revet_adjust) return; + const own = design.revet_adjust.own; + design.revet_adjust = own ? { own } : {}; + }; + const revetLinkControl: RevetLinkControl = { linkedFor: (section) => { + // 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를 + // 하나로 묶을 수 없다(2026-08-30 사용자). + if (!followGradeOf(ownerOf(section) ?? section)) return true; const session = linkDetached.get(chainageKey(section.chainage_m)); if (session !== undefined) return !session; return section.design?.revet_link_detached !== true; @@ -662,34 +710,26 @@ export function createStationControls(deps: StationControlDeps): StationControls patchCachedDesign(chainageM, { revet_link_detached: !linked }); deps.refreshCard(chainageM); }, - followGradeFor: (section) => { - const session = followGrades.get(chainageKey(section.chainage_m)); - if (session !== undefined) return session; - return section.design?.revet_follow_grade !== false; - }, + followGradeFor: followGradeOf, setFollowGrade: (section, follow) => { followGrades.set(chainageKey(section.chainage_m), follow); - persistLinkFlags(); patchCachedDesign(section.chainage_m, { revet_follow_grade: follow }); // 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그 // 연장 안의 측점만 다시 그린다. - const spec = section.culvert; - const reach = spec - ? Math.max( - ...[spec.inlet, spec.outlet].flatMap((side) => { - const span = - side?.structure === "집수정" - ? CulvertConst.basinSpanOfSpec(side) - : CulvertConst.revetSpanOfSpec(side); - return [span.beforeM, span.afterM]; - }), - ) - : 0; + const span = section.culvert ? culvertReach(section) : null; + const reach = span ? Math.max(span.beforeM, span.afterM) : 0; for (const other of deps.detail()?.cross_sections ?? []) { - if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) { - deps.refreshCard(other.chainage_m); + if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue; + // 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다 + // (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다). + if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) { + linkDetached.set(chainageKey(other.chainage_m), false); + patchCachedDesign(other.chainage_m, { revet_link_detached: false }); + dropLinkedAdjusts(other.chainage_m); } + deps.refreshCard(other.chainage_m); } + persistLinkFlags(); }, }; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index e9ebc848..bbd85b5c 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -331,6 +331,22 @@ export function createSectionView( card?.applySelection?.(stationId === selectedStationId, activeAreaKey); }; + /** + * 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는 + * 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다. + * 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존. + */ + const syncRevetHighlights = (): void => { + if (!currentDetail || !revetOffset) return; + for (const section of currentDetail.cross_sections) { + const card = document.getElementById( + `cross-${section.station_id}`, + ) as CrossCardElement | null; + card?.applyRevetHighlight?.(revetOffset.highlightFor(section)); + } + }; + if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; + /** * 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**. * 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다