From e8698a7fe3f220507653f95cdb2a80211df41145 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:44:34 +0900 Subject: [PATCH 01/16] =?UTF-8?q?chore(docs):=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20=EB=AC=B8=EC=84=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/사용자요구/2026-10-05_수정요구.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 740f9584f..241734bd4 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -160,3 +160,9 @@ (10-6 사용자 · 추가 요청) - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 + +(10-6 사용자 · 다음 수정) + - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. + +(10-6 사용자 · 신규 기능) +다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. From 968e11c46d7b68ebd8aeebdf00712100b7a45dbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:52:25 +0900 Subject: [PATCH 02/16] =?UTF-8?q?chore(plan):=2010-6=203D=20=EC=9B=90?= =?UTF-8?q?=EA=B7=BC=20=C2=B7=20=EC=A7=81=EA=B5=90=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 8129002e6..6d86979b0 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -172,3 +172,6 @@ (10-6 사용자 · 추가 요청) - 구조물 배치에 구조물 조합 선택의 프론트엔드를 2행 구조로 변경해줘. 다른 옵션들을 보면 제목과 입력을 2행 구조로 반영되어 있잖아. 글자 폰트와 크기도 같은걸 사용해줘. + +(10-6 사용자 · 추가항목) +B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 제목은 원근, 직교 로 변경하고 기본값을 직교로 변경해줘. From b5ded105b87f0d070e89d13013972ebd826543d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:53:45 +0900 Subject: [PATCH 03/16] =?UTF-8?q?chore(plan):=2010-6=203D=20FRONT=20=C2=B7?= =?UTF-8?q?=20SIDE=20=EB=B0=A9=ED=96=A5=20=EC=9A=94=EC=B2=AD=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 6d86979b0..3fb63dd6c 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -175,3 +175,6 @@ (10-6 사용자 · 추가항목) B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 제목은 원근, 직교 로 변경하고 기본값을 직교로 변경해줘. + +(10-6 사용자 · 추가항목) + - B05의 3D 영역에서 Front, Side버튼의 방향이 대각선 방향인것같아. 방향 재설정 해줘. From d05a0de164b13507f31732cff34fe6611497f56d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:58:37 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(B05):=203D=20=EC=9B=90=EA=B7=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EA=B5=90=20=EB=91=90=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EB=B3=B8=20=EC=A7=81=EA=B5=90=20=C2=B7=20FRO?= =?UTF-8?q?NT=20=C2=B7=20SIDE=20=EC=88=98=ED=8F=89=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「원근으로/직교로」 토글을 「원근」 「직교」 두 단추로 · 켜진 쪽 강조 - 카메라 기본 = 직교 - FRONT = 남에서 북 수평 · SIDE = 동에서 서 수평 · 앞 가장자리 반폭만큼 물려 화면맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_UI_Panel_View.ts | 36 +++++++++++++-------- B05_Profile/B05_Profile_UI_Viewer.ts | 9 ++++-- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 5 ++- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 6b340ab0e..b09d2e0e0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -33,19 +33,29 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 - // 가리킨다(누르면 그쪽으로 간다). - let projection: "perspective" | "ortho" = "perspective"; - const projectionButton = button( - "직교로", - () => { - projection = projection === "perspective" ? "ortho" : "perspective"; - projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; - callbacks.onProjection(projection); - }, - "glass", - ); - viewButtons.append(projectionButton); + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + const projectionButtons = ( + [ + ["perspective", "원근"], + ["ortho", "직교"], + ] as const + ).map(([kind, label]) => { + const element = button( + label, + () => { + projectionButtons.forEach(({ kind: other, element: el }) => { + el.classList.toggle("is-active", other === kind); + el.setAttribute("aria-pressed", String(other === kind)); + }); + callbacks.onProjection(kind); + }, + "glass", + ); + element.classList.toggle("is-active", kind === "ortho"); + element.setAttribute("aria-pressed", String(kind === "ortho")); + return { kind, element }; + }); + viewButtons.append(...projectionButtons.map(({ element }) => element)); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 165456dad..1d0d2e415 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -289,8 +289,9 @@ export function createRouteViewer(): RouteViewer { iso: [distance, distance, distance], // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. top: [0, distance, distance * TOP_VIEW_TILT], - front: [0, distance * 0.25, distance], - side: [distance, distance * 0.25, 0], + // 정면 = 남에서 북을, 측면 = 동에서 서를 수평으로 본다(장면 +z 가 남, +x 가 동). + front: [0, 0, distance], + side: [distance, 0, 0], } as const; const [x, y, z] = positions[view]; const camera = cameraRig.camera(); @@ -309,7 +310,9 @@ export function createRouteViewer(): RouteViewer { if (!bounds) return; const width = bounds.x[1] - bounds.x[0]; const depth = bounds.y[1] - bounds.y[0]; - placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view); + // 수평 보기는 지형 앞 가장자리가 카메라에 가까워 원근에서 잘린다 — 그 반폭만큼 물린다. + const nearHalf = view === "front" ? depth / 2 : view === "side" ? width / 2 : 0; + placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35 + nearHalf, view); } async function reloadContours(interval: number): Promise { diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 32f94f3fd..7dbe1cdd7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,7 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). - * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. @@ -22,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "perspective"; + let kind: ProjectionKind = "ortho"; let aspect = 1; let halfHeight = 100; From ae43a25b1a4f666566c94da3b119eeaac7e23ce8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:59:43 +0900 Subject: [PATCH 05/16] =?UTF-8?q?chore(plan):=2010-6=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EA=B3=B5=ED=86=B5=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=EC=A0=90=EA=B2=80=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 3fb63dd6c..32518256a 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -178,3 +178,6 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자 · 추가항목) - B05의 3D 영역에서 Front, Side버튼의 방향이 대각선 방향인것같아. 방향 재설정 해줘. + +(10-6 사용자 · 추가 요청) + - 페이지의 좌측 패널의 공통항목으로 들어온 버튼 세트의 버튼 크기가 조금 이상함. 점검 From 185e74646eebc8b1e09a76ed816d7fb5bd93dcac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:02:00 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(profile):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EC=A0=95=EB=B3=B4=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=E2=80=94=20=EB=A7=A8=20=EC=9C=84=20=EC=B8=B5=20=C2=B7=20?= =?UTF-8?q?=E2=96=B2=E2=96=BC=20=EC=9C=84=EB=A1=9C=20=EB=B9=84=ED=82=B4=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A7=88=EB=8B=A4=20=EC=B2=AB=20=EA=B0=92(54-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레인 쌓임 층 풂 · 라벨 z 6 — ▲▼ 단추층 · 고정 Y축 · 테이블 · 유토곡선 위 - 라벨을 그래프 바닥 ▼ 단추 높이만큼 위로 · 위가 잘리면 덜 잘리는 쪽(알약 아래) - 줄 = 종류 이름 · 기준 측점 · 좌측 폼 컨테이너마다 첫 옵션 값 · 빈 값 (기본) · auto (자동) · 메모 뺌 - B05 · B06 공용 레인 한 곳 · 시험 test_54_14_balloon_lines · ORCA 시나리오 54-14_알약_라벨 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- B05_Profile/B05_Profile_UI_Profile_Render.ts | 6 + .../B05_Profile_UI_Structures_Balloon.ts | 160 +++++++++++++ .../B05_Profile_UI_Structures_Marks.ts | 88 +++++--- .../B05_Profile_UI_Style_Structures.css | 13 +- .../tester/helper_54_14_balloon_lines.cjs | 121 ++++++++++ resources/tester/test_54_14_balloon_lines.py | 99 +++++++++ resources/tester/ui_checks/54-14_알약_라벨.js | 210 ++++++++++++++++++ 7 files changed, 652 insertions(+), 45 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Balloon.ts create mode 100644 resources/tester/helper_54_14_balloon_lines.cjs create mode 100644 resources/tester/test_54_14_balloon_lines.py create mode 100644 resources/tester/ui_checks/54-14_알약_라벨.js diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 062d250ed..6dd9a018e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -52,6 +52,7 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"; @@ -335,6 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) + containerCount: (structure) => + structure.type_id === "pipe" + ? (id) => (id === "soil_add" ? pipeExtraCount(structure.chainage_m ?? null) : undefined) + : undefined, }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts new file mode 100644 index 000000000..a627c2640 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Balloon.ts + * 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀. + * + * · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄 + * (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀 + * · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는 + * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) + * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) + * · 빈 값 = 등록부 기본값 「(기본)」 · 값 `auto` = 「(자동)」(54-13 표시와 같음) · 그것도 없으면 「—」 + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; +import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; +import { + branchOf, + choiceLabel, + choicesOf, + containerTitle, + itemLabel, + readState, + type Instance, + type SpecContainer, + type SpecItem, + type StructureSpec, +} from "../A00_Common/design/design_structure_spec"; + +const DEFAULT_MARK = "(기본)"; +const AUTO_MARK = "(자동)"; +const NONE = "—"; + +/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */ +export type ContainerCount = (containerId: string) => number | null | undefined; + +/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */ +export function balloonLines( + structure: StructureInstance, + type: StructureType | undefined, + head: { name: string; position: string }, + count?: ContainerCount, +): string[] { + const spec = STRUCTURE_SPECS[structure.type_id]; + const rest = spec + ? specLines(spec, structure, type, count) + : groupLines(structure.options ?? {}, type); + return [head.name, head.position, ...rest]; +} + +/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */ +function specLines( + spec: StructureSpec, + structure: StructureInstance, + type: StructureType | undefined, + count?: ContainerCount, +): string[] { + let names = type?.spec_names ?? {}; + if (structure.type_id === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + names = pipeNames; + } + const state = formState(spec, structure.type_id, structure.options ?? {}); + // 빈 칸이 그림에서 쓰는 값 — 등록부 기본값을 같은 길로 읽은 필수 컨테이너 값 + const fallback = formState(spec, structure.type_id, registryDefaults(type)); + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + return spec.containers.flatMap((c) => { + const title = containerTitle(c, names, groups[c.link]); + return instancesOf(c, state[c.id] ?? [], count).map((values, index) => { + const item = c.items.find( + (entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)), + ); + const head = c.required ? title : `${title} ${index + 1}`; + const base = c.required ? (fallback[c.id]?.[0]?.[item?.id ?? ""] ?? "") : ""; + return item + ? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names, base)}` + : `${head}: ${NONE}`; + }); + }); +} + +/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 · + * 세월교 옛 날개벽). */ +function formState( + spec: StructureSpec, + typeId: string, + options: Record, +): Record { + const input = + typeId === "box_culvert" + ? boxOptionsIn(options) + : typeId === "ford_bridge" + ? withLegacyGuards(options) + : options; + const state = readState(spec, input); + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 + if (typeId === "box_culvert") state.body = [{ ...state.body?.[0], ...bodyOf(options) }]; + return state; +} + +/** 보이는 인스턴스 — 필수 = 한 벌 · 선택 = 개수가 정해졌으면 그 수(모자라면 빈 인스턴스) · 아니면 저장된 것. */ +function instancesOf(c: SpecContainer, list: Instance[], count?: ContainerCount): Instance[] { + if (c.required) return [list[0] ?? {}]; + const n = count?.(c.id); + return n === undefined || n === null + ? list + : Array.from({ length: n }, (_, index) => list[index] ?? {}); +} + +function specValue( + c: SpecContainer, + item: SpecItem, + values: Instance, + names: Record, + fallback: string, +): string { + const shown = (raw: string): string => { + const choice = choicesOf(item, values).find((ch) => ch.value === raw); + return choice ? choiceLabel(c, item, choice, names) : raw; + }; + const raw = (values[item.id] ?? "").trim(); + if (raw === "auto") return AUTO_MARK; + if (raw) return shown(raw); + return fallback.trim() ? `${shown(fallback.trim())} ${DEFAULT_MARK}` : NONE; +} + +/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */ +function groupLines( + options: Record, + type: StructureType | undefined, +): string[] { + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + return (type?.groups ?? []).map((group) => { + const option = group.options + .map((key) => byKey.get(key)) + .find((entry) => entry && entry.visible !== false && entry.enabled !== false); + if (!option) return `${group.name}: ${NONE}`; + const unit = option.unit ?? ""; + const raw = options[option.key]; + const value = + raw === "auto" + ? AUTO_MARK + : raw !== undefined && raw !== "" + ? `${raw}${unit}` + : option.default !== null && option.default !== undefined && option.default !== "" + ? `${option.default}${unit} ${DEFAULT_MARK}` + : NONE; + return `${group.name}: ${option.label} ${value}`; + }); +} + +/** 등록부 기본값(키 → 값) — 빈 칸이 그림에서 쓰는 값. */ +function registryDefaults(type: StructureType | undefined): Record { + return Object.fromEntries( + (type?.options ?? []) + .filter((option) => option.default !== null && option.default !== undefined) + .map((option) => [option.key, option.default as string | number]), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 895276fd4..e05b782d7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -20,6 +20,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2; const OVERLAP_PX = 52; /** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ const ETC_PILL_CHARS = 3; +/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */ +const EDIT_BUTTON_CLEARANCE_PX = 21; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -64,18 +67,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; -} - -function optionSummary(structure: StructureInstance, type: StructureType | undefined): string { - if (!type) return ""; - return type.options - .map((option) => { - const value = structure.options?.[option.key]; - if (value === undefined || value === "") return null; - return `${option.label} ${value}${option.unit ?? ""}`; - }) - .filter(Boolean) - .join(" · "); + /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ + containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ @@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement if (selected) lane.append( - buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), + buildBalloon( + balloonLines( + structure, + type, + { name: displayName, position: positionText(structure, options.stationIntervalM) }, + options.containerCount?.(structure), + ), + type, + anchorPx, + row, + ), ); }); @@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 - * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ + * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. + * 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지 + * 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */ function buildBalloon( - structure: StructureInstance, + lines: string[], type: StructureType | undefined, anchorPx: number, row: number | null, - stationIntervalM: number, - /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ - displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; - balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; + balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; + const [name, ...rest] = lines; const title = document.createElement("strong"); - title.textContent = displayName ?? type?.name ?? structure.type_id; - const position = document.createElement("span"); - position.textContent = positionText(structure, stationIntervalM); - balloon.append(title, position); - - const summary = optionSummary(structure, type); - if (summary) { - const detail = document.createElement("span"); - detail.textContent = summary; - balloon.append(detail); - } - if (structure.memo) { - const memo = document.createElement("span"); - memo.className = "b05-structure__balloon-memo"; - memo.textContent = structure.memo; - balloon.append(memo); - } + title.textContent = name; + balloon.append( + title, + ...rest.map((line) => { + const span = document.createElement("span"); + span.textContent = line; + return span; + }), + ); + requestAnimationFrame(() => flipIfClipped(balloon)); return balloon; } +/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */ +function flipIfClipped(balloon: HTMLElement): void { + const lane = balloon.parentElement; + if (!balloon.isConnected || !lane) return; + let top = 0; + let bottom = window.innerHeight; + for (let at: HTMLElement | null = lane; at; at = at.parentElement) + if (getComputedStyle(at).overflowY !== "visible") { + const clip = at.getBoundingClientRect(); + top = Math.max(top, clip.top); + bottom = Math.min(bottom, clip.bottom); + } + const box = balloon.getBoundingClientRect(); + const hiddenAbove = top - box.top; + const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom; + if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return; + balloon.style.bottom = "auto"; + balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; +} + /** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */ function attachMarkDrag( mark: HTMLElement, diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..126a19b9f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -6,11 +6,11 @@ * ========================================================================== */ /* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6). - 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */ + 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. + z-index 를 두지 않음 — 쌓임 층을 만들면 라벨이 그 안에 갇혀 ▲▼ 단추층(4) 밑으로 감(54-14). */ .b05-structure__lane { position: relative; overflow: visible; - z-index: 3; } /* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로 @@ -105,7 +105,8 @@ /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ /* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향: - 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */ + 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). + 층 = 종단 패널 맨 위 — ▲▼ 단추층(4) · 고정 Y축 · 테이블 · 유토곡선(5) 위 · 우클릭 메뉴(7) 아래(54-14). */ .b05-structure__balloon { position: absolute; z-index: 6; @@ -122,7 +123,6 @@ line-height: 1.35; white-space: nowrap; pointer-events: none; - z-index: 4; } .b05-structure__balloon.is-left { @@ -138,11 +138,6 @@ font-size: 11.5px; } -.b05-structure__balloon-memo { - opacity: 0.7; - font-style: italic; -} - /* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ── * 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */ .b05-drainage__facility { diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs new file mode 100644 index 000000000..5816c7935 --- /dev/null +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -0,0 +1,121 @@ +/* 54-14 종단 알약 정보 라벨 줄 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { balloonLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); + +const opt = (key, label, extra = {}) => ({ key, label, default: null, unit: null, ...extra }); +const TYPES = { + pipe: { + type_id: "pipe", + name: "배관", + options: [ + opt("pipe_diameter_mm", "관경", { default: "1000" }), + opt("inlet_type", "유입", { default: "기슭막이" }), + ], + groups: [ + { id: "grv6lny", name: "관", options: [] }, + { id: "gnbf9m9", name: "관 보호공(유입)", options: [] }, + { id: "g9lq9uk", name: "관 보호공(유출)", options: [] }, + ], + spec_names: { soil_add: "흙막이 추가" }, + }, + box_culvert: { + type_id: "box_culvert", + name: "BOX암거", + options: [ + opt("body_width_m", "폭", { default: 2 }), + opt("body_height_m", "높이", { default: 2 }), + ], + groups: [], + }, + cross_underdrain: { + type_id: "cross_underdrain", + name: "맹암거", + options: [ + opt("culvert_height_m", "높이", { unit: "m" }), + opt("culvert_slope_pct", "기울기", { unit: "%" }), + ], + groups: [ + { id: "gcrossud", name: "맹암거", options: ["culvert_height_m", "culvert_slope_pct"] }, + ], + }, + retaining_wall: { + type_id: "retaining_wall", + name: "옹벽", + options: [ + opt("height_m", "높이", { default: 2, unit: "m", visible: false }), + opt("length_m", "길이", { default: 10, unit: "m" }), + ], + groups: [{ id: "gretaindetail", name: "상세 내용", options: ["height_m", "length_m"] }], + }, + mirror: { type_id: "mirror", name: "반사경", options: [], groups: [] }, + etc: { type_id: "etc", name: "기타", options: [opt("name", "이름")], groups: [] }, +}; +const point = (type_id, options = {}) => ({ + structure_id: type_id, + type_id, + placement: "point", + chainage_m: 100, + options, + memo: "메모는 안 씀", +}); +const head = (name) => ({ name, position: "5+0" }); +const run = (structure, count) => + balloonLines(structure, TYPES[structure.type_id], head("이름"), count); + +const out = { + pipeFull: run( + point("pipe", { + pipe_diameter_mm: 1200, + inlet_type: "집수정", + inlet_structure: "L", + outlet_type: "기슭막이", + soil_add_1_structure: "기슭막이", + soil_add_1_form: "돌망태", + chute_add_1_structure: "도수로", + }), + ), + pipeBlank: run(point("pipe")), + // 흙막이 개수 = 밖에서 정한 단 수(2) — 저장 1 벌 · 모자란 둘째는 빈 상자 + pipeCount: run(point("pipe", { soil_add_1_structure: "기슭막이" }), (id) => + id === "soil_add" ? 2 : undefined, + ), + boxPreset: run(point("box_culvert", { body_width_m: 3, body_height_m: 3, wing_in: "있음" })), + boxBlank: run(point("box_culvert")), + underdrain: run(point("cross_underdrain", { culvert_height_m: 0.6 })), + underdrainBlank: run(point("cross_underdrain")), + wall: run(point("retaining_wall")), + wallAuto: run(point("retaining_wall", { length_m: "auto" })), + mirror: run(point("mirror", { anything: 1 })), + etcEmpty: run(point("etc", { name: "안내판" })), + etcNote: run(point("etc", { name: "안내판", etc_detail_1_note: "시험 비고" })), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py new file mode 100644 index 000000000..6374f7903 --- /dev/null +++ b/resources/tester/test_54_14_balloon_lines.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만. + +• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만) +• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹) +• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀 +• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다. +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +MARKS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts") +STYLE = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_14_balloon_lines.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배관_컨테이너마다_첫_값_한_줄(out): + assert out["pipeFull"] == [ + "이름", + "5+0", + "관: 관경 1200", + "관 보호공(유입): 구조 집수정", + "관 보호공(유출): 구조 흙막이", + "흙막이 1: 구조 흙막이", + "도수로/돌붙임 1: 구조 도수로", + ] + + +def test_빈_값은_등록부_기본값에_기본_표시(out): + assert out["pipeBlank"] == [ + "이름", + "5+0", + "관: 관경 1000 (기본)", + "관 보호공(유입): 구조 흙막이 (기본)", + "관 보호공(유출): 구조 —", + ] + + +def test_흙막이_상자_수는_밖에서_정한_단_수(out): + assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"] + assert len(out["pipeCount"]) == 2 + 3 + 2 + + +def test_박스_본체_규격은_폭_높이로_되찾음(out): + assert out["boxPreset"] == [ + "이름", + "5+0", + "본체: 본체 규격 3.0 × 3.0", + "유입 구조: 구조 날개벽", + "유출 구조: 구조 —", + ] + assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)" + + +def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out): + assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"] + assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"] + # 끈 칸(높이)은 건너뜀 · 빈 값은 기본값 + assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"] + assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"] + + +def test_컨테이너가_없으면_이름과_기준_측점만(out): + assert out["mirror"] == ["이름", "5+0"] + assert out["etcEmpty"] == ["이름", "5+0"] + assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"] + + +def test_라벨은_단추층_위로_비켜_뜬다(): + with open(MARKS, encoding="utf-8") as handle: + marks = handle.read() + with open(STYLE, encoding="utf-8") as handle: + style = handle.read() + assert "EDIT_BUTTON_CLEARANCE_PX = 21" in marks + assert "ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX" in marks + assert "balloonLines(" in marks and "structure.memo" not in marks + lane = style.split(".b05-structure__lane {", 1)[1].split("}", 1)[0] + assert "z-index" not in lane # 레인이 쌓임 층을 만들면 라벨이 그 안에 갇힘 + balloon = style.split(".b05-structure__balloon {", 1)[1].split("}", 1)[0] + assert balloon.count("z-index") == 1 and "z-index: 6;" in balloon diff --git a/resources/tester/ui_checks/54-14_알약_라벨.js b/resources/tester/ui_checks/54-14_알약_라벨.js new file mode 100644 index 000000000..1e04a6b09 --- /dev/null +++ b/resources/tester/ui_checks/54-14_알약_라벨.js @@ -0,0 +1,210 @@ +/* 54-14 — 종단도 구조물 알약 정보 라벨: 맨 위 층 · ▲▼ 단추를 안 가림 · 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 값 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_메인) · 로그인 · 종단 도크에 배관 1(4+5) · 배관 3(22+0) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5414 = "단계";` + * seed → 처음 초안(세션 원문)을 `__5414_orig` 에 남김 · 구조물 초안에 맹암거 · 반사경 · 기타(안내판 · 비고) 심음 + * → 그 뒤 orca reload + * box → 배관 3(22+0) 종류를 BOX암거로(관 초안만 · 저장 안 함) + * check → 배관 · 박스 · 맹암거 · 반사경 · 기타 알약을 눌러 라벨 판정 — B05 에서 한 번(값을 세션에 둠) · + * B06(#/b06-section) 에서 한 번(B05 = B06) + * restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__5414_out_{단계}")` 로 읽음 + * 판정: 라벨 위 점 15 곳 모두 맨 위 = 라벨 · 라벨 상자와 ▲▼ 단추 상자 겹침 0 · 줄 수 = 1 + 1 + 컨테이너 수 + * (컨테이너 수 = 좌측 패널 폼에 보이는 상자 · 줄 머리 = 상자 제목) · B05 = B06 + * 쓰는 것: sessionStorage(구조물 · 관 초안) — 정본(서버)은 안 씀 + */ +(async () => { + const MODE = window.__m5414 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const visible = (n) => n.offsetParent !== null; + const DRAFTS = /^aislo:draft:(structures|pipes):/; + const b06 = location.hash.includes("b06"); + const out = { mode: MODE, page: location.hash, fails: [] }; + const fail = (what) => out.fails.push(what); + const marks = () => [...document.querySelectorAll(".b05-structure__mark")]; + const TARGETS = { + pipe: (m) => m.dataset.structureId === "pipe-85.05", + box: (m) => m.dataset.structureId === "pipe-440.24", + underdrain: (m) => m.title.startsWith("맹암거"), + mirror: (m) => m.title.startsWith("반사경"), + etc: (m) => m.title.startsWith("안내판"), + }; + const openDock = async () => { + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + await until(() => marks().length > 0, 30000); + }; + + if (MODE === "seed") { + const orig = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + sessionStorage.setItem("__5414_orig", JSON.stringify(orig)); + const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t])); + const base = api.readPendingStructures(pid) ?? (await api.fetchStructures(pid)).structures; + const add = (id, type_id, at, options) => { + const interval = types[type_id].placement === "interval"; + return { + structure_id: id, + type_id, + placement: types[type_id].placement, + chainage_m: at, + start_m: interval ? at - 5 : null, + end_m: interval ? at + 5 : null, + options, + memo: "54-14 메모(라벨에 안 나옴)", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + }; + api.writePendingStructures(pid, [ + ...base.filter((s) => !String(s.structure_id).startsWith("t5414")), + add("t5414ud", "cross_underdrain", 600, { culvert_height_m: 0.6 }), + add("t5414mr", "mirror", 660, {}), + add("t5414etc", "etc", 720, { name: "안내판", etc_detail_1_note: "시험 비고" }), + ]); + out.seeded = api.readPendingStructures(pid).length - base.length; + } + + if (MODE === "box") { + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^22\+0 (배관|BOX암거)/.test(txt(li))); + await until(() => row(), 30000); + if (!row().classList.contains("is-selected")) row().click(); + await w(1500); + const sel = document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + sel.value = "box_culvert"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await openDock(); + await until(() => marks().some((m) => TARGETS.box(m) && m.title.startsWith("BOX암거"))); + out.box = marks().find(TARGETS.box)?.title ?? null; + if (!out.box?.startsWith("BOX암거")) fail(`박스 알약 ${out.box}`); + row()?.click(); // 고른 것 풂 + await w(800); + } + + if (MODE === "check") { + await openDock(); + const body = document.querySelector(".b05-structure__lane").closest("[class*='__body']"); + const r = {}; + for (const [key, find] of Object.entries(TARGETS)) { + const mark = marks().find(find); + if (!mark) { + fail(`${key}: 알약 없음`); + continue; + } + mark.scrollIntoView({ inline: "center", block: "nearest" }); + await w(400); + const now = marks().find(find); + if (!now.classList.contains("is-selected")) now.click(); + await until(() => document.querySelector(".b05-structure__balloon")); + await w(1200); // 좌측 폼 · 레인 다시 그림 + const balloon = document.querySelector(".b05-structure__balloon"); + const box = balloon.getBoundingClientRect(); + const lines = [...balloon.children].map((c) => c.textContent); + // ① 맨 위 층 — 라벨 위 15 점에서 가장 위 요소가 라벨(재는 동안만 누름 받기 켬) + balloon.style.pointerEvents = "auto"; + let top = 0; + for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) + for (const fy of [0.2, 0.5, 0.8]) { + const hit = document.elementFromPoint( + box.left + box.width * fx, + box.top + box.height * fy, + ); + if (hit && balloon.contains(hit)) top += 1; + } + balloon.style.pointerEvents = ""; + // ② ▲▼ 단추와 안 겹침(실좌표) · 바로 아래 ▼ 단추와 틈 + const buttons = [...document.querySelectorAll(".b05-profile-edit__btn")].map((b) => ({ + down: b.classList.contains("is-down"), + rect: b.getBoundingClientRect(), + })); + const overlap = buttons.filter( + ({ rect }) => + rect.left < box.right && + rect.right > box.left && + rect.top < box.bottom && + rect.bottom > box.top, + ).length; + const under = buttons.filter( + ({ down, rect }) => down && rect.left < box.right && rect.right > box.left, + ); + const gap = under.length ? Math.min(...under.map(({ rect }) => rect.top)) - box.bottom : null; + // ③ 줄 수 = 1 + 1 + 좌측 폼 상자 수 · 줄 머리 = 상자 제목 + const legends = [ + ...document.querySelectorAll("fieldset[data-spec-container], [data-b05-group]"), + ] + .filter(visible) + .map((f) => { + const legend = f.querySelector("legend").cloneNode(true); + legend.querySelectorAll("button").forEach((b) => b.remove()); + return legend.textContent.trim(); + }); + const heads = lines.slice(2).map((l) => l.slice(0, l.indexOf(": "))); + r[key] = { + lines, + top, + overlap, + gap, + boxes: legends.length, + z: getComputedStyle(balloon).zIndex, + clipTop: body ? box.top - body.getBoundingClientRect().top : null, + }; + if (top !== 15) fail(`${key}: 맨 위 아님 ${top}/15`); + if (overlap) fail(`${key}: 단추 겹침 ${overlap}`); + if (lines.length !== 2 + legends.length) + fail(`${key}: 줄 ${lines.length} ≠ 2 + 상자 ${legends.length}`); + if (JSON.stringify(heads) !== JSON.stringify(legends)) + fail(`${key}: 줄 머리 ${heads} ≠ 상자 ${legends}`); + if (lines.some((l) => l.includes("메모"))) fail(`${key}: 메모 줄`); + marks().find(find).click(); // 고른 것 풂 + await w(800); + } + out.read = r; + const lineMap = JSON.stringify( + Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v.lines])), + ); + if (!b06) sessionStorage.setItem("__5414_b05", lineMap); + else { + out.sameAsB05 = sessionStorage.getItem("__5414_b05") === lineMap; + if (!out.sameAsB05) fail("B05 ≠ B06"); + } + } + + if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__5414_orig") || "null"); + if (!orig) return JSON.stringify({ error: "원래 값 없음" }); + for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k); + for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v); + const now = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + out.restored = JSON.stringify(now) === JSON.stringify(orig); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("__5414_")) sessionStorage.removeItem(k); + } + const result = JSON.stringify(out); + if (MODE !== "restore") sessionStorage.setItem(`__5414_out_${MODE}`, result); + return result; +})(); From 8aea7b8cbf77b16b2c1e32e21ba71ca46e95bf35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:03:59 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(structure):=20=EB=B9=88=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=A6=BC=EC=9D=B4=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B0=92=20=ED=91=9C=EC=8B=9C(54-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 폼 고르기 칸 — 비었거나 자동 규칙이면 빈 보기 자리에 「값 (기본)」 · 「값 (자동)」 · 고른 값과 구별 · 저장 값 그대로 - 배관: 관경 · 유출 구조 = 정의 기본값 · 유입 구조 · 집수정 모양 · 벽 형태 = 횡단 기하 결과(세션 유입 형식 반영) - 옛 「자동(규칙)」(유입 기슭막이 + 형식 없음) = 「집수정 (자동)」 등 그림 결과 · 유입 구조를 고를 때까지 형식 revet 안 굳음 - 그림 값을 보이던 위 칸 아래를 고치면 그 위 값을 고른 것(집수정 높이 → 집수정) - 세월교 관경 · 유입 · 유출 / BOX 규격 · 유입 · 유출 = 정의 기본값(날개벽 설치 규칙 = 엔진과 같음) - 정의 그룹 꼴 칸 — 빈 칸에 정의 기본값이 있으면 「값 (기본)」 - 시험: ui_checks/54-13_그림값_표시.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/design/design_structure_form.ts | 58 +++- .../design/design_structure_pipe_calc.ts | 35 +++ A00_Common/design/design_view_cross.ts | 3 + .../B05_Profile_UI_Drainage_Facility.ts | 95 ++++++- .../B05_Profile_UI_Structures_Fields.ts | 9 +- .../tester/ui_checks/54-13_그림값_표시.js | 255 ++++++++++++++++++ 6 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 resources/tester/ui_checks/54-13_그림값_표시.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 4adf4a6c4..875291406 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -59,6 +59,14 @@ export interface SpecFormHooks { * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; + /** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) · + * null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */ + shown?: ( + c: SpecContainer, + item: SpecItem, + values: Record, + index: number, + ) => { value: string; tag: string } | null; } export interface SpecForm { @@ -105,18 +113,27 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return slot; }; - const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => { + const control = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + tag?: string, + ): HTMLElement => { if (item.kind === "select") { const select = document.createElement("select"); const choices = choicesOf(item, values); + const now = values[item.id] ?? ""; + const drawn = choices.find((ch) => ch.value === now); + // 그림이 쓰는 값은 빈 보기 자리에 꼬리를 달아 보임 — 고른 값과 구별(54-13) + const name = drawn ? choiceLabel(c, item, drawn, names) : now; + const blank = !tag ? "— 안 정함 —" : name ? `${name} (${tag})` : tag; select.replaceChildren( - new Option("— 안 정함 —", ""), + new Option(blank, ""), ...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)), ); - const now = values[item.id] ?? ""; // 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음) - if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now)); - select.value = now; + if (!tag && now && !drawn) select.add(new Option(now, now)); + select.value = tag ? "" : now; return select; } if (item.kind === "text") { @@ -139,7 +156,19 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return stepper(input, item.step ?? 0.1); }; - const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => { + const onEdit = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + raw: string, + view: Instance, + ): void => { + // 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13) + const parent = item.by?.item; + if (parent && view[parent] !== values[parent]) { + values[parent] = view[parent]; + for (const child of c.items) if (child.by?.item === parent) values[child.id] = ""; + } values[item.id] = raw; if (item.split) { const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)]; @@ -170,15 +199,26 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const title = containerTitle(c, names, groupNames[c.link]); const box = group(c.required ? title : `${title} ${index + 1}`); box.root.dataset.specContainer = c.id; + // 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로 + const view: Instance = { ...values }; + const tags = new Map(); + for (const item of c.items) { + if (item.by && view[item.by.item] !== values[item.by.item]) view[item.id] = ""; + const drawn = item.kind === "select" ? hooks.shown?.(c, item, view, index) : null; + if (!drawn) continue; + view[item.id] = drawn.value; + tags.set(item.id, drawn.tag); + } const fields = c.items - .filter((item) => item.kind === "auto" || !item.by || branchOf(item, values)) + .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) .map((item) => { - const input = control(c, item, values); + const input = control(c, item, view, tags.get(item.id)); const wrap = labeled(itemLabel(c, item, names), input); wrap.dataset.specItem = `${c.id}.${item.id}`; + if (tags.has(item.id)) wrap.dataset.specShown = tags.get(item.id); const target = input.querySelector?.("input, select") ?? input; target.addEventListener("change", () => - onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value), + onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value, view), ); return wrap; }); diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 6e60c27bf..357ab0857 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe extraCountSource = source; } +/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ +type InletChoice = NonNullable["inlet_structure"]; +let inletSource: ((section: CrossSection) => InletChoice) | null = null; +export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void { + inletSource = source; +} + /** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ function ownerSection( chainageM: number | null, @@ -64,6 +71,34 @@ export function pipeExtraCount(chainageM: number | null): number | null { return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet; } +/** 그림이 쓰는 유입 · 유출 값(54-13) — 집수정 모양(없으면 null = 흙막이 벽) · 벽 형태(유입 · 유출 · + * 흙막이 n). 폼 빈 · 자동 칸에 보여 주기만. 횡단 자료가 없으면 null(모름). */ +export function pipeDrawn(chainageM: number | null): { + basin: string | null; + forms: Record; + extras: Array; +} | null { + const found = ownerSection(chainageM); + const design = found?.owner.design; + if (!found || !design) return null; + const { owner, sections } = found; + const outlet = extraCountSource?.(owner) ?? design.extra_wall_counts?.outlet ?? 0; + const counts = { basin: 0, ...design.extra_wall_counts, outlet }; + const inlet = inletSource?.(owner) ?? design.inlet_structure; + const probe = { + ...owner, + design: { ...design, extra_wall_counts: counts, inlet_structure: inlet }, + }; + const layout = computeStoredLayouts(probe, sections)?.culvert; + if (!layout) return null; + const formOf = (role: string) => layout.walls.find((w) => w.role === role)?.form ?? null; + return { + basin: layout.basin?.shape ?? null, + forms: { inlet: formOf("inlet"), outlet: formOf("outlet") }, + extras: layout.extraWalls.map((w) => w.form), + }; +} + /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..0ce481802 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -76,6 +76,7 @@ import type { DesignPageHost } from "./design_page"; import { pipeExtraCount, setPipeExtraCountSource, + setPipeInletSource, setPipeSectionsSource, } from "./design_structure_pipe_calc"; import { @@ -296,6 +297,8 @@ export async function mountCrossView( }); // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); + // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 + setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); const sectionView = createSectionView( (chainageM, change) => { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 35ee3c8ea..15d55e4c7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -46,6 +46,7 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; +import { branchOf } from "../A00_Common/design/design_structure_spec"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; import { BOX_SPEC, @@ -57,6 +58,7 @@ import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structu import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; import { guardHeightHint, + pipeDrawn, pipeExtraCount, pipeLengthM, } from "../A00_Common/design/design_structure_pipe_calc"; @@ -212,6 +214,8 @@ export interface FacilityOptionsForm { definition?: { spec_names?: Record; groups?: Array<{ id: string; name: string }>; + /** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */ + options?: Array<{ key: string; default: unknown }>; } | null, ) => void; /** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */ @@ -267,7 +271,52 @@ export function createFacilityOptionsForm( clear: (key) => linkSource?.clear(key), set: (key, item) => linkSource?.set(key, item), }; + // 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만 + let defaults: Record = {}; + const byDefault = (key: string | undefined): { value: string; tag: string } | null => { + const value = key ? defaults[key] : null; + return value === null || value === undefined || value === "" + ? null + : { value: String(value), tag: "기본" }; + }; + /** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */ + const wingDefault = (key: string): { value: string; tag: string } => { + const value = String(defaults[key] ?? ""); + return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" }; + }; + // 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를 + // 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음) + let inletAuto: { structure?: string | number } | null = null; + const inletIsAuto = (): boolean => + !!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이"; + // 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름) + let drawnMemo: ReturnType | undefined; + const drawnNow = (): ReturnType => { + if (drawnMemo === undefined) { + drawnMemo = pipeDrawn(chainage); + queueMicrotask(() => (drawnMemo = undefined)); + } + return drawnMemo; + }; const pipeForm = createSpecForm(specLinks, { + shown: (c, item, values, index) => { + if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) { + const drawn = drawnNow(); + return drawn + ? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" } + : { value: "", tag: "자동(지형 규칙)" }; + } + if (values[item.id]) return null; + if (item.id !== "form") return byDefault(branchOf(item, values)?.key); + const basin = c.id === "inlet" && values.structure === "집수정"; + const drawn = drawnNow(); + const form = basin + ? drawn?.basin + : c.id === "soil_add" + ? drawn?.extras[index] + : drawn?.forms[c.id]; + return form ? { value: form, tag: basin ? "자동" : "기본" } : null; + }, auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); @@ -276,13 +325,21 @@ export function createFacilityOptionsForm( // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + const soil = + c.id !== "inlet" || + (state.inlet?.[0]?.structure === "기슭막이" && !(inletIsAuto() && drawnNow()?.basin)); return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, onChange: (c, item) => { + if (c.id === "inlet" && item.id === "structure") inletAuto = null; if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) inletStructureHandler?.(inletStructureOf()); emit(); + // 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13) + if (c.id === "inlet") { + drawnMemo = undefined; + pipeForm.refresh(); + } }, }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) @@ -303,6 +360,11 @@ export function createFacilityOptionsForm( const fordNeed = (): number | null => fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); const fordForm = createSpecForm(specLinks, { + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null; + }, hint: (c, item, state) => c.id === "ford" && item.id === "height" ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) @@ -368,7 +430,18 @@ export function createFacilityOptionsForm( }; // ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ── - const boxForm = createSpecForm(specLinks, { onChange: () => emit() }); + const boxForm = createSpecForm(specLinks, { + onChange: () => emit(), + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + const size = bodyOf({ + body_width_m: Number(defaults.body_width_m ?? 2), + body_height_m: Number(defaults.body_height_m ?? 2), + }).size; + return item.id === "size" && size ? { value: size, tag: "기본" } : null; + }, + }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); @@ -460,8 +533,10 @@ export function createFacilityOptionsForm( inletStructure: inletStructureOf, setInletStructure(value) { // 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움) - if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이"); - else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); + if (value === "revet") { + inletAuto = null; + pipeForm.setValue("inlet", "structure", "기슭막이"); + } else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정") pipeForm.setValue("inlet", "form", value === "auto" ? "" : value); syncVisibility(); @@ -505,9 +580,16 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; + defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default])); if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; + const kind = options.inlet_structure; + inletAuto = + options.inlet_type === "기슭막이" && + !["revet", "I", "L", "U"].includes(String(kind ?? "")) + ? { structure: kind } + : null; pipeForm.write(PIPE_SPEC, options, pipeNames, groups); } if (facility === "box_culvert") { @@ -537,6 +619,11 @@ export function createFacilityOptionsForm( // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) + // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) + if (inletIsAuto()) { + if (inletAuto!.structure === undefined) delete options.inlet_structure; + else options.inlet_structure = inletAuto!.structure; + } } else if (current === "box_culvert") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 3ea90331b..8f446c04f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -65,7 +65,14 @@ export function optionControl( // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { - const blank = option.required ? "— 선택 —" : "— 안 정함 —"; + // 빈 칸이 쓰는 정의 기본값은 빈 보기에 꼬리를 달아 보임 — 값은 안 고름(54-13) + const fallback = option.default === null || option.default === "" ? null : option.default; + const blank = + fallback !== null && fallback !== undefined + ? `${label(String(fallback))} (기본)` + : option.required + ? "— 선택 —" + : "— 안 정함 —"; // 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음) const kept = value && !option.choices.includes(value) ? [value] : []; const element = select([ diff --git a/resources/tester/ui_checks/54-13_그림값_표시.js b/resources/tester/ui_checks/54-13_그림값_표시.js new file mode 100644 index 000000000..07f6a2918 --- /dev/null +++ b/resources/tester/ui_checks/54-13_그림값_표시.js @@ -0,0 +1,255 @@ +/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가 + * 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")` + * check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 · + * B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김 + * old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침 + * oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 · + * 12+18 배관 집수정 높이 고침 = 집수정을 고른 것 + * box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음 + * restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진 + * 쓰는 것: sessionStorage 만(서버 저장 없음) + * 기대 수치: 모든 단계 bad = [] + */ +(async () => { + const MODE = window.__m5413 || "check"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const DRAFT = `aislo:draft:pipes:${pid}`; + const B06 = location.hash.includes("b06"); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const flat = (s) => String(s ?? "").replace(/\s+/g, ""); + const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] }; + const bad = (what) => out.bad.push(what); + const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out)); + const api = async () => + (await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const rowOf = (head) => rows().find((li) => txt(li).startsWith(head)); + const draft = (at) => { + const raw = sessionStorage.getItem(DRAFT); + const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null; + return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null; + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent); + /** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */ + const formOf = () => { + const fields = {}; + const tagged = []; + for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) { + const sel = wrap.querySelector("select"); + if (!sel) continue; + fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? ""; + if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem); + } + return { fields, tagged }; + }; + const detailAt = (at) => { + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null; + const list = (raw?.value ?? raw)?.cross_sections ?? []; + return list.reduce( + (a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a), + null, + ); + }; + /** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */ + const drawn = () => { + const svg = document.querySelector(".b06-cross-card--selected svg"); + if (!svg) return null; + return { + labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent), + walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map( + (t) => t.textContent.split(" H=")[0], + ), + }; + }; + const open = async (head) => { + const row = await until(() => rowOf(head)).then(() => rowOf(head)); + if (!row) return (bad(`${head}: 목록에 없음`), false); + row.click(); + await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg"))); + await w(1200); + return true; + }; + const fire = (sel, value) => { + sel.value = value; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }; + const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`); + const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" }; + /** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */ + const compare = (head, at, kind) => { + const { fields, tagged } = formOf(); + const pic = B06 ? drawn() : null; + const set = detailAt(at); + const got = { head, fields, tagged, pic }; + const blank = Object.entries(fields).filter( + ([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k), + ); + if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`); + if (!pic) return got; // B05 — 글 대조는 B06 사진과 + const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, ""); + if (kind === "pipe") { + const basin = pic.labels.find((l) => l.startsWith("집수정")); + const inlet = head0(fields["inlet.structure"]); + if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`); + const form = head0(fields["inlet.form"]); + if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`); + for (const [side, role] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`)); + if (side === "inlet" && basin) continue; + const f = head0(fields[`${side}.form`]); + if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`)) + bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`); + } + const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000); + if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d)) + bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`); + } + if (kind === "ford") { + const label = pic.labels.find((l) => l.includes("Ø")) ?? ""; + if (!label.includes(`Ø${head0(fields["ford.diameter"])}`)) + bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`); + for (const side of ["in", "out"]) { + const wing = set?.ford?.[`wing_${side}`]?.installed; + const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]); + if (wing !== undefined && (text === "날개벽") !== wing) + bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`); + } + } + if (kind === "box") { + const label = pic.labels.find((l) => l.startsWith("BOX")) ?? ""; + if (flat(label) !== `BOX${head0(fields["body.size"])}`) + bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`); + } + return got; + }; + const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" }; + + try { + if (MODE === "check") { + if (!sessionStorage.getItem("__5413_orig")) + sessionStorage.setItem("__5413_orig", JSON.stringify(await api())); + const before = sessionStorage.getItem(DRAFT); + const heads = rows() + .map(txt) + .filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t)); + const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}"); + out.rows = []; + for (const t of heads) { + const head = t.split(" ").slice(0, 2).join(" "); + if (!(await open(head))) continue; + const station = head.split(" ")[0].split("+").map(Number); + const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]); + out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`); + if (B06) b06[t] = got.fields; + else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields)) + bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`); + else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`); + } + if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06)); + if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜"); + out.count = heads.length; + } else if (MODE === "old") { + const list = (await api()).map(({ lonlat, ...rest }) => rest); + const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51); + pipe.options = { ...pipe.options, inlet_type: "기슭막이" }; + delete pipe.options.inlet_structure; + sessionStorage.setItem(DRAFT, JSON.stringify(list)); + out.note = "초안 넣음 — 새로 고친 뒤 oldpick"; + done(); + location.reload(); + return; + } else if (MODE === "oldpick") { + await open("4+5 배관"); + out.start = compare("4+5 옛 자동", 85.05, "pipe"); + if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)")) + bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`); + // 관경만 고름 — 유입 형식은 그대로(자동 유지) + fire(selectOf("pipe.diameter"), "1200"); + await w(2500); + out.afterDiameter = draft(85.05)?.options; + if (out.afterDiameter?.inlet_structure !== undefined) + bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`); + if (out.afterDiameter?.inlet_type !== "기슭막이") + bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`); + out.stillAuto = formOf().fields["inlet.structure"]; + // 흙막이를 고름 = 형식 revet · 그림 벽 + fire(selectOf("inlet.structure"), "기슭막이"); + await w(3500); + out.afterPick = draft(85.05)?.options; + if (out.afterPick?.inlet_structure !== "revet") + bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`); + out.picked = compare("4+5 흙막이 고름", 85.05, "pipe"); + if (out.picked.fields["inlet.structure"] !== "흙막이") + bad(`고른 글 ${out.picked.fields["inlet.structure"]}`); + // 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것 + await open("12+18 배관"); + const shown = formOf().fields["inlet.structure"]; + out.other = shown; + if (flat(shown) === "집수정(자동)") { + const h = spec().querySelector('[data-spec-item="inlet.height"] input'); + h.value = "1.5"; + h.dispatchEvent(new Event("change", { bubbles: true })); + await w(3000); + const o = draft(258.12)?.options; + out.basinEdit = o; + if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5) + bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`); + if (formOf().fields["inlet.structure"] !== "집수정") + bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`); + } + } else if (MODE === "box") { + await open("17+12 세월교"); + out.ford = compare("17+12 세월교", 352.14, "ford"); + const type = document.querySelectorAll( + ".b05-structure-section .b05-structure__grid select", + )[1]; + fire(type, "box_culvert"); + await until(() => rowOf("17+12 BOX암거")); + await w(3000); + // 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게 + for (const k of ["body.size", "inlet.structure", "outlet.structure"]) { + fire(selectOf(k), ""); + await w(1500); + } + await w(2000); + out.box = compare("17+12 BOX", 352.14, "box"); + const f = out.box.fields; + if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`); + for (const k of ["inlet.structure", "outlet.structure"]) + if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`); + const o = draft(352.14)?.options ?? {}; + if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o)) + bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`); + } else if (MODE === "restore") { + sessionStorage.removeItem(DRAFT); + // 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음) + for (const key of Object.keys(sessionStorage)) + if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`)) + sessionStorage.removeItem(key); + const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null"); + const now = await api(); + out.same = JSON.stringify(now) === JSON.stringify(orig); + if (!out.same) bad("서버 관 지점이 처음 사진과 다름"); + done(); + location.reload(); + return; + } + } catch (error) { + bad(String(error?.stack ?? error)); + } + done(); +})(); From b2dd0b7cf380ef02e7bddaade62a412ed493c197 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:04:33 +0900 Subject: [PATCH 08/16] =?UTF-8?q?chore(plan):=2054-16=20B05=20=C2=B7=20B06?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=ED=95=9C=20=EA=B3=B3=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EA=B2=B0=EA=B3=BC=20=EB=B3=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/조사/2026-10-06_54-16_자료_한곳.md | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/raw/조사/2026-10-06_54-16_자료_한곳.md diff --git a/docs/raw/조사/2026-10-06_54-16_자료_한곳.md b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md new file mode 100644 index 000000000..8a1e5415e --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md @@ -0,0 +1,91 @@ +# 54-16 B05 · B06 자료 한 곳 확인 (조사 · 보고만) + +- 기준 커밋 `2104cc359` · 검증 프로젝트 `sub8_검증`(`c81b21f8…`) · 노선 224 +- 측정: 같은 창 안 넘김은 ORCA 탭 + `orca eval`. 도중 ORCA 탭이 통째로 새로 만들어져 세션 초안이 사라짐(앱 흠 아님) → 저장 · 새로고침 시나리오는 측정 전용 헤드리스 Chrome(같은 프로브 · 화면 안 띄움). ORCA 탭은 닫음 +- 값 되돌림: 높이 2 · 관경 1000 · 60m 계획고 865.48 · 3+0 측구 일반 으로 저장해 되돌림 확인. 단 횡단 저장 때 서버가 면적을 다시 계산해 누가토량은 −13,417.5 → −12,843.4 로 바뀜(서버 정본 재계산 결과 · 되돌릴 값 아님) + +## ① 자료마다 쥔 곳 + +세션 키 꼴 `aislo:{bucket}:{name}:{project}[:{route}]` (`A00_Common/b_page_state.ts:229-238`). 설계 가게(`design_store`)는 따로 값을 들지 않고 이 키를 바로 읽고 씀(`A00_Common/design/design_store.ts:133-153`) · 구독자 0. + +| 자료 | 기준 자리 | 같이 쥔 곳(두 벌) | 쓰는 곳 | 읽는 곳 | +|---|---|---|---|---| +| 구조물 목록 | ss `draft:structures` | 종단 다리 `ownStructures` · 횡단 패널 `structures` · 한 벌 폼 `structures` · 한 벌 종단도 `structures` · 서버 `structures.json`(보일 때마다 다시 받음) · 가게 칸 `structures`(아무도 안 씀) | `B05_Profile/B05_Profile_Api_Structures.ts:305` ← `B05_Profile_UI_Page_Structures.ts:245` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:177` | `B05_Profile_UI_Page_Structures.ts:331` · `B06_..._Structures_Panel.ts:345` · `B06_Section_Section_Store.ts:61` | +| 구조물 옵션 | 구조물 초안 안 `options` | 명세 폼 `state`(`A00_Common/design/design_structure_form.ts:278,286`) | 폼 commit | `B05_Profile_UI_Structures_Panel_Commit.ts:94` | +| 관 옵션 | 관 초안 안 `options` | 배수유역도 `facilityStore` · 횡단 캐시 culvert/ford/box 스펙 · ss `culvertopt` · ss `revetx`/`fordadjust`/`boxadjust` | 종단 `B05_Profile_UI_Drainage_Panel.ts:612` · 횡단 `B06_..._Structures_Panel.ts:277-284` · `B06_Section_UI_Page_Span_Control.ts:112` · `B06_Section_UI_Page_Ford_Controls.ts:204,398` | `design_view_cross.ts:623`(마운트 한 번) · 저장 `B06_Section_Api_Culvert_Options.ts:64` | +| 컨테이너 값 | 관 `options` 의 `soil_add_{n}_*` | ss `extrawall`(단 수) · ss `extraspan` + 캐시 `extra_spans` · ss `revetx` · 모듈 전역 `sectionsSource`/`extraCountSource`(나중에 선 보기가 이김) | `B06_Section_UI_Page_Station_Controls.ts:324` · `B06_Section_UI_Page_Span_Control.ts:78-80` · `B06_Section_UI_Page_Pipe_Options.ts:229` | `design_structure_spec.ts:112-140` · `design_view_cross.ts:298` · `design_structure_pipe_calc.ts:32,60` | +| 잇기 | 관 · 구조물 초안 `summary_item`/`group_items` | 배수유역도 `facilityStore` · ss `revetlink`(마운트 때 한 번) | `B05_Profile_UI_Drainage_Panel.ts:599-600` · `B06_..._Structures_Panel.ts:245-259` | `B05_Profile_UI_Structures_Panel_Commit.ts:114,198` | +| 관 지점 | ss `draft:pipes` | 배수유역도 `pipeEditor` + `facilityStore` · `shared.lastPipes` · 종단 보기 `lastPipes` · 횡단 패널 `pipeFacilities` · 한 벌 폼 `pipeFacilities` · 종단 다리 `pipeStations`/`pipeMarks` · 서버 `pipe_points.json` | `B05_Profile_Api_Pipes_Draft.ts:27` ← `Drainage_Panel.ts:397` · `B06_..._Structures_Panel.ts:430` | `Drainage_Panel.ts:404` · `B06_..._Structures_Panel.ts:350` · `B06_Section_Section_Store.ts:105` | +| 종단 계획선 | ss `draft:alignment-draft` | 모듈 전역 EditState Map(`B05_Profile_UI_Profile_Edit.ts:143,188`) · 한 벌 종단도 `store`/`base` | `B05_Profile_UI_Profile_Edit.ts:198` | `:97` · `B06_Section_UI_Page_Design_Sync.ts:132` | +| 횡단 설계 patch | 메모리 캐시 Map(`B06_Section/B06_Section_Section_Store.ts:178-201` · 두 보기 공유) | ss `result:section-detail` 사본(받을 때만 씀 · 고침 안 받음) · ss `crossdesign` · ss `rockb`/`cutslope`/`berm`/`crossw`/`inletstruct`/`basinadjust`/`std-cross` · 종단 보기 `currentSectionDetail` · 횡단 보기 `sectionDetail` | `B06_Section_Cross_Design_Session.ts:55` · `design_view_cross.ts:270-296,430` | `B06_Section_Cross_Refresh.ts:253`(재계산 때만) · `B06_Section_UI_Page_Persist.ts:377` · 마운트 `design_view_cross.ts:565-567,612` | +| 사토 | 구조물 목록 안 사토장 | 캐시 `design.spoil_fill_*` | 서버 저장 때만 `B06_Section_Server_Calc_Prebuild.py:197` | `B06_Section_Cross_Refresh.ts:316-327`(저장분 폭 그대로) | +| 선택 상태 | ss `draft:structure-pick` | 가게 `selection` + ss `draft:selection`(아무도 안 씀) · 횡단 `appliedPick` | `design_selection.ts:152,172` | `design_selection.ts:112` · `B05_Profile_UI_Structure_Pick_Session.ts:34` | + +## ② 재현 (측정) + +같은 창 안 넘김 · 저장 전 + +| 자료 | B05 바꿈 → B06 | B06 바꿈 → B05 | +|---|---|---| +| 구조물 옵션(돌쌓기 높이) | 맞음 — 폼 1.5 · 카드 H=1.5m | 맞음 — 1.8 | +| 관 옵션(관경) | 맞음 — 1200 | 맞음 — 800 | +| 종단 계획선(60m ▲) | 맞음 — 865.68 | 맞음 — 865.78 | +| 선택 | 맞음 — 같은 구조물 고름 | 맞음 | +| **누가토량(같은 초안)** | **어긋남 — B05 −13,478.3 · B06 −12,888.7** | 넘긴 뒤 B05 가 B06 값(−12,931.8)으로 바뀜 | + +저장 · 새로고침 (B05: 높이 1.2 · 관경 1200 · ▲2 → B06: 측구 L형 · 관경 800 · 높이 1.0) + +| 단계 | 높이 | 관경 | 60m 계획고 | 3+0 측구 | 누가토량 | 판정 | +|---|---|---|---|---|---|---| +| 새로고침 뒤 B05 (저장 전) | 1 | 800 | 865.68 | (B05 안 보임) | **−12,915.6** (직전 −12,931.8) | **옛 값** — L형 초안이 계산에 안 얹힘 | +| 새로고침 뒤 B05 에서 [저장] → 새 창 | 1 | 800 | 865.68 | **일반** | −13,494.6 | **옛 값** — 「편집 내용을 저장했습니다」인데 L형이 안 나감 | +| 같은 창 → B06 | 1 | 800 | 865.68 | **일반**(ss 엔 L형 초안 남음) | −13,494.6 | **옛 값** — 화면은 일반 · [저장]하면 L형이 나감(화면 ≠ 저장) | +| B06 에서 [저장] → 새 창 | 1 | 800 | 865.68 | L형 | −12,931.8 | 맞음 | +| B06 관경 1000 바꾸고 바로 [저장] → 새 창 | 1 | **800** | 865.68 | L형 | −12,931.8 | **옛 값** — `pipe-points` PUT 두 번 · 둘째(배수유역도 메모리 800)가 덮음 | + +재지 않은 것(코드 읽기만): 컨테이너 값 · 잇기 · 사토 + +## ③ 원인 + +두 벌 (6) + +| # | 자리 | 왜 | +|---|---|---| +| D1 | 관 목록 세 벌 — ss 초안 · 배수유역도 메모리 · 횡단 `pipeFacilities` | [저장]이 초안을 보낸 **뒤** 배수유역도 메모리를 한 번 더 PUT(`A00_Common/design/design_shared.ts:275-281` → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:636-642`) → 늦은 메모리가 새 값을 덮음 | +| D2 | 관 옵션 네 곳 — 관 초안 · 횡단 캐시 스펙 · `culvertopt` · `revetx`/`fordadjust`/`boxadjust` | 저장 차례상 `culvertopt` 가 관 초안 뒤에 나가 이김(`design_save.ts:217-222,282-285`) | +| D3 | 흙막이 단 수 두 벌 — ss `extrawall` · 관 `soil_add_{n}_*` | 컨테이너 수는 `extrawall` 을 따름 · 모듈 전역 `sectionsSource` 는 나중에 선 보기가 이김 | +| D4 | 구조물 목록 — ss 하나 + 메모리 셋 + 보일 때마다 서버 | 숨은 쪽은 보일 때 서버 + ss 로 다시 세움(`B05_Profile_UI_Page_Structures.ts:319-322` · `B06_..._Structures_Panel.ts:334-341`) | +| D5 | 종횡단 상세 — 메모리 캐시 · ss 사본 · 종단 `currentSectionDetail` · 횡단 `sectionDetail` | ss 사본은 제자리 고침을 안 받아 늘 옛 값 · 종단 쪽은 진입 때만 받음(`design_view_profile.ts:410,442`) | +| D6 | 가게 빈 칸 셋(`structures` · `sectionDetail` · `selection`) + `draft:selection` 키 | 실제 자리는 다른 키 — 「한 곳」이 겉모양뿐 · `subscribe` 구독자 0 | + +다른 보기로 갈 때 덮음 · 안 얹음 + +- **카드 선택 초안이 재계산 길에서만 얹힘** — `crossDesignChoices` 를 읽는 곳은 `B06_Section/B06_Section_Cross_Refresh.ts:253`(계획고 재계산)과 저장(`B06_Section_UI_Page_Persist.ts:377`)뿐 → 새로고침 · 저장 뒤 카드는 서버 설계(일반)를 보이고 ss 엔 L형이 남음 (재현 2 · 3) +- **일반 [저장]은 횡단 보기가 섰을 때만 횡단 초안을 보냄** — 안 섰으면 `saveCachedCrossPatches` 가 캐시 메모리만(`B06_Section_Section_Store.ts:259`) · ss 초안(`crossdesign` · `rockb` 등) 빠짐 · 그래도 「저장했습니다」 (재현 3) +- 종단 보기 onShow 가 횡단 자료를 다시 안 읽음(`B05_Profile_UI_Page_Life.ts:30-35` · `design_view_profile.ts:548-551`) · `applyPipes(lastPipes)`(`:550`)가 끝나지 않은 메모리 목록을 폼에 실음 +- 횡단 onShow 에 관 옵션 초안 다시 얹기 없음 — 마운트 때만(`design_view_cross.ts:623`) +- 종단 [저장] 뒤 캐시를 비워도(`B05_Profile_UI_Page_Actions.ts:154`) 숨은 횡단 `sectionDetail` 은 옛 객체 → `forwardPipeOptions`(`design_view_profile.ts:299`)가 버린 객체를 고침 +- 관을 전부 지운 빈 초안 `[]` 을 「없음」으로 봐 서버 목록을 받음(`Drainage_Panel.ts:405-407`) → [저장]이 지운 관을 되살림 + +한쪽만 알림 + +- **구조물 · 관 바꿈이 횡단 면적에 다시 얹히는 길이 횡단 쪽에만** — `refreshDetailForStructures`(`design_view_cross.ts:143`) · 종단 보기에서 바꾸면 종단 누가토량이 옛 면적 (재현 1: 같은 초안 −13,478.3 대 −12,888.7) +- `toAll("onPipesChanged")`(`design_shared.ts:158-161`) — 횡단 쪽 묶음(`B06_Section_UI_Section_View_Profile.ts:179-216`)에 받는 칸 없음 → 실제로는 종단만 받음 +- 「구조물 배치」 폼 알림은 지금 보기에만(`design_shared.ts:183`) +- 관 옵션 넘김 한 방향 — 종단 → 횡단 `forwardPipeOptions` · 횡단 → 종단은 초안 + 200ms 재읽기(`design_view_cross.ts:146`)뿐 +- 숨은 종단 보기는 관 목록을 담기만(`design_view_profile.ts:153`) → 횡단에서 [저장]하면 옛 비정규 측점이 `uphill` 로 나감(`B05_Profile_UI_Page_Actions.ts:128-134`) + +## ④ 한 곳으로 모을 갈래 (8 · 작은 것부터) + +목표: 자료 하나 = 세션 키 하나(가게 칸 하나) · 두 보기는 읽기만 · 바꿈 알림 한 길 · 보기 넘길 때 서버로 덮지 않음 · [저장]은 세션 키를 그대로 보냄(5장 데이터 3층) + +| 차례 | 갈래 | 크기 | 고치는 재현 | +|---|---|---|---| +| 1 | 저장 줄기 관 한 번 — 둘째 `side.savePipes()` PUT 을 빼고 초안 하나만(시설 되붙이기는 초안 쪽에서) · `design_shared.ts:275-281` | 작음 | 관경 바로 저장 옛 값 | +| 2 | 카드 선택 초안(`crossdesign` · `rockb` · `cutslope` 등)을 상세를 **불러올 때 한 번** 캐시에 얹음(`B06_Section_Section_Store.ts` load) — 재계산 길에만 두지 않음 | 작음~중간 | 새로고침 뒤 카드 일반 · B05 누가토량 어긋남 | +| 3 | 일반 [저장]이 보기와 무관하게 횡단 ss 초안을 늘 보냄(`saveCachedCrossPatches` 가 ss 초안 포함) | 작음~중간 | B05 [저장] L형 빠짐 | +| 4 | ss `result:section-detail` 사본 — 제자리 고침을 같이 쓰거나 지움 | 작음 | 새로고침 뒤 옛 사본 | +| 5 | 구조물 · 관 바꿈 → 횡단 상세 벽 · 면적 다시 얹기를 공용 한 길로(어느 보기에서 바꿔도) · 종단 `currentSectionDetail` 대신 `Section_Store` 캐시 하나만 읽음 | 중간 | 같은 초안 누가토량 두 값 | +| 6 | 관 목록 한 벌 — 배수유역도 메모리 · 횡단 `pipeFacilities` · `shared.lastPipes` 를 ss 초안 읽기 전용으로 · `onPipesChanged` 를 두 보기 모두 · 빈 초안 `[]` 도 초안으로 | 중간 | 관 지점 두 벌 | +| 7 | 관 옵션 한 자리 — `culvertopt` · `revetx` · `extrawall` 을 관 초안 `options` 로 모음 · 흙막이 단 수 한 벌 · 저장 차례 겹침 없앰 | 큼 | 관 옵션 네 곳 | +| 8 | 가게를 실제 한 창구로 — 자료마다 키 하나 · `subscribe` 알림으로 두 보기 다시 그림 · 빈 칸 정리 · 보기 onShow 서버 재조회는 초안 없을 때만 | 큼 | 두 벌 전반 | From e0953e333e85e9fa15e9877128b5b8307b4c66c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:12:50 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(ui):=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=ED=95=9C=20=EC=96=91=EC=8B=9D=20=C2=B7=20[=3F]=20?= =?UTF-8?q?=EC=A0=95=EC=82=AC=EA=B0=81=20=C2=B7=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EC=97=86=EC=9D=8C(54-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 규칙 한 곳(overlay.css) — 단추 사이 --btn-group-gap · 고르게 늘되 글보다 좁아지지 않음 · 줄바꿈 막음 - [?] 너비 = 높이(32px) - B05·B06 전용 여백 규칙(design_dock.css) 지움 — 공용으로 옮김 - 치수 재기 ui_checks/54-20_좌측단추_치수.js - 전: 높이 31.7·45.7(B04 [이 모델 확정] · B07 [현재 도면 확정] 두 줄) · 여백 8/16 섞임 · B06 [종단 보기] 글이 단추 꽉 참 · [?] 36×31.7 - 후: B02~B09 높이 31.7 하나 · 글 14px Inter 500 하나 · 줄바꿈·넘침 0 · 좁은 패널(280px)도 같음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_dock.css | 7 -- .../tester/ui_checks/54-20_좌측단추_치수.js | 83 +++++++++++++++++++ ui_template/ui_template_help.css | 7 +- ui_template/ui_template_overlay.css | 15 ++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 resources/tester/ui_checks/54-20_좌측단추_치수.js diff --git a/A00_Common/design/design_dock.css b/A00_Common/design/design_dock.css index dcfcf207f..e69de29bb 100644 --- a/A00_Common/design/design_dock.css +++ b/A00_Common/design/design_dock.css @@ -1,7 +0,0 @@ -/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가 - 두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */ -.b05-route__dock .b05-route__actions .ui-btn, -.b05-route__dock .b06-profile__actions .ui-btn { - padding-inline: var(--spacing-8); - white-space: nowrap; -} diff --git a/resources/tester/ui_checks/54-20_좌측단추_치수.js b/resources/tester/ui_checks/54-20_좌측단추_치수.js new file mode 100644 index 000000000..e99c7b4dd --- /dev/null +++ b/resources/tester/ui_checks/54-20_좌측단추_치수.js @@ -0,0 +1,83 @@ +/* 54-20 — B02~B09 좌측 패널 아래 공통 단추 묶음([?] · [초기화] · [저장] · [확정] · [횡단 보기] …) 치수 + * 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트) + * 부르는 법: orca eval --expression "$(cat 이 파일)" · 1분 넘으면 끊기므로 + * `window.__btn5420 = ["b05-profile", …];` 를 앞에 붙여 나눠 부름 + * 쓰는 것: 없음(주소 이동 · 재기만 · 끝나면 처음 주소로) + * 기대 수치: + * - 줄마다 보이는 단추 h 하나 · fs 하나 · ff 하나 · 줄 안 gap 하나 · wrap = false · over = false + * - [?] = 정사각(w = h) + * - 좁은 패널(narrow = true 로 패널 폭을 최소로 줄여 다시 잼)에서도 wrap · over 없음 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const vis = (n) => n && n.offsetParent !== null; + const start = location.hash; + const routes = window.__btn5420 ?? [ + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ]; + const measure = () => { + const help = [...document.querySelectorAll(".ui-help-btn")].find(vis); + const rows = help + ? [help.closest(".ui-sidebar-actions") ?? help.parentElement] + : [...document.querySelectorAll(".ui-sidebar-actions")].filter(vis); + return rows.map((row) => { + const btns = [...row.querySelectorAll("button")].filter(vis); + const rr = row.getBoundingClientRect(); + return { + row: row.className, + rowW: Math.round(rr.width), + gap: getComputedStyle(help?.parentElement ?? row).gap, + btns: btns.map((b) => { + const s = getComputedStyle(b); + const r = b.getBoundingClientRect(); + const text = document.createRange(); + text.selectNodeContents(b); + return { + t: b.innerText.trim(), + cls: b.className, + h: +r.height.toFixed(1), + w: +r.width.toFixed(1), + fs: s.fontSize, + fw: s.fontWeight, + ff: s.fontFamily.split(",")[0], + pad: s.padding, + bd: s.borderWidth + " " + s.borderStyle, + rad: s.borderRadius, + top: +(r.top - rr.top).toFixed(1), + wrap: text.getBoundingClientRect().height > parseFloat(s.fontSize) * 1.6, + // 글이 단추 안쪽(여백 뺀 폭)을 넘거나 단추가 줄 밖으로 나감 + over: + text.getBoundingClientRect().width > + b.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight) + 0.5 || + r.right > rr.right + 1, + }; + }), + }; + }); + }; + const out = []; + for (const route of routes) { + location.hash = `#/${route}`; + await w(4500); + const res = { page: route.slice(0, 3).toUpperCase(), rows: measure() }; + const panel = document.querySelector(".ui-workflow-overlay__panel--title"); + if (window.__btn5420narrow && panel) { + const was = panel.style.width; + panel.style.width = "var(--wf-left-panel-narrow-width)"; // 좁은 창(860px 아래) 폭 + await w(400); + res.narrowW = Math.round(panel.getBoundingClientRect().width); + res.narrow = measure(); + panel.style.width = was; + } + out.push(res); + } + location.hash = start; + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css index 7c9f3f69e..3585bea06 100644 --- a/ui_template/ui_template_help.css +++ b/ui_template/ui_template_help.css @@ -2,9 +2,10 @@ .ui-sidebar-actions .ui-help-btn, .ui-help-btn { flex: none; - width: auto; - min-width: 2.25rem; - padding-inline: var(--spacing-12); + /* 정사각 — 너비 = 높이(글 한 줄 · 위아래 여백 · 테두리) */ + width: calc(1em + 2 * var(--spacing-8) + 2px); + min-width: 0; + padding-inline: 0; } .ui-help.ui-modal { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index e7b8a37c0..cff809cab 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -368,6 +368,21 @@ min-width: 0; } +/* 아래 단추 묶음(54-20) — B03~B09 한 양식 · 사이 = --btn-group-gap · 고르게 늘되 글보다 좁아지지 + 않음 · 좁은 패널(280px)에서도 한 줄. 단추 줄 = [data-help-row](없으면 묶음 자신) */ +.ui-sidebar-actions:not(:has([data-help-row])), +.ui-sidebar-actions [data-help-row] { + gap: var(--btn-group-gap); +} + +.ui-sidebar-actions > .ui-btn:not(.ui-help-btn), +.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} + /* 사이드 패널 내부 컨테이너 공통 외곽선 — 기본 --color-border가 두 테마 모두 흐려 안 보인다는 보고(2026-08-05). 본문 보조색을 섞어 어느 테마에서든 뚜렷하게. 45%는 아직 흐리다는 재보고(2026-08-06)로 보조색 비중을 75%로 올렸다 — 테마 변수 From 976c9b28168cf6685fa8a4131465fc0508b12c52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:13:19 +0900 Subject: [PATCH 10/16] =?UTF-8?q?chore(plan):=2010-6=20=EB=8B=B5=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EB=A3=8C=20=EC=9D=BC=EC=9B=90=ED=99=94=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=C2=B7=20=EC=86=8D=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=201=EB=8B=A8=EA=B3=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 32518256a..dfcf22aab 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -181,3 +181,10 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자 · 추가 요청) - 페이지의 좌측 패널의 공통항목으로 들어온 버튼 세트의 버튼 크기가 조금 이상함. 점검 + +(10-6 사용자 · 답) +1. 공통 › 횡단도: 이 부분은 요청을 잊음. 생각나면 다시 말하겠음. 이슈 닫음 +2. B05와 B06은 공간이 부족하여 분리되어있는 상태임. 공통양식으로 좌측 패널을 사용하는 이유이며 어느 페이지에서 수정하던 공유가 되고 해당 값에 의해 종단과 횡단이, 페이지 제어가 되어야함. 꼼꼼히 분석 후 일원화 방향으로 제안. 단, 현재의 좌측패널로 종횡단 제어가 되어야함. 불필요한 정보라면 삭제도 가능(특히 이전 B08의 수량산출로 넘어가는 정보들은 재정의 필요) +3. 반영 +4. 일단 유지 +5. 너의 편의대로 진행 From 14a9366b30f6fb0c81a4c0b35f11819bc74c19ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:15:27 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(structure):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=EB=9E=A8=ED=94=84=20=E2=80=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=99=BC=EC=AA=BD=20=EA=B3=A0=EC=A0=95=20=ED=8F=AD?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=203=ED=96=89=20=C3=97=20=EC=97=B4=20?= =?UTF-8?q?=C2=B7=20=EC=B4=88=EB=A1=9D=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=95=84=EC=88=98=20=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=85=B8=EB=9E=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=A0=95=20=C2=B7=20=ED=88=B4=ED=8C=81=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=ED=95=AD=EB=AA=A9(54-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 램프 자리 = 폼 잇기 줄 규칙(명세 컨테이너 · 인스턴스 · 배관 흙막이 지형 단 수 · 정의 그룹 · 종류 단위 · B군 없음) - 단 수 바뀜 · 횡단 자료 도착 때 목록 다시 그림 - 지정 줄 왼쪽 선 = 안쪽 그림자(글 시작 자리 같게) - ui_checks/54-15_잇기_램프.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 1 + B05_Profile/B05_Profile_UI_Structures_List.ts | 94 ++++++- .../B05_Profile_UI_Structures_Panel.ts | 2 +- .../B05_Profile_UI_Style_Structures.css | 30 ++- resources/tester/ui_checks/54-15_잇기_램프.js | 242 ++++++++++++++++++ 5 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 resources/tester/ui_checks/54-15_잇기_램프.js diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..046fbeadf 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -576,6 +576,7 @@ export async function mountCrossView( } // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); + structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). if (sectionDetail) { diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index ee699ec95..9f75cc147 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -15,7 +15,17 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; import { stationLabel as formatStation } from "../common_util/common_util_station"; -import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item"; +import { + readGroupItems, + readSummaryItem, + summaryLabel, + summaryTooltip, + type SummaryItem, +} from "../common_util/common_util_summary_item"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; +import { containerTitle, linkOf, readState } from "../A00_Common/design/design_structure_spec"; +import { asRoad } from "./B05_Profile_UI_Structures_Define"; export interface StructureListParams { /** 목록을 담을
    . 그릴 때마다 통째로 갈아 끼운다. */ @@ -62,6 +72,72 @@ export function etcName(structure: StructureInstance): string | null { return name || null; } +/** 잇기 자리 하나 — 상자 이름 · 이은 항목(없으면 null) · 필수 여부. */ +interface LinkLamp { + title: string; + item: SummaryItem | null; + required: boolean; +} + +/** 줄의 잇기 자리(54-15) — 폼이 잇기 줄을 세우는 규칙 그대로: 명세 컨테이너마다(선택은 인스턴스마다 · + * 배관 흙막이는 지형이 세운 단까지) · 정의 그룹마다(옛 종류 단위 값 = 첫 그룹 · 54-6) · 그 밖은 + * 종류 단위 하나 · 종단배수(B군)는 없음. */ +export function linkLamps( + type: StructureType | undefined, + source: { + type_id: string; + options?: Record; + summary_item?: unknown; + group_items?: unknown; + }, + chainageM: number | null, +): LinkLamp[] { + const links = readGroupItems(source.group_items); + const summary = readSummaryItem(source.summary_item); + const spec = STRUCTURE_SPECS[source.type_id]; + if (spec?.containers.length) { + const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + // 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음) + const { soil_add: _old, ...pipeNames } = type?.spec_names ?? {}; + const names = source.type_id === "pipe" ? pipeNames : (type?.spec_names ?? {}); + const state = readState(spec, source.options ?? {}); + return spec.containers.flatMap((c): LinkLamp[] => { + const title = containerTitle(c, names, groupNames[c.link]); + if (c.required) return [{ title, item: links[c.link] ?? null, required: true }]; + const n = (c.id === "soil_add" ? pipeExtraCount(chainageM) : null) ?? state[c.id].length; + return Array.from({ length: n }, (_, i) => ({ + title: `${title} ${i + 1}`, + item: links[linkOf(c, i + 1)] ?? null, + required: false, + })); + }); + } + if (type?.groups?.length) { + const first = type.groups[0].id; + return type.groups.map((g) => ({ + title: g.name, + item: links[g.id] ?? (g.id === first ? summary : null), + required: true, + })); + } + if (!type || type.group === "B") return []; + return [{ title: "구조물 조합", item: summary, required: true }]; +} + +/** 램프 칸 — 3행 × 열(위 → 아래 → 다음 열) · 색 = 이음 · 필수 미지정 · 선택 미지정 · 툴팁 = 상자 · 항목. */ +function lampCell(lamps: LinkLamp[]): HTMLElement { + const cell = document.createElement("div"); + cell.className = "b05-route__lamps"; + for (const lamp of lamps) { + const dot = document.createElement("i"); + dot.className = lamp.item ? "is-linked" : lamp.required ? "is-required" : "is-optional"; + cell.append(dot); + } + if (lamps.length) + cell.title = lamps.map((lamp) => `${lamp.title} · ${summaryLabel(lamp.item)}`).join("\n"); + return cell; +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -101,6 +177,20 @@ export function renderStructureList(params: StructureListParams): void { : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), ), ); + const lamps = rows.map((row) => + row.kind === "structure" + ? linkLamps(typeMap.get(asRoad(row.structure).type_id), asRoad(row.structure), null) + : linkLamps( + typeMap.get(row.pipe.facility), + { ...row.pipe, type_id: row.pipe.facility }, + row.pipe.chainage_m, + ), + ); + // 램프 칸 폭 = 목록에서 가장 많은 열 — 줄마다 글 시작 자리가 같게(54-15) + list.style.setProperty( + "--lamp-cols", + String(Math.max(0, ...lamps.map((l) => Math.ceil(l.length / 3)))), + ); rows.forEach((row, index) => { const item = document.createElement("li"); item.className = "b05-route__irregular-item"; @@ -132,7 +222,7 @@ export function renderStructureList(params: StructureListParams): void { // 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다. if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" })); } - item.append(station, name); + item.append(lampCell(lamps[index]), station, name); list.append(item); }); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 4f3e2e137..aec944f83 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -540,7 +540,7 @@ export function createStructuresSection( return { root, - facility: facilityOptions, + facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) listRoot: list, hasSelection: () => editingId !== null || selectedPipeChainage !== null, setTypes(next, pick = () => true) { diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index cde7f1c91..fe14215b1 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -479,7 +479,35 @@ /* 목록 줄 — 집계 항목이 지정된 구조물 · 관은 왼쪽 선 + 이름 색으로(밝음 · 어두움 같은 토큰). */ .b05-route__irregular-item.is-assigned { - border-left: 3px solid var(--color-success); + /* 테두리 두께 대신 안쪽 그림자 — 글 시작 자리가 다른 줄과 같게(54-15) */ + border-left-color: var(--color-success); + box-shadow: inset 2px 0 0 var(--color-success); +} + +/* 목록 줄 잇기 램프(54-15) — 측점 왼쪽 고정 폭 칸 · 3행 × 열(위 → 아래 → 다음 열) · + 폭 = 목록에서 가장 많은 열(`--lamp-cols`) · 줄 높이를 늘리지 않음 */ +.b05-route__lamps { + display: grid; + flex: none; + grid-auto-flow: column; + grid-template-rows: repeat(3, 5px); + grid-auto-columns: 5px; + gap: 2px; + align-self: center; + width: max(0px, calc(var(--lamp-cols, 0) * 7px - 2px)); +} + +.b05-route__lamps i { + border-radius: 50%; + background: var(--color-success); +} + +.b05-route__lamps i.is-required { + background: var(--color-danger); +} + +.b05-route__lamps i.is-optional { + background: var(--color-warning); } .b05-route__irregular-item.is-assigned span { diff --git a/resources/tester/ui_checks/54-15_잇기_램프.js b/resources/tester/ui_checks/54-15_잇기_램프.js new file mode 100644 index 000000000..44cb3e42e --- /dev/null +++ b/resources/tester/ui_checks/54-15_잇기_램프.js @@ -0,0 +1,242 @@ +/* 54-15 — 구조물 목록 잇기 램프(측점 왼쪽 · 3행 × 열 · 초록 이음 · 빨강 필수 미지정 · 노랑 선택 미지정) + * 시작 화면: B06(#/b06-section) 또는 B05 · 검증 프로젝트(sub4_검증) · 로그인 + * 부르는 법: orca eval --expression "window.__m5415={mode:'단계'}; $(cat 이 파일)" + * look → 줄마다 램프 = 폼 잇기 줄(수 · 색) 대조 · 줄 높이(램프 끈 때와 같음) · 글 시작 x · 칸 폭 + * live → ARGS.row(기본 「배관 1」) 빨강 하나 잇기 → 초록 → 해제 → 빨강 · 「도수로/돌붙임」 추가 → + * 노랑 → 잇기 → 초록 → 해제 → 노랑 → 빼기 → 램프 수 되돌아옴(저장 안 함) + * sig → 줄마다 램프 색 글(B05 · B06 대조 · 저장 · 다시 열기 대조용) + * (결과는 window.__m5415_out 에도 — orca eval 응답이 끊기면 그것을 읽음) + * link / unlink → ARGS.row 첫 빨강 잇기 / 첫 초록 해제 → [저장] → sig + */ +(async () => { + const ARGS = window.__m5415 || { mode: "look" }; + const ROW = ARGS.row || "배관 1"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + // 명세 모듈 주소 — 자원 목록에 없으면(먼저 불린 계산 모듈 주소에서) 이름만 바꿔 씀 + const specUrl = + modUrl("/design/design_structure_pipe.ts") ?? + modUrl("/design/design_structure_pipe_calc.ts").replace("_calc.ts", ".ts"); + const { STRUCTURE_SPECS } = await import(specUrl); + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const nameOf = (li) => li.querySelector("span")?.textContent ?? ""; + const rowOf = (name) => items().find((li) => nameOf(li) === name); + const color = (dot) => + dot.classList.contains("is-linked") ? "G" : dot.classList.contains("is-required") ? "R" : "Y"; + const lampsOf = (li) => [...li.querySelectorAll(".b05-route__lamps i")].map(color).join(""); + const visible = (n) => n.offsetParent !== null; + /** 폼에 보이는 잇기 줄 — 색 글(같은 차례 · 위 → 아래). */ + const formLinks = () => + [...document.querySelectorAll(".b05-structure__summary")].filter(visible).map((row) => { + if (row.querySelector(".b05-structure__summary-name.is-assigned")) return "G"; + const box = row.closest("[data-spec-container]"); + if (!box) return "R"; // 종류 단위 · 정의 그룹 상자 = 필수 + const c = Object.values(STRUCTURE_SPECS) + .flatMap((s) => s.containers) + .find( + (c) => c.id === box.dataset.specContainer && row.dataset.specLink?.startsWith(c.link), + ); + return c && !c.required ? "Y" : "R"; + }); + const sorted = (s) => [...s].sort().join(""); + const select = async (name) => { + await until(() => items().length > 0); + const li = rowOf(name); + if (!li) return false; + if (!li.classList.contains("is-selected")) { + li.click(); + await until(() => rowOf(name)?.classList.contains("is-selected"), 5000); + } + await w(700); + return true; + }; + const release = async () => { + const li = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (li) li.click(); + await w(500); + }; + const sig = () => + items().map((li) => `${li.querySelector("strong")?.textContent}${nameOf(li)}:${lampsOf(li)}`); + const pickLink = async (row) => { + row.querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(1500); + }; + const linkRows = () => [...document.querySelectorAll(".b05-structure__summary")].filter(visible); + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + const ok = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000); + await w(1500); + return ok; + }; + const out = { mode: ARGS.mode }; + + if (ARGS.mode === "look") { + await release(); + const list = document.querySelector(".b05-route__irregular-list"); + const heights = () => + items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10); + const withLamps = heights(); + list.classList.add("m5415-off"); + const style = document.createElement("style"); + style.textContent = ".m5415-off .b05-route__lamps{display:none}"; + document.head.append(style); + const without = heights(); + style.remove(); + list.classList.remove("m5415-off"); + const xs = items().map( + (li) => Math.round(li.querySelector("strong").getBoundingClientRect().left * 10) / 10, + ); + const widths = items().map( + (li) => li.querySelector(".b05-route__lamps").getBoundingClientRect().width, + ); + const dot = items() + .map((li) => li.querySelector(".b05-route__lamps i")) + .find(Boolean) + ?.getBoundingClientRect(); + // 줄마다 폼을 열어 잇기 줄과 대조 + const rows = []; + for (const li of items()) { + const name = nameOf(li); + const lamps = lampsOf(li); + await select(name); + const form = formLinks().join(""); + rows.push({ name, lamps, form, ok: sorted(lamps) === sorted(form) }); + await release(); + } + const cols = Math.max(...rows.map((r) => Math.ceil(r.lamps.length / 3))); + Object.assign(out, { + rows: rows.length, + mismatch: rows.filter((r) => !r.ok), + lampTotal: rows.reduce((n, r) => n + r.lamps.length, 0), + colors: Object.fromEntries( + ["G", "R", "Y"].map((c) => [ + c, + rows + .map((r) => r.lamps) + .join("") + .split(c).length - 1, + ]), + ), + byKind: [...new Set(rows.map((r) => `${r.name.replace(/ \d+$/, "")}=${r.lamps.length}`))], + heightSame: JSON.stringify(withLamps) === JSON.stringify(without), + heights: [...new Set(withLamps)], + textX: [...new Set(xs)], + lampWidth: [...new Set(widths)], + cols, + dot: dot && [dot.width, dot.height], + dotColors: ["is-linked", "is-required", "is-optional"].map((c) => { + const d = document.querySelector(`.b05-route__lamps i.${c}`); + return d ? getComputedStyle(d).backgroundColor : null; + }), + }); + } + + if (ARGS.mode === "live") { + await select(ROW); + const lamps = () => lampsOf(rowOf(ROW)); + const steps = [["start", lamps(), formLinks().join("")]]; + const red = linkRows().find( + (r) => + !r.querySelector(".is-assigned") && + !r.closest("[data-spec-container]")?.querySelector("[data-spec-remove]"), + ); + if (red) { + await pickLink(red); + steps.push(["link", lamps(), formLinks().join("")]); + red.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + steps.push(["clear", lamps(), formLinks().join("")]); + } + const add = [...document.querySelectorAll("[data-spec-add]")].find(visible); + if (add) { + const id = add.dataset.specAdd; + add.click(); + await w(1500); + steps.push([`add ${id}`, lamps(), formLinks().join("")]); + // 램프 4 개 → 2 열 — 칸 폭 · 글 시작 x · 줄 높이가 목록 전체에서 하나 + const box2 = (sel) => [ + ...new Set( + items().map( + (li) => + Math.round( + li.querySelector(sel).getBoundingClientRect()[sel === "strong" ? "left" : "width"] * + 10, + ) / 10, + ), + ), + ]; + out.twoCols = { + lampWidth: box2(".b05-route__lamps"), + textX: box2("strong"), + heights: [ + ...new Set(items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10)), + ], + }; + const box = [...document.querySelectorAll(`[data-spec-container="${id}"]`)] + .filter(visible) + .at(-1); + await pickLink(box); + steps.push(["link", lamps(), formLinks().join("")]); + box.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + steps.push(["clear", lamps(), formLinks().join("")]); + [...document.querySelectorAll(`[data-spec-container="${id}"]`)] + .filter(visible) + .at(-1) + .querySelector("[data-spec-remove]") + .click(); + await w(1500); + steps.push(["remove", lamps(), formLinks().join("")]); + } + out.steps = steps.map(([s, l, f]) => `${s}: ${l} (폼 ${f})`); + out.back = steps[0][1] === steps.at(-1)[1]; + await release(); + } + + if (ARGS.mode === "link" || ARGS.mode === "unlink") { + await select(ROW); + const target = linkRows().find((r) => + ARGS.mode === "link" ? !r.querySelector(".is-assigned") : r.querySelector(".is-assigned"), + ); + if (ARGS.mode === "link") await pickLink(target); + else { + target.querySelector('[data-b05-summary="clear"]').click(); + await w(1500); + } + out.row = lampsOf(rowOf(ROW)); + out.saved = await save(); + await release(); + } + + out.sig = sig(); + window.__m5415_out = JSON.stringify(out); // 응답이 끊겨도 뒤에 읽음 + return window.__m5415_out; +})(); From c8ca79063c93462f7b92ddf8f4ddc195b5b313bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:22:08 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(B05):=2054-22=20H1=20=C2=B7=20H2=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=84=98=EA=B8=B0=20=EB=A7=89=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20id=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EC=A7=80=EB=A9=B4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=C2=B7=20=ED=91=9C=ED=98=84=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1: 구조물 저장 structure_id 를 안전 이름 규칙(FOLDER_PATTERN 과 한 벌)으로 검사 · 어기면 422 - H1: 복사본 맞춤에서 한 번 더 — 규칙 밖 · base 밖 폴더는 지우기 · 쓰기 건너뜀(못만듦) - H2: filter_key · method 를 허용 목록(Literal)으로 · 파일 이름 만드는 곳(표고 sampler · 비용면 · 스켈레톤)에서 한 번 더 - 시험: test_54_22_security 26 통과 · 관 간격 시험 가짜 필터 이름을 허용 값으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_Engine_Skeleton.py | 2 + B05_Profile/B05_Profile_Engine_Solver.py | 5 + B05_Profile/B05_Profile_Schema.py | 11 +- B05_Profile/B05_Profile_Structures_Schema.py | 6 +- M02_MasterTemplete/M02_Item_Snapshot.py | 21 +++- common_util/common_util_surface_sampler.py | 13 ++- resources/tester/test_54_22_security.py | 109 ++++++++++++++++++ .../tester/test_b05_pipe_clearance_switch.py | 2 +- 8 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_54_22_security.py diff --git a/B05_Profile/B05_Profile_Engine_Skeleton.py b/B05_Profile/B05_Profile_Engine_Skeleton.py index 385e31a78..947981955 100644 --- a/B05_Profile/B05_Profile_Engine_Skeleton.py +++ b/B05_Profile/B05_Profile_Engine_Skeleton.py @@ -20,6 +20,7 @@ from B05_Profile.B05_Profile_Engine_Solver import ( _cost_surface_signature, _load_or_build_cost_surface, ) +from common_util.common_util_surface_sampler import check_surface_keys from config.config_system import ( SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS, SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS, @@ -277,6 +278,7 @@ def load_or_build_skeleton( project_root: Path, filter_key: str, method: str, smooth: bool ) -> dict[str, Any]: """스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다.""" + check_surface_keys(filter_key, method) project_root = Path(project_root) models_dir = project_root / _MODELS_SUBDIR cache_dir = project_root / _ROUTE_CACHE_SUBDIR diff --git a/B05_Profile/B05_Profile_Engine_Solver.py b/B05_Profile/B05_Profile_Engine_Solver.py index 804d9c8de..4058c979b 100644 --- a/B05_Profile/B05_Profile_Engine_Solver.py +++ b/B05_Profile/B05_Profile_Engine_Solver.py @@ -19,6 +19,7 @@ from B05_Profile.B05_Profile_Engine_Geometry import ( resample_polyline_2d, single_segment_dijkstra, ) +from common_util.common_util_surface_sampler import check_surface_keys from config.config_system import ( FOREST_ROAD_MAX_GRADE, FOREST_ROAD_MIN_CURVE_R_M, @@ -45,6 +46,7 @@ _ROUTE_CACHE_SUBDIR = Path("B05_Profile") / "route" def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool): """필터의 정규 DTM 격자(x, y, z, valid_mask)를 로드한다.""" + check_surface_keys(filter_key) suffix = "_smooth" if smooth else "" dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz" if not dtm_path.exists(): @@ -70,6 +72,7 @@ def _sample_surface_on_grid( dtm_z: np.ndarray, ) -> np.ndarray: """확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백).""" + check_surface_keys(filter_key, method) if method == "dtm": return dtm_z @@ -145,6 +148,7 @@ def _sample_surface_on_grid( def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]: """비용면이 의존하는 소스 모델 파일(존재하는 것만, 안정 순서).""" + check_surface_keys(filter_key, method) suffix = "_smooth" if smooth else "" candidates = [ models_dir / f"dtm_{filter_key}{suffix}.npz", @@ -211,6 +215,7 @@ def _load_or_build_cost_surface( project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool ): """비용면을 반환한다(서명 일치 시 캐시 재사용, 아니면 재빌드·재캐시).""" + check_surface_keys(filter_key, method) cache_dir = project_root / _ROUTE_CACHE_SUBDIR suffix = "_smooth" if smooth else "" cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz" diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index d2790ec5b..e6407c988 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -4,6 +4,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator +from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from config.config_system import ( GRADE_MAIN_DIRECTIONS, GRADE_TERRAIN_TYPES, @@ -63,8 +64,10 @@ class RouteSolveRequest(BaseModel): model_config = ConfigDict(extra="forbid") - filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") - method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)") + filter_key: SurfaceFilter = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") + method: SurfaceMethod = Field( + default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)" + ) smooth: bool = Field(default=False) surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id") algorithm: str = Field( @@ -269,8 +272,8 @@ class RouteConfirmRequest(BaseModel): model_config = ConfigDict(extra="forbid") - filter_key: str | None = None - method: str | None = None + filter_key: SurfaceFilter | None = None + method: SurfaceMethod | None = None smooth: bool = False surface_model_id: int | None = None irregular_stations: list[IrregularStationInput] = Field(default_factory=list) diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index e7c8db289..c289c3363 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -164,6 +164,10 @@ def registry_schema_version() -> int: return int(_read_definition(*_stamp()).get("schema_version", 1)) +#: 구조물 id 모양 — 복사본 폴더 이름(`M02_Item_Snapshot.FOLDER_PATTERN`)이 되므로 경로 글자 금지 +STRUCTURE_ID_RULE = r"[0-9A-Za-z][0-9A-Za-z_-]*" + + class StructureInstance(BaseModel): """배치된 구조물 1건. @@ -173,7 +177,7 @@ class StructureInstance(BaseModel): model_config = ConfigDict(extra="forbid") - structure_id: str | None = None + structure_id: str | None = Field(default=None, pattern=rf"^{STRUCTURE_ID_RULE}$", max_length=64) type_id: str placement: Placement chainage_m: float | None = Field(default=None, ge=0) diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index d7e67022f..9df1bb0cb 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -26,6 +26,7 @@ from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import STRUCTURE_ID_RULE from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_json import atomic_write_json from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name @@ -51,7 +52,7 @@ LOCAL_FILES = (VALUES_FILE, PICKS_FILE) #: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` · 옵션 그룹(PLAN 46)은 #: 뒤에 `__{그룹 id}` FOLDER_PATTERN = re.compile( - r"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{1,16})?|[0-9A-Za-z][0-9A-Za-z_-]*)$" + rf"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{{1,16}})?|{STRUCTURE_ID_RULE})$" ) @@ -69,6 +70,14 @@ def folder_name(owner_id: str) -> str: return owner_id.replace("#", "n") +def safe_folder(base: Path, name: str) -> Path | None: + """`base` 바로 아래 복사본 폴더 — 이름이 규칙 밖 · `base` 밖이면 None(지우기 · 쓰기 막음).""" + target = base / name + if not FOLDER_PATTERN.match(name) or target.resolve().parent != base.resolve(): + return None + return target + + def now_text() -> str: return datetime.now().isoformat(timespec="seconds") @@ -181,8 +190,8 @@ def migrate_items(project_root: str | Path) -> list[str]: continue users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name] for owner in users: - target = base / folder_name(owner) - if (target / MANIFEST_NAME).is_file(): + target = safe_folder(base, folder_name(owner)) + if target is None or (target / MANIFEST_NAME).is_file(): continue # 이미 새 복사본이 있음 shutil.copytree(child, target, dirs_exist_ok=True) origin = manifest.get("원본") or {} @@ -212,7 +221,11 @@ def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: failed: list[str] = [] removed: list[str] = [] for folder, (owner, item) in sorted(wanted.items()): - target = base / folder + target = safe_folder(base, folder) + if target is None: + logger.warning("복사본 폴더 이름이 규칙 밖 — 건너뜀: %r", folder) + failed.append(folder) + continue manifest = read_json(target / MANIFEST_NAME) if isinstance(manifest, dict) and same_item(manifest, item): continue diff --git a/common_util/common_util_surface_sampler.py b/common_util/common_util_surface_sampler.py index 597243f2c..fddba295a 100644 --- a/common_util/common_util_surface_sampler.py +++ b/common_util/common_util_surface_sampler.py @@ -13,11 +13,21 @@ common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역 from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Protocol +from typing import Literal, Protocol, get_args import numpy as np from scipy.interpolate import RegularGridInterpolator +#: 지면 필터 · 지표면 표현 허용 목록 — 파일 이름에 들어감(B04 `_FILTERS` · `MODEL_METHODS`) +SurfaceFilter = Literal["classification", "grid_min_z", "csf", "pmf", "ransac"] +SurfaceMethod = Literal["dtm", "tin", "nurbs", "implicit", "meshfree"] + + +def check_surface_keys(filter_key: str, method: str = "dtm") -> None: + """파일 이름 만들기 전 검사 — 허용 목록 밖이면 ValueError(경로 넘기 막음).""" + if filter_key not in get_args(SurfaceFilter) or method not in get_args(SurfaceMethod): + raise ValueError(f"지면 필터 · 표현 이름이 허용 목록 밖: {filter_key!r} · {method!r}") + class SurfaceElevationSampler(Protocol): """종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스.""" @@ -129,6 +139,7 @@ def build_surface_sampler( models_dir: Path | str, source_filter: str, method: str, smooth: bool ) -> SurfaceElevationSampler: """1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다.""" + check_surface_keys(source_filter, method) models_dir = Path(models_dir) smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else "" dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz" diff --git a/resources/tester/test_54_22_security.py b/resources/tester/test_54_22_security.py new file mode 100644 index 000000000..42cf375a6 --- /dev/null +++ b/resources/tester/test_54_22_security.py @@ -0,0 +1,109 @@ +"""54-22 보안 — H1 구조물 id 경로 넘기 · H2 지면 필터 · 표현 이름 경로 넘기. + +못박는 것 + - H1: 구조물 저장 `structure_id` 는 안전 이름만(어기면 검사 실패 = 422) · 복사본 맞춤은 + 규칙 밖 · `base` 밖 폴더를 지우지도 쓰지도 않음. + - H2: `filter_key` · `method` 는 허용 목록만(스키마) · 파일 이름 만드는 곳에서도 한 번 더. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B05_Profile.B05_Profile_Engine_Skeleton import load_or_build_skeleton +from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _source_npz_paths +from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteSolveRequest +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys +from M02_MasterTemplete import M02_Item_Snapshot as snap + +BAD_IDS = ["../../..", "..", "a/b", "a\\b", "C:/Windows", "/etc", ".hidden", "", "x" * 65] +BAD_KEYS = ["../other/dtm_csf", "csf/../../x", "C:/x", "", "CSF"] + + +def _structure(structure_id): + return StructureInstance( + structure_id=structure_id, type_id="x", placement="point", chainage_m=1 + ) + + +@pytest.mark.parametrize("bad", BAD_IDS) +def test_H1_악성_id_거절(bad): + with pytest.raises(ValidationError): + _structure(bad) + + +@pytest.mark.parametrize("good", [None, "0c3d99cc5d8f49139c871d662537da42", "abc_1-2", "s1__g1"]) +def test_H1_정상_id_통과(good): + assert _structure(good).structure_id == good + + +def test_H1_safe_folder(tmp_path): + base = tmp_path / "templates" / "structures" + base.mkdir(parents=True) + assert snap.safe_folder(base, "abc123") == base / "abc123" + assert snap.safe_folder(base, "pipe@40.0__gin") == base / "pipe@40.0__gin" + for bad in BAD_IDS[:-1] + [str(tmp_path)]: # 길이는 스키마 몫 + assert snap.safe_folder(base, bad) is None + + +def test_H1_맞춤이_base_밖을_안_건드림(tmp_path, monkeypatch): + root = tmp_path / "proj" + base = snap.structures_dir(root) + base.mkdir(parents=True) + victim = root / "victim" # base / "../../victim" + victim.mkdir() + (victim / "keep.txt").write_text("살아야 함", encoding="utf-8") + item = {"layer": "master", "owner": "", "key": "k"} + monkeypatch.setattr( + snap, "assignments", lambda _root: {"../../victim": item, str(victim): item, "ok1": item} + ) + + def fake_write(target: Path, *_args): + target.mkdir(parents=True, exist_ok=True) + return {} + + monkeypatch.setattr(snap, "write_snapshot", fake_write) + result = snap.sync_snapshots(root) + assert (victim / "keep.txt").is_file() + assert set(result["못만듦"]) == {"../../victim", str(victim)} + assert result["만듦"] == ["ok1"] + assert result["지움"] == [] + + +@pytest.mark.parametrize("bad", BAD_KEYS) +def test_H2_스키마_허용_목록(bad): + body = {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}} + with pytest.raises(ValidationError): + RouteSolveRequest(filter_key=bad, **body) + with pytest.raises(ValidationError): + RouteSolveRequest(filter_key="csf", method=bad, **body) + with pytest.raises(ValidationError): + RouteConfirmRequest(filter_key=bad) + with pytest.raises(ValidationError): + RouteConfirmRequest(method=bad) + + +def test_H2_스키마_정상값_통과(): + assert RouteConfirmRequest(filter_key="grid_min_z", method="tin").method == "tin" + assert RouteConfirmRequest().filter_key is None + + +@pytest.mark.parametrize("bad", BAD_KEYS) +def test_H2_파일_이름_만드는_곳(bad, tmp_path): + with pytest.raises(ValueError): + check_surface_keys(bad) + with pytest.raises(ValueError): + check_surface_keys("csf", bad) + with pytest.raises(ValueError): + build_surface_sampler(tmp_path, bad, "dtm", False) + with pytest.raises(ValueError): + _load_dtm_grid(tmp_path, bad, False) + with pytest.raises(ValueError): + _source_npz_paths(tmp_path, "csf", bad, False) + with pytest.raises(ValueError): + load_or_build_skeleton(tmp_path, bad, "dtm", False) + check_surface_keys("classification", "meshfree") diff --git a/resources/tester/test_b05_pipe_clearance_switch.py b/resources/tester/test_b05_pipe_clearance_switch.py index 7daef7d92..4787d6a9f 100644 --- a/resources/tester/test_b05_pipe_clearance_switch.py +++ b/resources/tester/test_b05_pipe_clearance_switch.py @@ -71,7 +71,7 @@ def test_request_wins_over_stored(): def test_schema_defaults_to_none_and_ships_the_flag(): """스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다.""" request = RouteSolveRequest( - filter_key="f", + filter_key="csf", bp={"x": 0.0, "y": 0.0}, ep={"x": 100.0, "y": 0.0}, ) From 8e06412ea9bdf59f296eb841b4564849c2288b8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:26:10 +0900 Subject: [PATCH 13/16] =?UTF-8?q?chore(plan):=2010-6=20=EB=9E=A8=ED=94=84?= =?UTF-8?q?=20=ED=9A=8C=EC=83=89=20=C2=B7=20=EA=B2=BD=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=84=A4=EC=A0=95=20Old=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index dfcf22aab..2adb532a7 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -188,3 +188,7 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 3. 반영 4. 일단 유지 5. 너의 편의대로 진행 + +(10-6 사용자) +추가된 구조물 조합 연결 램프에서 색상의 정의를 알려줘. 그런데 빨간색보다는 지정안된 항목은 회색이 좋아보임 +다음 수정 B05/B06의 좌측 패널 컨테이너 중 경로 계산 설정 항목은 아주 예전의 자료로 이제는 필요 없어보이는데 관련 코드들이 사용하고 있는지 확인하고 없다면 일단 B05폴더에 Old 서브 폴더를 넣어줄것. 나중에 한번에 삭제예정 From d946fff8f7a6ec2dc2a785f9829c6ed69b6c1882 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:27:46 +0900 Subject: [PATCH 14/16] =?UTF-8?q?chore(plan):=2010-6=20=EB=B0=B0=EA=B4=80?= =?UTF-8?q?=20=EC=9C=A0=EC=B6=9C=20=EB=8B=A8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=90=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 2adb532a7..5528b77af 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -192,3 +192,6 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자) 추가된 구조물 조합 연결 램프에서 색상의 정의를 알려줘. 그런데 빨간색보다는 지정안된 항목은 회색이 좋아보임 다음 수정 B05/B06의 좌측 패널 컨테이너 중 경로 계산 설정 항목은 아주 예전의 자료로 이제는 필요 없어보이는데 관련 코드들이 사용하고 있는지 확인하고 없다면 일단 B05폴더에 Old 서브 폴더를 넣어줄것. 나중에 한번에 삭제예정 + +(10-6 사용자) +횡단배수 중 배관에서 관보호공의 유출 쪽에 단 추가 항목과 단 컨테이너 생성이 사라진것같음. 확인 From 943f7dce94563faa1f7aafdaea22dc84f3cf683c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:29:30 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=EF=BB=BFstyle(structure):=20=EC=9E=87?= =?UTF-8?q?=EA=B8=B0=20=EB=9E=A8=ED=94=84=20=EB=AF=B8=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=ED=9A=8C=EC=83=89=20=E2=80=94=20=ED=95=84=EC=88=98=20=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=ED=9A=8C=EC=83=89=20=EC=B1=84=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EC=84=A0=ED=83=9D=20=EB=AF=B8=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=ED=9A=8C=EC=83=89=20=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=86=8D=20?= =?UTF-8?q?=EB=B9=88=20=EC=9B=90=20=C2=B7=20=EB=B9=A8=EA=B0=95=20=C2=B7=20?= =?UTF-8?q?=EB=85=B8=EB=9E=91=20=EB=BA=8C(54-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B05_Profile/B05_Profile_UI_Style_Structures.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index efa72ff07..78c34e1c3 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -481,11 +481,13 @@ } .b05-route__lamps i.is-required { - background: var(--color-danger); + background: var(--color-text-muted); } .b05-route__lamps i.is-optional { - background: var(--color-warning); + box-sizing: border-box; + border: 1px solid var(--color-text-muted); + background: transparent; } .b05-route__irregular-item.is-assigned span { From 35bdda9d60f1caf8b23def5b7978027509a0aa27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:37:57 +0900 Subject: [PATCH 16/16] =?UTF-8?q?chore(plan):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=ED=91=9C=2009=20=EC=82=AC=EB=A9=B4=EC=95=88?= =?UTF-8?q?=EC=A0=95=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=C2=B7=20=EC=98=B9?= =?UTF-8?q?=EB=B2=BD=20=C2=B7=20=EC=86=8C=EB=8B=A8=20=EC=9B=90=EB=AC=B8(10?= =?UTF-8?q?-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- .../09_사면안정_흙막이_옹벽_소단.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md diff --git a/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md b/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md new file mode 100644 index 000000000..e69aa254b --- /dev/null +++ b/docs/raw/구조물정의_표/09_사면안정_흙막이_옹벽_소단.md @@ -0,0 +1,22 @@ +# 구조물 표 09 — 사면안정 › 흙막이 · 옹벽 · 소단 (사용자 10-6 · 원문 그대로) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 흙막이 +사면안정 흙막이 측점 No, + 측점거리 형태 돌 쌓기(찰), 돌 쌓기(메), 콘크리트, 돌망태, 통나무/목재틀, 바자 + 길이 수기입력 + 높이 수기입력 및 횡단도에서 제어 + 기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + 기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 옹벽 +사면안정 옹벽 측점 No, + 측점거리 형태 콘크리트 + 길이 수기입력 + 높이 수기입력 및 횡단도에서 제어 + 기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + 기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산) + +구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목 + (필수)컨테이너 제목 : 상세 내용 +사면안정 소단 측점 No, + 측점거리 소단 폭 수기입력 + 소단 높이 수기입력