diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1861a4ed7..0319b43d2 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ + "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ diff --git a/A00_Common/design/design_structure_slope_tier.ts b/A00_Common/design/design_structure_slope_tier.ts new file mode 100644 index 000000000..eba011102 --- /dev/null +++ b/A00_Common/design/design_structure_slope_tier.ts @@ -0,0 +1,34 @@ +/* ============================================================================= + * design_structure_slope_tier.ts + * 사면안정 흙막이 · 옹벽 「추가 단」 상자(178-3) — 배관 유출 흙막이 상자 양식(`design_structure_guard`) + * 재사용 · 공용 [추가] · [빼기] · 상자 수 = 추가 단. + * + * · 흙막이 = 형태 · 높이 · 옹벽 = 높이만(형태 = 콘크리트) · 저장 = structures.json `options` 키 `tier_{n}_*` + * · 정의 그룹 종류에 덧붙임 — 좌측 폼 선택 컨테이너만(그룹 상자 · 목록 · 벌룬 · M02 는 정의 그룹 그대로 · + * `STRUCTURE_SPECS` 에 안 넣음) + * · 단 그림 = `common_util_structure_walls`(`tierExtras` · 파이썬 짝) → 횡단 다단 + * import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec"; +import { guardItems } from "./design_structure_guard"; + +const TIER_ITEMS = guardItems().filter((item) => item.id === "form" || item.id === "height"); + +const tierAdd = (items: SpecItem[]): SpecContainer => ({ + id: "tier", + title: "추가 단", + required: false, + prefix: "tier", + link: "gtier", + items, +}); + +export const ADDON_SPECS: Readonly> = { + soil_guard: { typeId: "soil_guard", containers: [tierAdd(TIER_ITEMS)], retired: [] }, + retaining_wall: { + typeId: "retaining_wall", + containers: [tierAdd(TIER_ITEMS.filter((item) => item.id === "height"))], + retired: [], + }, +}; diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 5e4ff6444..64264b26d 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -146,13 +146,15 @@ def is_station_planting_type(item: StructureType) -> bool: - A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다. - D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면 그만큼 여러 장이 나온다(사용자 확정). + - C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료. + 안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다. 측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단 (`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다. """ if item.group == "A": return True - return item.group == "D" and item.placement == "interval" + return item.group in ("C", "D") and item.placement == "interval" def station_planting_labels() -> set[str]: diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 00de70541..c39573839 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -23,7 +23,12 @@ import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash"; import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { + culvertOwnerFor, + ownWallsDrawAt, + restrictToSide, + tierSpanOf, +} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford"; @@ -277,9 +282,9 @@ export function buildCorridorStructures( // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). - const revetLayout = section.culvert - ? null - : computeRevetmentLayout(section, section.design?.revet_adjust?.own); + const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) + ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) + : null; const paveChute = fordPavementChute(section, crossSections); if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue; const chainage = section.chainage_m; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index ff2ad8b7e..a9ab220df 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -16,6 +16,7 @@ import { type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; @@ -24,9 +25,10 @@ const forms = new WeakMap(); /** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */ const widenings = new WeakMap(); -/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ +/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */ export const structureSpecOf = (type: StructureType | null): StructureSpec | null => - (type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null; + (type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) || + null; /** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ export function createStructureSpecForm( diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 67266c563..9b7a7cdaa 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -265,7 +265,10 @@ export async function previewCrossDesigns( /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ - berms?: Record; + berms?: Record< + string, + { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } + >; /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ cutSlopeRatios?: Record; }, @@ -294,9 +297,15 @@ export interface CrossQuantitySheet { layout: { header: Array<[string, string]>; left: Array<[string | null, string | null, string | null]>; - middle: Array<[string | null, string, string]>; + /** 더한 홀로 선 줄(179-4)은 하위 이름 없음 */ + middle: Array<[string | null, string | null, string]>; right: Array<[string | null, string | null]>; + /** 본문 줄 id(양식 b0~ · 더한 행 r…) · 더한 열(키 = 본문 줄마다) — 179-4 */ + rows: string[]; + columns: Array<{ id: string; label: string; keys: string[] }>; }; + /** 길이 출처가 설계선을 벗어난 칸 — 값 비움 · 「다시 고름」(179-4). */ + repick: string[]; } /** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e59268017..1666ba773 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -471,6 +471,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ + detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ road_edges: { left: { offset_m: number; elevation_m: number }; @@ -528,7 +530,7 @@ export interface CrossDesign { display_half_width_m?: number; /** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음. * 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */ - berm?: { width_m: number; interval_m: number; slope_deg: number }; + berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ cut_slope_ratio_user?: number; @@ -617,4 +619,19 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ + detail_table?: CrossDetailTable; +} + +/** [상세] 표 칸 출처(179-4) — area = 설계 면적 칸(표 키) · length = 두 끝 [offset, 표고](m) · text = 글. */ +export type CrossCellSource = + | { src: "area"; key: string } + | { src: "length"; start: [number, number]; end: [number, number] } + | { src: "text"; text: string }; + +/** [상세] 표 손질 — 칸 출처(표 키 →) · 더한 행(`after` 아래 · 없으면 끝) · 더한 열(오른쪽 끝). */ +export interface CrossDetailTable { + cells: Record; + rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; + columns: Array<{ id: string; label: string }>; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fe8f9b199..fd6f8548e 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -29,6 +29,7 @@ import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { @@ -89,6 +90,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) + "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 "shoulder_extend_left_m", "shoulder_extend_right_m", @@ -189,9 +192,9 @@ function bermPayload( projectId: string, routeId: number, detail: SectionDetailResponse, -): Record { +): Record { const spans = readBermSpans(projectId, routeId); - const out: Record = {}; + const out: Record = {}; if (!spans.length) return out; for (const section of detail.cross_sections) { const spec = bermSpecAt(spans, section.chainage_m); @@ -256,7 +259,12 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const bermAt = (chainageM: number): BermSpec | null => { const spec = bermSpecAt(bermSpans, chainageM); return spec - ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + ? { + widthM: spec.width_m, + intervalM: spec.interval_m, + slopeDeg: spec.slope_deg, + depthM: spec.depth_m, + } : null; }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a993a1ef1..885293480 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -539,6 +539,7 @@ def compute_cross_design( "width_m": round(float(berm.width_m), 4), "interval_m": round(float(berm.interval_m), 4), "slope_deg": round(float(berm.slope_deg), 4), + "depth_m": round(max(float(berm.depth_m), 0.0), 4), } # 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력) for side, extend in ( diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 9f900def6..db48dd7c9 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -217,11 +217,8 @@ class _SectionGeometry: self.cut_ratio, self.soil_cut_ratio, boundary, - self.berm, - # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 - # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 - # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). - multi_knee=self.berm is not None, + # 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙). + None, ) self._cut_points_cache[side] = points return points @@ -235,7 +232,11 @@ class _SectionGeometry: if side in self._fill_points_cache: return self._fill_points_cache[side] start_dist, start_z = self._slope_start(side) - points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) + # 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2). + edge_z = ( + self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent) + ) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z) self._fill_points_cache[side] = points return points @@ -428,8 +429,8 @@ class _SectionGeometry: """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) - # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. - if self.two_stage or self.berm is not None: + # 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다. + if self.two_stage: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "cut": diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 1343e98bf..af7ae6052 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -17,6 +17,8 @@ """ import logging +import math +import re from pathlib import Path from typing import Any @@ -39,6 +41,31 @@ _FORM_BY_TYPE = { } +# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`. +_TIER_KEY = re.compile(r"^tier_(\d+)_") + + +def _num(value: Any) -> float | int | None: + """유한한 수만(TS `num` 짝 — 글 · 참거짓은 None).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if math.isfinite(value) else None + + +def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: + """「추가 단」 상자 → 추가 단 [{형태 · 높이}](n 차례) — 옹벽 = 콘크리트 · TS `tierExtras` 짝.""" + ns = sorted({int(m.group(1)) for key in options if (m := _TIER_KEY.match(key))}) + return [ + { + "form": "콘크리트" + if type_id == "retaining_wall" + else (options.get(f"tier_{n}_form") or None), + "height_m": _num(options.get(f"tier_{n}_height")), + } + for n in ns + ] + + # 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). BERM_TYPE_ID = "berm" @@ -60,6 +87,7 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: if start is None or end is None: continue options = structure.options or {} + extras = tier_extras(structure.type_id, options) found.append( { "structure_id": structure.structure_id, @@ -76,7 +104,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). "foundation": options.get("foundation"), - "tiers": options.get("tiers"), + # 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) + "tiers": len(extras) + 1 if extras else options.get("tiers"), + "extras": extras, "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키). diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py index a8b47e5e8..e13d35579 100644 --- a/B06_Section/B06_Section_Router_CrossTable.py +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -3,6 +3,7 @@ 값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 — 화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로 실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다. +표 손질(179-4 · 고른 칸 · 더한 행 · 열)은 설계 `detail_table` 을 `_Engine_Cross_Table_Edit` 가 읽음. """ from __future__ import annotations @@ -27,6 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import edited_cells, table_layout from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -42,21 +44,22 @@ class CrossQuantityTableRequest(BaseModel): def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]: - """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치.""" + """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치(+ 더한 행 · 열 179-4).""" table = _quantity_table(source, design) text = { key: _format(value) if isinstance(value, (int, float)) else "-" for key, value in table.items() } + _values, texts, repick = edited_cells(design) + text.update(texts) return { "quantity_table": table, "text": text, - "layout": { - "header": [list(row) for row in _CROSS_HEADER_KEYS], - "left": [list(row) for row in _CROSS_LEFT_ROWS], - "middle": [list(row) for row in _CROSS_MIDDLE_ROWS], - "right": [list(row) for row in _CROSS_RIGHT_ROWS], - }, + "layout": table_layout( + design, _CROSS_HEADER_KEYS, _CROSS_LEFT_ROWS, _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS + ), + # 길이 출처가 설계선을 벗어난 칸 — 값 비움 · 화면 「다시 고름」 + "repick": repick, } diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f3c644403..6a06c5481 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, ) from common_util.common_util_cross_berm import ( + BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, @@ -154,6 +155,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) + "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) "shoulder_extend_left_m", "shoulder_extend_right_m", @@ -456,6 +459,7 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None: width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), ) except (TypeError, ValueError): return None @@ -500,6 +504,7 @@ def recompute_designs_for_alignment( width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0), ) except (TypeError, ValueError, AttributeError): continue diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 2a864e6b2..b2d086b59 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -135,6 +135,40 @@ class BoxAdjustPatch(BaseModel): right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch) +class CrossCellSource(BaseModel): + """[상세] 표 칸 출처(179-4) — area = 설계 면적 칸 · length = 두 끝(m · offset, 표고) · text.""" + + src: Literal["area", "length", "text"] + key: str | None = Field(default=None, max_length=40) + start: list[float] | None = Field(default=None, min_length=2, max_length=2) + end: list[float] | None = Field(default=None, min_length=2, max_length=2) + text: str | None = Field(default=None, max_length=200) + + +class CrossDetailRow(BaseModel): + """[상세] 표 더한 행 — `after` 행 아래(없으면 끝) · grouped = 묶음 안 · 이름 좌 · 중 · 우.""" + + id: str = Field(..., pattern=r"^r\d{1,4}$") + after: str | None = Field(default=None, max_length=10) + grouped: bool = False + labels: list[str] = Field(default_factory=list, max_length=3) + + +class CrossDetailColumn(BaseModel): + """[상세] 표 더한 열 — 오른쪽 끝 · 모든 행.""" + + id: str = Field(..., pattern=r"^c\d{1,4}$") + label: str = Field(default="", max_length=40) + + +class CrossDetailTable(BaseModel): + """[상세] 표 손질(179-4) — 서버 `_Engine_Cross_Table_Edit` 가 읽어 모달 · B07 도면 표 한 벌.""" + + cells: dict[str, CrossCellSource] = Field(default_factory=dict, max_length=300) + rows: list[CrossDetailRow] = Field(default_factory=list, max_length=12) + columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -199,6 +233,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 + detail_table: CrossDetailTable | None = None class SectionConfirmRequest(BaseModel): @@ -329,7 +365,7 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None - # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로, # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. berms: dict[str, dict[str, float]] | None = None diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 936694a71..9c4aeaff3 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -22,6 +22,7 @@ import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_ import { computeCardCulvert, culvertLinkFor, + ownWallsDrawAt, tierSpanOf, } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -123,8 +124,9 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C culvert, ); // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. - const own = - !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; + const own = ownWallsDrawAt(section, link?.source) + ? computeRevetmentLayout(section, design.revet_adjust?.own) + : null; return { design, link, culvert, box, ford, own }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index ab8212da6..810f0396f 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -262,6 +262,15 @@ export function culvertWallsStandAt(owner: CrossSection, section: CrossSection): return keys.some((key) => wallStandsAt(owner, section, key)); } +/** + * 독립 벽(사면안정 C군 · 옛 D경로)을 이 측점에 그리나 — 카드 · 서버 면적 · B07 · 3D 가 + * 함께 쓰는 가드 한 벌(178-1). 관 세트가 붙은 측점이거나 연동 관(`owner`)의 벽이 이 + * 측점에 실제로 서면 배관 경로가 그린다(겹침 방지) — 링크만 있고 벽이 안 서면 그린다. + */ +export function ownWallsDrawAt(section: CrossSection, owner: CrossSection | undefined): boolean { + return !section.culvert && !(owner && culvertWallsStandAt(owner, section)); +} + export function culvertLinkFor( section: CrossSection, sections: readonly CrossSection[], diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 6a233b179..4f52eab17 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -6,10 +6,11 @@ * 칸 크기가 바뀌면 다시 그림) · * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · - * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). + * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · + * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — - * 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. + * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * ========================================================================== */ @@ -30,14 +31,20 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; +import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */ - chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + chart: ( + stationId: string, + widthPx: number, + heightPx: number, + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; } let host: CrossDetailHost | null = null; @@ -92,46 +99,110 @@ function cell( return element; } -/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */ +/** 더한 칸(179-4 · `r:` 행 · `c:` 열) — 값 없음 = 빈칸 · 글 손질. */ +const isAdded = (key: string): boolean => key.startsWith("r:") || key.startsWith("c:"); + +/** 손질 표시(179-4) — 값 칸 키 · 본문 줄 · 더한 열 · 글 손질 칸(처음 글 = `data-shown`). */ +function mark( + el: HTMLElement, + data: Record, + editable: boolean, +): HTMLElement { + for (const [name, value] of Object.entries(data)) + if (value !== undefined) el.dataset[name] = value; + if (editable) { + el.contentEditable = "plaintext-only"; + el.dataset.shown = el.textContent ?? ""; + } + return el; +} + +/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우) + * + 더한 행 · 오른쪽 끝 더한 열(179-4 · 서버 배치 그대로). */ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement { const { layout, text } = sheet; - const value = (key: string | null): string => (key ? (text[key] ?? "-") : ""); + const repick = new Set(sheet.repick ?? []); + const columns = layout.columns ?? []; + const rowIds = layout.rows ?? layout.left.map((_, index) => `b${index}`); + const value = (key: string | null): string => + !key ? "" : repick.has(key) ? "다시 고름" : (text[key] ?? (isAdded(key) ? "" : "-")); + const valueCell = (key: string | null, row?: string, col?: string): HTMLElement => { + const td = cell("td", value(key), undefined, key); + if (!key) return td; + if (repick.has(key)) td.dataset.repick = ""; + return mark(td, { cell: key, row, col }, isAdded(key)); + }; + // 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) + const nameCell = ( + label: string | null, + span: { col?: number; row?: number } | undefined, + key: string | null, + row: string, + slot: number, + ): HTMLElement => { + const added = row.startsWith("r"); + const data = { row, label: added ? `${row}:${slot}` : undefined }; + return mark(cell("th", label ?? "", span, key), data, added); + }; const table = document.createElement("table"); table.className = "b06-detail__qtable"; const titleRow = table.insertRow(); - titleRow.append(cell("th", title, { col: 8 })); + titleRow.append(cell("th", title, { col: 8 + columns.length })); const headRow = table.insertRow(); for (const [label, key] of layout.header) - headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key)); + headRow.append(cell("th", label, undefined, key), valueCell(key)); + for (const column of columns) + headRow.append(mark(cell("th", column.label), { col: column.id, colLabel: column.id }, true)); layout.left.forEach(([groupL, subL, keyL], index) => { + const id = rowIds[index]; const row = table.insertRow(); - // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) - if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL)); - else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL)); - if (subL !== null) row.append(cell("th", subL, undefined, keyL)); - row.append(cell("td", value(keyL), undefined, keyL)); + // 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4) + if (groupL !== null && subL !== null) + row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)); + else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0)); + if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0)); + row.append(valueCell(keyL, id)); const [groupM, subM, keyM] = layout.middle[index]; - if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM)); - row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM)); + if (groupM !== null && subM !== null) + row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)); + else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1)); + if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1)); + row.append(valueCell(keyM, id)); const [labelR, keyR] = layout.right[index]; - row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR)); + row.append(nameCell(labelR, undefined, keyR, id, 2), valueCell(keyR, id)); + for (const column of columns) row.append(valueCell(column.keys[index], id, column.id)); }); return table; } /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ -/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */ +/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ export function attachDetailZoom( clone: SVGSVGElement, + axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, -): ZoomPanHandle { - const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); - const layer = document.createElementNS("http://www.w3.org/2000/svg", "g"); - layer.append(...clone.childNodes); +): ZoomPanHandle | null { + const layer = clone.querySelector("g.b06-chart__plot-layer"); + if (!layer) return null; // 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확 - clone.append(layer); + const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number); + const axisLayer = clone.querySelector("g.b06-chart__axes"); + const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null; + const marker = layer.querySelector("g.b06-chart__center"); + const markerLine = marker?.querySelector("line"); + const cx = Number(markerLine?.getAttribute("x1")); + const cy = Number(markerLine?.getAttribute("y1")) + 18; + const follow = (state: ZoomPanState): void => { + renderAxes?.(state); + marker?.setAttribute( + "transform", + `translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`, + ); + }; + follow(initial ?? IDENTITY_VIEW); return attachSvgZoomPan({ svg: clone, layer, @@ -143,7 +214,10 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, - onChange, + onChange: (state) => { + follow(state); + onChange(state); + }, }); } @@ -164,12 +238,16 @@ let tableTimer = 0; let drawing = false; /** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */ -function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null { +function drawChart( + section: CrossSection, + widthPx: number, + heightPx: number, +): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { drawing = true; try { - const svg = host?.chart(section.station_id, widthPx, heightPx); - if (!svg) return null; - const clone = svg.cloneNode(true) as SVGSVGElement; + const drawn = host?.chart(section.station_id, widthPx, heightPx); + if (!drawn) return null; + const clone = drawn.svg.cloneNode(true) as SVGSVGElement; clone.querySelectorAll("clipPath[id]").forEach((clip) => { const id = clip.id; clip.id = `b06-detail-${id}`; @@ -177,7 +255,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return clone; + return { clone, axes: drawn.axes }; } finally { drawing = false; } @@ -233,6 +311,7 @@ export function openCrossDetail(section: CrossSection): void { // 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4) let zoom: ZoomPanState | undefined; + let axes: CrossAxesOptions | null = null; let drawnSize = ""; const resize = new ResizeObserver(() => state.paint()); resize.observe(cad); @@ -246,11 +325,13 @@ export function openCrossDetail(section: CrossSection): void { const size = `${width}x${height}`; if (size !== drawnSize) zoom = undefined; drawnSize = size; - const clone = drawChart(state.section, width, height); - if (!clone) cad.replaceChildren(document.createTextNode("그림 없음")); + const drawn = drawChart(state.section, width, height); + if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음")); else { - const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next)); - cad.replaceChildren(clone, buildZoomControls(handle)); + const { clone } = drawn; + axes = drawn.axes; + const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); } showPicked(); }, @@ -267,7 +348,7 @@ export function openCrossDetail(section: CrossSection): void { projectId, routeId, state.section.chainage_m, - state.section.design, + designForTable(state.section, projectId, routeId), ) .then((sheet) => { if (seq !== tableSeq || current !== state) return; @@ -284,8 +365,21 @@ export function openCrossDetail(section: CrossSection): void { forget(); close(); }, - stop: () => resize.disconnect(), + stop: () => { + resize.disconnect(); + detachTable(); + }, }; + // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 + const detachTable = attachDetailTable({ + tableBox, + cad, + section: () => state.section, + projectId: host?.projectId, + routeId: () => host?.routeId(), + axes: () => axes, + changed: () => state.loadTable(), + }); current = state; tableBox.textContent = "수량표 불러오는 중…"; state.paint(); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts new file mode 100644 index 000000000..4413240e1 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -0,0 +1,412 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Table.ts + * [상세] 수량표 손질(179-4) — 칸 우클릭 메뉴 · 그래프에서 고르기 · 행 · 열 더함 / 지움 · 세션 초안. + * + * 칸 출처 = 횡단 설계 patch `detail_table`(세션 초안 `detail-table` → [저장] patch → 서버). + * 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌. + * 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험 + * `resources/tester/test_179_4_detail_table.py`. + * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션). + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { + CrossCellSource, + CrossDesign, + CrossDetailTable, + CrossSection, +} from "./B06_Section_Api_Fetch"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; +import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; + +/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ +export const MAX_ROWS = 12; +export const MAX_COLUMNS = 4; +export const LINE_TOLERANCE_M = 0.05; + +/* ── 세션 초안(측점키 0.01 m → 표 손질) ─────────────────────────────────── */ + +type Drafts = Record; +const tableKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 표 손질 — 측점(m) → 표 손질. */ +export function readDetailTables( + projectId: string, + routeId: number, +): Map { + const raw = readState("detail-table", projectId, routeId) ?? {}; + return new Map(Object.entries(raw).map(([key, table]) => [Number(key), table])); +} + +/** 그 측점 표 손질 — 세션 초안 → 캐시 설계 → 빈 것(복사본). */ +export function detailTableOf( + section: CrossSection, + projectId?: string, + routeId?: number | null, +): CrossDetailTable { + const draft = + projectId && routeId !== null && routeId !== undefined + ? readState("detail-table", projectId, routeId)?.[tableKey(section.chainage_m)] + : undefined; + const table = draft ?? section.design?.detail_table; + return table ? structuredClone(table) : { cells: {}, rows: [], columns: [] }; +} + +/** 캐시 설계에도 얹음(모달 표 요청 · B05 [저장]이 같은 값을 실음) + 세션 초안. */ +export function writeDetailTable( + section: CrossSection, + table: CrossDetailTable, + projectId?: string, + routeId?: number | null, +): void { + if (section.design) section.design.detail_table = table; + if (!projectId || routeId === null || routeId === undefined) return; + const all = readState("detail-table", projectId, routeId) ?? {}; + all[tableKey(section.chainage_m)] = table; + writeState("detail-table", all, projectId, routeId); +} + +/** 표 요청에 실을 설계 — 표 손질을 얹음(카드가 다시 그려져 설계 객체가 바뀌어도 초안 그대로). */ +export function designForTable( + section: CrossSection, + projectId?: string, + routeId?: number | null, +): CrossDesign | undefined { + if (!section.design) return undefined; + return { ...section.design, detail_table: detailTableOf(section, projectId, routeId) }; +} + +/* ── 표 손질(순수) ──────────────────────────────────────────────────────── */ + +const nextId = (prefix: string, used: string[]): string => { + let n = 1; + while (used.includes(`${prefix}${n}`)) n += 1; + return `${prefix}${n}`; +}; + +export function setCell(table: CrossDetailTable, key: string, source: CrossCellSource | null) { + if (source) table.cells[key] = source; + else delete table.cells[key]; +} + +/** 행 더함 — `after` 줄 아래(null = 표 끝) · grouped = 그 줄 묶음 안. 새 id. */ +export function addRow(table: CrossDetailTable, after: string | null, grouped: boolean): string { + const id = nextId( + "r", + table.rows.map((row) => row.id), + ); + table.rows.push({ id, after, grouped, labels: ["", "", ""] }); + return id; +} + +export function addColumn(table: CrossDetailTable): string { + const id = nextId( + "c", + table.columns.map((column) => column.id), + ); + table.columns.push({ id, label: "" }); + return id; +} + +/** 더한 행 지움 — 그 행 칸 · 열 칸도 지우고 그 아래 붙던 행은 지운 행 자리로. */ +export function removeRow(table: CrossDetailTable, rowId: string): void { + const removed = table.rows.find((row) => row.id === rowId); + if (!removed) return; + table.rows = table.rows + .filter((row) => row.id !== rowId) + .map((row) => (row.after === rowId ? { ...row, after: removed.after } : row)); + for (const key of Object.keys(table.cells)) + if (key.startsWith(`r:${rowId}:`) || (key.startsWith("c:") && key.endsWith(`:${rowId}`))) + delete table.cells[key]; +} + +export function removeColumn(table: CrossDetailTable, columnId: string): void { + table.columns = table.columns.filter((column) => column.id !== columnId); + for (const key of Object.keys(table.cells)) + if (key.startsWith(`c:${columnId}:`)) delete table.cells[key]; +} + +export function setRowLabel(table: CrossDetailTable, rowId: string, slot: number, text: string) { + const row = table.rows.find((entry) => entry.id === rowId); + if (!row) return; + const labels = [0, 1, 2].map((index) => row.labels[index] ?? ""); + labels[slot] = text; + row.labels = labels; +} + +export function setColumnLabel(table: CrossDetailTable, columnId: string, text: string) { + const column = table.columns.find((entry) => entry.id === columnId); + if (column) column.label = text; +} + +/** 묶음 이름 칸이 덮는 줄 수 — 서버 `group_span` 과 같은 규칙. */ +export function groupSpan(slots: ReadonlyArray>, index: number) { + let span = 1; + while ( + index + span < slots.length && + slots[index + span][0] === null && + slots[index + span][1] !== null + ) + span += 1; + return span; +} + +/* ── 그래프 고르기 → 출처 ───────────────────────────────────────────────── */ + +/** 띠 · 사토장 클래스 → 표 키(서버 `AREA_KEYS` 의 표 키). */ +const AREA_BANDS: ReadonlyArray<[string, string]> = [ + ["b06-chart__area--cut_soil", "cut_soil"], + ["b06-chart__area--cut_rock", "cut_rock"], + ["b06-chart__area--fill", "embankment"], + ["b06-chart__spoil-fill", "spoil_fill"], +]; +export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; +export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit"; + +export function areaSourceOf(target: Element): CrossCellSource | null { + const hit = target.closest(AREA_PICK_SELECTOR); + const band = hit && AREA_BANDS.find(([name]) => hit.classList.contains(name)); + return band ? { src: "area", key: band[1] } : null; +} + +const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; + +/** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */ +export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null { + const hit = target.closest(LENGTH_PICK_SELECTOR); + const points = (hit?.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)); + if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite))) + return null; + const toM = ([px, py]: number[]): [number, number] => { + const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; + return [ + round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter), + round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration), + ]; + }; + return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) }; +} + +/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ + +const AREA_DESIGN: Readonly> = { + cut_soil: "cut_soil_area_m2", + cut_rock: "cut_rock_area_m2", + embankment: "fill_area_m2", + ditch_soil: "ditch_soil_area_m2", + ditch_rock: "ditch_rock_area_m2", + spoil_fill: "spoil_fill_area_m2", +}; + +const finite = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number]>): boolean { + for (let i = 0; i + 1 < line.length; i++) { + const [ax, ay] = line[i]; + const [bx, by] = line[i + 1]; + const dx = bx - ax; + const dy = by - ay; + const length2 = dx * dx + dy * dy; + const t = + length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2)); + if (Math.hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M) return true; + } + return false; +} + +/** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */ +export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) { + const record = (design ?? {}) as unknown as Record; + if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]); + if (source.src !== "length") return null; + const line = (design?.design_line ?? []) + .map((p): [number, number] => [p.offset_m, p.elevation_m]) + .filter((p) => p.every(Number.isFinite)); + const { start, end } = source; + if (!onLine(start, line) || !onLine(end, line)) return null; + return Math.hypot(end[0] - start[0], end[1] - start[1]); +} + +/* ── 우클릭 메뉴 ───────────────────────────────────────────────────────── */ + +/** 우클릭한 칸 — 값 칸 키 · 본문 줄 id · 더한 열 id. */ +export interface CellTarget { + key: string | null; + rowId: string | null; + columnId: string | null; +} + +export interface CellActions { + pick: (kind: "area" | "length") => void; + auto: () => void; + addRow: (after: string | null, grouped: boolean) => void; + addColumn: () => void; + removeRow: (rowId: string) => void; + removeColumn: (columnId: string) => void; +} + +export function cellMenuItems( + table: CrossDetailTable, + target: CellTarget, + act: CellActions, +): MapContextMenuItem[] { + const items: MapContextMenuItem[] = []; + const { key, rowId, columnId } = target; + if (key) { + items.push( + ["그래프에서 고르기 — 면적", () => act.pick("area")], + ["그래프에서 고르기 — 길이", () => act.pick("length")], + ); + if (table.cells[key]) items.push(["자동값으로", act.auto]); + } + items.push("-"); + if (table.rows.length < MAX_ROWS) { + if (rowId) items.push(["행 더함 — 이 행 아래(묶음 안)", () => act.addRow(rowId, true)]); + items.push(["행 더함 — 표 끝", () => act.addRow(null, false)]); + } + if (table.columns.length < MAX_COLUMNS) items.push(["열 더함 — 오른쪽 끝", act.addColumn]); + items.push("-"); + if (rowId?.startsWith("r")) items.push(["이 행 지움", () => act.removeRow(rowId)]); + if (columnId) items.push(["이 열 지움", () => act.removeColumn(columnId)]); + return items; +} + +/* ── 모달 배선 ─────────────────────────────────────────────────────────── */ + +export interface DetailTableHost { + tableBox: HTMLElement; + cad: HTMLElement; + section: () => CrossSection; + projectId: string | undefined; + routeId: () => number | null | undefined; + axes: () => CrossAxesOptions | null; + /** 표 손질이 바뀜 — 서버 표를 다시 받음. */ + changed: () => void; +} + +let menu: ReturnType | null = null; + +const PICK_HINT = { + area: "면적 띠를 누름 · Esc 취소", + length: "보라 선을 누름 · Esc 취소", +} as const; + +/** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */ +export function attachDetailTable(host: DetailTableHost): () => void { + menu ??= createMapContextMenu("b06-detail"); + const ctxMenu = menu; + if (!ctxMenu.element.isConnected) { + ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) · 모달 위 + document.body.append(ctxMenu.element); + } + const { tableBox, cad } = host; + const edit = (change: (table: CrossDetailTable) => void): void => { + const section = host.section(); + const table = detailTableOf(section, host.projectId, host.routeId()); + change(table); + writeDetailTable(section, table, host.projectId, host.routeId()); + host.changed(); + }; + + let picking: { kind: "area" | "length"; key: string } | null = null; + const stopPick = (): void => { + picking = null; + delete cad.dataset.pick; + delete cad.dataset.pickHint; + document.removeEventListener("keydown", onKey, true); + }; + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.stopPropagation(); // 모달은 그대로 — 고르기만 취소 + stopPick(); + }; + const startPick = (kind: "area" | "length", key: string): void => { + picking = { kind, key }; + cad.dataset.pick = kind; + cad.dataset.pickHint = PICK_HINT[kind]; + document.addEventListener("keydown", onKey, true); + }; + const onPick = (event: MouseEvent): void => { + if (!picking) return; + const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR; + // 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상 + const hit = document + .elementsFromPoint(event.clientX, event.clientY) + .find((el) => cad.contains(el) && el.closest(selector)); + const axes = host.axes(); + const source = !hit + ? null + : picking.kind === "area" + ? areaSourceOf(hit) + : axes && lengthSourceOf(hit, axes); + if (!source) return; + event.stopPropagation(); + const { key } = picking; + stopPick(); + const shown = localCellValue(source, host.section().design); + const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); + if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); + edit((table) => setCell(table, key, source)); + }; + cad.addEventListener("click", onPick); + + const onMenu = (event: MouseEvent): void => { + const el = (event.target as Element).closest( + "[data-cell], [data-row], [data-col]", + ); + if (!el || !tableBox.contains(el)) return; + event.preventDefault(); + const table = detailTableOf(host.section(), host.projectId, host.routeId()); + const key = el.dataset.cell ?? null; + const items = cellMenuItems( + table, + { key, rowId: el.dataset.row ?? null, columnId: el.dataset.col ?? null }, + { + pick: (kind) => key && startPick(kind, key), + auto: () => key && edit((next) => setCell(next, key, null)), + addRow: (after, grouped) => edit((next) => void addRow(next, after, grouped)), + addColumn: () => edit((next) => void addColumn(next)), + removeRow: (rowId) => edit((next) => removeRow(next, rowId)), + removeColumn: (columnId) => edit((next) => removeColumn(next, columnId)), + }, + ); + ctxMenu.open(event.clientX, event.clientY, items); + }; + tableBox.addEventListener("contextmenu", onMenu); + + // 더한 칸 글 — 손 뗄 때(포커스 잃음) 한 번 · Enter = 끝 + const onBlur = (event: FocusEvent): void => { + const el = event.target as HTMLElement; + if (!el.isContentEditable) return; + const text = (el.textContent ?? "").trim(); + if (text === (el.dataset.shown ?? "")) return; // 안 고침 + const { label, colLabel, cell } = el.dataset; + if (label) { + const [rowId, slot] = label.split(":"); + edit((table) => setRowLabel(table, rowId, Number(slot), text)); + } else if (colLabel) edit((table) => setColumnLabel(table, colLabel, text)); + else if (cell) edit((table) => setCell(table, cell, text ? { src: "text", text } : null)); + }; + const onEnter = (event: KeyboardEvent): void => { + if (event.key === "Enter" && (event.target as HTMLElement).isContentEditable) { + event.preventDefault(); + (event.target as HTMLElement).blur(); + } + }; + tableBox.addEventListener("focusout", onBlur); + tableBox.addEventListener("keydown", onEnter); + + return () => { + stopPick(); + ctxMenu.close(); + cad.removeEventListener("click", onPick); + tableBox.removeEventListener("contextmenu", onMenu); + tableBox.removeEventListener("focusout", onBlur); + tableBox.removeEventListener("keydown", onEnter); + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 0c802f065..db6536233 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -56,6 +56,8 @@ export interface RevetmentSpec { foundation?: string | null; /** 단 수(다단). 1이면 단일 벽. */ tiers?: number | null; + /** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3). 빈 값 = 본 벽 형태 · 기본 높이. */ + extras?: ReadonlyArray<{ form: string | null; height_m: number | null }> | null; /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ lift_m?: number | null; shift_m?: number | null; @@ -254,7 +256,12 @@ export function computeRevetmentLayout( outward, groundAt, limitOffset: limit, - adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })), + // 단마다 상자 형태 · 높이(178-3) — 자리(x · d)는 다단 자동 규칙 그대로 + adjusts: Array.from({ length: requestedTiers - 1 }, (_, i) => ({ + ...ZERO_ADJUST, + m: spec.extras?.[i]?.form ?? null, + h: spec.extras?.[i]?.height_m ?? null, + })), // 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로 // 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우). ownerForm: spec.form, diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts index d24ec6819..9fdf7053e 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts @@ -8,7 +8,7 @@ * 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤. * ========================================================================== */ -import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm"; +import { cutProfilePoints } from "../common_util/common_util_cross_berm"; import type { BermSpec } from "../common_util/common_util_cross_berm"; import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -27,12 +27,12 @@ export function structureCutSpec(design?: Partial | null): Structur const offset = design?.rock_boundary_offset_m; const twoStage = design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null; - const berm = design?.berm; return { cutRatio, soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1), rockOffsetM: twoStage ? offset : null, - berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null, + // 절토 소단 끔 — 본선 `cutPoints` 와 같음(178-2 · 소단 = 성토 사면 한 단). + berm: null, }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 18ff82f52..f148b57df 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -20,7 +20,7 @@ import { hideFordPavedRoad, appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; -import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; @@ -465,8 +465,7 @@ export function createCrossSectionCard( // 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다 // (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로 // 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다). - const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section); - if (!section.culvert && !linkedWallsHere) { + if (ownWallsDrawAt(section, culvertLink?.source)) { const ownAdjust = revetOffset?.adjustFor(section, "own"); const ownLayout = computeRevetmentLayout(section, ownAdjust); ownDesignTrim = ownLayout?.designTrim; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 88398bdcf..8959cf87b 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -19,6 +19,7 @@ import type { DesignViewSave } from "../A00_Common/design/design_shared"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; import { + BERM_DEFAULT_DEPTH_M, BERM_DEFAULT_INTERVAL_M, BERM_DEFAULT_SLOPE_DEG, BERM_DEFAULT_WIDTH_M, @@ -30,6 +31,7 @@ import { } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -59,6 +61,8 @@ export interface BermSessionSpec { width_m: number; interval_m: number; slope_deg: number; + /** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */ + depth_m: number; } /** @@ -122,13 +126,20 @@ export function bermSpansFromStructures( }; const width = number("width_m", BERM_DEFAULT_WIDTH_M); const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); - if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2) + // 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0 + const rawDepth = Number(options.depth_m); + const depth = + options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth) + ? BERM_DEFAULT_DEPTH_M + : Math.max(rawDepth, 0); spans.push({ start_m: Math.min(start, end), end_m: Math.max(start, end), width_m: width, interval_m: interval, slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + depth_m: depth, }); } return spans; @@ -142,7 +153,12 @@ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpe chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, ); return found - ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } + ? { + width_m: found.width_m, + interval_m: found.interval_m, + slope_deg: found.slope_deg, + depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M, + } : null; } @@ -393,6 +409,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 + if (ctx.projectId && routeId !== null) + readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { + patchFor(chainage).detail_table = table; + }); // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄 readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => { const patch = patchFor(chainage); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 805ac4c5d..a5a2517d5 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -37,6 +37,7 @@ import { import { type CrossPlotBase, crossPlotBase, + crossPlotFromBase, effectiveCardHalfWidth, fitPlotBase, naturalHeightAtWidth, @@ -561,9 +562,23 @@ export function createSectionView( // 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠) const cardZoom = cardZoomStates.get(stationId); cardZoomStates.delete(stationId); - const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx); + const fitted = base && fitPlotBase(base, heightPx); + const card = buildCrossCard(section, heightPx, fitted, widthPx); if (cardZoom) cardZoomStates.set(stationId, cardZoom); - return card.querySelector("svg.b06-section__chart"); + const svg = card.querySelector("svg.b06-section__chart"); + if (!svg) return null; + // 축 다시 그리기 옵션(179-3) — 카드 `createCrossAxes` 와 같은 기하 + const plot = fitted && crossPlotFromBase(fitted, heightPx); + const axes = plot && { + widthPx, + heightPx, + pixelsPerMeter: plot.pixelsPerMeter, + maxOffset: plot.maxOffset, + displayMax: plot.displayMax, + elevationMid: plot.elevationMid, + exaggeration: Math.max(currentExaggeration, 0.1), + }; + return { svg, axes }; }, focusStation(stationId) { if (stationId === null) clearSelection(); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 0e7439732..07a991790 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -11,6 +11,7 @@ import type { } from "./B06_Section_Api_Fetch"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** * 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**. @@ -37,8 +38,13 @@ export interface SectionViewController { refreshCard: (chainageM: number) => void; /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; - /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */ - detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null; + /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + detailChart: ( + stationId: string, + widthPx: number, + heightPx: number, + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index a50607922..ab0354901 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -54,9 +54,11 @@ } .b06-detail__qtable { + /* 이름 칸 바탕 = 데이터 표 머리색(179-3 · `ui_template_data_table` --dt-head) */ + --b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface)); width: 100%; border-collapse: collapse; - font-size: var(--text-caption); + font-size: var(--text-body-sm); font-variant-numeric: tabular-nums; } @@ -70,7 +72,8 @@ .b06-detail__qtable th { font-weight: 500; - color: var(--color-text-secondary); + background: var(--b06-detail-head); + color: var(--color-text); } .b06-detail__qtable tr:first-child > th { @@ -100,10 +103,18 @@ /* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */ .b06-detail__qtable [data-key][data-empty] { cursor: default; - background: none; color: var(--color-text-muted); } +/* 빈 칸 = 값 칸만 바탕 지움 · 이름 칸은 머리색 그대로(글만 회색) */ +.b06-detail__qtable td[data-key][data-empty] { + background: none; +} + +.b06-detail__qtable th[data-key][data-empty] { + background: var(--b06-detail-head); +} + /* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */ .b06-detail__cad .b06-chart__area.is-active > polygon { fill: color-mix(in srgb, var(--color-danger) 35%, transparent); @@ -131,3 +142,41 @@ .b06-detail__cad .b06-chart__area > polygon { cursor: default; } + +/* 표 손질(179-4) — 더한 칸 글 손질 · 다시 고를 칸 · 우클릭 메뉴는 모달 위 */ +.b06-detail__qtable [contenteditable] { + cursor: text; + min-width: 2em; +} + +.b06-detail__qtable [contenteditable]:focus { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +.b06-detail__qtable [data-repick] { + color: var(--color-danger); +} + +.b06-detail__context-menu.ui-ctx-menu { + z-index: 1001; +} + +/* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */ +.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon, +.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill, +.b06-detail__cad[data-pick="length"] .b06-chart__design-hit { + cursor: pointer; +} + +.b06-detail__cad[data-pick]::after { + content: attr(data-pick-hint); + position: absolute; + bottom: 4px; + left: 4px; + padding: 2px 8px; + border-radius: var(--radius-cards); + background: var(--color-accent); + color: var(--color-surface); + font-size: var(--text-caption); +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 2fd6e76a3..98a66b450 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -549,7 +549,7 @@ def build_cross_drawing( stack = 0.0 if quantity_table is not None: stack = ( - cross_table_height(table_scale) + cross_table_height(design, table_scale) + cross_note_height(cross_note_lines(design), table_scale) + 8.0 ) @@ -588,10 +588,10 @@ def build_cross_drawing( table_top -= cross_note_height(note_lines, table_scale) entities.extend( _cross_table_entities( - drawing_id, quantity_table, table_top, title_label, center_x, table_scale + drawing_id, quantity_table, table_top, title_label, center_x, design, table_scale ) ) - table_bottom = table_top - cross_table_height(table_scale) + table_bottom = table_top - cross_table_height(design, table_scale) # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. pad = CROSS_BORDER_PAD_MM diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index b5bcda6e2..e2b8d5eb5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -101,7 +101,7 @@ def section_block_size( below = half_height + _BORDER_PAD_MM if with_table: note_h = cross_note_height(cross_note_lines(design_spec), table_scale) - below += 8.0 + note_h + cross_table_height(table_scale) + below += 8.0 + note_h + cross_table_height(design_spec, table_scale) return (width, top + below) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 8c5af69fb..85a559e3e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -91,14 +91,18 @@ _CROSS_TABLE_FONT = 2.0 # 열 경계 비율 (좌: 그룹/하위/값/여백, 중: 그룹/하위/값/여백, 우: 라벨/값/여백) _CROSS_COLUMN_WEIGHTS: tuple[float, ...] = (1.0, 1.7, 2.1, 1.0, 1.9, 1.4, 2.1, 1.0, 2.6, 2.1, 1.0) +# [상세] 표에서 더한 열 하나의 비율(179-4 · 표 폭은 그대로 · 본문 열이 그만큼 좁아짐) +_CROSS_EXTRA_COLUMN_WEIGHT = 2.1 -def _cross_column_edges(width: float, center_x: float = 0.0) -> list[float]: - total = sum(_CROSS_COLUMN_WEIGHTS) +def _cross_column_edges( + width: float, center_x: float = 0.0, weights: tuple[float, ...] = _CROSS_COLUMN_WEIGHTS +) -> list[float]: + total = sum(weights) left = center_x - width / 2.0 edges = [left] accumulated = 0.0 - for weight in _CROSS_COLUMN_WEIGHTS: + for weight in weights: accumulated += weight edges.append(left + width * accumulated / total) return edges @@ -128,6 +132,7 @@ def _cross_table_entities( table_top: float, title_label: str, center_x: float = 0.0, + design: dict[str, Any] | None = None, scale: float = 1.0, ) -> list[dict[str, Any]]: """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2). @@ -135,19 +140,34 @@ def _cross_table_entities( 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. + [상세] 표 손질(179-4 · `design.detail_table`) — 더한 행은 본문에 끼고 더한 열은 오른쪽 끝. """ + from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import ( + edited_cells, + group_span, + table_layout, + ) + + layout = table_layout( + design, _CROSS_HEADER_KEYS, _CROSS_LEFT_ROWS, _CROSS_MIDDLE_ROWS, _CROSS_RIGHT_ROWS + ) + texts = edited_cells(design)[1] + left_rows, middle_rows, right_rows = layout["left"], layout["middle"], layout["right"] + extra = layout["columns"] width = _CROSS_TABLE_WIDTH * scale row_h = _CROSS_TABLE_ROW_HEIGHT * scale font = _CROSS_TABLE_FONT * scale left = center_x - width / 2.0 right = center_x + width / 2.0 - body_edges = _cross_column_edges(width, center_x) - header_cell = width / 8.0 + weights = _CROSS_COLUMN_WEIGHTS + (_CROSS_EXTRA_COLUMN_WEIGHT,) * len(extra) + body_edges = _cross_column_edges(width, center_x, weights) + # 머리행 8등분은 납품 양식 몫(더한 열 앞)만 — 더한 열이 없으면 표 폭 그대로 + header_cell = width * sum(_CROSS_COLUMN_WEIGHTS) / sum(weights) / 8.0 header_edges = [left + header_cell * index for index in range(9)] edges = _union_edges(body_edges, header_edges) column_widths = [edges[i + 1] - edges[i] for i in range(len(edges) - 1)] - body_rows = len(_CROSS_LEFT_ROWS) + body_rows = len(left_rows) rows = body_rows + 2 # 제목행 + 머리행 + 본문 cells: list[list[dict[str, Any] | None]] = [[None] * len(column_widths) for _ in range(rows)] @@ -177,8 +197,13 @@ def _cross_table_entities( cells[row][column] = cell def value_of(key: str) -> str: + if key in texts: + return texts[key] value = quantity_table.get(key) - return _format(value) if isinstance(value, (int, float)) else "-" + if isinstance(value, (int, float)): + return _format(value) + # 더한 칸(179-4 · `r:` 행 · `c:` 열)은 빈칸 · 양식 칸은 「-」 + return "" if key[:2] in ("r:", "c:") else "-" # ── 제목행 (No.측점) — 전체 병합 put(0, left, right, title_label, TABLE_LABEL_COLOR, align="left", font_size=font * 1.15) @@ -188,14 +213,18 @@ def _cross_table_entities( x0 = left + header_cell * pair_index * 2 put(1, x0, x0 + header_cell, label, TABLE_LABEL_COLOR) put(1, x0 + header_cell, x0 + header_cell * 2, value_of(key), TABLE_VALUE_COLOR, key=key) + # 더한 열 머리 = 열 이름 + for index, column in enumerate(extra): + put(1, body_edges[11 + index], body_edges[12 + index], column["label"], TABLE_LABEL_COLOR) - # ── 본문 6행 (좌/중/우 그룹) + # ── 본문 6행 (좌/중/우 그룹) + 더한 행 for row_index in range(body_rows): row = row_index + 2 - group_l, sub_l, key_l = _CROSS_LEFT_ROWS[row_index] + group_l, sub_l, key_l = left_rows[row_index] if group_l is not None and sub_l is not None: - # 그룹 라벨은 아래 행까지 두 행 병합 - put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=2) + # 그룹 라벨은 하위 줄까지 병합(양식 = 두 행 · 더한 하위 줄만큼 늘어남) + span = group_span(left_rows, row_index) + put(row, body_edges[0], body_edges[1], group_l, TABLE_LABEL_COLOR, row_span=span) elif group_l is not None: # 하위 라벨이 없으면 그룹 열과 하위 열을 합친다 put(row, body_edges[0], body_edges[2], group_l, TABLE_LABEL_COLOR) @@ -204,17 +233,31 @@ def _cross_table_entities( if key_l is not None: put(row, body_edges[2], body_edges[3], value_of(key_l), TABLE_VALUE_COLOR, key=key_l) - group_m, sub_m, key_m = _CROSS_MIDDLE_ROWS[row_index] - if group_m is not None: - put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=2) - put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) + group_m, sub_m, key_m = middle_rows[row_index] + if group_m is not None and sub_m is not None: + span = group_span(middle_rows, row_index) + put(row, body_edges[4], body_edges[5], group_m, TABLE_LABEL_COLOR, row_span=span) + elif group_m is not None: + put(row, body_edges[4], body_edges[6], group_m, TABLE_LABEL_COLOR) + if sub_m is not None: + put(row, body_edges[5], body_edges[6], sub_m, TABLE_LABEL_COLOR) put(row, body_edges[6], body_edges[7], value_of(key_m), TABLE_VALUE_COLOR, key=key_m) - label_r, key_r = _CROSS_RIGHT_ROWS[row_index] + label_r, key_r = right_rows[row_index] if label_r is not None: put(row, body_edges[8], body_edges[9], label_r, TABLE_LABEL_COLOR) if key_r is not None: put(row, body_edges[9], body_edges[10], value_of(key_r), TABLE_VALUE_COLOR, key=key_r) + for index, column in enumerate(extra): + column_key = column["keys"][row_index] + put( + row, + body_edges[11 + index], + body_edges[12 + index], + value_of(column_key), + TABLE_VALUE_COLOR, + key=column_key, + ) return [ table_entity( @@ -363,6 +406,8 @@ def cross_table_width(scale: float = 1.0) -> float: return _CROSS_TABLE_WIDTH * scale -def cross_table_height(scale: float = 1.0) -> float: - """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" - return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2) +def cross_table_height(design: dict[str, Any] | None = None, scale: float = 1.0) -> float: + """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행(+ [상세]에서 더한 행 · 179-4) × 배율(180-2).""" + from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import added_row_count + + return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2 + added_row_count(design)) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py new file mode 100644 index 000000000..bcdb964c2 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -0,0 +1,205 @@ +"""횡단 수량표 손질(179-4) — 횡단 설계 patch `detail_table` → 칸 값 · 표 배치 한 벌. + +모달(`B06_Section_Router_CrossTable`)과 B07 도면 표(`_Engine_Cad_Table`)가 같은 함수를 쓴다. + +칸 출처(`detail_table.cells` · 표 키 → 출처) — 그릴 때마다 서버가 다시 셈(브라우저 값 안 받음): + area = 설계 면적 칸 그대로(`AREA_KEYS` · 띠 키 = 표 키) + length = 두 끝(m · offset · 표고)이 설계선 위면 hypot · 아니면 비움 → 「다시 고름」 + text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 +행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). +열(`columns`) = 오른쪽 끝 · 모든 행(비고 등). + +짝(① 파이썬 · TS) = `B06_Section/B06_Section_UI_Cross_Detail_Table.ts` `localCellValue` +(조작 중 즉시 표시) · 거울 시험 `resources/tester/test_179_4_detail_table.py`. +""" + +from __future__ import annotations + +from math import hypot +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import AREA_KEYS + +_AREA_DESIGN = dict(AREA_KEYS) +#: 두 끝이 설계선에서 이만큼(m) 안이면 「선 위」 +LINE_TOLERANCE_M = 0.05 +#: 더한 행 · 열 상한 — 도면 표가 장 칸을 넘지 않게 +MAX_ROWS = 12 +MAX_COLUMNS = 4 + +Slot = tuple[str | None, str | None, str | None] + + +def _edit(design: dict[str, Any] | None) -> dict[str, Any]: + edit = design.get("detail_table") if isinstance(design, dict) else None + return edit if isinstance(edit, dict) else {} + + +def _num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None + + +def _point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, list) or len(value) != 2: + return None + x, y = _num(value[0]), _num(value[1]) + return (x, y) if x is not None and y is not None else None + + +def _design_line(design: dict[str, Any]) -> list[tuple[float, float]]: + points = [] + for point in design.get("design_line") or []: + if isinstance(point, dict): + x, y = _num(point.get("offset_m")), _num(point.get("elevation_m")) + if x is not None and y is not None: + points.append((x, y)) + return points + + +def _on_line(point: tuple[float, float], line: list[tuple[float, float]]) -> bool: + """점이 설계선(꺾은선) 조각 하나에서 `LINE_TOLERANCE_M` 안인가.""" + px, py = point + for (ax, ay), (bx, by) in zip(line, line[1:]): + dx, dy = bx - ax, by - ay + length2 = dx * dx + dy * dy + t = 0.0 if length2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length2)) + if hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M: + return True + return False + + +def cell_value( + source: dict[str, Any], design: dict[str, Any] | None +) -> tuple[float | None, str | None, bool]: + """출처 하나 → (숫자, 글, 다시 고름).""" + design = design if isinstance(design, dict) else {} + kind = source.get("src") + if kind == "area": + return _num(design.get(_AREA_DESIGN.get(str(source.get("key")), ""))), None, False + if kind == "length": + start, end = _point(source.get("start")), _point(source.get("end")) + line = _design_line(design) + if start and end and _on_line(start, line) and _on_line(end, line): + return hypot(end[0] - start[0], end[1] - start[1]), None, False + return None, None, True + if kind == "text": + text = source.get("text") + return None, text if isinstance(text, str) else "", False + return None, None, False + + +def edited_cells( + design: dict[str, Any] | None, +) -> tuple[dict[str, float | None], dict[str, str], list[str]]: + """patch 칸 출처 → (숫자 칸, 글 칸, 다시 고를 칸). 글은 더한 칸만.""" + values: dict[str, float | None] = {} + texts: dict[str, str] = {} + repick: list[str] = [] + cells = _edit(design).get("cells") + for key, source in (cells if isinstance(cells, dict) else {}).items(): + if not isinstance(source, dict): + continue + number, text, again = cell_value(source, design) + if text is not None: + if key[:2] in ("r:", "c:"): + texts[key] = text + continue + values[key] = number + if again: + repick.append(key) + return values, texts, repick + + +def _labels(row: dict[str, Any]) -> list[str]: + raw = row.get("labels") + raw = raw if isinstance(raw, list) else [] + return [str(raw[i]) if i < len(raw) and isinstance(raw[i], str) else "" for i in range(3)] + + +def _added_rows(edit: dict[str, Any]) -> list[dict[str, Any]]: + """쓸 수 있는 더한 행 — id = 「r…」 · 겹친 id 는 앞 것만 · 상한 `MAX_ROWS`.""" + raw = edit.get("rows") + rows: list[dict[str, Any]] = [] + for row in raw if isinstance(raw, list) else []: + row_id = row.get("id") if isinstance(row, dict) else None + if ( + isinstance(row_id, str) + and row_id.startswith("r") + and all(r["id"] != row_id for r in rows) + ): + rows.append(row) + return rows[:MAX_ROWS] + + +def added_row_count(design: dict[str, Any] | None) -> int: + """더한 행 수 — 도면 표 높이(`cross_table_height`)가 씀.""" + return len(_added_rows(_edit(design))) + + +def _slot(anchor: Slot | None, label: str, key: str) -> Slot: + """묶음(하위 이름이 있는 줄) 아래면 하위 줄 · 아니면 홀로 선 이름.""" + if anchor is not None and anchor[1] is not None: + return (None, label, key) + return (label, None, key) + + +def table_layout( + design: dict[str, Any] | None, + header: tuple[tuple[str, str], ...], + left: tuple[Slot, ...], + middle: tuple[Slot, ...], + right: tuple[tuple[str | None, str | None], ...], +) -> dict[str, Any]: + """납품 양식(머리 넷 쌍 + 본문 좌 · 중 · 우) + 더한 행 · 열 = 실제 표 배치.""" + edit = _edit(design) + rows: list[tuple[str, Slot, Slot, tuple[str | None, str | None]]] = [ + (f"b{index}", left[index], middle[index], right[index]) for index in range(len(left)) + ] + for row in _added_rows(edit): + row_id = row["id"] + labels = _labels(row) + index = next((i for i, r in enumerate(rows) if r[0] == row.get("after")), None) + anchor = rows[index] if index is not None and row.get("grouped") else None + rows.insert( + len(rows) if index is None else index + 1, + ( + row_id, + _slot(anchor[1] if anchor else None, labels[0], f"r:{row_id}:0"), + _slot(anchor[2] if anchor else None, labels[1], f"r:{row_id}:1"), + (labels[2], f"r:{row_id}:2"), + ), + ) + columns = [] + raw_columns = edit.get("columns") + for column in (raw_columns if isinstance(raw_columns, list) else [])[:MAX_COLUMNS]: + column_id = column.get("id") if isinstance(column, dict) else None + if not isinstance(column_id, str) or not column_id: + continue + label = column.get("label") + columns.append( + { + "id": column_id, + "label": label if isinstance(label, str) else "", + "keys": [f"c:{column_id}:{r[0]}" for r in rows], + } + ) + return { + "header": [list(pair) for pair in header], + "left": [list(r[1]) for r in rows], + "middle": [list(r[2]) for r in rows], + "right": [list(r[3]) for r in rows], + "rows": [r[0] for r in rows], + "columns": columns, + } + + +def group_span(slots: list[list[Any]] | list[Slot], index: int) -> int: + """묶음 이름 칸이 덮는 줄 수 — 이 줄 + 뒤따르는 하위 줄(이름 없음 · 하위 이름 있음).""" + span = 1 + while ( + index + span < len(slots) + and slots[index + span][0] is None + and slots[index + span][1] is not None + ): + span += 1 + return span diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ac63b7fac..b63a43981 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -43,6 +43,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import edited_cells from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields # 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). @@ -311,6 +312,8 @@ def _quantity_table( table.update(derived_cells(chainage, design)) for key in QUANTITY_VALUE_KEYS: table.setdefault(key, num(quantities.get(key))) + # [상세] 표 손질(179-4) — 고른 칸 · 더한 칸을 서버가 다시 셈(모달 · B07 도면 한 벌) + table.update(edited_cells(design)[0]) return table diff --git a/B07_DesignDetail/B07_DesignDetail_UI_GuideToggle.ts b/B07_DesignDetail/B07_DesignDetail_UI_GuideToggle.ts new file mode 100644 index 000000000..7fddc1c0c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_GuideToggle.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * B07_DesignDetail_UI_GuideToggle.ts + * 횡단 장 안내선 보이기 / 숨기기 단추(PLAN 180-3) — 좌측 「횡단도」 묶음 배치 칸 줄에 붙음. + * + * 끄는 것 = 화면 전용 안내선 둘(붉은 넘침 칸 틀 + 파란 점선 작도 영역) · 인쇄되는 칸선은 그대로 + * 상태 = 이 화면을 쓰는 동안만(저장 안 함) · 장을 바꾸거나 화면을 다시 세워도 이어짐(모듈 값) + * 숨김이면 「n 칸 넘침」 토스트도 안 띄움 · CAD 에는 `cad.setGuideVisible` 로 알림 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; + +/** 안내선 보임 — 모듈 값(화면 다시 세워도 유지 · 새로 고침하면 보임으로) */ +let shown = true; + +export const guideShown = (): boolean => shown; + +const labelOf = (visible: boolean): string => (visible ? "안내선 숨기기" : "안내선 보이기"); + +/** + * 단추 하나 — 누르면 보임 ↔ 숨김 · `apply(보임)` 로 CAD 에 알림. + * 장이 아닌 도면이면 꺼짐 — 부르는 쪽이 `button.disabled` 로 정함. + */ +export function buildGuideToggle(apply: (visible: boolean) => void): HTMLButtonElement { + const button = createButton({ + label: labelOf(shown), + variant: "ghost", + size: "sm", + onClick: () => { + shown = !shown; + paint(); + apply(shown); + }, + }); + const paint = (): void => { + const label = button.children[button.children.length - 1]; + if (label) label.textContent = labelOf(shown); + button.setAttribute("aria-pressed", String(!shown)); + button.title = shown + ? "점선 작도 영역 · 붉은 넘침 틀을 화면에서 숨김(인쇄 칸선은 그대로)" + : "숨긴 점선 작도 영역 · 붉은 넘침 틀을 다시 보임"; + }; + paint(); + return button; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index df2c278d7..22499e06f 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -53,6 +53,7 @@ import { type QuantityTable, } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; +import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle"; // CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; @@ -125,6 +126,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { onGuideChange: (area) => frameEditor.guideChanged(area), }); + // 180-3 안내선 보이기 / 숨기기 — 숨김은 화면을 다시 세워도 이어 줌(`…_UI_GuideToggle` 모듈 값) + if (!guideShown()) cad.setGuideVisible(false); + const guideToggle = buildGuideToggle((visible) => cad.setGuideVisible(visible)); + let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; @@ -154,10 +159,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { /** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */ const crossLayoutSlot = document.createElement("div"); crossLayoutSlot.className = "b07-cross-layout-slot"; + // 배치 칸을 못 받아도 안내선 단추는 남음 · 받으면 같은 줄 끝에 붙음 + const guideOnly = document.createElement("div"); + guideOnly.className = "b07-cross-layout"; + guideOnly.append(guideToggle); + crossLayoutSlot.append(guideOnly); let crossLayoutRequest: Promise | undefined; /** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */ const fillCrossLayout = async (drawing: DesignDrawingItem) => { + guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만 if (!projectId) return; let layout: CrossLayout; try { @@ -185,21 +196,21 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hideLoadingOverlay(); } }; - crossLayoutSlot.replaceChildren( - buildCrossLayoutControls( - (number && layout.sheets[String(number)]) || layout.default, - number - ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) - : null, - (grid) => void save({ ...layout, default: grid, sheets: {} }), - { - scale: { - value: layout.table_scale ?? 1, - onApply: (scale) => void save({ ...layout, table_scale: scale }), - }, + const controls = buildCrossLayoutControls( + (number && layout.sheets[String(number)]) || layout.default, + number + ? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }) + : null, + (grid) => void save({ ...layout, default: grid, sheets: {} }), + { + scale: { + value: layout.table_scale ?? 1, + onApply: (scale) => void save({ ...layout, table_scale: scale }), }, - ), + }, ); + controls.append(guideToggle); + crossLayoutSlot.replaceChildren(controls); }; /** @@ -341,7 +352,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const guide = response.guide ?? null; const meta = buildMeta(drawing, response, index); cad.load(stash ?? response.drawing, meta, false, {}, { guide }); - if (guide?.marks?.length) { + if (guideShown() && guide?.marks?.length) { showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning"); } if (response.drawing.stale_layout) { diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 961021c98..cedc8c175 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -35,7 +35,7 @@ import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; import { handleTableMessage } from './aislo-table-bridge.ts'; -import { registerGuideDrag, setGuide, setGuideArea } from './guide-area.ts'; +import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -50,6 +50,7 @@ export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save'; /** 작도 영역 가이드(169-1 · 169-3) — 부모 숫자 칸 → CAD 갈기 · CAD 끌기 → 부모 숫자 칸 */ export const AISLO_GUIDE_AREA_MESSAGE = 'aislo:b08:guide-area'; export const AISLO_GUIDE_CHANGED_MESSAGE = 'aislo:b08:guide-changed'; +export const AISLO_GUIDE_VISIBLE_MESSAGE = 'aislo:b08:guide-visible'; interface DrawingLoadMessage { type: typeof AISLO_DRAWING_LOAD_MESSAGE; @@ -201,8 +202,10 @@ export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장) - const guideMessage = event.data as { type?: string; area?: unknown }; + const guideMessage = event.data as { type?: string; area?: unknown; visible?: unknown }; if (guideMessage?.type === AISLO_GUIDE_AREA_MESSAGE) return setGuideArea(guideMessage.area); + if (guideMessage?.type === AISLO_GUIDE_VISIBLE_MESSAGE) + return setGuideVisible(guideMessage.visible); const message = event.data as Partial; if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { try { diff --git a/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts b/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts index 366f4d1f2..9becd673b 100644 --- a/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts @@ -1,13 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { GUIDE_MIN_MM, type GuideArea, dragGuideArea, + drawGuide, getGuide, + isGuideVisible, pickGuideHandle, setGuide, setGuideArea, + setGuideVisible, } from './guide-area.ts'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; const AREA: GuideArea = [42, 47, 812, 567]; @@ -40,4 +44,39 @@ describe('작도 영역 가이드(169-1 · 169-3)', () => { expect(dragGuideArea(AREA, 's', { x: 0, y: 9999 })).toEqual([42, 567 - GUIDE_MIN_MM, 812, 567]); expect(dragGuideArea(AREA, 'e', { x: -100, y: 0 })).toEqual([42, 47, 42 + GUIDE_MIN_MM, 567]); }); + + it('안내선 보이기 / 숨기기(180-3) — 숨김이면 점선 영역 · 붉은 틀 안 그림 · 싣기를 거쳐도 이어짐', () => { + const lines = vi.fn(); + const controller = { + setOpacity: vi.fn(), + setLineStyles: vi.fn(), + worldToTarget: (point: unknown) => point, + drawLineScreen: lines, + fillRectScreen: vi.fn(), + } as unknown as ScreenCanvasDrawController; + const guide = { area: AREA, marks: [[0, 0, 5, 5] as GuideArea], editable: false }; + + setGuide(guide); + expect(isGuideVisible()).toBe(true); // 기본 = 보임 + drawGuide(controller); + expect(lines).toHaveBeenCalledTimes(8); // 점선 영역 4 변 + 붉은 틀 4 변 + + setGuideVisible(false); + lines.mockClear(); + drawGuide(controller); + expect(lines).not.toHaveBeenCalled(); + setGuide(guide); // 장을 다시 실어도 숨김 유지 + setGuideArea([0, 0, 100, 50]); + drawGuide(controller); + expect(isGuideVisible()).toBe(false); + expect(lines).not.toHaveBeenCalled(); + expect(getGuide()?.marks).toHaveLength(1); // 값은 그대로 — 그리기만 거름 + + setGuideVisible(true); + drawGuide(controller); + expect(lines).toHaveBeenCalledTimes(8); + setGuideVisible('x'); // 어긋난 값 = 보임 + expect(isGuideVisible()).toBe(true); + setGuide(null); + }); }); diff --git a/B07_DesignDetail/openwebcad/src/integration/guide-area.ts b/B07_DesignDetail/openwebcad/src/integration/guide-area.ts index 953678ac9..1a8db4040 100644 --- a/B07_DesignDetail/openwebcad/src/integration/guide-area.ts +++ b/B07_DesignDetail/openwebcad/src/integration/guide-area.ts @@ -7,7 +7,8 @@ import { getCanvas, getScreenCanvasDrawController } from '../state.ts'; * * 도면 개체가 아니라 그리기 겹판(기준점 십자와 같은 자리)이라 저장 · DXF · 인쇄에 안 섞인다. * `editable` 이면 모서리 · 변을 끌어 영역을 고치고, 고친 값은 부모(숫자 칸)에 알린다. - * 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange`. + * 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange` · + * `setGuideVisible`(180-3 — 점선 영역 + 붉은 넘침 틀 보이기 / 숨기기 · 도각 칸선은 도면 개체라 그대로). */ /** [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */ @@ -32,6 +33,8 @@ const GRAB_PIXELS = 9; export const GUIDE_MIN_MM = 10; let guide: Guide | null = null; +/** 안내선(점선 영역 + 붉은 틀) 보임 — 싣기 때 안 지움 · 숨김이면 그리지도 · 집지도 않음 */ +let guideVisible = true; const isArea = (value: unknown): value is GuideArea => Array.isArray(value) && @@ -42,6 +45,13 @@ const isArea = (value: unknown): value is GuideArea => export const getGuide = (): Guide | null => guide; +export const isGuideVisible = (): boolean => guideVisible; + +/** 부모 단추(180-3) — `true` 만 보임 · 그 밖 값은 보임으로 */ +export function setGuideVisible(visible: unknown): void { + guideVisible = visible !== false; +} + /** 싣기 때 — 어긋난 값이면 가이드 없음 */ export function setGuide(next: unknown): void { const candidate = next as Partial | null | undefined; @@ -103,7 +113,7 @@ function strokeRect(drawController: ScreenCanvasDrawController, area: GuideArea) /** 그리기 겹판 — 엔티티 장면 위에 매 장 그림(`helpers/draw.ts`) */ export function drawGuide(drawController: ScreenCanvasDrawController): void { - if (!guide) return; + if (!guide || !guideVisible) return; drawController.setOpacity(0.9); drawController.setLineStyles(false, false, GUIDE_COLOR, 1, [8, 5]); strokeRect(drawController, guide.area); @@ -142,7 +152,7 @@ export function registerGuideDrag(notify: (area: GuideArea) => void): void { window.addEventListener( 'mousedown', (event: MouseEvent) => { - if (event.button !== 0 || event.target !== canvas || !guide?.editable) return; + if (event.button !== 0 || event.target !== canvas || !guide?.editable || !guideVisible) return; const world = worldAt(event); if (!world) return; const tolerance = GRAB_PIXELS / getScreenCanvasDrawController().getScreenScale(); diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 775553d86..127960c1b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -13,8 +13,11 @@ · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급) → 재료비목록표 단가 = 적용 단가 - 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) - (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) + 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) + (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 = + 입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 · + 없는 키 빈칸) · + `material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열) 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 @@ -178,14 +181,18 @@ def compare_sheet( return rows -def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: - """재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0).""" - keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty") +def material_sheet( + compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None +) -> list[dict[str, Any]]: + """재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 · + 총액 = ROUND(수량 × 단가, 0).""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin") rows = [] for row in compare: - price, q = row["applied"], row["qty"] + price = row["applied"] + q = row["qty"] if qty_of is None else num(qty_of.get(row["key"])) amount = None if price is None or q is None else round_half(q * price, 0) - rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount}) + rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount}) return rows @@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any] "expense": expense, "basis": basis, "compare": compare, - "material": material_sheet(compare), + "material": material_sheet(compare, inputs.get("material_qty")), "machine_blocks": blocks, "machine": machine, "gaps": gaps, diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index c5223530c..7c133ea91 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -9,7 +9,8 @@ * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · * 관급 · 별도 표시 → 재료비목록표 - * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 + * 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력 + * material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열) * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ @@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { }); } -function materialSheet(compare: Row[]): Row[] { - const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"]; +function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] { + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; return compare.map((row) => { const price: Num = row.applied; - const q: Num = row.qty; + const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]); return { ...Object.fromEntries(keep.map((k) => [k, row[k]])), + qty: q, price, amount: price === null || q === null ? null : roundHalf(q * price, 0), }; @@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row { expense, basis, compare, - material: materialSheet(compare), + material: materialSheet(compare, inputs.material_qty), machine_blocks: blocks, machine, gaps, diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index ded22e253..ff2427dd3 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -3,7 +3,9 @@ 연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용). 사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 -(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음). +(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · +사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림). +고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]: labor_doc.run_line( snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]: + code = str(mat.get("코드") or "") if isinstance(mat, dict) else "" + if code[:2] in RESOURCE_TABLES and code not in seen: + seen.append(code) return seen diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index ffa23ccb6..b4a7d7185 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router -from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines +from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> return sheets +def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]: + """183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 = + 1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표. + `inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음).""" + first = compute_sheets(root, inputs, user) + summary = compute_summary(first, hopyo_lines(first)) + keep = used_materials(first, inputs, user) + inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep] + inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]} + sheets = compute_sheets(root, inputs, user) + return sheets, compute_summary(sheets, hopyo_lines(sheets)) + + async def _demand(root: Path) -> list[str]: """단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2).""" keys = await asyncio.to_thread(linked_resource_keys, root) @@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: inputs["hopyo"] = hopyo_prices(root) inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) - sheets = await asyncio.to_thread(compute_sheets, root, inputs, user) + sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user) return { "inputs": inputs, "user": user, @@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "rate_table": rate_table(), "notes": notes, # 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜 - "summary": compute_summary(sheets, hopyo_lines(sheets)), + "summary": summary, } diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py index 5569f8f31..9a660b718 100644 --- a/B09_Estimation/B09_Estimation_Summary.py +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: return out +def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set: + """재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 = + 집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄. + 그 밖(고르기 후보 줄) = 뺌.""" + blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])] + blocks += sheets.get("machine_blocks") or [] + keep = { + str((line.get("ref") or {}).get("key")) + for block in blocks + for line in block.get("lines") or [] + if line.get("kind") == "material" and (line.get("ref") or {}).get("key") + } + marks = user.get("materials") or {} + for m in inputs.get("materials") or []: + if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"): + keep.add(m["key"]) + return keep + + def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]: """호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면).""" return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 8a81b93ae..67219f9e2 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -106,3 +106,9 @@ .b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { --pick-r: inset -2px 0 0 var(--color-danger); } + +/* 183-3 자재단가대비표 — 적용 단가를 낸 출처 단가 칸(고른 출처 · 기본 최저) */ +.b09-estimation .ui-data-table__table tbody td.is-picked { + background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas)); + font-weight: var(--font-weight-bold); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 39109d305..7147fe004 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -49,7 +49,7 @@ import { buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; -import { attachRowMenu, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { buildSumExpense, buildSumLabor, @@ -206,6 +206,7 @@ export function renderB09Estimation(root: HTMLElement): void { rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, + cellClass: spec.cellClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); @@ -215,6 +216,17 @@ export function renderB09Estimation(root: HTMLElement): void { // 166-2 줄 우클릭 메뉴(설계내역서) — 공용 표는 그대로 · 표 root 에 붙임 const menu = (spec as BillTable).menu; if (menu) attachRowMenu(made.root, menu); + // 183-3 칸 고르기 메뉴 — 누른 칸 아래에 B09 공용 메뉴 + if (spec.choices) + made.root.addEventListener("click", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const column = cell ? spec.columns[cell.cellIndex] : undefined; + const items = + cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key); + if (!cell || !items?.length) return; + const box = cell.getBoundingClientRect(); + openMenuAt(box.left, box.bottom, items); + }); // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title if (spec.tip) made.root.addEventListener("mouseover", (event) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b07aa109..8f68f8ead 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -7,6 +7,7 @@ * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; @@ -107,6 +108,10 @@ export interface TabTable { rowClass?: (index: number) => string | null | undefined; /** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */ tip?: (index: number, key: string) => string | null | undefined; + /** 칸 꼴 class(183-3 공용 표 `cellClass` · 예 고른 출처 칸 강조) */ + cellClass?: (index: number, key: string) => string | null | undefined; + /** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */ + choices?: (index: number, key: string) => MapContextMenuItem[] | null; } export type TabBuild = (ctx: TabContext) => TabTable | string; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 04670f20d..76d071d45 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -2,14 +2,18 @@ * B09_Estimation_UI_Tabs_Base.ts * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). - * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) + * 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 · + * 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`) * - * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 - * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) - * · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * 자재단가대비표 적용 단가 칸 누름 = 고르기 메뉴(183-3 · 값 있는 출처 · 견적 · 「최저(자동)」 → + * `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) · + * 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력 + * · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; @@ -107,10 +111,18 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; -/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */ -function nextPick(r: Row): string | null { +/** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 · + * `pick(s)` = 그 출처 · null = 자동 */ +function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] { const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); - return have[have.indexOf(r.pick) + 1] ?? null; + const label = (s: string): string => + `${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` + + (s === r.lowest_from ? " · 최저" : ""); + return [ + ...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]), + "-", + [`${r.pick || r.applied_from === "사용자" ? "" : "✓ "}최저(자동)`, () => pick(null)], + ]; } export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { @@ -148,35 +160,42 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { text("name", "품명", 180), text("spec", "규격", 140), text("unit", "단위", 56), - ...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]), + // 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값) + ...SOURCES.flatMap((s) => [ + money(`p:${s}`, "단가", [s]), + s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]), + ]), money("lowest", "최저"), money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), - text("files", "견적서", 72), ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", - editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "p:견적", onEdit: (i, key, value) => { const row = out[i]; - if (!row) return; - const cell = mine(String(row.key)); - if (key === "g:견적") { - if (value.trim()) cell.quote_page = value.trim(); - else delete cell.quote_page; - return put(String(row.key), cell); - } const v = parseNumber(value); - if (v === undefined) return; - const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; - if (!field) return; - if (v === null) delete cell[field]; - else cell[field] = v; - if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂 + if (!row || key !== "p:견적" || v === undefined) return; + const cell = mine(String(row.key)); + if (v === null) delete cell.quote; + else cell.quote = v; put(String(row.key), cell); }, + // 183-3 — 고른 출처(없으면 최저) 단가 칸 강조 · 적용 단가 칸 누름 = 고르기 메뉴 + cellClass: (i, key) => (key === `p:${rows[i]?.applied_from}` ? "is-picked" : null), + choices: (i, key) => { + const r = rows[i]; + if (key !== "applied" || !r) return null; + return appliedChoices(r, (s) => { + const cell = mine(r.key); + delete cell.applied; // 옛 수기 적용 단가 — 고르면 풂 + if (s) cell.pick = s; + else delete cell.pick; + put(r.key, cell); + }); + }, onCell: (i, key) => { const row = out[i]; const r = rows[i]; @@ -192,12 +211,6 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { put(r.key, now); }); } - if (key === "applied_from") { - const pick = nextPick(r); - if (pick) cell.pick = pick; - else delete cell.pick; - return put(String(row.key), cell); - } if (key !== "supply") return; const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; if (next) cell.supply = next; @@ -207,10 +220,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { }; }; +/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」 + * (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */ +function usesOf(sheets: Row): Map { + const out = new Map(); + const add = (key: unknown, label: string): void => { + if (!key) return; + const list = out.get(String(key)) ?? []; + if (!list.includes(label)) out.set(String(key), [...list, label]); + }; + const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void => + (blocks || []).forEach((b, n) => + (b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))), + ); + scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`); + scan(sheets.sg, (b) => `산근 ${b.no}호표`); + scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`); + for (const r of sheets.bill?.rows || []) + if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대"); + return out; +} + export const buildMaterial: TabBuild = ({ sheets }): TabTable => { + const uses = usesOf(sheets); const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({ ...r, supply: r.supply ?? "", + uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""), })); return { columns: [ @@ -222,14 +258,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { money("price", "단가"), money("amount", "총액"), text("supply", "관급 · 별도", 80), - text("origin", "비고", 90), + text("uses", "비고(쓰는 곳)", 240), ], rows, total: rows.length ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } : null, frozen: 2, - hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`, + hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 5f69f8ab6..5d9d186e1 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -348,17 +348,27 @@ export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); } +/** B09 메뉴 한 벌(우클릭 · 183-3 칸 고르기 같이 씀) — 화면 기준 자리 */ +function sharedMenu(): MapContextMenu { + shared ??= createMapContextMenu("b09-estimation"); + if (!shared.element.isConnected) { + shared.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) + document.body.append(shared.element); + } + return shared; +} + +/** 화면 자리(x · y)에 B09 메뉴를 엶(183-3 칸 고르기) */ +export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): void { + if (items.length) sharedMenu().open(x, y, items); +} + /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], ): void { - shared ??= createMapContextMenu("b09-estimation"); - const ctxMenu = shared; - if (!ctxMenu.element.isConnected) { - ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) - document.body.append(ctxMenu.element); - } + const ctxMenu = sharedMenu(); // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 root.addEventListener( "click", diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 4435b560d..29b3282e2 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -1,7 +1,7 @@ """절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). 짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 -`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. +`resources/tester/test_178_2_berm_fill_mirror.py` 가 그것을 지킨다. **왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. 지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 @@ -18,6 +18,8 @@ · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. 그래서 지식DB 에도 적지 않는다(사용자 지시). +· 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** · + 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음. """ import logging @@ -30,6 +32,7 @@ logger = logging.getLogger(__name__) BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 BERM_DEFAULT_SLOPE_DEG = 0.0 +BERM_DEFAULT_DEPTH_M = 1.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 @@ -41,11 +44,12 @@ _MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10 class BermSpec(NamedTuple): - """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토).""" width_m: float = BERM_DEFAULT_WIDTH_M interval_m: float = BERM_DEFAULT_INTERVAL_M slope_deg: float = BERM_DEFAULT_SLOPE_DEG + depth_m: float = BERM_DEFAULT_DEPTH_M def cut_profile_points( @@ -166,16 +170,16 @@ def fill_profile_points( fill_ratio: float, berm: BermSpec | None, max_reach_m: float = _MAX_REACH_M, + top_z: float | None = None, ) -> list[tuple[float, float]]: """성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**. - 절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 — - 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는 - 안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로). + 소단은 **한 단**(178-2) — 표고가 `노면 끝(top_z) − depth_m` 에 닿으면 폭 `width_m` 의 + 평탄부를 넣고(안쪽이 낮도록 `slope_deg` 만큼 기울어 바깥으로 조금 올라감) 그 아래로 + 같은 성토 경사로 이어 내린다. 그 깊이 전에 원지반을 만나면 지반 교점이 먼저라 안 선다. """ points: list[tuple[float, float]] = [(start_dist, start_z)] dist, elevation = start_dist, start_z - slant_since_berm = 0.0 limit = start_dist + max_reach_m berm_rise = ( math.tan(math.radians(berm.slope_deg)) * berm.width_m @@ -183,24 +187,24 @@ def fill_profile_points( else 0.0 ) ratio = max(fill_ratio, 1e-6) + top = start_z if top_z is None else top_z + berm_z = top - max(berm.depth_m, 0.0) if berm is not None and berm.width_m > 0 else None + placed = berm_z is None while dist < limit: drop = _STEP_M / ratio - slant = math.hypot(_STEP_M, drop) - if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: - remain = max(berm.interval_m - slant_since_berm, 0.0) - run = remain / math.hypot(1.0, 1.0 / ratio) + if not placed and berm_z is not None and elevation - drop <= berm_z: + run = max(elevation - berm_z, 0.0) * ratio dist += run elevation -= run / ratio points.append((dist, elevation)) # 소단 안쪽 모서리 dist += berm.width_m elevation += berm_rise points.append((dist, elevation)) # 소단 바깥 모서리 - slant_since_berm = 0.0 + placed = True continue dist += _STEP_M elevation -= drop - slant_since_berm += slant points.append((dist, elevation)) return _dedupe(points) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index a48422906..8f1f69dc3 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -3,7 +3,7 @@ * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) * * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. - * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * 거울 시험: `resources/tester/test_178_2_berm_fill_mirror.py` * * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. @@ -15,12 +15,15 @@ * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. + * · 성토 소단(178-2) = 노면 끝 아래 깊이 `depthM`(기본 1.0 · 사용자 값)에 **한 단만** · + * 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; export const BERM_DEFAULT_SLOPE_DEG = 0.0; +export const BERM_DEFAULT_DEPTH_M = 1.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; @@ -30,19 +33,21 @@ const MAX_REACH_M = 200.0; * 짝: 파이썬 `_MAX_STEPS`. */ const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10; -/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토). */ export interface BermSpec { widthM: number; intervalM: number; slopeDeg: number; + depthM?: number; } export function bermSpec( widthM = BERM_DEFAULT_WIDTH_M, intervalM = BERM_DEFAULT_INTERVAL_M, slopeDeg = BERM_DEFAULT_SLOPE_DEG, + depthM = BERM_DEFAULT_DEPTH_M, ): BermSpec { - return { widthM, intervalM, slopeDeg }; + return { widthM, intervalM, slopeDeg, depthM }; } /** @@ -148,7 +153,9 @@ export function cutProfilePoints( /** * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. * - * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. + * 소단은 **한 단** — 표고가 `노면 끝(topZ) − 깊이` 에 닿으면 폭만큼 수평(안쪽 기울기) + * 놓고 그 아래로 같은 성토 경사로 이어 내린다(178-2). 그 깊이 전에 원지반을 만나면 + * 지반 교점이 먼저라 소단이 안 선다. */ export function fillProfilePoints( startDist: number, @@ -156,34 +163,36 @@ export function fillProfilePoints( fillRatio: number, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, + topZ: number = startZ, ): Array<[number, number]> { const points: Array<[number, number]> = [[startDist, startZ]]; let dist = startDist; let elevation = startZ; - let slantSinceBerm = 0; const limit = startDist + maxReachM; const bermRise = berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; const ratio = Math.max(fillRatio, 1e-6); + const bermZ = + berm !== null && berm.widthM > 0 + ? topZ - Math.max(berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0) + : null; + let placed = bermZ === null; while (dist < limit) { const drop = STEP_M / ratio; - const slant = Math.hypot(STEP_M, drop); - if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { - const remain = Math.max(berm.intervalM - slantSinceBerm, 0); - const run = remain / Math.hypot(1, 1 / ratio); + if (!placed && bermZ !== null && elevation - drop <= bermZ) { + const run = Math.max(elevation - bermZ, 0) * ratio; dist += run; elevation -= run / ratio; points.push([dist, elevation]); // 소단 안쪽 모서리 - dist += berm.widthM; + dist += (berm as BermSpec).widthM; elevation += bermRise; points.push([dist, elevation]); // 소단 바깥 모서리 - slantSinceBerm = 0; + placed = true; continue; } dist += STEP_M; elevation -= drop; - slantSinceBerm += slant; } points.push([dist, elevation]); diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index d7a3a8910..b568edabb 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,7 +23,7 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ -import type { BermSpec } from "./common_util_cross_berm"; +import { BERM_DEFAULT_DEPTH_M, type BermSpec } from "./common_util_cross_berm"; import type { SpoilFillSection } from "./common_util_spoil_fill"; import { spoilFillSection } from "./common_util_spoil_fill"; import { @@ -182,7 +182,7 @@ export interface CrossDesignResult { * 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */ cut_slope_segments: CutSlopeSegment[]; /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ - berm?: { width_m: number; interval_m: number; slope_deg: number }; + berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number }; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -586,6 +586,7 @@ export function computeCrossDesign( width_m: round4(options.berm.widthM), interval_m: round4(options.berm.intervalM), slope_deg: round4(options.berm.slopeDeg), + depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)), }; } // 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬) diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index d3dbdf042..ec3d0f978 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -283,10 +283,8 @@ export class SectionGeometry { this.cutRatio, this.soilCutRatio, boundary, - this.berm, - undefined, - // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). - this.berm !== null, + // 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙). + null, ); this.cutPointsCache.set(side, points); return points; @@ -302,7 +300,16 @@ export class SectionGeometry { const cached = this.fillPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); - const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm); + // 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2). + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + const points = fillProfilePoints( + startDist, + startZ, + this.fillRatio, + this.berm, + undefined, + edgeZ, + ); this.fillPointsCache.set(side, points); return points; } @@ -467,8 +474,8 @@ export class SectionGeometry { breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. - if (this.twoStage || this.berm !== null) { + // 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다. + if (this.twoStage) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; if (role !== "cut") continue; diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 435e33c4a..8e1d65f49 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -35,6 +35,8 @@ export interface WallSpec { /** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */ foundation: string | null; tiers: number | null; + /** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3 · `tier_{n}_*`). 없으면 빈 목록. */ + extras: WallTier[]; lift_m: number | null; shift_m: number | null; /** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */ @@ -42,6 +44,12 @@ export interface WallSpec { face_slope_ratio: number | string | null; } +/** 추가 단 한 벌 — 형태 빈 값 = 본 벽 형태 · 높이 빈 값 = 기본 단 높이(그리는 쪽). */ +export interface WallTier { + form: string | null; + height_m: number | null; +} + /** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ const EDGE_TOLERANCE_M = 0.02; @@ -67,6 +75,24 @@ export const FORM_BY_TYPE: Record = { const num = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; +const TIER_KEY = /^tier_(\d+)_/; + +/** 「추가 단」 상자(`tier_{n}_*` · n 차례) → 추가 단 목록 — 옹벽 = 콘크리트(178-3) · 파이썬 `tier_extras` 짝. */ +export function tierExtras(typeId: string, options: Record): WallTier[] { + const ns = [ + ...new Set( + Object.keys(options) + .map((key) => TIER_KEY.exec(key)?.[1]) + .filter((n): n is string => !!n) + .map(Number), + ), + ].sort((a, b) => a - b); + return ns.map((n) => ({ + form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null, + height_m: num(options[`tier_${n}_height`]), + })); +} + /** * C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다. * 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다. @@ -83,6 +109,7 @@ export function wallSpecsFrom( const end = num(structure.end_m); if (start === null || end === null) continue; const options = (structure.options ?? {}) as Record; + const extras = tierExtras(structure.type_id, options); specs.push({ structure_id: structure.structure_id ?? null, type_id: structure.type_id, @@ -95,7 +122,9 @@ export function wallSpecsFrom( side: (options.side as string) ?? null, // 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다. foundation: (options.foundation as string) ?? null, - tiers: num(options.tiers), + // 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) + tiers: extras.length ? extras.length + 1 : num(options.tiers), + extras, lift_m: num(options.lift_m), shift_m: num(options.shift_m), face_role: (options.face_role as string) ?? null, diff --git a/docs/raw/plans/2026-10-10_plan_175-183.md b/docs/raw/plans/2026-10-10_plan_175-183.md new file mode 100644 index 000000000..fcc3db2b1 --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_175-183.md @@ -0,0 +1,83 @@ +# 2026-10-10 계획 175~183장 기록 — M02 사전조합 자재 · B05 3D · 좌측 패널 · 횡단 형상 · B06 횡단도 · B07 횡단 장 · B09 탭 · 내역 레벨 · 자재표 + +근거 = `docs/raw/요구_1010_2/`(원문 · 조사 T · U · V · W · X · Y · Z · ZA) · 메인 합침 aadb51c9b · 15번 = 확인 결과 보고(코드 고침 없음) · 27번 = 관급 조사 Artifact(코드 고침 없음) · 28번 = 대비표 수량 열 지금도 없음(안 고침). + +## 175. M02 일위대가 사전조합 자재목록 (sub3) +정함(브레인) = `docs/raw/요구_1010_2/조사_T_패널_M02.md` T1 · ⬜ 범위 (가) 한 번에(화면 · 저장 · 사전조합 값 · B08 참조 재료 · B09 겹 대가 · 사본 단가 묶음) · 수량 빈칸 = 안 셈(일위대가 자재와 같음) +- [x] 175-1 사전조합 모드 자재목록 막힘 세 곳 풂(구조물조합 일위대가 탭과 같은 부품 · 세 칸 · 운반비 포함) + 서버 저장 `_combo_lines` 가 자재 · 고름 · 세 칸 살림 — test_m02_labor_doc · 새 시험 + - 검증완료 — test_175_precombo_mats(저장 왕복 고름 · 자재표시 · 자재 남음 · 같은 품목 400) + m02 관련 6 파일 83 통과 · typecheck 통과 (1157af125) +- [x] 175-2 담은 자재를 사전조합 값 · B08 참조 재료 · B09 겹 대가 · 사본 단가 묶음에 셈(조용히 빠지지 않게) — 짝이면 거울 시험 · 새 시험 + - 검증완료 — 사전조합 값 = STONE + MT000008 × 2(빈칸 결속선 안 셈) · B08 겹 손 3 → 6 · B09 자재 줄 2 · 사본 값 = 층 값 · 관련 17 파일 156 통과 · 서버 단독이라 거울 시험 없음 (bc0160da2) + +## 176. B05 3D — 성토 깨짐 · 흑백 · 등고선 기본 끔 · 주황 구슬 지움 (sub4) +정함(브레인) = `docs/raw/요구_1010_2/조사_U_B05_3D.md` · 2번 = 비탈 끝 바깥 1.5 m 지형 점 빼서 잇는 띠 완만 + 성토 구간 끝 3 m 뜸 0 까지 감쇠(172 주름 줄임 유지) · 5번 = 구슬 · 클릭 · 처리기만 지움(저장 길 · B06 이 쓰는 merge 함수 남김) +- [x] 176-1 성토 깨짐 고침 — 수치 = 60° 넘는 띠 per 100 m · 성토↔절토 톱니 · 주름 수 전후(226 · d22) · helper_115_8 기준 1.5 m 맞춤 · test_172_1 · 115_8 · 115_10 + - 검증완료 — probe(226 · d22): 60° 넘는 띠 76 → 11 / 67 → 9 · 성토↔띠 꺾임 45° 넘음 51 → 11 / 42 → 13 · 톱니 11 → 0 / 14 → 0 · 면 안 10° 넘음 90 → 93 / 88 → 89 · 측점 뜸 0 · 제약 실패 3 / 2 그대로 · test_176_1(새) · 172_1 · 115_8 · 115_10 · 135_3 · b05_corridor 63 통과 · typecheck (23b5e93b0) +- [x] 176-2 흑백지형 · 등고선 처음 값 false(단추 · 화면 두 곳 다) — 새 시험 + - 검증완료 — 단추 두 개 false · 뷰어 surfaceGrayscale false · contours.visible false · test_176_2(새) · 135_2 3 통과 · typecheck (de1fc6326) +- [x] 176-3 3D 측점 노란 선 끝 주황 구슬 · 클릭 지움 — 저장 시험 셋 그대로 통과 · 새 시험 + - 검증완료 — 구슬 · 맞힘 구 · 클릭 분기 · onUphillPick · 처리기 지움(−74 줄) · 저장 길 · merge 함수 남김 · test_176_3(새) · design_store_save · b06_save_channels 11 통과 · b05_b06_save_roundtrip 2 건너뜀(배정 storage 프로젝트 없음 · 원래부터) · typecheck (c23ba2bd0) + +## 177. B05/B06 좌측 패널 — 미연결 노랑 · 동물이동형 드롭다운 · 노면공 박스 · 측점선 연동 · 사면안정 범위 (sub1 · sub5) +정함(브레인) = `docs/raw/요구_1010_2/조사_T_패널_M02.md` T6 · T8 · T9 · T10 · T11 · ⬜ 전부 추천 (가) +- [x] 177-1 구조물 조합 미연결 라벨 = 경고 노랑 배경(`--color-warning` · 밝음 · 어두움) (sub1) — 새 CSS 시험 + - 검증완료 — test_177_1(2) · test_173_1(1) 통과 · `:not(.is-assigned)` warning 18% · 글자색 없음 +- [x] 177-2 집수정 동물이동형 체크박스 → 드롭다운 유 / 무(저장 값 "true"/"false" 그대로 · 빈 값 = 유 · 체크박스 꼴 지움) (sub1) — test_173_7 고침 · 새 시험 + - 검증완료 — test_177_2(2) · test_173_7(5) · test_52_structure_spec_pipe · facility_options_merge · m02_so_def_spec 17+11 통과 · 빈 값 = "true" 적힘 · 무 = "false" · 정의 이름 표 두 줄 · typecheck 0 +- [x] 177-3 횡단 카드 고를 때 종단 측점 세로선 꺼지는 흠(setSelectedStructure 가 바로 끔 · 반대 방향도) (sub5) — 새 시험 + - 검증완료 — test_177_3_station_line_keep 5 통과(일반 · 벽 · 노면공 카드 = 그 카드 선 · 관 = 관 선 · 종단 누름 유지 · B05 그대로) · test_165 · 152_5 · 106 · selection_single 39 통과 · typecheck +- [x] 177-4 노면공 선택 = 범위 카드 전부 테두리(`b06-cross-card--span`) · 카드 하나 고르기 없이 첫 카드로 스크롤만 · 폼 안 닫힘 (sub5) — test_165 · test_167_3 · 새 시험 + - 검증완료 — test_177_4_pavement_span_box 3 통과(목록 · 알약 · 겹침 = 고른 카드 0 · 범위 카드 12 · 6 · 폼 · 알약 유지 · 스크롤 s40) · test_165(167-3 재누름 포함) · 106 · 152_5 · selection_single · 177_3 함께 47 통과 · typecheck +- [x] 177-5 좌측 폼 「측점 범위」 줄 = 사면안정군(C군)만 숨김 (sub5) — 새 시험 + - 검증완료 — test_177_5_slope_range_hidden 3 통과(C군 6종만 숨김 · 소단배수로 · 대피소 · 작업장 · 가드레일 그대로 · hasComputedRange 무변) · registry_policy · 54_6 함께 26 통과 · typecheck · PNL5 699줄 그대로 + +## 178. B05/B06 횡단 형상 — 흙막이 반영 · 소단 형상 · 옹벽 · 흙막이 단 (sub7) +정함(브레인) = `docs/raw/요구_1010_2/조사_V_횡단형상.md` · ⬜ 전부 추천 · 7 = 흙막이만 안 되는 코드 없음 → 유력 원인 둘 다 고침(사면안정군 측점 심음 · 관 근처 벽 넣고 빼는 기준 하나로) · 절토 쪽 흙막이 이번 밖 · 12 = 높이 칸 숨김 · 새 키 depth_m(기본 1.0 · 0 미만 금지 = 도로보다 높을 수 없음) · 기준 = 그쪽 노면 끝 표고 · 성토 한 단 · 절토 소단 끔 · 좌측 패널 칸 · 13 = 공용 상자 [추가] · [빼기] · 흙막이 단 = 형태 · 높이 · 옹벽 단 = 높이만 · structures.json +- [x] 178-1 사면안정군(C군) 시작 · 기준 · 종료 측점 심음 + 관 근처 벽 넣고 빼는 기준을 카드 · 서버 면적 · B07 · 3D 한 벌로 — 새 시험 + - 검증완료 — test_178_1(3) · extra_stations_supplier 외 5파일(100) · 벽 · 면적 7파일(18) 통과 · typecheck · 링크만 있고 관 벽 안 서는 160 · 180 성토 0.49 · 1.54 ㎡ 행 나옴(옛 = 없음) +- [x] 178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음(파이썬 · TS 짝 거울 시험 새로) — 새 시험 + - 검증완료 — test_178_2(7 · 거울 6갈래 · 깊이 1.0 = 노면 끝 100.06 → 99.06 · 폭 0.5 한 단 · 깊이 0 · 음수 = 0 · 깊이 > 사면 = 없음 · 절토 그대로) · 소단 · 사면 · 정의 10파일(40) 통과 · typecheck · 서버 Node 다시 빌드 +- [x] 178-3 (sub5) 옹벽 · 흙막이 단 상자(배관 유출 흙막이 상자 양식 재사용) · 단마다 형태 · 높이 → 형상 — 새 시험 + - 검증완료 — test_178_3_slope_tiers 5 통과(명세 왕복 · 임시 storage 저장 → 서버 벽 제원 흙막이 3단 · 옹벽 2단 콘크리트 · 형상 2 · 2 · 1.2 m 단마다 형태 · 빈 상자 = 본 형태 1.5 m · 옛 tiers 그대로) · walls_mirror(짝 extras 같음) · 54_26 · 54_6 · 54_27 · wall_shape_unified · 52_17 · 52_1b · 79 외 84 통과 · typecheck + +## 179. B06 횡단도 — 길이 정보창 자리 · 측구 줄 컴팩트 · [상세] 축 고정 · 표 손질 (sub1 · sub2) +정함(브레인) = `docs/raw/요구_1010_2/조사_W_B06횡단도.md` · W16 (가) 막힌 단추 숨김 + 「측구형식」 글 지움 + 단면유형 알약에 쪽(E-6 「막혀도 늘 보임」 뒤집음 · 사용자에게 알림) · W18 = 기존 칸 덮어쓰기 + 「자동값으로」 · 길이 = 두 끝 좌표 저장 · 서버 검산 · 행 · 열 = 같은 표 자료(서버 `_quantity_table` 한 벌 → 모달 · B07 도면 둘 다) · 행 = 묶음 안 / 끝에 항목 줄 · 열 = 오른쪽 끝(비고 등) +- [x] 179-1 선 길이 정보창 = 늘 줌 [+ − ⤢] 왼쪽(열 수 무관 · CSS) (sub1) — 새 CSS 시험 + - 검증완료 — test_179_1(2) · test_137(고침) · test_173_areas_detail · test_139_6 22 통과 · `@container` 620px 내림 규칙 지움 +- [x] 179-2 측구 줄 컴팩트(W16 (가)) (sub1) — 새 시험 + - 검증완료 — test_179_2(4) · cut_slope_user_input · 138 둘 · 106 · 171_1 · 54_27 · 61_2 49 통과 · 막힌 묶음 display none · 「측구형식」 글 → 툴팁 · 알약 「편측 성토(좌 · 우 절토)」 · 넘침 셈 숨긴 칸 0 · typecheck 0 +- [x] 179-3 [상세] 모달 팬 · 줌 = 안쪽 내용만 · 축 고정(카드 축 다시 그리기 재사용) · 표 글자 14px · 항목 이름 칸 = 표 머리색 (sub2) — test_164 · test_150 · test_138 · 새 시험 + - 검증완료 — test_164 · 150 · 138 · 173_areas_detail 28 통과(팬 뒤 도형 층만 transform · 축 층 · clip 그대로 · 눈금 다시 그림 · 십자 배율 되돌림 · 14px · th 머리색) · typecheck 0 +- [x] 179-4 [상세] 표 칸 우클릭 = 그래프에서 고르기(면적 띠 · 판정 선 길이) · 자동값으로 · 행 · 열 더함 / 지움 → 횡단 설계 patch 새 칸 · 재계산 살림 목록 짝 둘 · 서버 검산 (sub2) — 새 Node 시험 · 새 pytest(거울) + - 검증완료 — test_179_4 18 통과(칸 값 거울 8갈래 · 쌓기 2.25 · 길이 hypot 4.47 · 선 0.5 m 밖 = 비움 · 다시 고름 · 메뉴 항목 · 초안 → patch → 스키마 · 묶음 안 깍기 4줄 · 표 끝 줄 · 비고 열 · B07 표 11줄 · 높이 55 mm · 손질 없으면 그대로) · 164 · 150 · 138 · 173 · 140 · 174 · 150_b07 · user_touched_keys 외 85 통과 · typecheck 0 + +## 180. B07 횡단 장 — 원지반선 고르기 · 측점 한 벌 · 붉은 선 숨김 · 표 · 글 배율 (sub6) +정함(브레인) = `docs/raw/요구_1010_2/조사_X_B07횡단장.md` · 19 = 횡단 장만 원지반 층 잠금 풂 · 측점 한 벌 = groupId(설계선 · 구조물도) · 배치 판 그대로 · 20 = 붉은 넘침 틀 + 파란 점선 작도 영역(화면 전용 안내선) 함께 숨김 · 인쇄 칸선 그대로 · 자리 = 좌측 「횡단도」 묶음 배치 칸 줄 · 21 = 표 · 글 한 배율 · 모든 장 한 값 · 0.5~2.0 · 기본 1 · 표 + 표 위 글 · 확정 장 그대로 + 안내 +- [x] 180-1 원지반선 고르기 · 측점 한 벌 groupId — test_140 · 새 시험 + - 검증완료 — test_180_b07_cross_group 3 · test_140 · 174 · 169 · cross_sheet_pad 22 통과 · 2 × 2 장 groupId 4 개(측점마다 원지반 · 표 · 글 함께) · 도각 0 · typecheck +- [x] 180-2 표 · 글 배율(cross_layout.json 칸 · 저장 검사 · 「모든 장」 저장이 안 지움 · 174 배치 · 넘침 다시 셈) — test_140:122 고침 · test_174 · 새 시험 + - 검증완료 — test_180_2_b07_cross_scale 5 · 140 · 174 · 169 · cross_sheet_pad · 153_1 · 180 32 통과 · 배율 1.5 표 162 × 60 · 아래선 그대로 · 2.0 = 4 × 4 칸 넘침 1 · 1 = 옛 엔티티 같음 · 옛 배율 확정 장 stale_scale · typecheck +- [x] 180-3 (sub8) 안내선 보이기 / 숨기기 단추(CAD 덧그림 · cad_host 메서드 · 토스트) — vitest · CAD build · 새 시험 + - 검증완료 — test_180_3(새) 3 · 169 · 174 · 153_1 합 20 통과 · vitest 14 통과(숨김 = 선 0 · 싣기 뒤에도 유지) · CAD build 통과 · typecheck 통과 · 숨김이면 넘침 토스트 없음 · 점선 영역 + 붉은 틀 함께 · 인쇄 칸선 그대로 + +## 181. B09 탭 두 층 · 박스보기 (sub8) +정함(브레인) = `docs/raw/요구_1010_2/조사_Y_B09탭박스내역.md` Y29 · Y30 · Y22 · ⬜ 추천 그대로(이름 「자재단가대비표」 유지 · 환율및기초자료 지금 차례 상위 단독 · 위 탭 다시 누름 = 마지막 본 하위 · 글 「박스보기」) +- [x] 181-1 상위 · 하위 탭(B08 꼴 · 묶음 = 일위대가 · 단가산출 · 중기 · 재료 · 노무 · 나머지 상위 단독 · TABS 그대로 묶음 표 더함) — test_144 · 새 시험 + - 검증완료 — test_144 · test_181_1(새) · test_171_2 · 171_3 · 157_2 · 145_1 34건 통과 · typecheck 통과 · 위 10 · 아래 묶음 19 키 한 번씩 · 단독 위 탭 = 빈 보조 줄 · 위 탭 누름 = 마지막 본 하위 +- [x] 181-2 「호표별 보기」 → 「박스보기」 · 접기(요약 보기) 지움 · 목록표 행 고름 = 그 호표 상자 붉은 외곽선 + 스크롤 · 표 보기 = 그 호표 줄 구간 전체 강조(`--color-danger`) — test_171_2 · 3 · 새 시험 + - 검증완료 — test_181_2(새) · test_171_2 · 171_3 · 157_2 · 181_1 · 144 30건 통과 · typecheck 통과 · 13 묶음도 접힘 없음 · 고른 박스만 `is-pick` · 표 보기 호표 구간 첫 · 끝 줄 class 판정 + +## 182. B09 설계내역서 레벨 · 상위 합계 · 연결 (데스크탑 보조) +정함(브레인) = 조사 Y Y23 · ⬜ 추천 그대로(저장 = parent 키 · 새 1단 공종 안 허용 · 손 묶음 깊이 2 · 자동 줄은 안 옮김) +- [x] 182-1 손 줄 묶음 줄 여부 · 부모 줄 → 레벨 표시(들여쓰기 · 글자 크기 · 굵기) · 하위 금액 상위 합계(Calc_Bill 파이썬 · TS 짝 거울) — test_166_2 · 새 시험 +- [x] 182-2 손 줄 연결 = 우클릭 메뉴 + 「연결 ▾」 칸 단추(단가산출 · 일위대가 고르기 재사용) — 새 시험 + - 검증완료 — 데스크탑 보조 8b64d794 · 4da01d74 · test_182_b09_bill_level 6(레벨 · 묶음 합 14,760 · 공종 합 · sums · totals 같음 · TS 거울 · 저장 왕복 · 메뉴 · 연결) + 166_2 · 145-1~5 · 155 · 166-1 합 75 · typecheck 0 + +## 183. B09 재료비목록표 · 자재단가대비표 (sub3) +정함(브레인) = `docs/raw/요구_1010_2/조사_Z_B09자재표.md` · 24 = 쓰는 자재만(대가 · 산근 · 중기) · 사본 자재 코드 단가 목록에 더함 · 수량 = 역전개 총수량(재료비수량금액집계표와 같은 값) · 비고 = 쓰는 곳 · B08 줄 남김 · 25-a 관급 출처 열 그대로 · 26 = 수기 적용 칸 없앰 · 출처 칸 돌림 없앰 · 28 = 대비표 수량 열 지금도 없음(확인 말 · 안 고침) +- [x] 183-1 재료비목록표 = 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳(서버 두 번 셈 · Calc_Base 짝) — 검증 프로젝트 줄 수 전후 · 거울 · 새 시험 + - 검증완료 — 엄상준_검증 141 → 24줄(수량 8 · 총액 6 · 목록 수량 = 재료비수량금액집계표 · 목록표에 없음 0 · 두 번 셈 2.1초) · 검증_sub3 연결 사본 없음 0 → 0 · test_183_b09_material_list 5(거울 포함) + B09 시험 86 통과 · typecheck (20f17827d) +- [x] 183-2 대비표 머리 = 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」 · 견적서 = 견적 아래 — test_171_1 고침 · 새 시험 + - 검증완료 — test_183_2_b09_compare_head 2 · test_171_1 · 166-4 · 155 · 171-3 17 통과 · typecheck (8cd87f48b) +- [x] 183-3 적용 단가 칸 누름 = 드롭다운(출처 단가 · 견적) · 고른 출처 칸 강조 · 기본 최저(공용 표 칸 강조 옵션 · 우클릭 메뉴 재사용) — 새 시험 + - 검증완료 — test_183_3_b09_applied_pick 3(공용 표 cellClass 칸 class 1 · 없으면 같은 글 · 항목 「✓ 유통물가 1,000 · 최저」 · 고름 → pick) + B09 · 공용 표 시험 56 통과 · typecheck (9d5694371) diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 732b9baa1..b52e8ccca 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -2013,6 +2013,16 @@ "required": false, "phase": "b05" }, + { + "key": "depth_m", + "label": "소단 위치(도로 아래)", + "input": "number", + "unit": "m", + "default": 1.0, + "required": false, + "phase": "b05", + "default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)" + }, { "key": "height_m", "label": "소단 높이", @@ -3563,6 +3573,7 @@ "weep_hole_area_m2": false }, "berm": { + "height_m": false, "length_m": false, "before_m": false, "after_m": false, @@ -3797,7 +3808,7 @@ "이름": "상세 내용", "옵션": [ "width_m", - "height_m" + "depth_m" ], "형상": {} } diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index ada95dc88..9cb7ffa3f 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -11,11 +11,29 @@ const B06 = path.join(ROOT, "B06_Section"); const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); // 카드 여백은 원본에서 읽음(화면과 같은 값) const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기) +const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0]; +const niceTickStep = ts + .transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} }) + .outputText.concat("\nreturn niceTickStep;"); +const svgElement = (tag, attrs = {}) => { + const node = el(tag); + for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); + return node; +}; +const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text }); const STUBS = { "@ui/ui_template_modal": { openModal: () => {} }, + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, "../A00_Common/b_page_state": {}, "./B06_Section_Api_Fetch": {}, - "./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key }, + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + L: (key) => key, + niceTickStep: Function(niceTickStep)(), + svgElement, + svgText, + }, "./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); @@ -52,6 +70,24 @@ function el(tag) { node.parent = self; self.childNodes.push(node); }), + replaceChildren: (...nodes) => { + self.childNodes.splice(0).forEach((node) => (node.parent = null)); + self.append(...nodes); + }, + // 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것 + querySelector: (selector) => { + const [tagName, cls] = selector.split("."); + const walk = (node) => { + for (const child of node.childNodes) { + const classes = (child.attrs.class ?? "").split(" "); + if (child.tag === tagName && (!cls || classes.includes(cls))) return child; + const found = walk(child); + if (found) return found; + } + return null; + }; + return walk(self); + }, addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; @@ -69,16 +105,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); -const svg = el("svg"); -svg.setAttribute("viewBox", "0 0 600 300"); -svg.append(el("rect"), el("g"), el("g")); +// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150) +const AXES = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 24, + displayMax: 120, + elevationMid: 110, + exaggeration: 1, +}; +function chart() { + const svg = el("svg"); + svg.setAttribute("viewBox", "0 0 600 300"); + const axes = svgElement("g", { class: "b06-chart__axes" }); + const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); + const plot = svgElement("g", { class: "b06-chart__plot-layer" }); + const center = svgElement("g", { class: "b06-chart__center" }); + center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 })); + plot.append(center); + clipGroup.append(plot); + svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); + return { svg, axes, plot, center }; +} +const ticks = (axes) => + axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent); +const { svg, axes, plot: layer, center } = chart(); let saved; -const handle = attachDetailZoom(svg, undefined, (next) => (saved = next)); -const layer = svg.childNodes[0]; +const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next)); +const ticksBefore = ticks(axes); const out = { - wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3, + // 179-3 — 감싸지 않음 · 자식 넷 그대로 + wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g", + children: svg.childNodes.length, ratio: svg.attrs["style:--ratio"], before: layer.getAttribute("transform"), + ticksBefore: ticksBefore.length, }; // 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정 svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); @@ -97,19 +159,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); svg.fire("pointermove", { clientX: 300, clientY: 140 }); svg.fire("pointerup", {}); out.middlePan = { ...saved }; +// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜 +out.plotTransform = layer.getAttribute("transform"); +out.axesTransform = axes.getAttribute("transform"); +out.clipTransform = svg.childNodes[2].getAttribute("transform"); +out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore); +out.ticksAfter = ticks(axes).length; out.panClickStopped = svg.fire("click", { stopPropagation() { this.stopped = true; }, }).stopped; // 다시 떠도 배율 이어 감 -const again = el("svg"); -again.setAttribute("viewBox", "0 0 600 300"); -attachDetailZoom(again, saved, () => {}); -out.restored = again.childNodes[0].getAttribute("transform"); +const again = chart(); +attachDetailZoom(again.svg, AXES, saved, () => {}); +out.restored = again.plot.getAttribute("transform"); +// 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름) +out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore); +// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음 +const bare = chart(); +out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null; +const flat = el("svg"); +flat.setAttribute("viewBox", "0 0 600 300"); +out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {}); // 더블클릭 원복 svg.fire("dblclick", {}); out.reset = layer.getAttribute("transform"); +// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로) +svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); +out.centerTransform = center.getAttribute("transform"); out.max = (() => { for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 }); return saved.scale; diff --git a/resources/tester/helper_178_3_slope_tiers.cjs b/resources/tester/helper_178_3_slope_tiers.cjs new file mode 100644 index 000000000..4c155ed27 --- /dev/null +++ b/resources/tester/helper_178_3_slope_tiers.cjs @@ -0,0 +1,112 @@ +/* 178-3 사면안정 흙막이 · 옹벽 「추가 단」 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * ① 명세 상자 저장 왕복(readState → writeOptions) ② 구조물 정본 → 벽 제원(`wallSpecsFrom`) → 횡단 형상 + * (`computeRevetmentLayout`) 단 수 · 단마다 형태 · 높이. 입력 측점 = 73-4 픽스처(helper_79 와 같은 180 측점). + * 결과 = 표준출력 JSON 한 벌 · 짝 = `test_178_3_slope_tiers.py`. + */ +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(); +const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const design = (f) => path.join(ROOT, "A00_Common", "design", f); +const spec = loadTs(design("design_structure_spec.ts")); +const { ADDON_SPECS } = loadTs(design("design_structure_slope_tier.ts")); +const { STRUCTURE_SPECS } = loadTs(design("design_structure_pipe.ts")); +const { wallSpecsFrom } = loadTs(path.join(ROOT, "common_util", "common_util_structure_walls.ts")); +const { computeRevetmentLayout } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Revetment.ts"), +); + +const r3 = (v) => Math.round(v * 1000) / 1000; +const out = {}; + +// ① 저장 왕복 — 상자 둘(흙막이) · 하나(옹벽) · 빈 상자(값 없이 추가만) +const soil = ADDON_SPECS.soil_guard; +const wall = ADDON_SPECS.retaining_wall; +const soilOptions = { + form: "돌쌓기(찰)", + height_m: 2, + tier_1_form: "콘크리트", + tier_1_height: 2, + tier_2_form: "돌망태", + tier_2_height: 1.2, +}; +const soilState = spec.readState(soil, soilOptions); +out.roundTrip = { + items: Object.fromEntries( + Object.entries(ADDON_SPECS).map(([k, s]) => [k, s.containers[0].items.map((i) => i.id)]), + ), + soilState: soilState.tier, + soilBack: spec.writeOptions(soil, soilState), + wallBack: spec.writeOptions(wall, { tier: [{ height: "1.8" }] }), + emptyBox: spec.writeOptions(soil, { tier: [{}] }), + notMainSpecs: Object.keys(ADDON_SPECS).filter((k) => k in STRUCTURE_SPECS), +}; + +// ② 형상 — 정본 → 벽 제원 → 횡단 단 +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const base = fixture.cross_sections.find((s) => Math.abs(s.chainage_m - 180) < 0.6); +const names = new Map([ + ["soil_guard", "흙막이"], + ["retaining_wall", "옹벽"], +]); +function shape(typeId, options) { + const [wallSpec] = wallSpecsFrom( + [{ structure_id: "t", type_id: typeId, chainage_m: 180, start_m: 170, end_m: 190, options }], + names, + ); + const section = JSON.parse(JSON.stringify(base)); + section.revetment = { ...wallSpec, side: "우" }; + const layout = computeRevetmentLayout(section, null); + return { + tiers: wallSpec.tiers, + extras: wallSpec.extras, + requested: layout?.requestedTiers ?? null, + walls: (layout?.walls ?? []).map((w) => ({ form: w.form, pure: r3(w.height + 0.5) })), + }; +} +out.soil2 = shape("soil_guard", soilOptions); +out.wall1 = shape("retaining_wall", { height_m: 2.5, tier_1_height: 1.8 }); +out.emptyBox = shape("soil_guard", { form: "돌망태", height_m: 2, tier_1_form: "" }); +out.legacy = shape("soil_guard", { form: "돌망태", height_m: 2, tiers: 2 }); +out.none = shape("retaining_wall", { height_m: 2.5 }); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_179_4_detail_table.cjs b/resources/tester/helper_179_4_detail_table.cjs new file mode 100644 index 000000000..80bf13535 --- /dev/null +++ b/resources/tester/helper_179_4_detail_table.cjs @@ -0,0 +1,198 @@ +/* PLAN 179-4 단위검증 헬퍼 — [상세] 표 손질(`B06_Section_UI_Cross_Detail_Table.ts`). + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON {design, sources} · + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_179_4_detail_table.py`(파이썬 `cell_value` 거울). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); + +// 세션 저장소 — 진짜 b_page_state 를 그대로 돌림 +const store = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; +globalThis.window = { sessionStorage: store(), localStorage: store() }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const T = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Table.ts")); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; + +// ① 칸 값 거울 — 같은 출처 · 설계를 파이썬 `cell_value` 와 견줌 +out.local = input.sources.map((source) => T.localCellValue(source, input.design)); + +// ② 그래프 고르기 — 띠 → 표 키 · 판정 선 화면 px → m(카드 x · toDisplayY 의 역) +const fake = (classes, attrs = {}) => { + const self = { + classList: { contains: (name) => classes.includes(name) }, + getAttribute: (name) => attrs[name] ?? null, + closest: (selector) => + selector.split(",").some((part) => classes.includes(part.trim().slice(1))) ? self : null, + }; + return self; +}; +out.area = { + cut_soil: T.areaSourceOf(fake(["b06-chart__area", "b06-chart__area--cut_soil"])), + cut_rock: T.areaSourceOf( + fake(["b06-chart__area", "b06-chart__area--cut_rock", "b06-chart__area--cut_rr"]), + ), + fill: T.areaSourceOf(fake(["b06-chart__area", "b06-chart__area--fill"])), + spoil: T.areaSourceOf(fake(["b06-chart__spoil-fill"])), + other: T.areaSourceOf(fake(["b06-chart__ground"])), +}; +const AXES = { + widthPx: 600, + heightPx: 300, + pixelsPerMeter: 10, + maxOffset: 24, + displayMax: 120, + elevationMid: 110, + exaggeration: 2, +}; +const px = ([offset, elevation]) => [ + CROSS_PAD.left + (AXES.maxOffset - offset) * AXES.pixelsPerMeter, + CROSS_PAD.top + + (AXES.displayMax - (AXES.elevationMid + (elevation - AXES.elevationMid) * AXES.exaggeration)) * + AXES.pixelsPerMeter, +]; +const points = input.pick + .map(px) + .map((p) => p.join(",")) + .join(" "); +out.length = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points }), AXES); +out.lengthBad = T.lengthSourceOf(fake(["b06-chart__design-hit"], { points: "1,2" }), AXES); +out.lengthValue = T.localCellValue(out.length, input.design); + +// ③ 우클릭 메뉴 항목 +const noop = () => {}; +const act = { + pick: noop, + auto: noop, + addRow: noop, + addColumn: noop, + removeRow: noop, + removeColumn: noop, +}; +const labels = (items) => items.map((item) => (typeof item === "string" ? item : item[0])); +const empty = { cells: {}, rows: [], columns: [] }; +out.menuHeader = labels( + T.cellMenuItems(empty, { key: "ground", rowId: null, columnId: null }, act), +); +out.menuBase = labels( + T.cellMenuItems( + { ...empty, cells: { cut_soil: { src: "area", key: "embankment" } } }, + { key: "cut_soil", rowId: "b0", columnId: null }, + act, + ), +); +out.menuAdded = labels( + T.cellMenuItems( + { + cells: {}, + rows: [{ id: "r1", after: "b0", grouped: true, labels: [] }], + columns: [{ id: "c1", label: "" }], + }, + { key: "c:c1:r1", rowId: "r1", columnId: "c1" }, + act, + ), +); +const full = { + cells: {}, + rows: Array.from({ length: T.MAX_ROWS }, (_, i) => ({ + id: `r${i + 1}`, + after: null, + grouped: false, + labels: [], + })), + columns: Array.from({ length: T.MAX_COLUMNS }, (_, i) => ({ id: `c${i + 1}`, label: "" })), +}; +out.menuFull = labels(T.cellMenuItems(full, { key: "fence", rowId: "b0", columnId: null }, act)); +let picked = null; +T.cellMenuItems( + empty, + { key: "fence", rowId: "b0", columnId: null }, + { + ...act, + pick: (kind) => (picked = kind), + }, +).find((item) => item[0] === "그래프에서 고르기 — 길이")[1](); +out.menuPicks = picked; + +// ④ 표 손질 → 세션 초안 → patch(Persist 가 싣는 꼴) +const section = { station_id: "s40", chainage_m: 40, design: { ...input.design } }; +const table = T.detailTableOf(section, "p1", 7); +out.fresh = JSON.parse(JSON.stringify(table)); +T.setCell(table, "cut_soil", { src: "area", key: "embankment" }); +T.setCell(table, "fence", out.length); +const r1 = T.addRow(table, "b0", true); +const r2 = T.addRow(table, null, false); +const r3 = T.addRow(table, r1, true); +const c1 = T.addColumn(table); +T.setRowLabel(table, r1, 0, "풍화암"); +T.setColumnLabel(table, c1, "비고"); +T.setCell(table, `c:${c1}:${r1}`, { src: "text", text: "L형" }); +T.setCell(table, `r:${r2}:2`, { src: "area", key: "cut_rock" }); +T.writeDetailTable(section, table, "p1", 7); +out.ids = [r1, r2, r3, c1]; +out.cacheHas = section.design.detail_table === table; +out.drafts = Object.fromEntries(T.readDetailTables("p1", 7)); +out.forTable = T.designForTable({ ...section, design: { paved: true } }, "p1", 7).detail_table; +// 지움 — r1 을 지우면 r3 은 r1 자리(b0 아래)로 · 그 행 열 칸도 지움 +const removed = T.detailTableOf(section, "p1", 7); +T.removeRow(removed, r1); +T.removeColumn(removed, c1); +out.removed = removed; +out.autoBack = (() => { + const back = T.detailTableOf(section, "p1", 7); + T.setCell(back, "cut_soil", null); + return Object.keys(back.cells); +})(); +out.span = [ + T.groupSpan( + [ + ["깍기", "토사", "a"], + [null, "x", "r"], + [null, "암석", "b"], + ["쌓기", null, "c"], + ], + 0, + ), + T.groupSpan( + [ + ["쌓기", null, "c"], + ["층따기", null, "d"], + ], + 0, + ), +]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_180_3_b07_guide_toggle.cjs b/resources/tester/helper_180_3_b07_guide_toggle.cjs new file mode 100644 index 000000000..5dd7883f8 --- /dev/null +++ b/resources/tester/helper_180_3_b07_guide_toggle.cjs @@ -0,0 +1,101 @@ +/* 180-3 B07 안내선 보이기 / 숨기기 단추 헬퍼 — `…_UI_GuideToggle.ts` 를 Node 로 돌려 JSON 한 벌로. + * 공용 단추(`ui_template_elements`)는 진짜 · 화면 그릇만 흉내(helper_153 꼴). + */ +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(); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + this.disabled = false; + this.title = ""; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + contains: (name) => self._classes.has(name), + }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + setAttribute(key, value) { + this.attrs[key] = String(value); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({})); + } +} +globalThis.document = { createElement: (tag) => new FakeNode(tag) }; + +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 === "@ui/ui_template_elements") + return loadTs(path.join(ROOT, "ui_template", "ui_template_elements.ts")); + if (request.startsWith(".")) { + const target = path.join(path.dirname(full), `${request}.ts`); + return fs.existsSync(target) ? loadTs(target) : {}; + } + return {}; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { buildGuideToggle, guideShown } = loadTs( + path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_GuideToggle.ts"), +); + +const sent = []; +const button = buildGuideToggle((visible) => sent.push(visible)); +const view = () => ({ + text: button.textContent, + cls: button.className, + pressed: button.attrs["aria-pressed"], + shown: guideShown(), +}); +const out = { first: view() }; +button.click(); +out.hidden = view(); +button.click(); +out.again = view(); +button.click(); +out.last = view(); +// 화면을 다시 세워 새 단추를 만들어도 숨김 유지(모듈 값) +const rebuilt = buildGuideToggle((visible) => sent.push(visible)); +out.rebuilt = { text: rebuilt.textContent, pressed: rebuilt.attrs["aria-pressed"] }; +out.sent = sent; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 04b48be77..e85518359 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -40,10 +40,24 @@ def out() -> dict: return json.loads(proc.stdout) -def test_복제_자식을_g_하나로_감쌈_비율(out): - assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임 +def test_감싸지_않음_비율(out): + # 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만) + assert out["wrapped"] is False and out["children"] == 4 assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음 assert out["before"] is None + assert out["ticksBefore"] > 0 + + +def test_축_고정_도형_층만_움직임_눈금_다시(out): + # 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로 + assert out["plotTransform"] == "translate(100 20) scale(1)" + assert out["axesTransform"] is None and out["clipTransform"] is None + assert out["ticksChanged"] is True and out["ticksAfter"] > 0 + assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로 + # 확대 — 십자는 중심(300,150)에서 배율 되돌림 + assert out["centerTransform"].startswith("translate(300 150) scale(0.869") + assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만 + assert out["noLayer"] is None # 도형 층 없음 = 줌 없음 def test_휠_당기면_확대_커서_고정(out): @@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율(): src = _read("B06_Section_UI_Cross_Detail.ts") css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src - assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src - assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src + assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src + assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) + assert 'clone.querySelector("g.b06-chart__plot-layer")' in src + assert "createCrossAxes(axisLayer, axes)" in src + view = _read("B06_Section_UI_Section_View.ts") + assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view + assert "return { svg, axes };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css @@ -100,5 +120,30 @@ def test_빈칸_누름_없음_이름_칸_첫_키(): src = _read("B06_Section_UI_Cross_Detail.ts") assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src assert 'if (!key || el.hasAttribute("data-empty")) return;' in src - assert 'cell("th", groupL, { row: 2 }, keyL)' in src - assert 'cell("th", groupM, { row: 2 }, keyM)' in src + # 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 + assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src + assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src + + +def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만(): + # 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만 + css = _read("B06_Section_UI_Style_Cross_Detail.css") + table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0] + assert "font-size: var(--text-body-sm);" in table + assert ( + "--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));" + in table + ) + head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0] + assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head + empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0] + assert "background" not in empty + assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css + assert ( + ".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);" + in css + ) + theme = open( + os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8" + ).read() + assert "--text-body-sm: 14px;" in theme diff --git a/resources/tester/test_171_1_manual_input_label.py b/resources/tester/test_171_1_manual_input_label.py index c8a37513c..2969bb99c 100644 --- a/resources/tester/test_171_1_manual_input_label.py +++ b/resources/tester/test_171_1_manual_input_label.py @@ -14,7 +14,7 @@ SPOTS = { "B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'], "B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'], - "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"], + "B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"], "B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'], "ui_template/ui_template_locale_b2.ts": [ "수량 원천: 수기 입력(직접비)", diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 215419903..0ac4e246e 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -87,7 +87,7 @@ def test_상세_테마_토큰_어두운_고정색_없음(): def test_상세_칸_크기로_새로_그림_배선(): src = _read("B06_Section_UI_Cross_Detail.ts") assert "const { clientWidth: width, clientHeight: height } = cad;" in src - assert "const clone = drawChart(state.section, width, height);" in src + assert "const drawn = drawChart(state.section, width, height);" in src assert "host?.chart(section.station_id, widthPx, heightPx)" in src assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src # 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음 @@ -99,7 +99,12 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "base && fitPlotBase(base, heightPx)" in view assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") - assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types + # 179-3 — svg + 축 옵션 + assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," + in types + ) sync = _read("B06_Section_UI_Page_Design_Sync.ts") assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync diff --git a/resources/tester/test_178_1_slope_wall_guard.py b/resources/tester/test_178_1_slope_wall_guard.py new file mode 100644 index 000000000..b743055ad --- /dev/null +++ b/resources/tester/test_178_1_slope_wall_guard.py @@ -0,0 +1,105 @@ +"""178-1 사면안정 벽(흙막이 · 옹벽) 가드 한 벌 — 카드 · 서버 면적 · B07 · 3D. + +흠: 카드는 「연동 관 벽이 이 측점에 실제로 설 때만」 독립 벽을 건너뛰는데, 서버 면적 +(`computeStoredLayouts` → Node) · B07 은 링크만 있어도 건너뛰고 3D 는 관 측점만 봤다. +⇒ 관 링크는 있으나 관 벽이 안 서는 측점에서 카드엔 흙막이가 서고 서버 면적 · B07 엔 없음. + +고침: `ownWallsDrawAt` 한 벌을 세 곳이 함께 씀. 서버 면적은 같은 TS 를 Node 로 돌리므로 +번들을 새로 빌드해 그 결과로 판정한다. +""" + +import copy +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" +FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "b06_73_4_body_guard_sections.json" + +SOIL_GUARD = { + "structure_id": "w1", + "type_id": "soil_guard", + "name": "흙막이", + "start_m": 150, + "end_m": 370, + "anchor_m": 260, + "form": "돌쌓기(찰)", + "height_m": 1.5, +} + + +@pytest.fixture(scope="module") +def bundle(tmp_path_factory) -> Path: + if not VITE.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + out = tmp_path_factory.mktemp("server_calc") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(VITE), + "build", + "--configLoader", + "runner", + "--ssr", + "../B06_Section/B06_Section_Server_Calc_Node.ts", + "--outDir", + str(out), + "--logLevel", + "error", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return out / "B06_Section_Server_Calc_Node.js" + + +def _areas(bundle: Path, sections: list, tmp_path: Path) -> dict[float, tuple]: + src, dst = tmp_path / "in.json", tmp_path / "out.json" + src.write_text(json.dumps({"detail": {"cross_sections": sections}}), encoding="utf-8") + subprocess.run(["node", str(bundle), str(src), str(dst)], check=True, cwd=str(ROOT)) # noqa: S603 + rows = json.loads(dst.read_text(encoding="utf-8"))["areas"] + return {row["chainage_m"]: (row["cut_area_m2"], row["fill_area_m2"]) for row in rows} + + +def _sections() -> list: + return copy.deepcopy(json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"]) + + +def test_링크만_있고_관_벽이_안_서면_흙막이_면적_행이_나온다(bundle, tmp_path): + """173.087 관의 다단 구간값(20 m)이 160 · 180 을 덮어 링크는 서지만 관 벽(전 · 후 5 m)은 안 섬.""" + sections = [s for s in _sections() if s["chainage_m"] < 300] + sections[1]["design"]["extra_spans"] = {"extra0": {"before_m": 20, "after_m": 20}} + for section in sections: + if not section.get("culvert"): + section["revetment"] = SOIL_GUARD + areas = _areas(bundle, sections, tmp_path) + assert 160 in areas and 180 in areas, f"흙막이가 선 측점 면적 행 없음: {sorted(areas)}" + # 표준 성토(160 = 1.45 · 180 = 5.32 ㎡)보다 벽이 사면을 끊어 줄어듦 + assert areas[160][1] < 1.45 and areas[180][1] < 5.32 + + +def test_관_벽이_서는_측점은_독립_벽을_안_그린다(bundle, tmp_path): + """340 · 360 은 352.14 관 벽이 섬 — 흙막이를 얹어도 면적이 관 벽 그대로(겹쳐 그리지 않음).""" + plain = _areas(bundle, _sections(), tmp_path) + walled = _sections() + for section in walled: + if not section.get("culvert"): + section["revetment"] = SOIL_GUARD + areas = _areas(bundle, walled, tmp_path) + assert areas[340] == plain[340] and areas[360] == plain[360] + + +def test_카드_서버_3D_가_가드_한_벌을_쓴다(): + files = { + "카드": ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts", + "서버 면적 · B07": ROOT / "B06_Section" / "B06_Section_Structure_Layouts.ts", + "3D": ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Structures.ts", + } + for where, path in files.items(): + source = path.read_text(encoding="utf-8") + assert "ownWallsDrawAt(" in source, f"{where} 가 가드 한 벌을 안 씀" + assert "culvertWallsStandAt(" not in source, f"{where} 에 따로 가드가 남음" diff --git a/resources/tester/test_178_2_berm_fill_mirror.py b/resources/tester/test_178_2_berm_fill_mirror.py new file mode 100644 index 000000000..90cc7231d --- /dev/null +++ b/resources/tester/test_178_2_berm_fill_mirror.py @@ -0,0 +1,206 @@ +"""178-2 소단 = 성토 사면 노면 끝 아래 depth_m 에 수평 한 단 · 아래로 성토 이음 — 파이썬 · TS 거울. + +짝: `common_util_cross_berm.py` ↔ `.ts` · `B06_Section_Engine_Design_Geometry.py` ↔ +`common_util_cross_design_geometry.ts`. 절토 소단은 끔 · 깊이 0 미만 = 0(도로보다 높을 수 없음). +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from common_util.common_util_cross_berm import BermSpec, fill_profile_points +from config.config_system import STANDARD_CROSS_SECTION + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _ground(slope: float) -> list[dict]: + return [ + {"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True} + for o in range(-40, 41) + ] + + +def _case(name, slope, z, mode, berm): + return {"name": name, "samples": _ground(slope), "z": z, "mode": mode, "berm": berm} + + +# 좌절토 = 우측 성토(지반이 오른쪽으로 내려감) +CASES = [ + _case("깊이 1.0 · 폭 0.5", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 1.0)), + _case("깊이 0 = 노면 끝 높이", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, 0.0)), + _case("깊이 음수 = 0", 0.6, 100.0, "left_cut", (0.5, 3.0, 0.0, -2.0)), + _case("깊이 > 사면 높이 = 소단 없음", 0.15, 100.0, "left_cut", (0.5, 3.0, 0.0, 5.0)), + _case("안쪽 기울기 5°", 0.6, 100.0, "left_cut", (1.0, 3.0, 5.0, 1.5)), + _case("소단 없음", 0.6, 100.0, "left_cut", None), +] + + +def _python(case): + berm = BermSpec(*case["berm"]) if case["berm"] else None + return compute_cross_design( + case["samples"], + case["z"], + standard=STANDARD_CROSS_SECTION, + ground_type="soil", + section_mode=case["mode"], + ditch_side="left", + berm=berm, + ) + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { computeCrossDesign } from "./common_util_cross_design.js"; +import { fillProfilePoints } from "./common_util_cross_berm.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const designs = input.cases.map((c) => computeCrossDesign(c.samples, c.z, { + standard: input.standard, groundType: "soil", sectionMode: c.mode, ditchSide: "left", + berm: c.berm ? { widthM: c.berm[0], intervalM: c.berm[1], slopeDeg: c.berm[2], depthM: c.berm[3] } : null, +})); +const profile = fillProfilePoints(4, 99.9, 1.5, { widthM: 0.5, intervalM: 3, slopeDeg: 0, depthM: 1 }, 20, 100); +writeFileSync(process.argv[3], JSON.stringify({ designs, profile })); +""" + + +@pytest.fixture(scope="module") +def ts_results(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("berm") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "common_util" / "common_util_cross_design.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + src, dst = tmp / "in.json", tmp / "out.json" + src.write_text( + json.dumps({"cases": CASES, "standard": STANDARD_CROSS_SECTION}, ensure_ascii=False), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True + ) + return json.loads(dst.read_text(encoding="utf-8")) + + +def _flats(design: dict) -> list[tuple[float, float, float]]: + """성토(오른쪽 · 음수 오프셋) 노면 끝 밖의 평탄 · 완만 도막 [(안 오프셋, 바깥 오프셋, 표고)].""" + edge = design["road_edges"]["right"]["offset_m"] + line = sorted(design["design_line"], key=lambda p: -p["offset_m"]) # 노면 → 바깥 + found: list[tuple[float, float, float]] = [] + for a, b in zip(line, line[1:], strict=False): + run = a["offset_m"] - b["offset_m"] + if a["offset_m"] > edge + 1e-6 or run <= 1e-6: + continue + if abs(b["elevation_m"] - a["elevation_m"]) / run < 0.1: # 소단(≤ 5°) · 지반 0.15 는 빠짐 + if found and abs(found[-1][1] - a["offset_m"]) < 1e-6: # 이어진 도막은 하나로 + found[-1] = (found[-1][0], b["offset_m"], found[-1][2]) + else: + found.append((a["offset_m"], b["offset_m"], a["elevation_m"])) + return found + + +def _close(left, right, path=""): + if isinstance(left, dict): + assert set(left) == set(right), f"{path} 키 다름" + for key in left: + _close(left[key], right[key], f"{path}.{key}") + elif isinstance(left, list): + assert len(left) == len(right), f"{path} 길이 {len(left)} vs {len(right)}" + for i, (a, b) in enumerate(zip(left, right, strict=True)): + _close(a, b, f"{path}[{i}]") + elif isinstance(left, (int, float)) and not isinstance(left, bool): + assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}" + else: + assert left == right, f"{path}: {left!r} vs {right!r}" + + +def test_파이썬_TS_설계선_같음(ts_results): + for case, ts in zip(CASES, ts_results["designs"], strict=True): + _close(_python(case), ts, case["name"]) + + +def test_사면_꼭짓점_거울(): + py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) + assert py[1] == pytest.approx((4 + 0.9 * 1.5, 99.0)) # 노면 끝 100 − 1.0 + assert py[2] == pytest.approx((4 + 0.9 * 1.5 + 0.5, 99.0)) # 폭 0.5 수평 + assert len(py) == 4 # 한 단만 · 그 아래 같은 경사로 끝까지 + + +def test_사면_꼭짓점_TS_같음(ts_results): + py = fill_profile_points(4, 99.9, 1.5, BermSpec(0.5, 3, 0, 1), 20, top_z=100) + _close([list(p) for p in py], ts_results["profile"], "profile") + + +def test_한_단_깊이_폭(): + by = {case["name"]: _python(case) for case in CASES} + edge_z = by["깊이 1.0 · 폭 0.5"]["road_edges"]["right"]["elevation_m"] + flats = _flats(by["깊이 1.0 · 폭 0.5"]) + assert len(flats) == 1 + inner, outer, z = flats[0] + assert z == pytest.approx(edge_z - 1.0, abs=1e-3) + assert inner - outer == pytest.approx(0.5, abs=1e-3) + assert by["깊이 1.0 · 폭 0.5"]["berm"]["depth_m"] == 1.0 + + zero = _flats(by["깊이 0 = 노면 끝 높이"]) + assert len(zero) == 1 and zero[0][2] == pytest.approx(edge_z, abs=1e-3) + assert by["깊이 음수 = 0"]["design_line"] == by["깊이 0 = 노면 끝 높이"]["design_line"] + assert by["깊이 음수 = 0"]["berm"]["depth_m"] == 0.0 + + assert _flats(by["깊이 > 사면 높이 = 소단 없음"]) == [] + assert _flats(by["소단 없음"]) == [] + # 소단이 선 만큼 성토 사면이 바깥으로 밀려 성토 면적이 늘어남 + assert by["깊이 1.0 · 폭 0.5"]["fill_area_m2"] > by["소단 없음"]["fill_area_m2"] + + +def test_절토_소단_끔(): + """좌측 절토 사면은 소단을 놓아도 소단 없는 단면과 같음.""" + with_berm = _python(CASES[0]) + plain = _python(CASES[-1]) + left = [p for p in with_berm["design_line"] if p["offset_m"] > 0] + assert left == [p for p in plain["design_line"] if p["offset_m"] > 0] + assert with_berm["cut_area_m2"] == plain["cut_area_m2"] + + +def test_저장분_깊이_읽기(): + """[저장] 재계산이 읽는 저장분 — 없으면 기본 1.0 · 음수는 0.""" + from B06_Section.B06_Section_Router_Design import stored_berm + + assert stored_berm({"berm": {"width_m": 0.5}}).depth_m == 1.0 + assert stored_berm({"berm": {"width_m": 0.5, "depth_m": -1}}).depth_m == 0.0 + assert stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}}).depth_m == 2.5 + + +def test_좌측_패널_칸_구간_깊이_나름(): + """구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴).""" + source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source + refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + assert "depthM: spec.depth_m" in refresh diff --git a/resources/tester/test_178_3_slope_tiers.py b/resources/tester/test_178_3_slope_tiers.py new file mode 100644 index 000000000..85fc6bbdf --- /dev/null +++ b/resources/tester/test_178_3_slope_tiers.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +"""178-3 사면안정 흙막이 · 옹벽 「추가 단」 상자 → 여러 단 횡단 형상. + +못박는 것 + - 상자 = 유출 흙막이 상자 양식(공용 [추가] · [빼기]) · 흙막이 = 형태 · 높이 · 옹벽 = 높이만 · 키 `tier_{n}_*`. + - 명세 저장 왕복 그대로 · 빈 상자도 한 단으로 남음 · 정의 그룹 종류 그대로(`STRUCTURE_SPECS` 에 안 넣음). + - 서버 저장(structures.json) → 벽 제원(파이썬) 단 수 = 1 + 상자 수 · 단마다 형태 · 높이 · 옹벽 단 = 콘크리트. + - 횡단 형상(진짜 `computeRevetmentLayout`) — 단 수 · 단마다 형태 · 높이(빈 값 = 본 벽 형태 · 1.5 m) · 옛 `tiers` 그대로. + +Node 헬퍼 `helper_178_3_slope_tiers.cjs`. 화면은 사용자 확인. +""" + +import json +import os +import subprocess + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +SOIL = { + "form": "돌쌓기(찰)", + "length_m": 20, + "height_m": 2, + "before_m": 10, + "after_m": 10, + "tier_1_form": "콘크리트", + "tier_1_height": 2, + "tier_2_form": "돌망태", + "tier_2_height": 1.2, +} + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_178_3_slope_tiers.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["roundTrip"]["items"] == { + "soil_guard": ["form", "height"], + "retaining_wall": ["height"], + } + assert out["roundTrip"]["notMainSpecs"] == [] + + +def test_명세_저장_왕복(out): + trip = out["roundTrip"] + assert trip["soilState"] == [ + {"form": "콘크리트", "height": "2"}, + {"form": "돌망태", "height": "1.2"}, + ] + assert trip["soilBack"] == { + "tier_1_form": "콘크리트", + "tier_1_height": 2, + "tier_2_form": "돌망태", + "tier_2_height": 1.2, + } + assert trip["wallBack"] == {"tier_1_height": 1.8} + assert trip["emptyBox"] == {"tier_1_form": ""} # 값 없이 추가만 해도 단 하나 + + +def test_서버_저장_뒤_벽_제원(tmp_path): + from B05_Profile.B05_Profile_Structures_Repository import save_structures + from B06_Section import B06_Section_Engine_Structures_Wall as wall + + items = [ + StructureInstance( + type_id="soil_guard", + placement="interval", + chainage_m=180, + start_m=170, + end_m=190, + options=SOIL, + ), + StructureInstance( + type_id="retaining_wall", + placement="interval", + chainage_m=300, + start_m=290, + end_m=310, + options={"height_m": 2.5, "tier_1_height": 1.8}, + ), + ] + save_structures(str(tmp_path), items, base_revision=0) + got = {spec["type_id"]: spec for spec in wall.load_wall_structures(tmp_path)} + assert got["soil_guard"]["tiers"] == 3 + assert got["soil_guard"]["extras"] == [ + {"form": "콘크리트", "height_m": 2}, + {"form": "돌망태", "height_m": 1.2}, + ] + assert got["retaining_wall"]["tiers"] == 2 + assert got["retaining_wall"]["extras"] == [{"form": "콘크리트", "height_m": 1.8}] + + +def test_횡단_형상_단_수_단마다_형태_높이(out): + assert out["soil2"]["requested"] == 3 + assert out["soil2"]["walls"] == [ + {"form": "돌쌓기(찰)", "pure": 2}, + {"form": "콘크리트", "pure": 2}, + {"form": "돌망태", "pure": 1.2}, + ] + assert out["wall1"]["walls"] == [ + {"form": "콘크리트", "pure": 2.5}, + {"form": "콘크리트", "pure": 1.8}, + ] + # 빈 상자 = 본 벽 형태 · 기본 1.5 m · 상자 없으면 옛 tiers 그대로 · 둘 다 없으면 1단 + assert out["emptyBox"]["walls"][1] == {"form": "돌망태", "pure": 1.5} + assert out["legacy"]["requested"] == 2 and out["legacy"]["extras"] == [] + assert out["none"]["requested"] == 1 + + +def test_배선(): + spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts") + assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec + walls = _read("common_util", "common_util_structure_walls.ts") + assert "파이썬 `tier_extras` 짝" in walls + engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py") + assert "TS `tierExtras` 짝" in engine diff --git a/resources/tester/test_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py new file mode 100644 index 000000000..a513fc37e --- /dev/null +++ b/resources/tester/test_179_4_detail_table.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-4 — B06 [상세] 표 손질(그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움). + +못박는 것 + - 칸 값 짝(① 파이썬 `cell_value` ↔ TS `localCellValue`) 거울 · 면적 → 설계 칸 값 · 길이 = hypot. + - 길이 검산 — 두 끝이 설계선 밖이면 값 비움 + 「다시 고름」(브라우저 값 안 받음). + - 그래프 고르기(띠 → 표 키 · 판정 선 px → m) · 우클릭 메뉴 항목 · 세션 초안 → patch → 스키마. + - 살림 목록 짝(RD ↔ CR)에 `detail_table` · 표 배치 한 벌(모달 sheet · B07 도면 표 · 높이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_179_4_detail_table.py -q +""" + +import json +import os +import re +import subprocess +from math import hypot +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from B06_Section import B06_Section_Router_CrossTable as table_mod +from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS +from B06_Section.B06_Section_Schema import CrossSectionPatch +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + _cross_table_entities, + cross_table_height, + extract_quantity_table, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +LINE = [(-6.0, 104.0), (-1.0, 110.0), (1.0, 110.0), (5.0, 108.0), (9.0, 112.0)] +DESIGN = { + "design_elevation_m": 110.0, + "cut_soil_area_m2": 1.234, + "cut_rock_area_m2": 0.5, + "fill_area_m2": 2.25, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in LINE], +} +SOURCES = [ + {"src": "area", "key": "cut_soil"}, + {"src": "area", "key": "embankment"}, + {"src": "area", "key": "spoil_fill"}, # 설계에 없음 → 빈 값 + {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.0]}, + {"src": "length", "start": [3.0, 109.0], "end": [9.0, 112.0]}, # 조각 가운데 점도 선 위 + {"src": "length", "start": [5.0, 108.5], "end": [1.0, 110.0]}, # 0.5 m 벗어남 + {"src": "length", "start": [5.0, 108.0], "end": [1.0, 110.04]}, # 허용 0.05 m 안 + {"src": "text", "text": "L형"}, +] +PICK = [[5.0, 108.0], [1.0, 110.0]] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_179_4_detail_table.cjs")], + input=json.dumps({"design": DESIGN, "sources": SOURCES, "pick": PICK}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 칸 값 짝 · 검산 ────────────────────────────────────────────────────── + + +def test_칸_값_파이썬_TS_거울(out): + for source, local in zip(SOURCES, out["local"], strict=True): + number, _text, _again = cell_value(source, DESIGN) + if number is None: + assert local is None, source + else: + assert local == pytest.approx(number), source + + +def test_면적_출처는_설계_칸_값_길이는_hypot(): + assert cell_value(SOURCES[0], DESIGN) == (1.234, None, False) + assert cell_value(SOURCES[1], DESIGN) == (2.25, None, False) # 쌓기 = fill_area_m2 + assert cell_value(SOURCES[2], DESIGN) == (None, None, False) + assert cell_value(SOURCES[3], DESIGN)[0] == pytest.approx(hypot(4.0, 2.0)) + assert cell_value(SOURCES[4], DESIGN)[0] == pytest.approx(hypot(6.0, 3.0)) + assert cell_value(SOURCES[7], DESIGN) == (None, "L형", False) + + +def test_길이_검산_설계선_밖이면_비우고_다시_고름(): + assert cell_value(SOURCES[5], DESIGN) == (None, None, True) + assert cell_value(SOURCES[6], DESIGN)[0] == pytest.approx(hypot(4.0, 2.04)) + # 설계선이 바뀌면(옛 두 끝이 선 밖) 다시 고름 + moved = {**DESIGN, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} + assert cell_value(SOURCES[3], moved) == (None, None, True) + assert cell_value(SOURCES[3], {**DESIGN, "design_line": []})[2] is True + + +# ── 그래프 고르기 · 메뉴 ───────────────────────────────────────────────── + + +def test_그래프_고르기_띠_키_판정_선_m(out): + area = out["area"] + assert area["cut_soil"] == {"src": "area", "key": "cut_soil"} + assert area["cut_rock"] == {"src": "area", "key": "cut_rock"} + assert area["fill"] == {"src": "area", "key": "embankment"} + assert area["spoil"] == {"src": "area", "key": "spoil_fill"} + assert area["other"] is None + length = out["length"] + assert length["src"] == "length" + assert length["start"] == pytest.approx(PICK[0]) and length["end"] == pytest.approx(PICK[1]) + assert out["lengthBad"] is None + assert out["lengthValue"] == pytest.approx(hypot(4.0, 2.0)) + + +def test_우클릭_메뉴_항목(out): + pick = ["그래프에서 고르기 — 면적", "그래프에서 고르기 — 길이"] + add = ["행 더함 — 표 끝", "열 더함 — 오른쪽 끝"] + assert out["menuHeader"] == [*pick, "-", *add, "-"] # 머리 칸 = 줄 없음 · 자동값 없음 + assert out["menuBase"] == [ + *pick, + "자동값으로", + "-", + "행 더함 — 이 행 아래(묶음 안)", + *add, + "-", + ] + assert out["menuAdded"][-2:] == ["이 행 지움", "이 열 지움"] + assert "자동값으로" not in out["menuAdded"] + # 상한(행 12 · 열 4)이면 더함 없음 + assert not any(label.startswith(("행 더함", "열 더함")) for label in out["menuFull"]) + assert out["menuPicks"] == "length" + + +# ── 세션 초안 → patch → 스키마 → 서버 표 한 벌 ──────────────────────────── + + +def test_세션_초안_patch_스키마_통과(out): + assert out["fresh"] == {"cells": {}, "rows": [], "columns": []} + assert out["ids"] == ["r1", "r2", "r3", "c1"] + assert out["cacheHas"] is True + draft = out["drafts"]["40"] + assert draft == out["forTable"] # 표 요청 설계에도 초안 그대로 + patch = CrossSectionPatch(chainage_m=40.0, detail_table=draft).model_dump() + table = patch["detail_table"] + assert [row["id"] for row in table["rows"]] == ["r1", "r2", "r3"] + assert table["rows"][0]["labels"] == ["풍화암", "", ""] + assert table["columns"] == [{"id": "c1", "label": "비고"}] + assert table["cells"]["c:c1:r1"]["text"] == "L형" + + +def test_지움_아래_행은_지운_행_자리로_열_칸도_지움(out): + removed = out["removed"] + assert [(row["id"], row["after"]) for row in removed["rows"]] == [("r2", None), ("r3", "b0")] + assert removed["columns"] == [] + assert not any(key.startswith(("r:r1:", "c:")) for key in removed["cells"]) + assert "cut_soil" not in out["autoBack"] and "fence" in out["autoBack"] + assert out["span"] == [3, 1] + + +@pytest.mark.parametrize( + "bad", + [ + {"rows": [{"id": "x1"}]}, + {"columns": [{"id": "r1"}]}, + {"cells": {"cut_soil": {"src": "volume"}}}, + {"cells": {"fence": {"src": "length", "start": [1.0], "end": [2.0, 3.0]}}}, + {"rows": [{"id": f"r{i}"} for i in range(1, 14)]}, + {"columns": [{"id": f"c{i}"} for i in range(1, 6)]}, + ], +) +def test_스키마가_틀린_표를_거름(bad): + with pytest.raises(ValidationError): + CrossSectionPatch(chainage_m=40.0, detail_table=bad) + + +def _stored(out) -> dict: + dumped = CrossSectionPatch(chainage_m=40.0, detail_table=out["drafts"]["40"]).model_dump() + return {**DESIGN, "detail_table": dumped["detail_table"]} + + +def test_모달_표_배치_값_검산(out): + design = _stored(out) + sheet = table_mod.cross_quantity_sheet({"chainage_m": 40.0, "center_z": 111.0}, design) + layout = sheet["layout"] + assert layout["rows"] == ["b0", "r1", "r3", "b1", "b2", "b3", "b4", "b5", "r2"] + assert layout["left"][1] == [None, "풍화암", "r:r1:0"] # 깍기 묶음 안 하위 줄 + assert layout["left"][2] == [None, "", "r:r3:0"] + assert layout["middle"][1] == [None, "", "r:r1:1"] # 면고르기 묶음 안 + assert layout["left"][8] == ["", None, "r:r2:0"] # 끝 = 홀로 선 줄 + assert layout["right"][8] == ["", "r:r2:2"] + assert layout["columns"] == [ + {"id": "c1", "label": "비고", "keys": [f"c:c1:{row}" for row in layout["rows"]]} + ] + table = sheet["quantity_table"] + assert table["cut_soil"] == 2.25 # 덮어쓰기 = 쌓기 면적 + assert table["fence"] == pytest.approx(hypot(4.0, 2.0)) # 판정 선 길이 + assert table["r:r2:2"] == 0.5 + assert sheet["text"]["c:c1:r1"] == "L형" and sheet["repick"] == [] + # 설계선이 바뀌면 길이 칸 비움 · 다시 고름 + moved = {**design, "design_line": [{"offset_m": x, "elevation_m": y + 1} for x, y in LINE]} + again = table_mod.cross_quantity_sheet({"chainage_m": 40.0}, moved) + assert again["quantity_table"]["fence"] is None and again["repick"] == ["fence"] + + +def test_B07_도면_표_같은_자료_행_열_높이(out): + design = _stored(out) + from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table + + values = _quantity_table({"chainage_m": 40.0, "center_z": 111.0}, design) + [entity] = _cross_table_entities("d", values, 0.0, "No.2", 0.0, design) + shape = entity["shapeData"] + assert len(shape["rowHeights"]) == 2 + 6 + 3 + assert sum(shape["columnWidths"]) == pytest.approx(108.0) + cells = [cell for row in shape["cells"] for cell in row if cell] + texts = {cell.get("key"): cell["text"] for cell in cells if cell.get("key")} + assert texts["cut_soil"] == "2.25" and texts["fence"] == "4.47" + assert texts["c:c1:r1"] == "L형" and texts["r:r1:0"] == "" + assert any(cell["text"] == "비고" for cell in shape["cells"][1] if cell) + labels = [cell["text"] for cell in cells if not cell.get("key")] + assert "풍화암" in labels + # 깍기 묶음 = 4줄(토사 · 풍화암 · 더한 줄 · 암석) + cut = next(cell for cell in cells if cell["text"] == "깍기") + assert cut["rowSpan"] == 4 + assert cross_table_height(design) == pytest.approx(5.0 * 11) + assert cross_table_height() == pytest.approx(40.0) # 손질 없으면 그대로 + # 확정 되읽기 = 양식 칸만(더한 칸은 patch 몫) + extracted = extract_quantity_table("d", {"entities": [entity]}) + assert extracted["cut_soil"] == 2.25 and "c:c1:r1" not in extracted + + +def test_손질_없으면_도면_표_그대로(): + plain = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0)[0]["shapeData"] + empty = {"detail_table": {"cells": {}, "rows": [], "columns": []}} + same = _cross_table_entities("d", {"ground": 1.0}, 0.0, "No.1", 0.0, empty)[0]["shapeData"] + assert plain == same and len(plain["rowHeights"]) == 8 + + +# ── 살림 목록 짝 · 배선 ──────────────────────────────────────────────── + + +def test_살림_목록_짝에_detail_table(): + assert "detail_table" in USER_TOUCHED_KEYS + text = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8") + block = re.search(r"export const USER_TOUCHED_KEYS = \[(.*?)\] as const;", text, re.S) + assert block and '"detail_table"' in block.group(1) + + +def test_저장_patch_배선_초안_등록(): + persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") + assert "readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => {" in persist + assert "patchFor(chainage).detail_table = table;" in persist + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"detail-table": { bucket: "draft", scope: "route" }' in state + detail = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail.ts").read_text(encoding="utf-8") + assert "designForTable(state.section, projectId, routeId)" in detail + assert "const detachTable = attachDetailTable({" in detail diff --git a/resources/tester/test_180_3_b07_guide_toggle.py b/resources/tester/test_180_3_b07_guide_toggle.py new file mode 100644 index 000000000..abbdef089 --- /dev/null +++ b/resources/tester/test_180_3_b07_guide_toggle.py @@ -0,0 +1,76 @@ +"""180-3 B07 횡단 장 안내선 보이기 / 숨기기 단추. + +- 끄는 것 = 점선 작도 영역 + 붉은 넘침 틀(화면 전용) · 인쇄 칸선 · 도면 개체는 그대로 +- 단추 = 배치 칸 줄 · ghost sm · 글 「안내선 숨기기」 ↔ 「안내선 보이기」 · 상태는 모듈 값(저장 안 함) +- 숨김이면 넘침 토스트도 안 띄움 · 메시지 한 줄(`aislo:b08:guide-visible`) · cad_host `setGuideVisible` +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +B07 = ROOT / "B07_DesignDetail" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_단추_글_누름_상태_이어짐(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_180_3_b07_guide_toggle.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["first"] == { + "text": "안내선 숨기기", + "cls": "ui-btn ui-btn--ghost ui-btn--sm", + "pressed": "false", + "shown": True, + } + assert out["hidden"]["text"] == "안내선 보이기" and out["hidden"]["pressed"] == "true" + assert out["hidden"]["shown"] is False + assert out["again"]["text"] == "안내선 숨기기" and out["again"]["shown"] is True + assert out["last"]["shown"] is False + assert out["sent"] == [False, True, False] # CAD 에 알린 값 + # 화면을 다시 세워 새 단추를 만들어도 숨김 유지 + assert out["rebuilt"] == {"text": "안내선 보이기", "pressed": "true"} + + +def test_메시지_짝_호스트_CAD(): + host = _read(ROOT / "ui_template" / "cad_host" / "cad_host.ts") + bridge = _read(B07 / "openwebcad" / "src" / "integration" / "aislo-drawing-bridge.ts") + guide = _read(B07 / "openwebcad" / "src" / "integration" / "guide-area.ts") + name = "aislo:b08:guide-visible" + assert f'"{name}"' in host and f"'{name}'" in bridge # 짝 메시지 이름 같음 + assert "setGuideVisible: (visible) => postAfterLoad(" in host + assert "setGuideVisible: (visible: boolean) => void;" in host + assert "return setGuideVisible(guideMessage.visible);" in bridge + assert "if (!guide || !guideVisible) return;" in guide # 그리기 거름 + assert "!guideVisible) return;" in guide # 끌기 집기도 거름 + # 싣기(setGuide)가 보임 값을 건드리지 않음 + body = guide[ + guide.index("export function setGuide(") : guide.index("export function setGuideArea") + ] + assert "guideVisible" not in body + + +def test_페이지_잇기_토스트_거름_장만(): + page = _read(B07 / "B07_DesignDetail_UI_Page.ts") + assert "if (guideShown() && guide?.marks?.length) {" in page # 숨김 = 넘침 토스트 없음 + assert "cad.setGuideVisible(visible)" in page + assert "if (!guideShown()) cad.setGuideVisible(false);" in page # 다시 세운 화면에 이어 줌 + assert "controls.append(guideToggle);" in page # 배치 칸 줄 끝 + assert "guideToggle.disabled = !isCrossSheet(drawing);" in page # 장만 + # 단추는 인쇄 칸선(도각 층) · 도면 개체를 건드리지 않음 — 파이썬 쪽 변경 없음 + assert not re.search( + r"guide.?visible", _read(B07 / "B07_DesignDetail_Engine_Cad_Sheet.py"), re.I + ) diff --git a/resources/tester/test_183_2_b09_compare_head.py b/resources/tester/test_183_2_b09_compare_head.py new file mode 100644 index 000000000..3040fe43a --- /dev/null +++ b/resources/tester/test_183_2_b09_compare_head.py @@ -0,0 +1,29 @@ +"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래. + +소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SRC = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + + +def _compare() -> str: + start = SRC.index("export const buildCompare") + return SRC[start : SRC.index("export const buildMaterial")] + + +def test_견적은_단가_견적서_그_밖은_단가_출처(): + body = _compare() + assert ( + 's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body + ) + assert '"면"' not in body # 면 머리 없음 + assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움 + + +def test_견적_면_수기_칸_없앰(): + body = _compare() + assert 'key === "g:견적"' not in body and "quote_page" not in body + assert "견적 단가 = 수기 입력" in body and "견적 단가 · 면" not in body diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py new file mode 100644 index 000000000..dcc5b1c91 --- /dev/null +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -0,0 +1,113 @@ +"""183-3 자재단가대비표 적용 단가 — 칸 누름 고르기 메뉴 · 고른 출처 칸 강조 · 기본 최저. + +- 공용 표 `cellClass` 옵션(칸 class · 안 주면 같은 글) — Node 로 `renderDataTableHtml` +- 고르기 항목 `appliedChoices`(값 있는 출처 · 견적 · 최저(자동) · ✓ 표시) — Node 로 함수만 떼어 돌림 +- 배선: 수기 적용 칸 · 출처 칸 돌림 없앰 · Shell 이 cellClass · choices 를 넘김 · B09 메뉴 한 벌 +셈 · 저장(pick › applied › 최저)은 그대로(test_144 · test_166_4). +""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "config" / "node_modules" / "typescript" + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(script: str): + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +PRELUDE = f""" +const fs = require("fs"); +const ts = require({json.dumps(str(TS))}); +const js = (code) => ts.transpileModule(code, {{ + compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }}, +}}).outputText; +""" + + +def test_공용_표_cellClass_칸_class_안_주면_같은_글(): + table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts")) + got = _node( + PRELUDE + + f""" +const box = {{ exports: {{}} }}; +new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))( + box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p))); +const m = box.exports; +const o = {{ columns: [{{ key: "a", label: "A" }}, {{ key: "b", label: "B" }}], + rows: [{{ a: 1, b: 2 }}, {{ a: 3, b: 4 }}] }}; +const marked = m.renderDataTableHtml({{ ...o, + cellClass: (i, k) => (i === 0 && k === "b" ? "is-picked" : null) }}); +console.log(JSON.stringify({{ + plain: m.renderDataTableHtml(o), + none: m.renderDataTableHtml({{ ...o, cellClass: () => null }}), + count: marked.split("is-picked").length - 1, + row0: marked.split("")[1].split("")[0], +}})); +""" + ) + assert got["plain"] == got["none"] + assert got["count"] == 1 and '2' in got["row0"] + + +def test_고르기_항목_값_있는_출처_견적_최저_자동(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + start = base.index("function appliedChoices") + body = base[start : base.index("\n}\n", start) + 3] + sources = _src("B09_Estimation/B09_Estimation_Calc_Base.ts") + at = sources.index("export const SOURCES") + line = sources[at : sources.index(";", at) + 1] + "\n" + got = _node( + PRELUDE + + f""" +const appliedChoices = new Function(js({json.dumps(line.replace("export ", "") + body)}) + "; return appliedChoices;")(); +const r = {{ prices: {{ 관급: null, 물가정보: 1200, 물가자료: 0, 유통물가: 1000, 견적: 1500 }}, + lowest_from: "유통물가", applied_from: "유통물가", pick: null }}; +const seen = []; +const items = appliedChoices(r, (s) => seen.push(s)); +items.forEach((x) => Array.isArray(x) && x[1]()); +const pick = {{ ...r, applied_from: "견적", pick: "견적" }}; +console.log(JSON.stringify({{ + labels: items.map((x) => (Array.isArray(x) ? x[0] : x)), + seen, + picked: appliedChoices(pick, () => 0).map((x) => (Array.isArray(x) ? x[0] : x)), +}})); +""" + ) + assert got["labels"] == [ + "물가정보 1,200", + "✓ 유통물가 1,000 · 최저", + "견적 1,500", + "-", + "✓ 최저(자동)", + ] + assert got["seen"] == ["물가정보", "유통물가", "견적", None] + assert got["picked"][2] == "✓ 견적 1,500" and got["picked"][-1] == "최저(자동)" + + +def test_배선_수기_적용_칸_출처_돌림_없앰(): + base = _src("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts") + compare = base[base.index("export const buildCompare") : base.index("function usesOf")] + assert 'editable: (_i, key) => key === "p:견적",' in compare + assert "nextPick" not in base and 'key === "applied_from"' not in compare + assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare + assert 'if (key !== "applied" || !r) return null;' in compare + assert "delete cell.applied" in compare + shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") + assert ( + "cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell + ) + edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") + assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit + css = _src("B09_Estimation/B09_Estimation_UI_Page.css") + assert "td.is-picked" in css and "var(--color-accent)" in css diff --git a/resources/tester/test_183_b09_material_list.py b/resources/tester/test_183_b09_material_list.py new file mode 100644 index 000000000..e6905631c --- /dev/null +++ b/resources/tester/test_183_b09_material_list.py @@ -0,0 +1,88 @@ +"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳. + +- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`) +- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`) +- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`) +- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`) +""" + +import json +from pathlib import Path + +from B09_Estimation import B09_Estimation_Router as router_mod +from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Linked import used_keys +from B09_Estimation.B09_Estimation_Summary import used_materials +from resources.tester.test_144_b09_base_calc import _run_ts + +ROOT = Path(__file__).resolve().parents[2] + +MATERIALS = [ + {"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}}, + {"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}}, + {"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}}, + {"key": "MO1", "name": "경유", "prices": {"유가": 1800}}, + {"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"}, +] +USER = {"materials": {"MT3": {"supply": "관급"}}} +SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}] +MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}] + + +def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path): + labor = { + "줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]}, + "참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}}, + } + (tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8") + assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖 + + +def test_쓰는_자재만_남김(): + sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE} + inputs = {"materials": MATERIALS} + assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"} + + +def test_수량_원천_material_qty_거울(): + inputs = {"labor": [], "machines": [], "materials": MATERIALS} + old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]} + assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길) + got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER) + rows = {r["key"]: r for r in got["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000) + assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸 + for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}): + assert _run_ts(case, USER) == compute_base(case, USER) + + +def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch): + calls = [] + + def fake(root, inputs, user): + calls.append([m["key"] for m in inputs["materials"]]) + base = compute_base(inputs, user) + bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]} + return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill} + + monkeypatch.setattr(router_mod, "compute_sheets", fake) + inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)} + sheets, summary = router_mod.compute_twice(Path("."), inputs, USER) + assert calls[0] == [m["key"] for m in MATERIALS] + assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌 + assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2 + rows = {r["key"]: r for r in sheets["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000) + assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값 + + +def test_화면_비고_쓰는_곳(): + src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8") + for label in ( + "`대가 ${b.no}호표`", + "`산근 ${b.no}호표`", + "`중기 ${n + 1}호표`", + '"내역 자재대"', + ): + assert label in src + assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src diff --git a/resources/tester/test_54_26_slope_table09.py b/resources/tester/test_54_26_slope_table09.py index 514fd4b34..d39729a73 100644 --- a/resources/tester/test_54_26_slope_table09.py +++ b/resources/tester/test_54_26_slope_table09.py @@ -28,7 +28,7 @@ TABLE = { "옹벽", ["wall_form", "length_m", "height_m", "before_m", "after_m"], ), - "berm": ("gbermdetail", "상세 내용", ["width_m", "height_m"]), + "berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2) } SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"] diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index 237ce6a70..9aa2c9c9d 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -133,17 +133,10 @@ def test_group_a_point_and_revetment_facility_join(tmp_path): def test_other_groups_do_not_plant_stations(tmp_path): - """C·E·G군은 측점을 만들지 않는다 — 지금 켠 것은 A군과 D군 구간형뿐이다.""" + """E·G군은 측점을 만들지 않는다 — 켠 것은 A군과 C·D군 구간형뿐이다(178-1).""" _write_structures( tmp_path, [ - { - "type_id": "retaining_wall", - "chainage_m": 40.0, - "start_m": 40.0, - "end_m": 60.0, - "placement": "interval", - }, { "type_id": "pavement_concrete", "chainage_m": 300.0, @@ -157,6 +150,40 @@ def test_other_groups_do_not_plant_stations(tmp_path): assert [chainage for chainage, _label in extras] == [84.3, 149.73, 200.92, 275.71] +def test_slope_group_interval_plants_start_anchor_end(tmp_path): + """C군 구간형(옹벽·흙막이·소단)도 시작·기준·종료 셋을 심는다 — 격자 사이 벽도 횡단에 얹힘(178-1).""" + _write_structures( + tmp_path, + [ + { + "type_id": "soil_guard", + "chainage_m": 45.0, + "start_m": 40.0, + "end_m": 50.0, + "placement": "interval", + }, + { + "type_id": "retaining_wall", + "chainage_m": 125.0, + "start_m": 120.0, + "end_m": 130.0, + "placement": "interval", + }, + { + "type_id": "berm", + "chainage_m": 165.0, + "start_m": 160.0, + "end_m": 170.0, + "placement": "interval", + }, + ], + ) + extras = resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) + assert [c for c, label in extras if label == "흙막이"] == [40.0, 45.0, 50.0] + assert [c for c, label in extras if label == "옹벽"] == [120.0, 125.0, 130.0] + assert [c for c, label in extras if label == "소단"] == [160.0, 165.0, 170.0] + + def test_broken_structures_file_keeps_pipe_derived_stations(tmp_path): route_dir = tmp_path / "B05_Profile" / "route" route_dir.mkdir(parents=True, exist_ok=True) diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index e8a2bf1df..d16dc199c 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -46,6 +46,24 @@ STRUCTURES = [ "end_m": None, "options": {}, }, + { + # 178-3 「추가 단」 상자 둘(둘째 상자 높이 빈 값 · 글 값은 수 아님) + "structure_id": "tiers", + "type_id": "soil_guard", + "placement": "interval", + "chainage_m": 260.0, + "start_m": 250.0, + "end_m": 270.0, + "options": { + "form": "돌망태", + "height_m": 2.0, + "tiers": 5, + "tier_2_form": "", + "tier_2_height": "1.5", + "tier_1_form": "콘크리트", + "tier_1_height": 2.0, + }, + }, ] NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"} CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0] @@ -62,7 +80,11 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections })); def _python_result() -> dict: - from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures + from B06_Section.B06_Section_Engine_Structures_Wall import ( + _FORM_BY_TYPE, + attach_wall_structures, + tier_extras, + ) specs = [] for item in STRUCTURES: @@ -71,6 +93,7 @@ def _python_result() -> dict: if not name or start is None or end is None: continue options = item["options"] or {} + extras = tier_extras(item["type_id"], options) specs.append( { "structure_id": item["structure_id"], @@ -85,7 +108,9 @@ def _python_result() -> dict: # 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면 # 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다. "foundation": options.get("foundation"), - "tiers": options.get("tiers"), + # 178-3 단 수 = 1 + 「추가 단」 상자 수 · 상자 없으면 옛 tiers + "tiers": len(extras) + 1 if extras else options.get("tiers"), + "extras": extras, "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), # 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로. @@ -145,8 +170,16 @@ def test_wall_specs_mirror(): with tempfile.TemporaryDirectory() as workdir: ts = _ts_result(Path(workdir)) py = _python_result() - assert len(ts["specs"]) == len(py["specs"]) == 2 + assert len(ts["specs"]) == len(py["specs"]) == 3 assert ts["specs"] == py["specs"] + tiered = ts["specs"][2] + assert tiered["tiers"] == 3 + assert tiered["extras"] == [ + {"form": "콘크리트", "height_m": 2.0}, + {"form": None, "height_m": None}, + ] + assert ts["specs"][1]["extras"] == [] # 상자 없는 옹벽 = 옛 tiers 2 그대로 + assert ts["specs"][1]["tiers"] == 2 ts_marks = [ (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"] ] diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 07801f0c7..51ea8b6f3 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -36,6 +36,7 @@ const CAD_PLACE_CANCELLED_MESSAGE = "aislo:b08:place-cancelled"; /** 작도 영역 가이드(169-1 · 169-3) — 짝 = CAD `integration/guide-area.ts` */ const CAD_GUIDE_AREA_MESSAGE = "aislo:b08:guide-area"; const CAD_GUIDE_CHANGED_MESSAGE = "aislo:b08:guide-changed"; +const CAD_GUIDE_VISIBLE_MESSAGE = "aislo:b08:guide-visible"; /** 작도 영역 [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */ export type CadGuideArea = [number, number, number, number]; @@ -128,6 +129,8 @@ export interface CadHost { updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise; /** 가이드 사각형만 갈기(숫자 칸 고침 · 169-3) — 도면은 다시 안 실음. */ setGuideArea: (area: CadGuideArea) => void; + /** 안내선(점선 작도 영역 + 붉은 넘침 틀) 보이기 / 숨기기(180-3) — 싣기를 거쳐도 이어짐 · 도면에 안 섞임. */ + setGuideVisible: (visible: boolean) => void; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } @@ -360,6 +363,7 @@ export function createCadHost postAfterLoad({ type: CAD_GUIDE_AREA_MESSAGE, area }), + setGuideVisible: (visible) => postAfterLoad({ type: CAD_GUIDE_VISIBLE_MESSAGE, visible }), destroy: () => { stopLoadWatchdog(); window.removeEventListener("message", onMessage); diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index f16bc7ecf..13e7a3b54 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -60,6 +60,8 @@ export interface DataTableOptions { onEdit?: (index: number, key: string, text: string) => void; /** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */ rowClass?: (index: number) => string | null | undefined; + /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ + cellClass?: (index: number, key: string) => string | null | undefined; } export interface DataTable { @@ -139,6 +141,7 @@ function rowHtml( editable?: (index: number, key: string) => boolean, focus = false, extra: string | null | undefined = null, + cellClass?: (key: string) => string | null | undefined, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -152,7 +155,12 @@ function rowHtml( } const [txt, empty] = formatCell(r[key], fmts[j], blank); const frz = left[j] !== null; - const cls = [frz && "is-frozen", frz && left[j + 1] == null && "is-edge", empty && "is-empty"] + const cls = [ + frz && "is-frozen", + frz && left[j + 1] == null && "is-edge", + empty && "is-empty", + cellClass?.(key), + ] .filter(Boolean) .join(" "); const attrs = @@ -218,6 +226,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin o.editable, o.focus === i, o.rowClass?.(i), + o.cellClass ? (key) => o.cellClass?.(i, key) : undefined, ); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : "";