diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index f7f83289..4a492dcb 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -33,6 +33,13 @@ export interface StructureOptionField { phase?: "b05" | "detail"; } +/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 + * 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */ +export interface StructuresSectionOptions { + /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ + includeDetail?: boolean; +} + /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ export function isB05Option(option: StructureOptionField): boolean { return (option.phase ?? "b05") !== "detail"; diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 3abed2df..d1e4ba90 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -956,6 +956,15 @@ "required": true, "phase": "detail" }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": ["메쌓기", "찰쌓기"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6363fb7f..fe1ab070 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -18,6 +18,7 @@ import { isB05Option, structureAnchorM, type StructureInstance, + type StructuresSectionOptions, type StructurePlacement, type StructureType, } from "./B05_Profile_Api_Structures"; @@ -49,7 +50,10 @@ import type { StructuresSection, } from "./B05_Profile_UI_Structures_Panel_Types"; -export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { +export function createStructuresSection( + callbacks: StructuresCallbacks, + sectionOptions: StructuresSectionOptions = {}, +): StructuresSection { // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. const form = buildStructuresForm({ getInterval: () => callbacks.getInterval(), @@ -294,8 +298,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncRangeDisplay(); } - /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 - * 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ + /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면 + * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */ function renderOptionFields(values: Record = {}): void { const type = currentType(); // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 @@ -303,7 +307,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. - const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; + const all = sectionOptions.includeDetail === true; + const visible = + type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : []; if (!visible.length) { optionRow.hidden = true; syncOptionLock(); @@ -317,8 +323,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); + // 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다. + const mustPick = option.required === true && (option.default ?? "") === ""; + if (mustPick) choices.unshift(["", "— 선택 —"]); input = select(choices); - input.value = String(preset || (option.choices[0] ?? "")); + input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index a0152ed3..b3bb3fd4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -234,67 +234,70 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); - const section = createStructuresSection({ - onChange: (next) => { - structures = withLocalIds(next); - section.setStructures(structures); - pushMarks(); - if (deps.projectId) writePendingStructures(deps.projectId, structures); - deps.onStructuresChanged?.(); - }, - onSelect: (structure) => { - if (structure) deps.focusChainage(structureAnchorM(structure)); - }, - getInterval: deps.stationInterval, - onReveal: () => deps.reveal?.(), - onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), - // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → - // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. - onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; - if (moved) { - if (deps.movePipe) { - deps.movePipe(fromChainageM, toChainageM); - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.chainage_m = toChainageM; - currentChainageM = toChainageM; - section.setPipeFacilities(pipeFacilities); - pushMarks(); - showToast(PIPE_MOVE_NOTICE, "success"); - } else { - showToast(PIPE_MOVE_GUIDE, "error"); + const section = createStructuresSection( + { + onChange: (next) => { + structures = withLocalIds(next); + section.setStructures(structures); + pushMarks(); + if (deps.projectId) writePendingStructures(deps.projectId, structures); + deps.onStructuresChanged?.(); + }, + onSelect: (structure) => { + if (structure) deps.focusChainage(structureAnchorM(structure)); + }, + getInterval: deps.stationInterval, + onReveal: () => deps.reveal?.(), + onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), + // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → + // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. + onPipeUpdate: (fromChainageM, toChainageM, attributes) => { + // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 + // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 + // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). + const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; + if (moved) { + if (deps.movePipe) { + deps.movePipe(fromChainageM, toChainageM); + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.chainage_m = toChainageM; + currentChainageM = toChainageM; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + showToast(PIPE_MOVE_NOTICE, "success"); + } else { + showToast(PIPE_MOVE_GUIDE, "error"); + } } - } - const patch = attributes.options; - if (!patch || !Object.keys(patch).length) return; - if (!deps.queuePipeOptions) { - showToast(PIPE_ADD_GUIDE, "error"); - return; - } - // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 - // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). - // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. - deps.queuePipeOptions(fromChainageM, patch as Record); - deps.applyPipeOptions?.(fromChainageM, patch as Record); - // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; - section.setPipeFacilities(pipeFacilities); + const patch = attributes.options; + if (!patch || !Object.keys(patch).length) return; + if (!deps.queuePipeOptions) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 + // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). + // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. + deps.queuePipeOptions(fromChainageM, patch as Record); + deps.applyPipeOptions?.(fromChainageM, patch as Record); + // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; + section.setPipeFacilities(pipeFacilities); + }, + onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), + onPipeSelect: (chainageM) => { + // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. + writeStructurePick(deps.projectId, chainageM); + if (chainageM !== null) deps.focusChainage(chainageM); + }, }, - onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), - onPipeSelect: (chainageM) => { - // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. - writeStructurePick(deps.projectId, chainageM); - if (chainageM !== null) deps.focusChainage(chainageM); - }, - }); + { includeDetail: true }, + ); // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: