diff --git a/A00_Common/b_structures_section.ts b/A00_Common/b_structures_section.ts new file mode 100644 index 00000000..051169fc --- /dev/null +++ b/A00_Common/b_structures_section.ts @@ -0,0 +1,17 @@ +/* ============================================================================= + * b_structures_section.ts + * 「구조물 배치」 컨테이너 공용 진입점 — B05·B06 두 페이지가 같은 주소로 쓴다 + * (2026-08-29 사용자: B05/B06 인터페이스 일원화 — 배치 폼·하단 목록 템플릿 동일). + * + * 구현은 B05 모듈(`B05_Profile_UI_Structures_Panel` 외)에 있다. 이 파일은 재수출과 + * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. + * ========================================================================== */ +import "../B05_Profile/B05_Profile_UI_Style.css"; +import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; + +export { + createStructuresSection, + GROUP_LABELS, + type PipeFacilityItem, + type StructuresSection, +} from "../B05_Profile/B05_Profile_UI_Structures_Panel"; 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/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 1c25fc4b..89b86d98 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -40,6 +40,7 @@ import { BOX_SIZE_CUSTOM, BOX_SIZE_PRESETS, createRevetmentFields, + createRevetSideGroup, createWingFields, fordSection, grid, @@ -51,10 +52,35 @@ import { OUTLET_REVET_KEYS, putNumber, REVET_COMMON_DEFAULTS, + stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; +/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합). + * 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */ +export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U"; + +/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한 + * 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다). + * 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06 + * 유입측 형식(`inlet_structure`). */ +const INLET_KINDS: ReadonlyArray<{ + label: string; + type: "기슭막이" | "집수정"; + structure: InletStructureKind; +}> = [ + { label: "기슭막이", type: "기슭막이", structure: "revet" }, + { label: "집수정", type: "집수정", structure: "auto" }, + { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, + { label: "집수정 I형", type: "집수정", structure: "I" }, + { label: "집수정 ㄴ형", type: "집수정", structure: "L" }, + { label: "집수정 ㄷ형", type: "집수정", structure: "U" }, +]; + +const inletKindOf = (label: string): (typeof INLET_KINDS)[number] => + INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0]; + /** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을 * 가지면 저장한다. */ export interface FacilityAttributes { @@ -153,6 +179,21 @@ export interface FacilityOptionsForm { /** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(2026-08-18)의 * 유역 재계산 결과를 입력 중인 폼에 살짝 반영하는 용도. */ setDesignFlow: (designFlow: number | null) => void; + /** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */ + inletSlot: HTMLElement; + outletSlot: HTMLElement; + /** 다단(추가) 기슭막이 행이 옮겨 붙는 자리 — 비면 그룹째 숨는다(2026-08-30 지시 4). */ + extraSlot: HTMLElement; + /** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */ + revetInletSlot: HTMLElement; + revetOutletSlot: HTMLElement; + /** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */ + inletStructure: () => InletStructureKind; + setInletStructure: (value: InletStructureKind) => void; + /** 독립 기슭막이 좌·우 칸의 이름표를 그 측점 방향으로 맞춘다(B06 전용). */ + setRevetSideLabels: (labels: { inlet: "좌" | "우"; outlet: "좌" | "우" }) => void; + /** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */ + onInletStructureChange: (handler: (value: InletStructureKind) => void) => void; } /** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */ @@ -182,8 +223,8 @@ export function createFacilityOptionsForm( // 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로 // 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시). const inletType = document.createElement("select"); - inletType.replaceChildren(...["기슭막이", "집수정"].map((value) => new Option(value, value))); - inletType.value = "기슭막이"; + inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label))); + inletType.value = INLET_KINDS[0].label; const basinForm = optionalSelect("선택", [ "동물이동형", "□형(기본형)", @@ -201,17 +242,25 @@ export function createFacilityOptionsForm( // 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자). const basinLength = numberInput("0.1"); basinLength.value = "2"; - const basinRows = [grid(labeled("형식", basinForm), labeled("길이 (m)", basinLength))]; + const basinRows = [ + grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))), + ]; const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(찰)", }); // 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다 // (2026-08-17 사용자 지시 3). + // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4). + // 비어 있으면 아무 자리도 차지하지 않는다. + const inletSlot = document.createElement("div"); + inletSlot.className = "b05-structure__adjust-slot"; + const outletSlot = inletSlot.cloneNode() as HTMLDivElement; inletGroup.body.append( grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField), ...basinRows, ...inletRevet.rows, + inletSlot, ); // ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ── @@ -225,45 +274,59 @@ export function createFacilityOptionsForm( outletGroup.body.append( grid(labeled("구조", outletType), outletRevet.formField), ...outletRevet.rows, + outletSlot, ); + // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 + // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 + // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). + const extraGroup = group("추가 기슭막이"); + const extraSlot = document.createElement("div"); + extraSlot.className = "b05-structure__adjust-slot"; + extraGroup.body.append(extraSlot); + extraGroup.root.hidden = true; + // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── - // 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과 - // 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다. - const revetGroup = group("기슭막이"); + // 설치 측은 **컨테이너 밖**(배관의 관종·관경과 같은 층), 벽 제원과 **단 수**는 좌·우 + // 두 칸이 각자 갖는다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다. + // 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만 + // 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다. const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]); revetSide.value = "양쪽"; - const revetTiers = numberInput("1", "1"); - revetTiers.value = "1"; - // 옵션 키는 배관 유출 기슭막이와 **같은 한 벌**을 쓴다 — B06 조정창의 구간값도 이 키로 - // 저장되므로 원천이 하나여야 한다(2026-08-28: 전용 키 `length_m`을 쓰던 동안 조정창 - // 값이 매번 되돌아갔다). 읽을 때만 옛 전용 키를 폴백으로 본다. - const standaloneRevet = createRevetmentFields(OUTLET_REVET_KEYS, { - ...REVET_COMMON_DEFAULTS, - form: "돌쌓기(메)", - }); - /** 옛 전용 키(`form`·`length_m`…)로 저장된 값을 배관 키 자리로 옮겨 읽는다. */ + const revetTopRow = grid(labeled("설치 측", revetSide)); + const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)"); + const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)"); + /** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */ + let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = { + inlet: "좌", + outlet: "우", + }; + /** 옛 전용 키(`form`·`length_m`…)와 한 벌 저장분을 **양쪽 칸**으로 펼쳐 읽는다. */ const legacyRevetOptions = ( options: Record, ): Record => { const merged = { ...options }; - const pairs: Array<[string, string]> = [ - ["outlet_revet_form", "form"], - ["outlet_revet_length_m", "length_m"], - ["outlet_revet_height_m", "height_m"], - ["outlet_revet_before_m", "before_m"], - ["outlet_revet_after_m", "after_m"], + const pairs: Array<[string, string, string]> = [ + ["outlet_revet_form", "inlet_revet_form", "form"], + ["outlet_revet_length_m", "inlet_revet_length_m", "length_m"], + ["outlet_revet_height_m", "inlet_revet_height_m", "height_m"], + ["outlet_revet_before_m", "inlet_revet_before_m", "before_m"], + ["outlet_revet_after_m", "inlet_revet_after_m", "after_m"], ]; - for (const [key, legacy] of pairs) { - if (merged[key] === undefined && options[legacy] !== undefined) merged[key] = options[legacy]; + for (const [outletKey, inletKey, legacy] of pairs) { + if (merged[outletKey] === undefined && options[legacy] !== undefined) { + merged[outletKey] = options[legacy]; + } + // 한쪽만 저장된 옛 자료 — 반대쪽 칸도 같은 값으로 세운다. + if (merged[inletKey] === undefined && merged[outletKey] !== undefined) { + merged[inletKey] = merged[outletKey]; + } + if (merged[outletKey] === undefined && merged[inletKey] !== undefined) { + merged[outletKey] = merged[inletKey]; + } } return merged; }; - revetGroup.body.append( - grid(labeled("설치 측", revetSide), standaloneRevet.formField), - ...standaloneRevet.rows, - grid(labeled("단 수(다단)", revetTiers)), - ); // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). @@ -364,7 +427,10 @@ export function createFacilityOptionsForm( pipeRow, inletGroup.root, outletGroup.root, - revetGroup.root, + extraGroup.root, + revetTopRow, + revetInlet.root, + revetOutlet.root, boxWrap, wingInFields.root, wingOutFields.root, @@ -396,7 +462,18 @@ export function createFacilityOptionsForm( // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; - revetGroup.root.hidden = current !== "revetment"; + const isRevet = current === "revetment"; + revetTopRow.hidden = !isRevet; + // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다. + revetInlet.root.hidden = + !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet); + revetOutlet.root.hidden = + !isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet); + revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`; + revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`; + // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, + // 선택이 풀리면 스스로 사라진다. + extraGroup.root.hidden = extraSlot.childElementCount === 0; if (isFord) syncFordSummary(); if (isBox) syncBoxSize(); if (hasWing) { @@ -405,8 +482,10 @@ export function createFacilityOptionsForm( } if (isPipe) { // 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8). - const basin = inletType.value === "집수정"; - const revet = inletType.value === "기슭막이"; + // 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29). + const kind = inletKindOf(inletType.value); + const basin = kind.type === "집수정"; + const revet = kind.type === "기슭막이"; basinRows.forEach((row) => (row.hidden = !basin)); basinMaterialField.hidden = !basin; inletRevet.formField.hidden = !revet; @@ -430,8 +509,11 @@ export function createFacilityOptionsForm( ); inletRevet.onChange(emit); outletRevet.onChange(emit); - standaloneRevet.onChange(emit); - [revetSide, revetTiers].forEach((input) => input.addEventListener("change", emit)); + revetInlet.fields.onChange(emit); + revetOutlet.fields.onChange(emit); + [revetSide, revetInlet.tiers, revetOutlet.tiers].forEach((input) => + input.addEventListener("change", emit), + ); [ pipeMaterial, pipeDiameter, @@ -444,8 +526,36 @@ export function createFacilityOptionsForm( fordWidth, ].forEach((input) => input.addEventListener("change", emit)); + /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ + let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; + inletType.addEventListener("change", () => + inletStructureHandler?.(inletKindOf(inletType.value).structure), + ); + return { root, + inletSlot, + outletSlot, + extraSlot, + revetInletSlot: revetInlet.slot, + revetOutletSlot: revetOutlet.slot, + setRevetSideLabels(labels) { + revetSideLabels = labels; + syncVisibility(); + }, + inletStructure: () => inletKindOf(inletType.value).structure, + setInletStructure(value) { + // 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다. + const type = inletKindOf(inletType.value).type; + const hit = + INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ?? + INLET_KINDS.find((kind) => kind.structure === value); + if (hit) inletType.value = hit.label; + syncVisibility(); + }, + onInletStructureChange(handler) { + inletStructureHandler = handler; + }, setFacility(facility, options = {}, designFlow = null) { current = facility; designFlowM3s = designFlow ?? null; @@ -458,7 +568,15 @@ export function createFacilityOptionsForm( const isFord = facility === "ford_bridge"; pipeMaterial.value = text("pipe_kind") || "파형강관"; pipeDiameter.value = text("pipe_diameter_mm") || "1000"; - inletType.value = text("inlet_type") || "기슭막이"; + // 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다. + const savedType = text("inlet_type") || "기슭막이"; + const savedStructure = text("inlet_structure"); + inletType.value = ( + INLET_KINDS.find( + (kind) => + kind.type === savedType && (!savedStructure || kind.structure === savedStructure), + ) ?? inletKindOf(savedType) + ).label; outletType.value = text("outlet_type") || "기슭막이"; basinForm.value = text("inlet_basin_form"); basinMaterial.value = text("inlet_basin_material"); @@ -491,8 +609,13 @@ export function createFacilityOptionsForm( fordHeight.value = text("ford_height_m"); fordSlope.value = text("ford_slope_pct"); revetSide.value = text("side") || "양쪽"; - revetTiers.value = text("tiers") || "1"; - standaloneRevet.write(legacyRevetOptions(options)); + // 단 수 — 좌·우 각자 키가 먼저고, 옛 한 벌 값(`tiers`)이 폴백이다. + const legacyTiers = text("tiers") || "1"; + revetInlet.tiers.value = text("inlet_revet_tiers") || legacyTiers; + revetOutlet.tiers.value = text("outlet_revet_tiers") || legacyTiers; + const spread = legacyRevetOptions(options); + revetInlet.fields.write(spread); + revetOutlet.fields.write(spread); // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { fordWidth.value = String( @@ -510,12 +633,15 @@ export function createFacilityOptionsForm( if (current === "pipe") { options.pipe_diameter_mm = Number(pipeDiameter.value); options.pipe_kind = pipeMaterial.value; - options.inlet_type = inletType.value; - if (inletType.value === "집수정") { + const inletKind = inletKindOf(inletType.value); + options.inlet_type = inletKind.type; + // B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29). + options.inlet_structure = inletKind.structure; + if (inletKind.type === "집수정") { if (basinForm.value) options.inlet_basin_form = basinForm.value; if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value; putNumber(options, "inlet_basin_length_m", basinLength.value); - } else if (inletType.value === "기슭막이") { + } else { inletRevet.read(options); } options.outlet_type = outletType.value; @@ -541,20 +667,19 @@ export function createFacilityOptionsForm( wingOutFields.read(options); } else if (current === "revetment") { options.side = revetSide.value; - const tiers = Number.parseInt(revetTiers.value, 10); - if (Number.isFinite(tiers) && tiers >= 1) options.tiers = tiers; - standaloneRevet.read(options); - // 양쪽 벽은 같은 한 벌이 정본 — 유입 키에도 같은 값을 실어 벽 둘이 갈리지 않게 한다. - for (const [inletKey, outletKey] of [ - [INLET_REVET_KEYS.form, OUTLET_REVET_KEYS.form], - [INLET_REVET_KEYS.length, OUTLET_REVET_KEYS.length], - [INLET_REVET_KEYS.height, OUTLET_REVET_KEYS.height], - [INLET_REVET_KEYS.before, OUTLET_REVET_KEYS.before], - [INLET_REVET_KEYS.after, OUTLET_REVET_KEYS.after], - ] as Array<[string | undefined, string | undefined]>) { - if (inletKey && outletKey && options[outletKey] !== undefined) - options[inletKey] = options[outletKey]; - } + const tierOf = (input: HTMLInputElement): number | null => { + const value = Number.parseInt(input.value, 10); + return Number.isFinite(value) && value >= 1 ? value : null; + }; + const inletTiers = tierOf(revetInlet.tiers); + const outletTiers = tierOf(revetOutlet.tiers); + if (inletTiers !== null) options.inlet_revet_tiers = inletTiers; + if (outletTiers !== null) options.outlet_revet_tiers = outletTiers; + // 옛 한 벌 키도 남긴다 — 서버 `_revet_set`이 아직 이 값을 읽는다. + options.tiers = Math.max(inletTiers ?? 1, outletTiers ?? 1); + // 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30). + revetInlet.fields.read(options); + revetOutlet.fields.read(options); } return options; }, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 3ab454dc..0877a2b7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -60,6 +60,10 @@ export function labeled(text: string, input: HTMLElement): HTMLLabelElement { const caption = document.createElement("span"); caption.textContent = text; wrapper.append(caption, input); + // 묶음(스텝 등)을 담으면 라벨의 암묵 대상이 첫 버튼이 된다 — 안쪽 입력을 명시로 + // 가리켜 라벨 hover·클릭이 버튼으로 새지 않게 한다(2026-08-29 사용자 보고). + const inner = input.querySelector?.("input, select, textarea") as HTMLElement | null; + if (inner?.id) wrapper.htmlFor = inner.id; return wrapper; } @@ -72,6 +76,48 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu return input; } +/** + * 숫자 칸 + [-][+] 한 묶음(2026-08-29 사용자 지시 2) — 조정창처럼 눈금으로도 + * 고칠 수 있게 한다. 래퍼 여백은 0이라 기존 격자 레이아웃을 흔들지 않는다. + * 버튼은 값을 고친 뒤 input·change를 모두 울려 기존 리스너(연동·저장)가 그대로 탄다. + */ +/** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */ +let stepperSeq = 0; + +export function stepper(input: HTMLInputElement, stepM: number): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b05-structure__stepper"; + // 라벨이 이 입력을 가리키게 id를 붙인다. 없으면