diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index de75abeb6..10d353761 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -41,7 +41,6 @@ import { import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; import { followPipeHeights } from "../../common_util/common_util_culvert_sets"; -import { createCheckbox } from "../../ui_template/ui_template_checkbox"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { @@ -144,13 +143,6 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = tag ? "" : now; return select; } - if (item.kind === "bool") { - // 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false" - const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" }); - box.input.value = String(box.input.checked); - box.input.addEventListener("change", () => (box.input.value = String(box.input.checked))); - return box.root; - } if (item.kind === "text") { const input = document.createElement("input"); input.type = "text"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 3aa987a31..9b1e7b79e 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -70,11 +70,15 @@ export const PIPE_SPEC: StructureSpec = { [BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" }, [SOIL]: revetKeys("inlet"), }), - // 집수정만 · 빈 값 = 참(키 없는 옛 관도 동물이동형 · 173-7) · 저장만(B07 · B08 안 읽음) + // 집수정만 · 유 / 무 = 저장 "true" / "false" · 빈 값 = 유(필수 상자 기본 채움 · 177-2) · 저장만(B07 · B08 안 읽음) { id: "animal", label: "동물이동형", - kind: "bool", + kind: "select", + choices: [ + { value: "true", label: "유" }, + { value: "false", label: "무" }, + ], default: "true", by: { item: "structure", map: { [BASIN]: { key: "inlet_basin_animal" } } }, }, diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 5c80408e8..3ba11d1d1 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -32,8 +32,7 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - /** bool = 체크박스 · 저장 글 "true" / "false" · 빈 값 = `default`(173-7). */ - kind: "select" | "number" | "text" | "auto" | "bool"; + kind: "select" | "number" | "text" | "auto"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index d29b60ecb..7bb08fc1e 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -206,10 +206,9 @@ export async function mountProfileView( let selectionSyncing = false; /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── - * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 - * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ + * solve가 자동 판정한 uphill_side를 사용자가 바꾼 값. 세션에 보관했다가 + * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ const uphillOverrides = loadUphillOverrides(activeProjectId); - const uphillKey = (chainage: number): string => chainage.toFixed(3); const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), @@ -356,25 +355,6 @@ export async function mountProfileView( profilePanel.setSelectedStation(stationId); syncIrregularSelection(stationId); }); - // 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더. - viewer.markers.onUphillPick((stationId, side) => { - if (!currentSectionDetail) return; - const base = currentSectionDetail.longitudinal.stations.find( - (station) => station.station_id === stationId, - ); - let chainage = base?.chainage_m; - if (chainage === undefined) { - const prefix = irregularStationId(""); - if (stationId.startsWith(prefix)) { - const id = stationId.slice(prefix.length); - chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m; - } - } - if (chainage === undefined) return; - uphillOverrides.set(uphillKey(chainage), side); - saveUphillOverrides(activeProjectId, uphillOverrides); - renderStationLines(currentSectionDetail); - }); viewer.root.append(panel.viewControls); function restorePanel(next: RouteLatestResponse): void { @@ -389,7 +369,6 @@ export async function mountProfileView( routePoints: latest?.route_points ?? [], liveSamples: profilePanel.alignmentSamples(), corridorVisible, - uphillOverrides, }); viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2); } 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_Loft.ts b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts index 113de4888..6830e7acd 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Loft.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Loft.ts @@ -69,6 +69,8 @@ const POINT_GAP_M = 0.3; // 늘리면 더 매끈 · 더 뜸. const LIFT_SIGMA_M = 3; const LIFT_CAP_M = 0.5; +/** 성토 덩이 끝(성토 아닌 행)에서 이 누가거리 안은 뜸을 0 까지 줄인다(smoothstep · 176-1 · 절토와 톱니 없게). */ +const LIFT_TAPER_M = 3; /** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */ const HOLE_CLEAR_M = 0.3; /** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */ @@ -348,9 +350,21 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => { (r) => fill(r) && stationAt(chainages[r]) >= 0, fill, ); + // 가장 가까운 성토 아닌 행까지 누가거리(앞 · 뒤) — 덩이 끝 줄이기. + const gap = new Float64Array(n).fill(Infinity); + for (let r = 0, last = -Infinity; r < n; r += 1) { + if (!fill(r)) last = chainages[r]; + else gap[r] = chainages[r] - last; + } + for (let r = n - 1, next = Infinity; r >= 0; r -= 1) { + if (!fill(r)) next = chainages[r]; + else gap[r] = Math.min(gap[r], next - chainages[r]); + } for (let r = 0; r < n; r += 1) { if (!fill(r)) continue; - toeOff[si][r] = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r])); + const t = Math.min(1, gap[r] / LIFT_TAPER_M); + const off = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r])); + toeOff[si][r] = off * t * t * (3 - 2 * t); raw[r] += toeOff[si][r]; } }); 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_Corridor_Tin.ts b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts index b48e6fd37..8baab4e3e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Tin.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Tin.ts @@ -38,8 +38,9 @@ const FALLBACK_RGB = [0.6, 0.6, 0.6]; /** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */ const TINT_MIN_M = 0.01; const GRID_M = 2; -/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */ -const ON_BOUNDARY_M = 1e-3; +/** 설계 구역 · 구멍 둘레에서 이만큼 안 지형 정점은 빼고 잇는다(m · 176-1) — 비탈 끝 뜸이 짧은 턱 · 홈이 되지 않게 + * 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */ +export const SEAM_M = 1.5; /** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */ class RoadStrip extends CellRegion { @@ -88,8 +89,8 @@ function harvestPart( if (!state[v]) { const x = position.getX(v); const z = position.getZ(v); - // 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게). - state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2; + // 경계 선 위 · 둘레 SEAM_M 안 정점도 안으로(제약이 점을 관통하지 않게 · 잇는 띠 완만). + state[v] = inRegion(x, z) || boundary.passesNear(x, z, SEAM_M) ? 1 : 2; } return state[v] === 1; }; @@ -209,11 +210,12 @@ export function tinTerrain( segment += 1; } }); + // SEAM_M 만큼 넓힘 — 둘레 띠 안 정점을 쓰는 삼각형이 상자 밖이라 남아 틈이 되지 않게. const box = { - minX: Math.min(outer.minX, holes.minX), - maxX: Math.max(outer.maxX, holes.maxX), - minZ: Math.min(outer.minZ, holes.minZ), - maxZ: Math.max(outer.maxZ, holes.maxZ), + minX: Math.min(outer.minX, holes.minX) - SEAM_M, + maxX: Math.max(outer.maxX, holes.maxX) + SEAM_M, + minZ: Math.min(outer.minZ, holes.minZ) - SEAM_M, + maxZ: Math.max(outer.maxZ, holes.maxZ) + SEAM_M, }; const root = new THREE.Group(); diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index f115c28c2..f6f4e1918 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -32,8 +32,6 @@ export interface SectionStationMarker { center_x: number; center_y: number; center_z: number | null; - /** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */ - uphill_side?: "left" | "right" | null; frame: { left_xy: [number, number] }; /** 측점 종류(`bp`/`ep`/`regular`/`irregular`) — 어느 측점에 라벨을 달지 가르는 기준. */ kind?: string; @@ -54,10 +52,6 @@ const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ { within: Infinity, step: 5 }, ]; -// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. -const UPHILL_LAMP_COLOR = 0xf97316; -const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af; - const COLORS: Record = { bp: 0x10b981, ep: 0xef4444, @@ -124,7 +118,6 @@ export function createRouteMarkers( let changeListener: ((points: RouteDesignPoints) => void) | undefined; let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined; let stationSelectionListener: ((stationId: string | null) => void) | undefined; - let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined; let selectedStationId: string | null = null; function allPoints(): PlacedRoutePoint[] { @@ -423,7 +416,7 @@ export function createRouteMarkers( .map((entry) => ({ chainage_m: entry.chainage_m as number })), ); // 측점선(노란 띠)은 도로 반폭의 1.5배로 그린다 — 지형에 묻혀 짧아 보이는 문제 - // (2026-08-19 사용자 지시). 측구 방향 램프는 늘어난 선의 **끝단**에 따라붙는다. + // (2026-08-19 사용자 지시). const barHalfWidth = halfWidth * 1.5; stations.forEach((station) => { if (station.center_z === null) return; @@ -456,39 +449,12 @@ export function createRouteMarkers( bar.userData.stationId = station.station_id; stationGroup.add(bar); - // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. - // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). const label = stationLabelText(station, stationIntervalM); if (label) { const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); sprite.userData.stationNumber = label.number; stationLabelGroup.add(sprite); } - - (["left", "right"] as const).forEach((side, endIndex) => { - const active = station.uphill_side === side; - // 램프 절반 축소(1.1→0.55) — 측점 고를 때 가리는 문제(2026-08-06 보고). - const lamp = new THREE.Mesh( - new THREE.SphereGeometry(0.55, 14, 10), - new THREE.MeshBasicMaterial({ - color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR, - }), - ); - lamp.position.copy(points[endIndex]); - lamp.position.y += 0.3; - lamp.userData.uphillStationId = station.station_id; - lamp.userData.uphillSide = side; - stationGroup.add(lamp); - // 클릭 판정은 예전 크기 그대로 — 투명 히트 구를 겹쳐 "작게 보여도 잘 눌리게". - const lampHit = new THREE.Mesh( - new THREE.SphereGeometry(1.1, 8, 6), - new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }), - ); - lampHit.position.copy(lamp.position); - lampHit.userData.uphillStationId = station.station_id; - lampHit.userData.uphillSide = side; - stationGroup.add(lampHit); - }); }); // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. applyLabelStep(); @@ -527,7 +493,7 @@ export function createRouteMarkers( function selectStation(stationId: string | null): void { selectedStationId = stationId; stationGroup.children.forEach((object) => { - // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다 — 램프(uphill*)는 제외. + // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다. if (!(object instanceof THREE.Mesh) || typeof object.userData.stationId !== "string") return; const selected = object.userData.stationId === selectedStationId; (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); @@ -553,14 +519,6 @@ export function createRouteMarkers( updateSelected, deleteSelected, selectObject(object: THREE.Object3D | undefined) { - // 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정). - if (typeof object?.userData.uphillStationId === "string") { - uphillPickListener?.( - object.userData.uphillStationId, - object.userData.uphillSide as "left" | "right", - ); - return; - } if (typeof object?.userData.stationId === "string") { selectStation(object.userData.stationId); selectionListener?.(null); @@ -610,9 +568,6 @@ export function createRouteMarkers( onStationSelectionChange(listener: (stationId: string | null) => void) { stationSelectionListener = listener; }, - onUphillPick(listener: (stationId: string, side: "left" | "right") => void) { - uphillPickListener = listener; - }, dispose() { disposeGroup(interactionGroup); disposeGroup(routeGroup); diff --git a/B05_Profile/B05_Profile_UI_Page_Stations.ts b/B05_Profile/B05_Profile_UI_Page_Stations.ts index b33f0762b..da83c9639 100644 --- a/B05_Profile/B05_Profile_UI_Page_Stations.ts +++ b/B05_Profile/B05_Profile_UI_Page_Stations.ts @@ -17,8 +17,6 @@ export interface StationLinesInput { liveSamples: ReadonlyArray<{ chainage_m: number; elevation_m: number }> | null; /** [예상형상] 켜짐 — 측점 바를 계획고 위로 올린다. */ corridorVisible: boolean; - /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 3자리. */ - uphillOverrides: Map; } export function stationLinesOf(input: StationLinesInput) { @@ -60,8 +58,6 @@ export function stationLinesOf(input: StationLinesInput) { // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. center_z: design !== null && station.center_z !== null ? design : station.center_z, - uphill_side: - input.uphillOverrides.get(station.chainage_m.toFixed(3)) ?? station.uphill_side ?? null, }; }); } diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index b09d2e0e0..93e751618 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -62,13 +62,13 @@ export function createViewControls(callbacks: ViewControlCallbacks): { toggleButton("지표면", true, callbacks.onSurfaceVisible), // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. toggleButton("예상형상", true, callbacks.onCorridorVisible), - toggleButton("등고선", true, callbacks.onContoursVisible), + // 등고선 · 흑백 지형 기본 꺼짐(176-2) — 뷰어 처음 값과 맞춘다. + toggleButton("등고선", false, callbacks.onContoursVisible), toggleButton("축 표시", false, callbacks.onAxesVisible), toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). - // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. - toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), + toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale), ); // [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이 // 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은 diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 63c9af9cc..d8fcc2277 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -453,6 +453,12 @@ background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface)); } +/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */ +.b05-structure__summary-name:not(.is-assigned) { + border-color: var(--color-warning); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); +} + .b05-structure__summary .b05-route__irregular-btn { flex: none; } diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 64591f2cc..fde2c5705 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -182,6 +182,7 @@ export function createRouteViewer(): RouteViewer { return heightIndex.triangleCount > 0 ? heightIndex : null; } const contours = new THREE.Group(); + contours.visible = false; // 기본 꺼짐(176-2) — 패널 토글 처음 값과 맞춘다. scene.add(contours); let bounds: ModelBounds | null = null; let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = @@ -386,8 +387,8 @@ export function createRouteViewer(): RouteViewer { // 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라 // (원본은 색 버퍼 열쇠 `ORIGINAL_COLORS`에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다. - // 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다. - let surfaceGrayscale = true; + // 기본 꺼짐(176-2) — 패널 토글 초기값과 맞춘다. + let surfaceGrayscale = false; function applySurfaceGrayscale(): void { [terrain, corridor.clipped()].forEach((target) => applyGrayscaleTo(target)); } diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 67266c563..111cecb06 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; }, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e59268017..85f5aacb3 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -528,7 +528,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; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index fe8f9b199..68c590c6d 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 { @@ -189,9 +190,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 +257,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_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f3c644403..89598f6b9 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, @@ -456,6 +457,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 +502,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..f30f46037 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -329,7 +329,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_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 723b97f95..5020be6bb 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -385,7 +385,7 @@ export function buildDesignControls( const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). - // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). + // 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에. const rockCut = isRock(state.ground) && state.mode !== "both_fill"; const hasDitch = state.mode !== "both_fill"; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. @@ -417,9 +417,9 @@ export function buildDesignControls( !hasDitch, L("B06_Design_Disabled_NoDitch"), ); - // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성. + // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성 · 이름 글 없이 툴팁(179-2). const ditchTypeSeg = segment( - L("B06_Design_DitchType_Legend"), + "", DITCH_TYPE_OPTIONS, state.ditchType, (value) => { @@ -429,6 +429,7 @@ export function buildDesignControls( !rockCut || !ditchOn, !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), ); + ditchTypeSeg.title = L("B06_Design_DitchType_Legend"); // 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7). const twoStage = toggle( "", @@ -486,7 +487,8 @@ export function buildDesignControls( let overflow = bar.scrollWidth - clientWidth; let moveCount = 0; while (overflow > 1 && moveCount < moveable.length) { - overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; + const width = widths[moveable.length - 1 - moveCount]; // 숨긴 칸(179-2)은 폭 · 간격 0 + overflow -= width ? width + barGapPx : 0; moveCount += 1; } return moveCount; diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 6a233b179..3d7e62700 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -9,7 +9,7 @@ * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — - * 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임. + * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. * ========================================================================== */ @@ -30,14 +30,19 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +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; @@ -121,17 +126,32 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(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 +163,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 +187,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 +204,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; } @@ -246,11 +273,12 @@ 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, axes } = drawn; + const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); } showPicked(); }, 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..7c45b7aac 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, @@ -59,6 +60,8 @@ export interface BermSessionSpec { width_m: number; interval_m: number; slope_deg: number; + /** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */ + depth_m: number; } /** @@ -122,13 +125,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 +152,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; } 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.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2694593da..0effa7db6 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -532,13 +532,9 @@ white-space: nowrap; } -/* 선행 조건 미충족 옵션(E-6) — 상시 노출하되 흐리게·클릭 불가. */ +/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */ .b06-design__seg--disabled { - opacity: 0.4; -} - -.b06-design__seg--disabled .b06-design__btn { - cursor: not-allowed; + display: none; } .b06-design__seg-buttons { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index d12b689e4..f4fee5c15 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,7 +223,7 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ +/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ .b06-cross-card__line-dims { align-self: center; padding: 1px var(--spacing-4); @@ -234,30 +234,6 @@ white-space: pre-line; } -/* 좁은 카드(3열 등)는 막대 옆에 두면 왼쪽 면적표를 덮음 — 표 아래 높이로 내려 오른쪽에 붙임(151-1). */ -.b06-cross-card__chart-wrap { - container-type: inline-size; -} - -@container (max-width: 620px) { - .b06-cross-card__zoom { - overflow: visible; - } - - .b06-cross-card__line-dims { - position: absolute; - top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem)); - right: -1px; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); - } - - .b06-cross-card__line-dims + .b06-cross-card__zoom-btn { - border-left: none; - } -} - .b06-cross-card__line-dims[hidden] { display: none; } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index a50607922..d5218d7e2 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); diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 4cec19747..696c3f773 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -39,6 +39,8 @@ export interface CadDrawing { }[]; /** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */ stale_layout?: boolean; + /** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */ + stale_scale?: boolean; layers: { id: string; name: string; @@ -178,10 +180,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P ); } -/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */ +/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */ export interface CrossLayout { default: [number, number]; sheets: Record; + table_scale?: number; } export function fetchCrossLayout(projectId: string): Promise { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index f6813dc6f..2fd6e76a3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -19,7 +19,7 @@ from uuid import UUID, uuid5 from config.config_system import DRAWING_SCALE_CROSS -# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3). +# 레이어 정의 — 지표면선은 종단에서 잠금(N-1-3) · 횡단은 장에서 옮기므로 풂(180-1). GROUND_LAYER_ID = "b08-ground" GROUND_COLOR = "#f5f7fa" DESIGN_LAYER_ID = "b08-design" @@ -498,6 +498,7 @@ def build_cross_drawing( overlay: tuple[float, float, float, float] | None = None, border: bool = True, table_bottom: float | None = None, + table_scale: float = 1.0, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -507,6 +508,7 @@ def build_cross_drawing( 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. table_bottom(장 칸 · 174-1)을 주면 표 아래선을 그 y 에 두고 글 · 그림을 위로 쌓는다 — 표 · 글 가운데 x = 도로 중심(origin x) · origin y 는 쌓은 결과로 정함. + table_scale = 표 · 표 위 글 배율(180-2) — 8 mm 간격은 그대로. """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") @@ -546,7 +548,11 @@ def build_cross_drawing( # 아래에서 위로 — 표 → 글(150-3) → 간격 8 → 그림(174-1) stack = 0.0 if quantity_table is not None: - stack = cross_table_height() + cross_note_height(cross_note_lines(design)) + 8.0 + stack = ( + cross_table_height(table_scale) + + cross_note_height(cross_note_lines(design), table_scale) + + 8.0 + ) oy = table_bottom + stack + own_half_height * CROSS_MM def paper(point: tuple[float, float]) -> tuple[float, float]: @@ -576,18 +582,23 @@ def build_cross_drawing( table_top = oy - half_height - 8.0 # 표 위 글(150-3) — 줄 수만큼 표를 내림 note_lines = cross_note_lines(design) - entities.extend(_cross_note_entities(drawing_id, note_lines, table_top, center_x)) - table_top -= cross_note_height(note_lines) entities.extend( - _cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x) + _cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale) ) - table_bottom = table_top - cross_table_height() + 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 + ) + ) + table_bottom = table_top - cross_table_height(table_scale) # 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다. pad = CROSS_BORDER_PAD_MM # 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1) - frame_left = min(ox + x0 * CROSS_MM, center_x - cross_table_width() / 2.0) - pad - frame_right = max(ox + x1 * CROSS_MM, center_x + cross_table_width() / 2.0) + pad + table_half = cross_table_width(table_scale) / 2.0 + frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad + frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad frame_top = oy + half_height + pad frame_bottom = table_bottom - pad corners = [ @@ -622,7 +633,7 @@ def build_cross_drawing( } ], "layers": [ - _layer(GROUND_LAYER_ID, "원지반", locked=True), + _layer(GROUND_LAYER_ID, "원지반"), _layer(DESIGN_LAYER_ID, "계획선"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(ROCK_LAYER_ID, "암 경계선"), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 46818bbd6..b5bcda6e2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -9,9 +9,11 @@ import re from typing import Any +from uuid import uuid5 from B05_Profile.B05_Profile_Engine_Sections import cross_key from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _ENTITY_NS, CROSS_BORDER_PAD_MM, CROSS_MM, CROSS_TABLE_LAYER_ID, @@ -70,6 +72,7 @@ def section_block_size( with_table: bool = True, design: dict[str, Any] | None = None, overlay: tuple[float, float, float, float] | None = None, + table_scale: float = 1.0, ) -> tuple[float, float]: """단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다. @@ -91,13 +94,14 @@ def section_block_size( width = 2.0 * max( (x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM, - cross_table_width() / 2.0 + _BORDER_PAD_MM, + cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM, ) # 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유 top = half_height + _BORDER_PAD_MM below = half_height + _BORDER_PAD_MM if with_table: - below += 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height() + note_h = cross_note_height(cross_note_lines(design_spec), table_scale) + below += 8.0 + note_h + cross_table_height(table_scale) return (width, top + below) @@ -144,6 +148,8 @@ def plan_cross_sheets( "chainages": group, "grid": [cols, rows], "slots": grid_slots(len(group), cols, rows), + # 표 · 글 배율(180-2) — 모든 장 한 값 + "table_scale": float((layout or {}).get("table_scale") or 1.0), } ) start += len(group) @@ -205,12 +211,19 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> overlay=section.get("overlay"), border=False, table_bottom=cell[1] + CELL_TABLE_PAD_MM, + table_scale=float(sheet.get("table_scale") or 1.0), ) # 칸을 넘는 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2) frames = [p.get("frame") for p in placed.get("cross_placements") or []] left, _bottom, right, top = frames[0] if frames and frames[0] else cell if left < cell[0] - 1e-6 or right > cell[2] + 1e-6 or top > cell[3] + 1e-6: overflow.append(cell) + # 측점 한 벌 = groupId 하나 — 한 번 클릭에 그림 · 표 · 글 함께(180-1) + group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group")) + for entity in placed["entities"]: + entity["groupId"] = group_id + for placement in placed.get("cross_placements") or []: + placement["group_id"] = group_id entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) @@ -233,7 +246,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> # 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2) "overflow_cells": overflow, "layers": [ - _layer(GROUND_LAYER_ID, "원지반", locked=True), + _layer(GROUND_LAYER_ID, "원지반"), _layer(DESIGN_LAYER_ID, "계획선"), _layer(STRUCTURE_LAYER_ID, "구조물"), _layer(ROCK_LAYER_ID, "암 경계선"), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index b92b8f5c3..8c5af69fb 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -128,16 +128,17 @@ def _cross_table_entities( table_top: float, title_label: str, center_x: float = 0.0, + scale: float = 1.0, ) -> list[dict[str, Any]]: - """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다. + """횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2). 머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를 합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은 표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다. """ - width = _CROSS_TABLE_WIDTH - row_h = _CROSS_TABLE_ROW_HEIGHT - font = _CROSS_TABLE_FONT + 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) @@ -331,24 +332,24 @@ def cross_note_lines(design: dict[str, Any] | None) -> list[str]: return [line.rstrip() for line in note.strip().splitlines()] -def cross_note_height(lines: list[str]) -> float: - """표 위 글 높이(mm) — 줄 수 × 줄 높이.""" - return _CROSS_NOTE_LINE_HEIGHT * len(lines) +def cross_note_height(lines: list[str], scale: float = 1.0) -> float: + """표 위 글 높이(mm) — 줄 수 × 줄 높이 × 배율.""" + return _CROSS_NOTE_LINE_HEIGHT * scale * len(lines) def _cross_note_entities( - drawing_id: str, lines: list[str], top: float, center_x: float = 0.0 + drawing_id: str, lines: list[str], top: float, center_x: float = 0.0, scale: float = 1.0 ) -> list[dict[str, Any]]: """표 위 글 줄마다 Text — 표 왼쪽 끝 맞춤 · 위에서 아래로(id `{도면}:note:{n}`).""" - left = center_x - _CROSS_TABLE_WIDTH / 2.0 + left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0 return [ _text_entity( f"{drawing_id}:note:{index}", line, left, - top - _CROSS_NOTE_LINE_HEIGHT * (index + 0.5), + top - _CROSS_NOTE_LINE_HEIGHT * scale * (index + 0.5), CROSS_TABLE_LAYER_ID, - _CROSS_TABLE_FONT, + _CROSS_TABLE_FONT * scale, TABLE_LABEL_COLOR, align="left", ) @@ -357,11 +358,11 @@ def _cross_note_entities( ] -def cross_table_width() -> float: +def cross_table_width(scale: float = 1.0) -> float: """수량 산출표 가로 폭(mm).""" - return _CROSS_TABLE_WIDTH + return _CROSS_TABLE_WIDTH * scale -def cross_table_height() -> float: +def cross_table_height(scale: float = 1.0) -> float: """수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행.""" - return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2) + return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 36bc1b6f5..7ffc5ceb4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -551,10 +551,11 @@ async def invalidate_design_drawing( class CrossLayoutRequest(BaseModel): - """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3).""" + """횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2).""" default: list[int] = Field(default_factory=lambda: [4, 4]) sheets: dict[str, list[int]] = Field(default_factory=dict) + table_scale: float = 1.0 @router.get("/{project_id}/cross-layout") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b42a95f53..ac63b7fac 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -104,6 +104,8 @@ CROSS_SHEET_LAYOUT = 5 CROSS_LAYOUT_FILE = "cross_layout.json" CROSS_GRID_DEFAULT = [4, 4] _CROSS_GRID_MAX = 10 +# 표 · 표 위 글 배율(180-2) — 모든 장 한 값 · 기본 1 +CROSS_SCALE_RANGE = (0.5, 2.0) def _cross_grid(value: Any) -> list[int]: @@ -116,13 +118,22 @@ def _cross_grid(value: Any) -> list[int]: return [int(value[0]), int(value[1])] +def _cross_scale(value: Any) -> float: + low, high = CROSS_SCALE_RANGE + if value is None: + return 1.0 + if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high: + raise ValueError(f"표 배율은 {low}~{high} 사이로 적어 주세요.") + return round(float(value), 2) + + def read_cross_layout(project_root: Path) -> dict[str, Any]: - """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만.""" + """횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 · 배율 1 만.""" path = _design_root(project_root) / CROSS_LAYOUT_FILE try: return checked_cross_layout(_read_json(path)) except (OSError, ValueError, AttributeError): - return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}} + return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}, "table_scale": 1.0} def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: @@ -132,6 +143,7 @@ def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]: return { "default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT), "sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()}, + "table_scale": _cross_scale(layout.get("table_scale")), } @@ -449,6 +461,10 @@ def _read_drawing( label = str(manifest_entry.get("label") or drawing_id) if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 + elif kind == "cross" and drawing_id.startswith("cross_s"): + scale = read_cross_layout(project_root)["table_scale"] + if manifest_entry.get("table_scale", 1.0) != scale: + saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2) stored_table = manifest_entry.get("quantity_table") table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None return kind, label, saved, True, table @@ -594,6 +610,7 @@ def _store_confirmed_drawing( } if item.kind == "cross": entry["layout"] = CROSS_SHEET_LAYOUT + entry["table_scale"] = read_cross_layout(project_root)["table_scale"] if item.kind == "cross" and isinstance(quantity_table, dict): entry["quantity_table"] = quantity_table if isinstance(quantity_tables, dict) and quantity_tables: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index e5aeaa014..f0e55d6ba 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -32,6 +32,8 @@ export interface CrossPlacement { x1: number; /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ frame?: number[]; + /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ + group_id?: string; } /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ @@ -78,6 +80,7 @@ export async function appendStructureEntities( continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. } if (!entities.length) continue; + if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. replaced.push(frame); fresh.push(...entities); 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 c5dd63a11..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,15 +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({ default: grid, sheets: {} }), - ), + 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); }; /** @@ -335,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) { @@ -343,6 +360,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { "옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.", "info", ); + } else if (response.drawing.stale_scale) { + showToast( + "옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.", + "info", + ); } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 83bfd7725..0f214f7c5 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -175,14 +175,24 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); } +/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */ +const CROSS_SCALE_MIN = 0.5; +const CROSS_SCALE_MAX = 2; + +/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */ +export interface CrossLayoutExtras { + scale?: { value: number; onApply: (scale: number) => void }; +} + /** - * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」. + * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」. * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. */ export function buildCrossLayoutControls( grid: [number, number], onApply: ((grid: [number, number]) => void) | null, onAllDefault: (grid: [number, number]) => void, + extras: CrossLayoutExtras = {}, ): HTMLElement { const box = document.createElement("div"); box.className = "b07-cross-layout"; @@ -222,6 +232,31 @@ export function buildCrossLayoutControls( action("이 장", "이 장만 이 배치로", onApply), action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), ); + if (extras.scale) { + const { onApply: applyScale } = extras.scale; + const scale = createInputField({ + label: "표 배율", + type: "number", + value: String(extras.scale.value), + min: CROSS_SCALE_MIN, + max: CROSS_SCALE_MAX, + compact: true, + }); + scale.input.setAttribute("step", "0.1"); + const button = createButton({ + label: "배율 적용", + variant: "ghost", + size: "sm", + onClick: () => { + const value = Number(scale.input.value); + if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value); + else + showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error"); + }, + }); + button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; + box.append(scale.root, button); + } return box; } diff --git a/B07_DesignDetail/openwebcad/package-lock.json b/B07_DesignDetail/openwebcad/package-lock.json index d6c4ec55c..b4a1a1f01 100644 --- a/B07_DesignDetail/openwebcad/package-lock.json +++ b/B07_DesignDetail/openwebcad/package-lock.json @@ -49,6 +49,8 @@ "name": "forest-road-webapp", "version": "0.1.0", "dependencies": { + "@kninnug/constrainautor": "^4.1.0", + "delaunator": "^5.1.0", "maplibre-gl": "^5.24.0", "three": "^0.185.0" }, 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/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 4c7334dbd..d8e6626cb 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -57,6 +57,7 @@ def _qty(value: Any) -> float | None: def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]: """참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길: 승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 · + 사전조합 자재(175-2) = 손 수량(빈칸 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = 연료 거름 안 함) · 순환 · 깊이 넘음 · 없는 문서 = 빈 것.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -94,6 +95,10 @@ def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> continue hand = _qty((one.get("수량") or {}).get("손")) out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub] + for mat in doc.get("자재") or []: + if hand := _qty((mat.get("수량") or {}).get("손")): + line = {k: mat.get(k) for k in ("이름", "단위")} + out.append(({**line, "품목": {"키": mat.get("코드")}, "자재": True}, hand)) return out @@ -129,7 +134,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ def logic_mat(mat: dict, qty: float) -> None: name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") - if _fuel(name, unit): + if _fuel(name, unit) and not mat.get("자재"): # 사전조합 사용자 자재는 거르지 않음 return goods = mat.get("품목") code = str(goods.get("키") or "") if isinstance(goods, dict) else "" 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_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 8ecc2ff6f..05866a5e3 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -1,7 +1,7 @@ """B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - 거울 시험 `resources/tester/test_145_3_b09_bill.py`. + 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨). 실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔): 1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액) @@ -18,6 +18,9 @@ 단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 · logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) · 그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴). +레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 + 아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · + 묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로). 원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료). """ @@ -153,6 +156,62 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list return out +def _group_row(item: dict[str, Any]) -> dict[str, Any]: + """묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`).""" + return { + "kind": "group", + "key": item["key"], + "section": item["section"], + "name": item.get("name") or "", + "spec": item.get("spec") or "", + "unit": item.get("unit") or "", + "qty": None, + "note": "", + "ref": None, + "hand": True, + "sum": 0.0, + "labor": 0.0, + "material": 0.0, + "expense": 0.0, + "blank": 0, + } + + +def _nest(rows: list[dict[str, Any]]) -> None: + """182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액.""" + groups = {row["key"]: row for row in rows if row["kind"] == "group"} + for row in rows: + if not row.get("hand"): + continue + up = groups.get(row.get("parent") or "") + if up is None or up["section"] != row["section"]: + row["parent"] = None + continue + at, n = up, 0 + while at is not None and n <= len(groups): + if at["key"] == row["key"]: + row["parent"] = None + break + at, n = groups.get(at.get("parent") or ""), n + 1 + for row in rows: + chain = [] + at = groups.get(row.get("parent") or "") + while at is not None: + chain.append(at) + at = groups.get(at.get("parent") or "") + depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1 + row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain) + if row["kind"] != "item": + continue + for g in chain: + for p in ("sum", *PARTS): + g[p] += row[p] or 0 + if row["sum"] is None: + g["blank"] += 1 + for g in groups.values(): + g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else "" + + def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None: """손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝.""" at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None) @@ -213,8 +272,13 @@ def compute_bill( prices = None if price is None else {"labor": 0, "material": price, "expense": 0} items.append(_item_row(item, prices, gaps, qty_of)) for item, prices, why in hand_items(user, books, hopyo): - _place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "") - owner = [row for row in items if row["section"] == "G"] + if item.get("kind") == "group": + row = _group_row(item) + else: + row = _item_row({**item, "why": why}, prices, gaps) + row["parent"] = item.get("parent") + _place(items, row, item.get("after") or "") + owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"] net = 0.0 for row in owner: net += row["material"] or 0 @@ -248,6 +312,8 @@ def compute_bill( for code, name, parent in SECTIONS: sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0} for row in items: + if row["kind"] != "item": + continue code = row["section"] chain = [code] parent = next(p for c, _n, p in SECTIONS if c == code) @@ -276,6 +342,7 @@ def compute_bill( } ) rows.extend(row for row in items if row["section"] == code) + _nest(rows) direct = {p: 0.0 for p in ("sum", *PARTS)} blank = 0 for code in DIRECT: diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index c1065cf0b..3bc38f886 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -3,7 +3,7 @@ * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). * * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - * 거울 시험 `resources/tester/test_145_3_b09_bill.py`. + * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨). * * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 @@ -11,6 +11,9 @@ * 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) * 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) · * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) + * 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) · + * 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 · + * 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로) * ========================================================================== */ import { @@ -143,6 +146,62 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | return out; } +/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */ +function groupRow(item: Row): Row { + return { + kind: "group", + key: item.key, + section: item.section, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + qty: null, + note: "", + ref: null, + hand: true, + sum: 0, + labor: 0, + material: 0, + expense: 0, + blank: 0, + }; +} + +/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */ +function nest(rows: Row[]): void { + const groups = new Map(); + for (const row of rows) if (row.kind === "group") groups.set(row.key, row); + for (const row of rows) { + if (!row.hand) continue; + const up = groups.get(row.parent ?? ""); + if (!up || up.section !== row.section) { + row.parent = null; + continue; + } + let at: Row | undefined = up; + for (let n = 0; at && n <= groups.size; n += 1) { + if (at.key === row.key) { + row.parent = null; + break; + } + at = groups.get(at.parent ?? ""); + } + } + for (const row of rows) { + const chain: Row[] = []; + for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? "")) + chain.push(at); + const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1; + row.level = row.kind === "title" ? depth : depth + 1 + chain.length; + if (row.kind !== "item") continue; + for (const g of chain) { + for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0; + if (row.sum === null) g.blank += 1; + } + } + for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : ""; +} + /** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */ function place(items: Row[], hand: Row, after: string): void { let at = items.findIndex((row) => row.key === after); @@ -204,9 +263,12 @@ export function computeBill( const prices = price === null ? null : { labor: 0, material: price, expense: 0 }; items.push(itemRow(item, prices, gaps, qtyOf)); } - for (const [item, prices, why] of handItems(user, book, hopyo)) - place(items, itemRow({ ...item, why }, prices, gaps), item.after || ""); - const owner = items.filter((row) => row.section === "G"); + for (const [item, prices, why] of handItems(user, book, hopyo)) { + const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps); + row.parent = item.parent ?? null; + place(items, row, item.after || ""); + } + const owner = items.filter((row) => row.section === "G" && row.kind === "item"); let net = 0; for (const row of owner) net += row.material || 0; const pct = num((user.estimate || {}).procurement_fee_pct); @@ -238,6 +300,7 @@ export function computeBill( for (const [code] of SECTIONS) sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 }; for (const row of items) { + if (row.kind !== "item") continue; const code = row.section; const chain = [code]; const parent = SECTIONS.find(([c]) => c === code)![2]; @@ -264,6 +327,7 @@ export function computeBill( }); rows.push(...items.filter((row) => row.section === code)); } + nest(rows); const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 }; let blank = 0; for (const code of DIRECT) { diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index b6d73a401..cb49e0f09 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -4,7 +4,8 @@ (`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 - 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) + 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) · + 자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음) 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) @@ -206,7 +207,7 @@ class Builder: "spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: - """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표.""" + """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2).""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] @@ -226,6 +227,10 @@ class Builder: continue qty = 1 if hand is None else hand lines.append(self.line_of(whole, one, qty, times, name, copies, depth)) + lines += [ + _material(whole, mat, (mat.get("수량") or {}).get("손")) + for mat in doc.get("자재") or [] + ] return self.put( { "kind": "사전조합", @@ -262,11 +267,7 @@ class Builder: if line.get("qty") is None and line["kind"] != "blank": line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} lines.append(line) - for mat in result.get("자재") or []: - row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} - lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "", - "spec": row.get("규격") or "", "unit": row.get("단위") or "", - "qty": _f(mat.get("수량"))}) # fmt: skip + lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []] manifest = read_json(folder / MANIFEST_NAME) or {} origin = manifest.get("원본") or {} return self.put( @@ -282,6 +283,13 @@ class Builder: ) +def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: + """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" + row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} + names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))} + return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)} + + def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: """엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`.""" out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()} 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_Store.py b/B09_Estimation/B09_Estimation_Store.py index 858575c01..905be8362 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -109,7 +109,8 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: def clean_bill(raw: Any) -> dict[str, Any]: - """설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만).""" + """설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) · + 182 레벨 = `kind`(group) · `parent`(`hand:` 키).""" raw = raw if isinstance(raw, dict) else {} qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {} codes = {code for code, _name, _parent in SECTIONS} @@ -119,14 +120,18 @@ def clean_bill(raw: Any) -> dict[str, Any]: continue if row.get("section") not in codes: continue + group = row.get("kind") == "group" # 182 묶음 줄 = 이름만 + parent = str(row.get("parent") or "") rows.append( { "key": str(row["key"]), "section": row["section"], "after": _text(row.get("after")), + **({"kind": "group"} if group else {}), + **({"parent": parent} if parent.startswith("hand:") else {}), **{k: _text(row.get(k)) for k in ("name", "spec", "unit")}, - "qty": _num(row.get("qty")), - "ref": _clean_ref(row.get("ref")), + "qty": None if group else _num(row.get("qty")), + "ref": None if group else _clean_ref(row.get("ref")), } ) out: dict[str, Any] = {} 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 b08290070..8a81b93ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -20,11 +20,16 @@ .b09-estimation__tabs { display: flex; flex-wrap: wrap; - gap: var(--spacing-4); + gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */ padding-bottom: var(--spacing-8); border-bottom: 1px solid var(--color-border); } +/* 아래 탭 없는 위 탭 — 보이지 않는 단추 한 줄로 높이만 채움(탭 바꿀 때 몸통이 안 튐) */ +.b09-estimation__tabs--blank > .ui-btn { + visibility: hidden; +} + /* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */ [data-b09-table] thead th { white-space: normal; @@ -39,7 +44,65 @@ gap: var(--spacing-8); } +/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */ +.b09-estimation .ui-data-table__table tbody tr.is-lv1 td { + font-size: var(--text-body); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td { + font-size: var(--text-body-sm); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td { + font-size: 13px; +} + +.b09-estimation .ui-data-table__table tbody tr.is-group td { + font-weight: var(--font-weight-bold); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child { + padding-left: calc(var(--spacing-8) + 1em); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child { + padding-left: calc(var(--spacing-8) + 2em); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child { + padding-left: calc(var(--spacing-8) + 3em); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child { + padding-left: calc(var(--spacing-8) + 4em); +} + /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); } + +/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */ +.b09-estimation .ui-data-table__table tbody tr.is-pick td { + --pick-t: inset 0 0 0 0 transparent; + --pick-b: inset 0 0 0 0 transparent; + --pick-l: inset 0 0 0 0 transparent; + --pick-r: inset 0 0 0 0 transparent; + box-shadow: var(--pick-t), var(--pick-b), var(--pick-l), var(--pick-r); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-top td { + --pick-t: inset 0 2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-end td { + --pick-b: inset 0 -2px 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:first-child { + --pick-l: inset 2px 0 0 var(--color-danger); +} + +.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child { + --pick-r: inset -2px 0 0 var(--color-danger); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index a9497f0e8..39109d305 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -28,6 +28,7 @@ import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; +import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { buildCheck, @@ -150,15 +151,14 @@ export function renderB09Estimation(root: HTMLElement): void { let sheets: Row = {}; let current: TabId = "check"; - const tabBar = document.createElement("div"); - tabBar.className = "b09-estimation__tabs"; - tabBar.setAttribute("role", "tablist"); + // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` + const bars = createTabBars((tab) => show(tab)); const panel = document.createElement("div"); panel.className = "b09-estimation__panel"; panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); body.className = "b09-estimation"; - body.append(tabBar, panel); + body.append(bars.top, bars.sub, panel); const made = document.createElement("p"); made.className = "b09-estimation__hint"; @@ -181,20 +181,7 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; - tabBar.replaceChildren( - ...TABS.map(([id, label]) => { - const button = createButton({ - label, - variant: id === tab ? "filled" : "ghost", - size: "sm", - onClick: () => id !== current && show(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === tab)); - button.dataset.b09Tab = id; - return button; - }), - ); + bars.draw(tab); if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const build = BUILDERS[tab]; @@ -205,7 +192,8 @@ export function renderB09Estimation(root: HTMLElement): void { const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); }; - const makeTable = (): DataTable => { + // extra = 줄 class 곁값(181-2 표 보기 호표 구간) + const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ columns: spec.columns, rows: spec.rows, @@ -215,7 +203,9 @@ export function renderB09Estimation(root: HTMLElement): void { caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, onEdit: spec.onEdit, - rowClass: spec.rowClass, + rowClass: extra + ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null + : spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts new file mode 100644 index 000000000..c80caaf7e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -0,0 +1,71 @@ +/* ============================================================================= + * B09_Estimation_UI_TabBar.ts + * B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴. + * + * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움) + * 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭 + * 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface TabBars { + top: HTMLElement; + sub: HTMLElement; + /** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */ + draw: (tab: TabId) => void; +} + +function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) { + const button = createButton({ + label, + variant: on ? "filled" : "ghost", + ...(size ? { size } : {}), + onClick: () => !on && pick(), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(on)); + return button; +} + +export function createTabBars(onPick: (tab: TabId) => void): TabBars { + const top = document.createElement("div"); + top.className = "b09-estimation__tabs"; + top.setAttribute("role", "tablist"); + const sub = document.createElement("div"); + sub.className = "b09-estimation__tabs"; + const lastSeen = new Map(); + + const draw = (tab: TabId): void => { + const [, picked] = groupOf(tab); + lastSeen.set(picked[0], tab); + top.replaceChildren( + ...TAB_GROUPS.map(([label, tabs]) => { + const button = tabButton(label, tabs === picked, undefined, () => + onPick(lastSeen.get(tabs[0]) ?? tabs[0]), + ); + button.dataset.b09Group = tabs[0]; + if (tabs.length === 1) button.dataset.b09Tab = tabs[0]; + return button; + }), + ); + const single = picked.length === 1; + sub.classList.toggle("b09-estimation__tabs--blank", single); + sub.setAttribute("role", single ? "presentation" : "tablist"); + sub.setAttribute("aria-hidden", String(single)); + if (single) { + const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" }); + ghost.tabIndex = -1; + return void sub.replaceChildren(ghost); + } + sub.replaceChildren( + ...picked.map((id) => { + const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id)); + button.dataset.b09Tab = id; + return button; + }), + ); + }; + return { top, sub, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index e2c2ae4aa..1b07aa109 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,24 @@ export const TABS: Array<[TabId, string]> = [ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id; +/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ +export const TAB_GROUPS: Array<[string, TabId[]]> = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +]; + +/** 탭이 든 위 탭 묶음 */ +export const groupOf = (tab: TabId): [string, TabId[]] => + TAB_GROUPS.find(([, tabs]) => tabs.includes(tab)) ?? TAB_GROUPS[0]; + /** 다른 탭 줄 — 키가 같은 줄로 이동 */ export interface Link { tab: TabId; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 04670f20d..d81d42d73 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -2,11 +2,13 @@ * 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-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) + * · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ @@ -148,26 +150,24 @@ 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 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "applied" || 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; @@ -207,10 +207,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 +245,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.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index f7fa8dc8e..c9e2fce17 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -6,7 +6,9 @@ * 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) · * 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음) * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) - * 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`) + * 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`) + * 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침 + * 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -17,6 +19,7 @@ import { editQty, editText, handEditable, + openLinkMenu, type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; @@ -25,7 +28,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { const bill: Row = sheets.bill || { rows: [], totals: null }; const rows: DataRow[] = bill.rows.map((r: Row) => ({ ...r, - name: r.kind === "title" ? r.name : ` ${r.name}`, + connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -42,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), + text("connect", "연결", 96), text("note", "비고", 200), ], rows, @@ -49,10 +53,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { frozen: 1, hint: "공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " + - "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + + "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + + "레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, editable: (i, key) => { const r = rows[i]; + if (r?.kind === "group") return key === "name"; if (!r || r.kind !== "item") return false; return r.hand ? handEditable(key) : key === "qty"; }, @@ -71,9 +77,19 @@ export const buildBill: TabBuild = (ctx): BillTable => { if (key !== "qty" || !r || !("qty_auto" in r)) return null; return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`; }, - rowClass: (i) => (rows[i]?.hand ? "is-hand" : null), + // 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게 + rowClass: (i) => { + const r = rows[i]; + if (!r) return null; + const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; + return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" "); + }, menu: (i) => billMenu(ctx, rows, i), - link: (i) => { + onCell: (i, key) => { + if (key === "connect") openLinkMenu(ctx, rows, i); + }, + link: (i, key) => { + if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함) const ref = rows[i]?.ref as Row | null | undefined; return ref?.key ? { tab: ref.tab as TabId, key: String(ref.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 350b0185c..5f69f8ab6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -7,6 +7,8 @@ * rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref} * 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) * 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용 + * 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 · + * 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -29,6 +31,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuIte const NEW_NAME = "새 줄"; const TEXT_KEYS = ["name", "spec", "unit"]; +/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */ +const MAX_GROUP = 2; interface Bill { qty: Record; @@ -69,35 +73,138 @@ export function editText(ctx: TabContext, key: string, field: string, value: str put(ctx, bill); } -/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */ -function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } { +const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0); + +/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */ +function subtreeEnd(rows: DataRow[], i: number): number { + let k = i + 1; + while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1; + return k; +} + +/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */ +function reparent(h: Row, parent: unknown): void { + if (parent) h.parent = parent; + else delete h.parent; +} + +/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */ +function fits(bill: Bill): boolean { + const byKey = new Map(bill.rows.map((h) => [String(h.key), h])); + return bill.rows.every((h) => { + let n = 0; + const seen = new Set([h.key]); + let at = byKey.get(String(h.parent ?? "")); + while (at && at.kind === "group" && !seen.has(at.key)) { + seen.add(at.key); + n += 1; + at = byKey.get(String(at.parent ?? "")); + } + return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP); + }); +} + +/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */ +function slot(rows: DataRow[], i: number, below: boolean): Row { const row = rows[i]; const section = String(row.section); if (row.kind === "title") return { section, after: `sec:${section}` }; - if (below) return { section, after: String(row.key) }; + const parent = row.hand && row.parent ? { parent: String(row.parent) } : {}; + if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent }; const prev = rows[i - 1]; const same = prev && prev.kind !== "title" && prev.section === section; - return { section, after: same ? String(prev.key) : `sec:${section}` }; + return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent }; } -function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { +function newRow(ctx: TabContext, at: Row): void { const bill = billOf(ctx.user); const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; - const at = slot(rows, i, below); bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null }); put(ctx, bill); } -/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */ +function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { + newRow(ctx, slot(rows, i, below)); +} + +/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */ +function addChild(ctx: TabContext, rows: DataRow[], i: number): void { + const after = String(rows[subtreeEnd(rows, i) - 1].key); + newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) }); +} + +/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */ function removeRow(ctx: TabContext, key: string): void { const bill = billOf(ctx.user); const gone = bill.rows.find((h) => h.key === key); if (!gone) return; bill.rows = bill.rows.filter((h) => h.key !== key); for (const h of bill.rows) if (h.after === key) h.after = gone.after; + for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent); put(ctx, bill); } +/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */ +function levelAction( + ctx: TabContext, + key: string, + change: (own: Row, bill: Bill) => void, +): (() => void) | null { + const bill = billOf(ctx.user); + const own = bill.rows.find((h) => h.key === key); + if (!own) return null; + change(own, bill); + return fits(bill) ? () => put(ctx, bill) : null; +} + +/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */ +function indentTarget(rows: DataRow[], i: number): DataRow | null { + const row = rows[i]; + for (let j = i - 1; j >= 0; j -= 1) { + const r = rows[j]; + if (lv(r) > lv(row)) continue; + return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null; + } + return null; +} + +/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */ +function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { + const row = rows[i]; + const key = String(row.key); + const out: MapContextMenuItem[] = []; + const add = (label: string, action: (() => void) | null): void => { + if (action) out.push([label, action]); + }; + if (row.kind === "group") + add( + "항목으로 바꾸기", + levelAction(ctx, key, (own, bill) => { + delete own.kind; + for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent); + }), + ); + else + add( + "묶음 줄로 바꾸기", + levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })), + ); + const target = indentTarget(rows, i); + if (target) + add( + "들여쓰기 →", + levelAction(ctx, key, (own) => reparent(own, String(target.key))), + ); + if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row)) + add( + "내어쓰기 ←", + levelAction(ctx, key, (own, bill) => + reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent), + ), + ); + return out; +} + /** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */ function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void { const bill = billOf(ctx.user); @@ -182,7 +289,24 @@ function pickLogic(ctx: TabContext, key: string): void { openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름)); } -/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */ +type Leaf = [string, () => void]; + +/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */ +function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] { + const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null; + const more: Leaf[] = []; + if (ref?.tab === "logic") + more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); + if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]); + const picks: Leaf[] = [ + ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], + ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], + ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], + ]; + return [picks, more]; +} + +/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { const row = rows[i]; if (!row) return []; @@ -191,19 +315,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex const items: MapContextMenuItem[] = []; if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]); items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]); + if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]); if (row.hand) { - const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null; - items.push("-", { - label: "연결", - children: [ - ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], - ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], - ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], - ], - }); - if (ref?.tab === "logic") - items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); - if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]); + if (row.kind !== "group") { + const [picks, more] = linkItems(ctx, key); + items.push("-", { label: "연결", children: picks }, ...more); + } + items.push("-", ...levelMenu(ctx, rows, i)); items.push("-", ["줄 지우기", () => removeRow(ctx, key)]); } else if (row.kind === "item" && key in bill.qty) { items.push("-", [ @@ -218,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex } let shared: MapContextMenu | null = null; +/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ +let lastCell: Element | null = null; + +/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */ +export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void { + const row = rows[i]; + const box = lastCell?.getBoundingClientRect(); + if (!shared || !box || !row?.hand || row.kind !== "item") return; + const [picks, more] = linkItems(ctx, String(row.key)); + shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); +} /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( @@ -230,6 +359,12 @@ export function attachRowMenu( ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) document.body.append(ctxMenu.element); } + // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 + root.addEventListener( + "click", + (event) => (lastCell = (event.target as Element).closest("td")), + true, + ); root.addEventListener("contextmenu", (event) => { const target = event.target as Element; if (target.closest("input")) return; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 957d62c6d..8b523847e 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로. + * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) + * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 * ========================================================================== */ @@ -29,8 +30,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); /** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => - view === "cards" ? "표로 보기" : "호표별 보기"; +export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); @@ -146,6 +146,22 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] { return cards; } +/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */ +export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null { + if (focus === undefined) return null; + const head = focus.split("|")[0]; + const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : [])); + return at.length ? [at[0], at[at.length - 1]] : null; +} + +/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */ +export function pickClass(range: [number, number] | null, i: number): string | null { + if (!range || i < range[0] || i > range[1]) return null; + return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"] + .filter(Boolean) + .join(" "); +} + /** 표 열과 같은 숫자 꼴(열 소수 자리) */ function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string { const c = spec.columns.find((col) => col.key === key); @@ -168,7 +184,7 @@ export function renderGroupPanel(o: { hint: HTMLElement | null; focus?: string; goto: (link: Link | null) => void; - makeTable: () => DataTable; + makeTable: (rowClass?: (index: number) => string | null) => DataTable; redraw: () => void; }): HTMLElement[] { const view = chosen.get(o.tab) ?? readGroupView(o.tab); @@ -201,7 +217,8 @@ export function renderGroupPanel(o: { cards.dataset.b09Table = o.tab; return [bar, cards]; } - const table = o.makeTable(); + const range = pickRange(o.spec.rows, o.focus); + const table = o.makeTable((i) => pickClass(range, i)); if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); return [bar, table.root]; } diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 30d53ed22..717791ba8 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -162,10 +162,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values() ] + materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} if mine := own.bundle( - own.codes(doc), root - ): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로 + [c for c in materials if own.is_own(c)], root + ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 files[PRICE_FILE][own.BUNDLE] = mine return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 0692a0245..890c7155a 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -607,8 +607,13 @@ def server(doc: Any) -> dict[str, Any]: 사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈).""" got = clean(doc) out = calc(got) + no_twice(got, out) + return {**got, "계산값": out} + + +def no_twice(got: dict, out: dict) -> None: + """사용자 자재가 로직 자재 품목과 같으면 400 — 일위대가 · 사전조합(175-1) 저장 한 벌.""" inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")} twice = sorted({m["코드"] for m in got["자재"]} & inside) if twice: raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음") - return {**got, "계산값": out} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 126a968a0..bc06b9ac0 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -6,8 +6,10 @@ 층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + `승격`(`unpromote`) · 프로젝트 사본은 그대로 셈 precombo `{층}/precombo/SC-######.json` 사전조합 `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모} - | {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) · - 수량은 손(기본 1) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + | {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` — + 승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · + 자재 수량은 손 · 빈칸 = 안 셈(175) · + 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손 참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) · 위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) · 같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭. @@ -142,6 +144,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: if not name: raise _bad("사전조합 이름이 빔") out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None return out @@ -206,7 +209,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} if "로직" in one: head["입력값"] = one["입력값"] - head.update({k: one[k] for k in ("켜쌓기율",) if k in one}) + head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -219,6 +222,18 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: return lines +def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]: + """사전조합 자재(175-1) — 일위대가 자재와 같은 검사(`labor.clean` · 개인 자재 · 세 칸) · + 로직 자재와 같은 품목 400(`labor.no_twice`).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + mats = labor.clean({"자재": doc.get("자재") or []})["자재"] + if mats: + got = {"담은로직": lines, "자재": mats} + labor.no_twice(got, labor.calc(got, {})) + return mats + + def _no_loop(ref: Ref, trail: list[Ref]) -> None: """저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」.""" if len(trail) > DEPTH: @@ -446,6 +461,29 @@ class Expand: why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}" continue rows.append({k: v * qty for k, v in sub["기준"].items()}) + for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 + qty = labor._number((mat.get("수량") or {}).get("손")) + if qty is None: + continue + got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) + price = labor._number(got["기준단가"]) + zero = dict.fromkeys(labor.COSTS, Decimal(0)) + base = None if price is None else {**zero, "재료비": price, "계": price} + name = mat.get("이름") or mat["코드"] + inner.append( + { + "자재": mat["코드"], + "수량": qty, + "이름": name, + "단위": mat.get("단위"), + "기준": base, + "까닭": got["까닭"], + } + ) + if base is None: + why = why or f"「{name}」 {got['까닭']}" + continue + rows.append({k: v * qty for k, v in base.items()}) return { "이름": doc.get("이름"), "단위": doc.get("단위"), @@ -498,6 +536,16 @@ def with_pills(doc: dict[str, Any]) -> dict[str, Any]: return {**doc, "알약": combo_store.combo_pills({"담은로직": lines})["물량"]} +def frozen_materials(copies: dict[str, Any]) -> list[str]: + """굳힌 사전조합의 자재 · 고른 로직 자재 코드(175-2) — 프로젝트 단가 묶음에 넣음.""" + out = [] + for doc in copies.values(): + out += [str(m.get("코드")) for m in doc.get("자재") or [] if isinstance(m, dict)] + lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + out += [str(key) for one in lines for key in (one.get("고름") or {}).values()] + return out + + def frozen_logics(copies: dict[str, Any]) -> list[str]: """굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음.""" return [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 2024f610a..529f6bba4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -40,12 +40,13 @@ export interface CalcLine { /** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */ 재료?: LogicMat[]; /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · - * 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */ + * 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */ 이름?: string | null; 원로직?: string | null; 안?: { 참조?: LaborRef; 로직?: string; + 자재?: string; 수량: number; 이름?: string | null; 단위?: string | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index a37a05411..71a5e79ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -10,7 +10,7 @@ * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 - * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · + * · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1) * · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음) * ========================================================================== */ @@ -228,13 +228,13 @@ export function mountLaborDetail( cur.담은로직, calc ?? last, !calc && !fail, - // 세 칸(148-4) = 구조물 일위대가만(사전조합 줄은 `_combo_lines` 가 안 남김) + // 세 칸(148-4) — 사전조합도 같음(175-1 · `_combo_lines` 가 남김) edit ? { open: openRow, swap: swapMat, reset: resetMat, - mark: lib ? undefined : markLogicMat, + mark: markLogicMat, } : { open: openRow }, ), @@ -641,7 +641,7 @@ export function mountLaborDetail( ]), // 사전조합 = 같은 [로직 추가](51-11) section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), - ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), + section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), ...(opt.card || lib ? [] : [dgBox]), ], }), @@ -668,12 +668,11 @@ export function mountLaborDetail( }); return { - // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 + // 서고 모드 = 문서 종류 그대로 · 열 없음 getDoc: () => { if (!lib) return cur; const out: Record = { ...cur, 종류: "사전조합" }; delete out.열; - delete out.자재; return out as unknown as LaborDoc; }, saveArea: async () => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 0380f2469..4b67f4686 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -267,14 +267,14 @@ export interface LogicTableData { } /** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 · - * 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) · + * 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 자재 줄 175-2 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) · * 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) · * 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] { const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1); const items = line?.안 ? line.안.map((x) => { - const key = x.참조?.키 ?? x.로직 ?? ""; + const key = x.참조?.키 ?? x.로직 ?? x.자재 ?? ""; const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만 return { name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`, 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/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md new file mode 100644 index 000000000..5d5e37883 --- /dev/null +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -0,0 +1,45 @@ +# 사용자 요구 10-10 둘째 묶음 (원문 · 번호만 붙임) + +지시: 신규 계획서 작업 및 코드 작업. + +## M02 일위대가 사전조합 +1. 구조물조합의 일위대가와 동일하게 자재목록도 있어야함. 공통양식이니 코드 재활용 + +## B05 3D +2. 성토의 스무딩처리가 깨지는 경우가 더 심해짐 +3. 흑백지형을 기본값을 false로 +4. 등고선도 기본값을 false로 +5. 예전로직으로 3D에 측점의 노란색 선 끝단에 주황색 구슬을 선택하여 측구의 방향을 선택하는 기능이 있었음. 3D에서 구슬 삭제 + +## B05/06 +6. 좌측패널의 구조물 조합 연결여부 색상선택은 반영은 잘되었고 연결이 안된 항목은 경고성으로 노란색 계열로 반영 +7. 오류 확인및 원인 파악(흙막이휴 횡단도 형상 반영 문제) +8. 동물이동형도 체크박스에서 드롭다운 메뉴로 변경 (항목값. 유 / 무) +9. 구조물 리스트를 선택하는 기능 노면공 선택을 누르면 구조물선택은 되나 박스형상을 잡도록 변경하고 횡단도 컨테이너 선택을 안해도됨. +10. 횡단도 컨테이너 선택시 종단에 측점 세로선 하이라이트 연동이 안됨. 하이라이트 기능 제대로 확인할 것. +11. 사면안정군의 종류들이 기준측점 상단의 범위를 알려주는데 필요없음 +12. 소단은 옵션값 중 높이는 필요없음. 형상필요. 성토사면에 도로에서 높이 1m를 기준으로 성토사면에 수평선을 긋고 하단부에 성토를 이으면 됨. 이때 소단의 Y값을 위치를 제어할수 있어야하면 도로보다 높을 수 없음 +13. 사면안정군의 옹벽과 흙막이의 경우 단이 존재할수 있음. 기존 유출 컨테이너를 보면 양식 있음. 재활용 + +## B06 횡단도 +14. 횡단도에서 선의 길이를 알려주는 기능에서 정보창이 줌인아웃 버튼의 좌측에 배치 +15. 횡단도의 측구여부와 좌우 방향을 선택하는 버튼의 비활성화 여부 확인(확인만 코드 요청아님) +16. 횡단도 내부의 측구관련 정보들이 많은데 조금 컴팩트하게 표현할수 있는 방법이 없는지 제안 및 반영 +17. 횡단도의 상세 모달창에서 팬동작으로 그래프 전체가 움직임. 축은 고정하고 내부 내용이 동작해야함. 그리고 하단 테이블의 글자를 더 큰글자로 변경, 항목이름의 셀은 색상 다른게 +18. 횡단도의 상세 모달창에서 각 항목의 열이나 행을 추가하는 기능을 부여하고 각 셀에대한 특정항목을 값을 그래프에서 선택할 수 있는 기능을 부여. 선택은 면적과 길이가 될것같고 이때 마우스 우클릭을 하여 값 재 지정을 할수 있게 해줘. + +## B07 상세설계 +19. 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘. 추가로 레이어(도면층을 활용하여 해당도면에 측점을 한번에 잡을 수 있게 반영) +20. 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼을 횡단도 컨테이너 내부에 존재 +21. 횡단도의 테이블을 현재값을 1로 두고 테이블과 텍스트의 크기를 스케일 조절할 수 있게 반영, 기본값 1 + +## B09 원가검토 +22. 일위대가표와 단가산출근거에서 구현한 호표별 보기는 이름을 박스보기로 변경하고 접었다 펼 필요는 없음(접으면 목록표와 동일함으로). 그리고 목록표에서 행을 선택할때는 해당 호표를 외곽선을 붉은 색 하이라이트로 구현해줘. 테이블 보기도 역시 해당 호표의 제목이 아니라 영역이 하이라이트 되어야함. +23. 설계내역서에 행을 추가하는 항목이 완료되었는데 이방식은 레벨구분이 없음. 레벨을 구분하고 생성할수 있도록 들여쓰기와 폰트로 구분 사이즈로 구분하여 표기하였으면함. 중요한점은 각 행에대해 레벨이 구분되어야하면 레벨에 따라 상위로 숫자들도 합계되는 구조임. 여기에 단가산출과 일위대가를 연결할 수 있어야함. 마우스 우클릭이과 버튼으로 +24. 재료비목록표는 연결되어 있는 일위대가에 대한 내용이 들어와야하는데 지금 어떤값이 들어왔는지 이해가 안됨. +25. 자재단가대비표에 들어오는 견적에는 면이 필요없으며, 다른 항목의 면은 출처로 변경(값은 유지), 견적서는 견적 하위로 이동 +26. 자재단가대비표에 적용 열은 사용자가 수기입력이 아니라 적용단가 셀을 선택하면 드롭다운 메뉴로 항목들을 보여주고 선택을 하면 그값을 보여주면 됨. 이때 적용된 값은 하이라이트 표시, 기본값은 최저단가로 반영 +27. 관급에 대한 현재 구현 여부를 조사만해서 알려줘. 별도의 아티팩트로 보여주면 됨 +28. 지제단가대비표에는 수량열이 빠지는게 맞음. +29. 원가검토 페이지의 상위탭과 하위탭의 구성을 하려고 함. 양식은 B08의 페이지 구성을 보면됨. +30. 원가검토 페이지의 탭구성, 일위대가(일위대가목록표, 일위대가표), 단가산출(단가산출근거목록표, 단가산출근거), 중기(중기목록표, 중기사용료, 중기시간금액집계표, 경비목록표, 경비수량금액집계표), 재료(재료비목록표, 자재단가목록표, 재료비수량금액집계표), 노무(노무비목록표, 노무비수량금액집계표)<<<지정안된 항목들은 상위탭임. diff --git a/docs/raw/요구_1010_2/조사_T_패널_M02.md b/docs/raw/요구_1010_2/조사_T_패널_M02.md new file mode 100644 index 000000000..124f2b864 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_T_패널_M02.md @@ -0,0 +1,91 @@ +# 조사 T — M02 사전조합 자재목록 · B05/B06 패널(연결 라벨 노랑 · 동물이동형 드롭다운 · 노면공 박스 · 측점선 연동 · 사면안정 범위) (10-10 · 읽기 전용 조사 에이전트) + +원문 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 1 · 6 · 8 · 9 · 10 · 11번. + +| 약칭 | 파일 | +|---|---| +| LD · ROWS · LM · MP | `M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts` · `…_Labor_Rows.ts` · `…_Labor_LogicMats.ts` · `M02_Material_Pick.ts` | +| LR · LDOC · LRT · ISRC | `M02_MasterTemplete/M02_Labor_Ref.py` · `M02_Labor_Doc.py` · `M02_Labor_Router.py` · `M02_Item_Source.py` | +| B8M · B9L | `B08_Quantity/B08_Quantity_Router_Material.py` · `B09_Estimation/B09_Estimation_Labor_Source.py` | +| SR · SS · TH | `B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts` · `B05_Profile/B05_Profile_UI_Style_Structures.css` · `ui_template/ui_template_theme.css` | +| PIPE · SPC · SF · DEF | `A00_Common/design/design_structure_pipe.ts` · `…/design_structure_spec.ts` · `…/design_structure_form.ts` · `resources/master_template/구조물정의.json` | +| SV · SVT · SVP · SPN | `B06_Section/B06_Section_UI_Section_View.ts` · `…_Section_View_Types.ts` · `…_Section_View_Profile.ts` · `…_UI_Page_Structures_Panel.ts` | +| DVC · DP · PST · CSX | `A00_Common/design/design_view_cross.ts` · `A00_Common/design/design_profile.ts` · `B05_Profile/B05_Profile_UI_Profile_Structures.ts` · `B06_Section/B06_Section_UI_Style_Cross.css` | +| FORM · PNL5 · PT · MARKS | `B05_Profile/B05_Profile_UI_Structures_Form.ts` · `…_Structures_Panel.ts` · `…_Structures_Panel_Types.ts` · `…_Structures_Marks.ts` | +| T | `resources/tester/` | + +## 0. 코드 지도 + +| 요구 | 손댈 층 | 계산 자리(5장) | 저장 | +|---|---|---|---| +| T1 사전조합 자재목록 | LD(문 셋 지움) · LR · B8M · B9L · ISRC | 화면 = 서버 calc 답 그대로 · 사전조합 1 단위 값 · B08 · B09 펼침 = ③ 서버 단독 | 사전조합 문서 `{층}/precombo/SC-*.json` 에 `자재` · 줄 `고름` · `자재표시` 새로 남김 | +| T6 미연결 라벨 노랑 | SS 한 규칙 | 화면만 | 없음 | +| T8 동물이동형 드롭다운 | PIPE 한 칸 · SPC · SF 지우기 | 저장만(읽는 곳 없음) | `pipe_points.json` `options.inlet_basin_animal` 키 · 값("true"/"false") 그대로 | +| T9 노면공 박스 | SV · SPN · SVT · DVC 한 줄 | 화면만 | 세션 고름만(카드 고름 안 적음) | +| T10 카드 → 종단 측점선 | DP 두 줄(줄 수 그대로) · SVP 두 곳 | 화면만 | 없음 | +| T11 사면안정 범위 | PNL5 한 줄(줄 수 그대로) | 화면만 | 없음 | + +## T1. M02 일위대가 사전조합 — 구조물 조합 일위대가와 같은 자재목록 +- 현재: 사전조합 편집 = 일위대가 상세와 **같은 부품** `mountLaborDetail` 의 서고 모드(MAIN:416-420 · `mode: "precombo"`). 자재목록(로직 자재 줄 LM + 사용자 자재 줄 + [자재 추가] MP + 세 칸 자재구분 · 별도계상 · 운반비 168장)은 이미 한 벌(`matTable` ROWS)인데 서고 모드에서만 막음: LD:644(`lib ? [] : [section(Labor_Mats…)]`) · LD:237(로직 자재 세 칸 `mark: lib ? undefined`) · LD:671-677(`getDoc` 이 `자재` 지움). 셈 길은 이미 같음 — 사전조합도 `/labor/{SC키}/calc`(LRT:41-55 · `labor.clean` → `labor.calc` 가 `자재` 를 셈 LDOC:439-481). +- 서버가 버리는 자리: 저장 `clean_doc` → `_combo_lines`(LR:134-147 · 199-219)가 줄에서 `참조 · 로직 · 입력값 · 켜쌓기율 · 수량 · 메모` 만 남김 → `고름`(로직 자재 [바꾸기]) · `자재표시`(세 칸) · 문서 `자재` 가 저장 때 사라짐. +- 값이 안 닿는 자리(자재를 담아도 아무 데도 안 셈): ① 사전조합 1 단위 값 `Expand._combo`(LR:420-455 · 안 줄 기준 × 손 수량만 · 구조물 일위대가의 참조 줄 값 · 목록 기준금액이 이것) ② B08 참조 줄 재료 `_ref_mats`(B8M:57-96 · 160-3) ③ B09 겹 대가 블록 `precombo`(B9L:208-239 · 자재 줄 없음 · 조합 블록은 B9L:265-268 이 자재 줄 냄) ④ 프로젝트 사본 단가 묶음(ISRC:157-163 · 구조물 `자재` · `고름` 코드만 모음 · 굳힌 참조사본 안 사전조합 자재는 안 모음 → 사본 셈 단가 없음). +- 고칠 자리: + - 화면(LD · 지우기만): 644 의 `lib ?` 조건 · 237 의 `lib ? undefined :` · 676 `delete out.자재`. [자재 추가] · 수량 모달(서고는 손만 LD:329) · 같은 품목 막음은 그대로 탐. + - 저장(LR:199-219): 줄에 `고름` · `자재표시` 남김(`labor.clean` 이 이미 검사) · `clean_doc` 에 `자재 = labor.clean({자재}).자재`(개인 `PM` 검사 · 로직 자재 같은 품목 400 = LDOC:605-613 `server` 길 재사용). + - 값(③ 서버 단독 · 파이썬만): LR `_combo` 끝에 사용자 자재 행 = `labor.material_price(self.whole, 코드, 단위)` × 손 수량 → `{재료비, 계}`(LDOC:399-403 `settle` 꼴) · B8M `_ref_mats` 에 문서 `자재` 를 `(재료 줄, 손)` 으로 더함(`logic_mat` 이 코드로 이름 · 규격 맞춤) · B9L `precombo` 줄에 `kind: "material"`(B9L:265-268 꼴) · ISRC:160-163 에 굳힌 사전조합 `자재` · `고름` 코드 더함. +- 재사용: 자재 표 · 세 칸 · 고르기 = 구조물 일위대가 그대로(LD · ROWS · LM · MK · MP) · 자재 단가 `material_price` · 참조사본 굳힘 `frozen`(LR:465-480 · 문서째 굳으니 `자재` 도 따라감). +- 시험: T test_m02_labor_mat_marks:128(`mark: lib ? undefined` 단정 고침) · test_m02_card_shell:85-86(무변 확인) · test_m02_labor_ref(사전조합 저장 꼴 · 값 = 로직 + 자재) · test_160_3_b08_unmatched_total(참조 줄 재료 + 사전조합 자재) · test_145_2_b09_labor_calc(겹 대가 자재 줄) · test_m02_precombo_class · 새 시험(저장 왕복 `자재` · `고름` 남음 · `_combo` 재료비 = 단가 × 손 · 빈 수량 안 셈). +- 위험: 구조물 일위대가의 자재목록은 참조 줄(사전조합) 안 자재를 지금도 안 보임(로직 자재 줄 = 로직 줄만 · 이번 밖 · 기록) · 사본 단가 묶음에 코드가 빠지면 B09 단가 빈칸 · LD 692줄(지우기만 · 늘리지 말 것). +- 크기: 중간~큼(화면 작음 · 서버 네 자리). +- ⬜ 범위: (가) 화면 · 저장 · 사전조합 값 · B08 · B09 · 사본 묶음 한 번에 / (나) 화면 · 저장 · M02 값까지(B08 · B09 · 묶음은 다음 장) — 추천 (가)(담은 자재가 아래에서 조용히 빠지지 않게). +- ⬜ 사전조합 사용자 자재 수량 기본: (가) 빈칸 = 안 셈(일위대가 자재와 같음) / (나) 손 1(사전조합 로직 줄 51-6 과 같음) — 추천 (가). + +## T6. 구조물 조합 라벨 — 미연결 = 노란 경고 배경 +- 현재: 라벨 = SR 한 벌(B05 폼 FORM:149 · 옵션 그룹 `B05_Profile_UI_Structures_Groups.ts`:190 · 명세 폼 SF:272 — M02 는 안 씀). 연결 = `is-assigned`(SR:58) → SS:450-454 초록 배경 + 테두리(173-1). 미연결 = 클래스 없음 · 「미지정」 글 · 공용 입력 칸 바탕. +- 토큰: `--color-warning` 있음(TH:39 밝음 `#b8860b` · TH:322 어두움 `#e0b64a`). 같은 꼴 선례 `A00_Common/spreadsheet/spreadsheet.css`:206(`color-mix(warning 15%, surface-raised)`). +- 고칠 자리: SS:454 뒤 한 규칙 `.b05-structure__summary-name:not(.is-assigned)` = `border-color: var(--color-warning)` · `background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface))`(173-1 과 대칭 · 글자색 없음). TS 안 고침. +- 시험: T test_173_1_summary_assigned_bg 에 함수 하나(미연결 규칙 · warning · surface · color 없음) · test_b05_structures_form_summary(무변). +- 위험: 숨은 줄(`[hidden]` SS:440)은 안 보임 · 하단 목록 줄 미지정 표시는 요구 밖. +- 크기: 작음. + +## T8. 집수정 「동물이동형」 체크박스 → 드롭다운(유 / 무) +- 현재: 칸 = PIPE:73-80(`kind: "bool"` · `default: "true"` · 집수정 갈래 키 `inlet_basin_animal`) · 그리기 = SF:147-153(공용 체크박스 · `.value` = "true"/"false") · 꼴 정의 SPC:35-36 · 체크박스 import SF:44 · 폼 키 목록 MRG:31. `bool` 꼴을 쓰는 칸은 이것 하나 · 읽는 곳 없음(저장만). +- 고칠 자리: PIPE:77 `kind: "select"` + `choices: [{ value: "true", label: "유" }, { value: "false", label: "무" }]`(default "true" 그대로). 그러면 SF:147-153 · SF:44 · SPC:35-36 의 `bool` 은 죽은 갈래 → 지움. DEF 이름 표에 `inlet.animal=true: 유` · `inlet.animal=false: 무`(DEF:3335 옆 · M02 이름 표 `nameTable` SPC:249 와 맞춤 · 없어도 보기 글은 명세 글). +- 빈 값 = 유: `inlet` 은 필수 상자 → 빈 고르기 칸은 그릴 때 기본값을 **실제 값으로 적음**(SF:224-232 · 54-43) → 옛 관 · 자동 관도 「유」로 보이고 다음 [저장]에 "true" 가 적힘. 173-7 「빈 값 저장 안 함」만 바뀜(무해 · 읽는 곳 없음). 흙막이로 바꾸면 갈래 밖 → 비움(SF:206-212 · MRG 키 목록 그대로). +- 재사용: 명세 select · `by` 갈래 · 필수 상자 기본 채움(그대로). +- 시험: T test_173_7_inlet_animal + helper_173_7_inlet_animal.cjs 고침(select · 빈 값 = "true" 적힘 · 무 = "false" · 흙막이 칸 없음) · test_52_structure_spec_pipe · test_b05_facility_options_merge(무변). +- 위험: 없음(서버 · B07 · B08 안 읽음 · `bool` 지워도 M02 `OptionForm` "bool"(`M02_SO_Def_Model.ts`:9)은 다른 꼴 · 무관). +- 크기: 작음. +- ⬜ 저장 값: (가) 키 · 값 그대로("true"/"false" · 보기 글만 유/무 · 이주 없음) / (나) 값을 "유"/"무" 로 — 추천 (가). + +## T9. 목록 · 알약에서 노면공 고름 = 박스 형상 · 카드 하나 고르기 없음 +- 지금 흐름(목록 누름): 공용 폼 `onSelect` → SPN:210-224 → `focusChainage(focusOf)`(SPN:220 · 범위 안 기준점에 가까운 카드 하나를 **고름** = `--selected` 진한 빨강 테두리 · 세션 · 측점 리스너) → `markStructure(id)` → SV `selectMark`(SV:577-584) → `applySpan`(SV:254-262). +- 「박스 형상」 판정: 다른 구간형(옹벽 · 흙막이 · 사토장) 고름 = 범위 안 카드 **전부** `b06-cross-card--span` 테두리(SV:260 · CSX:231-234 옅은 빨강 2px · 106-1). 노면공(G군)만 152-5 에서 테두리를 빼고 포장층 강조만(`span && !pave` SV:260 · `highlightPavement` `B06_Section_UI_Cross_Design_Surface.ts`:70-86). → 「박스」 = 이 범위 카드 테두리. +- 고칠 자리: ① SV:260 `span && !pave` → `span`(포장층 강조 SV:261 은 둠) ② SPN:220 — 노면공(`!coverable(structure)` SPN:163)이면 카드를 고르지 않고 보이게만: SV 핸들에 `revealSpan(chainageM)` 하나(옛 고른 카드는 리스너 없이 풂 = `selectedStationId = null` · `markCard` · `profile.showCard(null)` · 그 카드로 `revealCard`(SV:208)) · SVT:44 옆 한 줄 · DVC:189-191 옆 deps 한 줄 · SPN deps 한 줄. 리스너(`setStationSelectListener` DVC:381)를 거치면 `showAtChainage(null)` 이 폼을 닫음 → 꼭 리스너 없이. +- 그대로 둠: 범위 안 카드 누름 = 노면공 유지(165-1 · SPN:551-560) · 재누름 유지(167-3 SV:288-298) · 알약 누름(SVP:204-212 → `selectStructure`)도 같은 `onSelect` 길. +- 시험: T test_106_span_highlight_wording:34(`span && !pave` 단정 고침) · test_165_pavement_flow(step1 · step5 카드 40 → 없음 · test_배선 `focusChainage` 단정) + helper_165 · test_152_5_pavement_pick(무변 확인) · 새 시험(노면공 고름 = `--span` 범위 카드 수 · `--selected` 0 · 폼 · 알약 유지). +- 위험: SV 614줄(+ ~12) · DVC 680줄(+1 · 늘리지 말 것 — 가능하면 SPN 이 이미 받는 deps 만) · 카드 고름이 없으니 [상세] · 조정창 등 카드 대상 동작은 카드를 눌러야 함. +- 크기: 중간. +- ⬜ (가) 노면공 = 범위 카드 전부 테두리 + 포장층 강조 · 카드 하나 고르기 없음(첫 카드로 스크롤만) / (나) 테두리만 더하고 가까운 카드 고름은 그대로 — 추천 (가). + +## T10. 횡단 카드 고름 → 종단 측점 세로선 하이라이트가 안 따라옴 +- 연동 길: 카드 누름 → SV `selectStation`(SV:288) → `applySelection`(SV:279-286) → `profile.showCard`(SVP:270-285 → `setSelectedStation(station_id)` DP:576-581 · 측점선 켬) → 그 **뒤** 측점 리스너(DVC:381-405) → `sectionView.selectMark(mark, false)`(DVC:398) → SVP `selectMark`(SVP:266-269) → `profile.setSelectedStructure(mark)`(DP:626-631). +- 원인(코드 판정): DP:629-630 `keep = 구조물 ≠ null && 그 측점 구조물 === 구조물` · 아니면 `selectedStationId = stationIdAtStructure(구조물)`. 일반 카드는 mark = null → 측점선 = null(**방금 켠 선을 지움**) · 덮는 구조물(벽 · 노면공) 카드도 측점선 없는 구조물(PST:173-184 · 관만 선이 있음) → null. 152-5(4ae7ee25c)가 DVC:398 의 `if (mark)` 를 빼면서 모든 일반 카드에서 지움 · SPN:214(`markStructure(null)`)와 SVP:283(`setSelectedStructure(markedId)`)도 같은 함수로 지움. 관 카드만 mark = `pipe-…` → 관 측점선이라 맞음. 반대 길(종단 측점 누름 → 카드)도 같은 리스너를 타서 누른 선이 곧 꺼짐. 측점 키 · 반올림은 무관(카드 · 종단 같은 `station_id` · 관 맞춤 0.51 m SVP:276-279). +- 고칠 자리(모든 B06 길이 지나는 한 곳): DP:626 `setSelectedStructure(structureId, keepStation = false)` · DP:630 `selectedStationId = keep ? selectedStationId : (stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null))`(줄 수 그대로 · 698줄) · SVP:268 · SVP:283 에 `true`(B06 = 카드가 측점선 주인). B05(`design_view_profile.ts`:132 · 331)는 기본값 → 지금 그대로. +- 재사용: 판단을 순수 함수로 PST(184줄)에 빼면(`stationAfterPick(지금, 구조물, keep, 선)`) Node 시험 가능 · DP 는 부름만. +- 시험: 새 시험(일반 카드 → 측점선 유지 · 벽 카드 유지 · 관 카드 = 관 선 · B05 목록 비관 구조물 = 선 끔 그대로) · test_165_pavement_flow · test_b05_b06_selection_single(무변 확인). +- 위험: DP 698줄 — 줄 늘리지 말 것 · 목록 해제는 `releaseCard` → `showCard(null)` 이 선을 끔(유지 확인). +- 크기: 작음. + +## T11. 사면안정군 「기준측점 위 범위」 — 필요 없음 +- 코드 판정: 「기준 측점 위 범위」 = 좌측 폼 **「측점 범위」 줄**(FORM:98-108 · 위치 줄 첫 칸 = 시작 · **기준 측점** · 종료 칸 위). 켜는 조건 `hasComputedRange`(PT:242-252 · 구간형 + B05 `length_m` 옵션) → PNL5:156-169 가 `시작 ~ 종료` 를 계산해 보임. 해당 종류(DEF 기준): C 사면안정 6(retaining_wall · masonry_wet · masonry_dry · soil_guard · boulder_masonry · berm) + B ditch_berm + E refuge · work_yard · guardrail. B05 · B06 같은 폼 한 벌. +- 종단도 확인: 사면안정 전용 범위 그림 없음 — 범위 띠는 알약 레인(MARKS:171-186 · **모든** 구간형 B · C · E · F · G · 09-07 사용자 지시로 늘 보임 · 기준측점 알약 줄 가운데)뿐 → 이번에 안 건드림 추천. +- 고칠 자리: PNL5:159 `const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";`(줄 수 그대로 · 699줄). `hasComputedRange` 자체는 시작 · 종료 칸 숨김에도 쓰므로 안 고침. +- 시험: 새 소스 시험(PNL5 `rangeWrap.hidden` 조건에 C군 거름) · test_b05_structures_registry_policy · test_54_6_slope_container(무변). +- 위험: C군은 폼에서 범위를 볼 곳이 없어짐(하단 목록 측점 · 알약 띠 · 벌룬엔 남음) · PNL5 699줄 — 한 줄도 늘리지 말 것. +- 크기: 작음. +- ⬜ 지울 자리: (가) 좌측 폼 「측점 범위」 줄 — C군만 / (나) 그 줄 전부(B 소단배수로 · E 대피소 · 작업장 · 가드레일도) / (다) 종단 알약 레인 띠 중 C군 — 추천 (가). + +## 고칠 파일 · 겹침 +- 700 위험: LD 692(T1 지우기만) · DP 698(T10 줄 수 그대로) · PNL5 699(T11 줄 수 그대로) · DVC 680(T9 +1) · SV 614(T9 + ~12) · SS 628(T6 +5). +- 겹침: T9 · T10 = SV · SVP · DVC 같은 고름 길(한 사람이 차례로 · T10 먼저 — T9 의 `showCard(null)` 이 T10 유지 규칙에 기댐) · T6 · T8 = 좌측 패널 같은 폼(파일 다름) · T1 은 M02 · B08 · B09 서버(다른 창과 안 겹침). diff --git a/docs/raw/요구_1010_2/조사_U_B05_3D.md b/docs/raw/요구_1010_2/조사_U_B05_3D.md new file mode 100644 index 000000000..62e6fabbc --- /dev/null +++ b/docs/raw/요구_1010_2/조사_U_B05_3D.md @@ -0,0 +1,72 @@ +# 조사 U — B05 3D(성토 깨짐 · 흑백 · 등고선 기본값 · 측구 방향 구슬) (요구 1010_2 2~5번 · 읽기 전용 조사 에이전트) + +약칭 `B05_Profile/B05_Profile_` + LO `UI_Corridor_Loft.ts` · LT `UI_Corridor_Loft_Toe.ts` · TIN `UI_Corridor_Tin.ts` · SH `UI_Corridor_Loft_Shade.ts` · VC `UI_Viewer_Corridor.ts` · VW `UI_Viewer.ts` · PV `UI_Panel_View.ts` · PF `UI_Panel_Fields.ts` · PN `UI_Panel.ts` · MK `UI_Markers.ts` · PS `UI_Page_Stations.ts` · PH `UI_Page_Helpers.ts` · PA `UI_Page_Actions.ts` · AF `Api_Fetch.ts` · RL `Router_Lifecycle.py` · RC `Router_Confirm.py` · SC `Schema.py` / DVP `A00_Common/design/design_view_profile.ts` · DS `A00_Common/design/design_save.ts` · CD `B06_Section/B06_Section_UI_Cross_Design.ts` · CC `B06_Section/B06_Section_UI_Cross_Card_Chrome.ts` · B6C `B06_Section/B06_Section_Router_Confirm.py` · T `resources/tester/` + +## 0. 코드 지도 + +| 요구 | 손댈 층 | 계산 자리(5장) | 저장 | +|---|---|---|---| +| U2 성토 깨짐 | TIN 한 상수 + LO 덩이 끝 줄이기 ~10줄 | 브라우저 단독 3D 표시(VC:199 → LO) · 서버 Node(`B05_Profile_Corridor_Node.ts`)는 노면 리본만 · 사면 망은 저장 안 함 → 짝 · 스냅샷 없음 | 없음 | +| U3 흑백 기본 false | PV:71 · VW:390 | 화면만 | 없음(세션 · 사용자 설정 키 없음) | +| U4 등고선 기본 false | PV:65 · VW:184-185 | 화면만 | 없음 | +| U5 구슬 삭제 | MK + DVP 처리기(⬜ 세션 · 서버 사슬) | 화면만 | 세션 `uphill` → 확정 `uphill_overrides`(⬜ 남김 / 지움) | + +## U2. 성토 스무딩 깨짐 — 172 뒤 심해짐 +- 어떻게 쟀나: 조사 P 입력 그대로(226 · d22 · `aislo_3d_probe/input_*.json` · storage `dtm_classification_smooth_preview.glb` · 읽기만) · 지금 TS 와 `cea89e945~1` TS(git show → 메모리)를 Node 로 그대로 돌리고 안은 메모리에서만 끼움 · 스크래치 `probe_u.cjs` · 저장소 변경 0. +- 172 가 바꾼 것(cea89e945): + - LO:341-355 성토 행 드레이프 보정량을 노선 따라 가우스 σ 3 m(LT:45-69 · `only` 행만 셈) · 측점 행 되돌림(LT:73-99 `pinBack`) · 원래와 차 ±0.5 m 한도 = `toeOff`. + - LO:425-427 비탈 끝 점 높이 = 지형 + toeOff(전 = 지형 그대로). + - LO:376 측점 앞 0.2 m 덧행 없앰 · LT smoothToe 는 같은 동작으로 함수만 꺼냄. +- 원인(판정): **비탈 끝이 지형에서 뜨거나 잠겨 바깥 지형과 잇는 띠가 가파른 턱 · 홈이 됨** — 성토 면 안쪽 주름은 줄었으나 비탈 끝 가장자리가 깨짐. + - 성토 비탈 끝 둘레 점 |높이 − 지형| 중앙 0 → 0.26 · p90 0 → 0.5 m · 성토 행 25 / 22 % 가 한도 0.5 m 에 걸림. + - 잇는 삼각형 = 설계 구역 밖 · 지형 색 · 면 법선(TIN:287-311 · SH:40-48) · 비탈 끝에서 가장 가까운 지형 정점(DTM 1.08 m 격자 · 0~1 m 거리)까지 바로 이음 — 지형 정점은 둘레 1 mm 안 것만 뺌(TIN:42 · 92) → 0.5 m 턱이 0.5 m 안팎 거리 = 45° 넘는 벽 · 홈. + - 성토 ↔ 절토 바뀜 — 성토 덩이 끝 행 toeOff 14 / 14 곳 다 0.5 m(한도) · 이웃 절토 비탈 끝은 지형 그대로 → 둘레가 0.2~0.4 m 사이에서 0.5 m 뜀(톱니). + - 성토 면 정점 중 지형보다 0.2 m 넘게 낮은 것 3,792 → 5,662 / 3,442 → 4,649(비탈 끝 홈). + - 아닌 것: 덧행 없앰 — 되살려도 가장자리 수치 그대로 · 면 안쪽은 오히려 나빠짐(10° 넘음 90 → 106 / 88 → 103) · 제약 실패 3 / 2 그대로 · 삼각형 뒤집힘 없음(평면 들로네 · 감기 맞춤 TIN:292-295) · 구멍 없음. +- 측정(per 100 m · 226 / d22): + +| 안 | 잇는 띠 60° 넘음 | 성토 ↔ 잇는 띠 꺾임 45° 넘음 | 면 안쪽 10° 넘음 | 둘레 성토↔절토 뜀 0.2 m 넘음(곳) | +|---|---|---|---|---| +| 172 전 | 17 / 10 | 18 / 18 | 149 / 132 | 0 / 0 | +| 지금(172) | **76 / 67** | **51 / 42** | 90 / 88 | 11 / 14 | +| ㉮ 되돌림(git revert) | 17 / 10 | 18 / 18 | 149 / 132 | 0 / 0 | +| ㉯ 한도 0.25 m | 48 / 43 | 40 / 35 | 107 / 96 | 11 / 14 | +| ㉰ 비탈 끝 = 지형 · 면만 고름 | 17 / 10 | 32 / 31 | 139 / 137 | 0 / 0 | +| ㉱ 잇는 띠 넓힘 1.5 m | 11 / 10 | 12 / 14 | 90 / 88 | 11 / 14 | +| **㉱ + 덩이 끝 3 m 줄이기** | **11 / 9** | **11 / 13** | **93 / 89** | **0 / 0** | + + - ㉰ = 턱이 성토 면 마지막 칸으로 옮겨 갈 뿐(둘레 꺾임 20° 넘음 35 → 47 / 30 → 43). + - ㉱ 2 m 는 1.5 m 와 비슷(60° 넘음 10 / 9) · 1 m 는 14 / 9 · 계산 약 1.0 s 그대로 · 제약 실패 3 → 2 / 2. +- 고칠 자리: + - ㉱ TIN:92 지형 정점 「안」 판정 거리 `ON_BOUNDARY_M`(1 mm) → 새 상수 `SEAM_M` 1.5 m(정점 판정만 · 변 판정 TIN:150 은 그대로) → 비탈 끝 1.5 m 안 지형 정점을 빼서 잇는 삼각형이 1.5~2.5 m 로 길어짐(0.5 m 턱 ≈ 15~18°). + - 덩이 끝 줄이기 LO:351-355 뒤 — 성토 아닌 행에서 누가거리 3 m 안 toeOff 를 smoothstep 으로 0 까지 줄이고 liftOf 도 같은 만큼 되돌림(~10 줄 · LO 665 → 약 675 줄). + - 덤(선택): LT:73-99 pinBack 이 고정 행(측점)을 성토 덩이 넘어 다른 덩이 측점까지 이음 → 짧은 덩이(20 m · 측점 1 개) 한도 걸림 59 % · 74 % · 덩이 끝 줄이기로 대부분 덮임. +- 시험: T test_172_1 · test_115_10 · test_135_3 · test_b05_corridor_*(그대로) · test_115_8 `lostVerts == 0` — T helper_115_8_corridor_loft.cjs:277 이 1 mm 기준 → SEAM_M 로 같이 고침(1.5 m 안 빠짐은 의도) · `npm run typecheck` · 새 시험(물결 지형 성토 · 비탈 끝 아래 사인 진폭 1 m): 잇는 삼각형 경사 p99 < 45° · 덩이 끝 toeOff = 0 · 틈 0 · 제약 실패 0. +- 위험: 비탈 끝 바깥 1.5 m 안 지형 굴곡이 긴 삼각형으로 펴짐(절토 둘레 · 구조물 구멍 둘레도 같은 규칙 · 절토 잇는 띠 45° 넘음 91 → 79 로 오히려 고름) · 사진 색은 버린 삼각형에서 보간(TIN:263-274)이라 그 띠 색이 흐려짐 · 3D 참고용이라 수량 무관. +- 크기: 작음(TIN 2 줄 + LO ~10 줄 + 시험 도우미 1 줄). +- ⬜ 고칠 방향: (가) ㉱ + 덩이 끝 줄이기(172 주름 줄임 유지 · 가장자리는 172 전보다 고름) / (나) ㉮ 되돌림(가장자리 예전 · 주름 다시 181 per 100 m) / (다) ㉯ 한도 0.25 m(한 줄 · 반만 나음) — 추천 (가). +- ⬜ 띠 넓이: 1.5 m / 2 m — 추천 1.5 m. + +## U3 · U4. 흑백 지형 · 등고선 기본값 false +- 현재: 단추 처음 상태 = PV:65 `toggleButton("등고선", true, …)` · PV:71 `toggleButton("흑백 지형", true, …)`. `toggleButton`(PF:117-135)은 처음 상태를 칠하기만 하고 콜백을 안 부름 → 뷰어 처음 값이 따로: 흑백 VW:390 `let surfaceGrayscale = true` · 등고선 VW:184-185 그룹(visible 설정 없음 = 보임). 콜백 연결 DVP:262 · 266. +- 덮는 저장: 없음 — 두 단추 상태는 sessionStorage · localStorage · 사용자 설정에 안 적음(검색 0) · 다시 들어오면 늘 코드 기본값. +- 고칠 자리: PV:65 · 71 `true → false` + VW:390 `false` + VW:185 뒤 `contours.visible = false` — 두 쪽 같이(한쪽만이면 단추는 꺼짐인데 화면은 켜짐). 주석 PV:69-70 · VW:389(「기본 켜짐 08-23 확정」) 고침 · PN:45 주석(「기본 꺼짐」)은 이번에 맞게 됨. +- 이어지는 것: 흑백 끔 = 지형 무지개 고도색 · 예상형상 청록 · 자주는 keepColor 라 그대로(VW:411) · 등고선은 적재 때 그대로 만들고(VW:483) 숨기기만. +- 시험: T test_135_2_grayscale(기본값 단정 없음 → 그대로) · 새 소스 시험(PV 두 단추 false · VW 처음 값 false · contours.visible = false). +- 위험: 08-23 사용자 확정(흑백 기본 = 예상형상 색이 고도색에 안 묻힘)을 뒤집음 — 이번 요구가 명시라 따름. +- 크기: 아주 작음(4 줄 + 주석). + +## U5. 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) 삭제 +- 현재: + - 그리기 MK:467-491 — 측점마다 띠 좌 · 우 끝에 구(반지름 0.55 · 상단측 주황 0xf97316 · 반대 회색 MK:57-59) + 투명 맞힘 구(1.1). 띠 = 반폭 × 1.5(MK:427). + - 클릭 MK:556-563 — `selectObject` 가 `uphillStationId` 를 측점 선택보다 먼저 봄 → DVP:359-377 그 측을 세션 `uphill` 키에 적고(PH:238-268) 띠 다시 그림(PS:63-64 = 구 색). + - 저장 — B05 [저장]·[확정] PA:69-73 `uphill_overrides` · B06 저장 DS:20 · 102 → AF:269-280 → RL:69-90 → RC:43-73 종단 정본 `uphill_side`(source user) + RC:75-170 저장 횡단 설계 `section_mode`(편절 left_cut ↔ right_cut) · `ditch_side` 다시 계산. +- B06 에서 되나: **반만** — B06 좌 · 우 단추(CD:396-405)는 양절 · 양성에서만 켜짐(CD:352 `needsDitch` · 편절은 꺼짐 + 「양측 절토·양측 성토만」 안내) · 단면 유형 pill 은 읽기 전용(CC:107-111). → 편절 측점의 절토 · 측구 쪽 뒤집기는 지금 3D 구슬이 유일한 길. 양절 · 양성은 B06 단추로 되고 B6C:369-385 가 정본 `uphill_side` 로 되돌려 적음. +- 고칠 자리(최소): MK:467-491 구 · 맞힘 구 · MK:556-563 분기 · MK:127 · 613-615 `onUphillPick` · MK:57-59 색 · MK:35-36 `uphill_side` 칸 · 주석 MK:426 · 459-460 · 530 / DVP:359-377 처리기 · PS:20-21 · 63-64(구 색 전용). +- 함께 죽는 것(⬜): 세션 `uphill` 사슬 — DVP:208-212 · 392 · 531-532 · 540 · PH:238-268 · PA:33-35 · 69-73 · AF:250-251 · 269-280 · DS:20 · 102 / 서버 SC:257-260 · 280-281 · RL:69-90 · RC:75-170(`sync_uphill_overrides_into_designs`). + - 남김: RC:43-73 `_merge_uphill_overrides_into_longitudinal`(B6C:369-385 가 씀) · `uphill_side` 데이터(구조물 유입측 `_UI_Corridor_Station_Structure.ts`:258 · `_UI_Corridor_Structures_Box.ts`:114 · B06 기본 단면 유형 CD:335). +- 시험: 최소안 = 새 소스 시험(MK 에 측점 구 · `uphillStationId` 없음 · DVP 에 onUphillPick 없음) + test_design_store_save · test_b06_save_channels · test_b05_b06_save_roundtrip 그대로 통과(사슬 남김). 사슬까지 지우면 test_design_store_save.py:51 · 61 · 72-73 · test_b06_save_channels.py:27 · 73 · 84 · 100 · test_b05_b06_save_roundtrip.py:373 · 420-424 고침. +- 위험: 편절 방향을 사용자가 바꿀 길이 없어짐(자동 판정 = 지형 높은 쪽 `B05_Profile_Engine_Sections_Core.py`:443-448 만) · 지난 세션에 남은 `uphill` 값은 최소안이면 다음 저장 때 한 번 더 나감(무해). +- 크기: 최소안 작음(MK 약 −35 줄 · DVP −19 줄 · PS −3 줄) · 사슬까지 중간(파일 10 개 · 시험 3 개). +- ⬜ 지우는 범위: (가) 3D 구슬 · 클릭 · 처리기만(세션 · 서버 사슬은 빈 채로 둠) / (나) 사슬까지 다 지움 — 추천 (가)(편절 뒤집기 자리를 정한 뒤 (나)). +- ⬜ 편절 방향 뒤집기 자리: (가) 없앰(자동 판정만) / (나) B06 좌 · 우 단추를 편절에도 켜서 `section_mode` 를 바꿈(CD:353-366 emit 이 mode 를 같이 보냄 · 서버 재계산은 기존 [저장] 길) — 추천: 사용자 확인(요구 15 단추 확인과 묶음). diff --git a/docs/raw/요구_1010_2/조사_V_횡단형상.md b/docs/raw/요구_1010_2/조사_V_횡단형상.md new file mode 100644 index 000000000..9fb47f5e2 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_V_횡단형상.md @@ -0,0 +1,105 @@ +# 조사 V — B05/B06 횡단 형상(7 흙막이 형상 오류 · 12 소단 형상 · 13 옹벽 · 흙막이 단) (10-10 · 읽기 전용 조사 에이전트) + +| 약칭 | 파일 | 약칭 | 파일 | +|---|---|---|---| +| WAL | `common_util/common_util_structure_walls.ts` | WPY | `B06_Section/B06_Section_Engine_Structures_Wall.py`(WAL 짝) | +| STO | `B06_Section/B06_Section_Section_Store.ts` | REV | `B06_Section/B06_Section_UI_Cross_Revetment.ts` | +| EXT | `B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts` | CV | `B06_Section/B06_Section_UI_Cross_View.ts` | +| LAY | `B06_Section/B06_Section_Structure_Layouts.ts` | WIRE | `B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts` | +| COR | `B05_Profile/B05_Profile_UI_Corridor_Structures.ts` | NODE | `config/server_calc_node/B06_Section_Server_Calc_Node.js`(번들) | +| SCH | `B05_Profile/B05_Profile_Structures_Schema.py` | ESEC | `B05_Profile/B05_Profile_Engine_Sections.py` | +| BRM | `common_util/common_util_cross_berm.ts` | BPY | `common_util/common_util_cross_berm.py`(BRM 짝) | +| GEO | `common_util/common_util_cross_design_geometry.ts` | GPY | `B06_Section/B06_Section_Engine_Design_Geometry.py`(GEO 짝) | +| CD | `common_util/common_util_cross_design.ts` | ED | `B06_Section/B06_Section_Engine_Design.py`(CD 짝) | +| RD | `B06_Section/B06_Section_Router_Design.py` | PER | `B06_Section/B06_Section_UI_Page_Persist.ts` | +| REF | `B06_Section/B06_Section_Cross_Refresh.ts` | DEF | `resources/master_template/구조물정의.json` | +| GRD | `A00_Common/design/design_structure_guard.ts` | PIPE | `A00_Common/design/design_structure_pipe.ts` | +| SPF | `B05_Profile/B05_Profile_UI_Structures_Spec.ts` | FRM | `B05_Profile/B05_Profile_UI_Structures_Form.ts` | +| DRF | `B05_Profile/B05_Profile_UI_Drainage_Facility.ts` | RCH | `B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts` | +| PPO | `B06_Section/B06_Section_UI_Page_Pipe_Options.ts` | T | `resources/tester/` | + +## 0. 코드 지도 + +| 요구 | 손댈 층 | 계산 자리(5장) | 저장 | +|---|---|---|---| +| V7 흙막이 형상 오류 | ① 측점 심기 규칙 SCH ② 벽 가드 한 벌 LAY · CV · COR ④ 옹벽 형태 키 WAL ↔ WPY | ① 서버 단독 ② TS 를 서버 Node 로(NODE 다시 빌드) ④ 짝 | 바뀜 없음(측점 파일은 재생성 때 늘어남) | +| V12 소단 형상 | DEF · BRM ↔ BPY · GEO ↔ GPY · 키 나르기(PER · REF · CD ↔ ED · RD · 타입 2) | ① 짝(있음 · 거울 시험 새로) | structures.json `options.depth_m`(새 키 · 추천) · 측점 결과 = 횡단 설계 patch `design.berm` | +| V13 옹벽 · 흙막이 단 | GRD · PIPE(명세) · WAL ↔ WPY · REV · DEF 그룹 | 제원 = 짝 · 형상 = TS 를 서버 Node 로 | structures.json `options.soil_add_{n}_form · _height` | + +## V7. 흙막이(흙막이류) 횡단도 형상 반영 오류 +- 원문 「흙막이휴」 = 「흙막이류」 오타로 봄(자판 ㄹ · ㅎ 이웃 · 1009 요구의 「흙막이류」 = 흙막이 계열) — 추정. +- 현재: 사면안정 흙막이 = `soil_guard`(C · 구간형 · DEF:1548) · 옹벽 = `retaining_wall`(DEF:986). **두 종류가 같은 길 하나** — 서버 얹기 WPY:46-121(`B06_Section_Router.py`:286 · 449) · 브라우저 초안 얹기 STO:63-100 → WAL:74-134 → `section.revetment` → REV `computeRevetmentLayout`:157-323 → 카드 CV:461-500 · 서버 면적 LAY:126-127(NODE) · 3D COR:280-282 · B07 `B07_DesignDetail_Cross_Overlay.ts`:484-506. 종류 키로 갈리는 곳 = 형태 기본(WAL:59-65 · WPY:33-39) · 옹벽 물빼기(REV:273)뿐. +- 재현(시스템 임시 폴더 · 읽기만): 픽스처 `T fixtures/b06_73_4_body_guard_sections.json` 관 없는 4측점(160 · 180 · 340 · 360)에 흙막이 기본값(돌쌓기(찰) · H1.5) · 옹벽 기본값(H2)을 얹고 NODE 실행 → 두 종류 성토 면적 같음(0.49 · 1.54 · 2.14 · 2.03 ㎡ · 표준 1.45 · 5.32 · 9.68 · 6.04). 다단 3 · H2 도 같은 트림. T test_b06_structure_walls_mirror · test_b06_wall_shape_unified · test_54_26_slope_table09 7 통과. +- ⇒ 「흙막이만 안 됨」을 만드는 정의 이름 · 종류 키 · 그리기 분기 · patch 누락 **없음**. storage 검증 프로젝트엔 흙막이 · 옹벽 · 소단을 놓은 자료가 없어 사용자 화면 그대로는 재현 못 함. +- 원인(코드로 확인된 「안 그려짐 · 틀림」 갈래 · 종류 무관): + - ① **C군 벽은 측점을 안 심음** — SCH:153-155(A군 · D군 구간형만) · ESEC:297-307 · 시험이 못 박음(T test_b05_extra_stations_supplier:133-157). 기본 길이 10 m(전 5 · 후 5)가 20 m 격자(`config/config_system_design.py`:247) 사이에 놓이면 얹힐 횡단 0장 → 종단엔 보이고 횡단 · 3D 엔 없음. D군 기슭막이는 시작 · 기준 · 종료 셋을 심음(ESEC:283-288). **가장 유력.** + - ② **카드와 다른 곳의 가드가 다름** — 카드 = 「연동 관 벽이 이 측점에 실제로 설 때만」 건너뜀(CV:468-469 · 09-09 고침 WIRE:244-263) / `computeStoredLayouts` = 연동 링크만 있어도 건너뜀(LAY:126-127) → 서버 면적([저장] · NODE) · B07 횡단 그림 · 배관 단 수 셈에서 벽 빠짐 / 3D = 관 측점만 봄(COR:280-282 → 연동 관 벽과 겹쳐 그림). 예: 유입 집수정 관의 집수정 범위 안 측점(WIRE:240 — 집수정은 링크 카드에 안 섬). + - ③ 설치 측 자동 + 양측 절토면 안 그림(REV:172-179) · 높이 빈칸이면 안 그림(REV:164-166) · 설치 측 칸 숨김(DEF:3541 · 3559) → 늘 성토 사면(노견 아래) 모양만 · 절토 쪽 흙막이를 세울 길 없음. + - ④ (잠복) 옹벽 형태 칸 `wall_form`(DEF:1002)을 그림이 안 읽음 — `form`(형식 · 중력식 …)이 있으면 돌 무늬(WAL:93 · `B06_Section_UI_Cross_Wall_Hatch.ts`:36-44). 형식 칸 숨김(DEF:3558)이라 지금은 안 드러남. +- 고칠 자리: + - ① SCH:153-155 에 C군 구간형 더함 → `resolve_extra_stations` 가 시작 · 기준 · 종료 심음(ESEC 그대로 · 라벨 = 정의 이름). 측점은 저장 · 재생성 때 섬. + - ② LAY 에 가드 한 벌(관 측점 또는 `culvertWallsStandAt`) → LAY:126-127 · CV:468-469 · COR:280-282 셋이 같이 씀 · NODE 다시 빌드(`B06_Section/B06_Section_Server_Calc_Prebuild.py`). + - ④ WAL:93 · WPY:73 — 옹벽은 `wall_form` 먼저(짝 둘 · 거울 시험 한 줄). +- 재사용: 기슭막이 측점 규칙(ESEC:283-288) · 카드 가드 `culvertWallsStandAt`(WIRE:253). +- 시험: T test_b05_extra_stations_supplier:133 고침(C 구간형 셋 심음 · E · G 그대로) · test_regeneration_extra_stations · 새 시험(NODE — 링크는 있으나 관 벽이 안 서는 측점에서 벽 면적 행 나옴 · 관 벽이 서는 측점은 안 나옴) · test_b06_structure_walls_mirror(`wall_form`) · test_b06_structure_areas.mjs. +- 위험: ① 카드 · 파일 수 늘어남 · 기존 프로젝트는 재생성해야 섬 · 재이관 차단 라벨(SCH:158-160)도 같이 늘어남(같은 함수 · 의도대로) · 소단 측점도 섬 / ② CV 690 · COR 675줄 — 바꾸기만(늘리지 말 것). +- 크기: ① 작음 · ② 작음~중간 · ④ 작음. +- ⬜ 사용자가 본 증상(어느 측점 · 안 보임/모양 틀림) — 확인 전 추천 = ① + ② 함께. +- ⬜ C군 측점: (가) 흙막이 · 옹벽 · 소단 시작 · 기준 · 종료 심음 / (나) 안 심고 「이 구간 횡단 없음」 안내만 — 추천 (가). +- ⬜ 절토 쪽 흙막이: (가) 이번 밖(기록만) / (나) 설치 측 칸 보이기 + 절토 쪽 모양 새로 — 추천 (가). + +## V12. 소단 — 높이 칸 빼고 성토 사면 한 단(도로 아래 Y) +- 현재 칸: DEF:1993 `berm` = 폭 `width_m`(0.5 · DEF:2008) · 높이 `height_m`(빈 값 · 「아직 계산에 안 쓰임」 DEF:2017) · 길이 · 전 · 후 · 간격(사면길이 3 · DEF:2053) · 안쪽 기울기(0° · DEF:2062). 보이는 칸 = 폭 · 높이(그룹 `gbermdetail` DEF:3794 · 표시 DEF:3563-3569). +- 흐름: 구조물 목록 → `bermSpansFromStructures` PER:102-135(세션 `berm` 구간) · `syncBermSpans`(`B06_Section_UI_Page_Design_Sync.ts`:197) → 측점별 제원 REF:188-201(서버 payload) · REF:255-262 · 320(브라우저 재계산) → 짝 BRM ↔ BPY(두 머리 짝 표시 있음). +- 지금 형상 = 사면길이 `interval_m` 마다 폭 평탄부 **되풀이** — 성토 BRM:153-191 · BPY:163 · **절토도** BRM:57-146(GEO:275-293 `cutPoints` 가 같은 소단 씀) · 설계선 꼭짓점 GEO:469-500 · 평탄부 거름 GEO:440-443(짝 GPY:207-244 · 283-286 · 432-443). +- 저장: 결과 `design.berm {width_m, interval_m, slope_deg}` CD:582-589 · ED:535-541 → [저장] 재계산 `stored_berm` RD:449-459 · 세션 `berms` RD:496-503(`B06_Section_Schema.py`:335) · 사용자 키 REF:87(서버 RD:151 · 172). 하류 = B07 평탄부 판정 `B07_DesignDetail_Engine_SlopeGeometry.py`:178-199(폭만 봄) · 3 m 검토 정보 `B06_Section_UI_Cross_Berm_Info.ts`. +- 「형상 필요」 까닭(추정): 성토 사면이 3 m(사면길이) 안 되면 소단이 안 섬 · 높이 칸은 어디서도 안 읽음 · C군이라 측점 안 심음(V7 ①). +- 거울 시험: 머리글의 `tmp/tests/test_cross_berm_mirror.py` 는 git 밖 — T 에 성토 소단 거울 시험 없음(test_berm_knee_progress = 절토 끝남만). +- 고칠 자리(⬜ (가) 기준): + - DEF: `depth_m`「소단 위치(도로 아래)」 m · 기본 1.0(사용자 값) · 그룹 옵션 [width_m, depth_m] · `height_m` 표시 끔(값 남김). + - BRM · BPY: BermSpec 에 깊이 · `fillProfilePoints` = 내려가다 표고가 「노면 끝 − 깊이」에 닿으면 폭만큼 수평 → 그 아래 같은 성토 경사로 이어 내림 · 한 단만 · 깊이 < 0 은 0(도로보다 높을 수 없음) · 그 깊이 전에 원지반을 만나면 소단 없음. + - GEO · GPY: `fillPoints` 에 노면 끝 표고 넘김(`slopeStart` GEO:258-266 은 측구가 그쪽이면 측구 바깥 표고 — 기준 다름) · (⬜) `cutPoints` 소단 끔. + - 키 나르기 `depth_m`: PER:58-62 · 117-131(지금 음수 = 기본값 → 0 으로 자름) · REF:188-201 · 255-262 · `B06_Section_Api_Fetch.ts`:268 · `B06_Section_Api_Types.ts`:531 · CD:185 · 582-589 · ED:535-541 · RD:449-459 · 496-503. +- 재사용: 지금 소단 길(구간 → 세션 → 측점 제원 → 짝 계산 → 설계선 · 면적 · 3D) 그대로 · 칸 = 정의 그룹 숫자 칸([−][숫자][+] `B05_Profile_UI_Structures_Panel_Options.ts`:103-106). +- 시험: 새 T test_cross_berm_mirror(TS ↔ 파이썬 · 깊이 1.0 · 폭 0.5 · 1:1.5 꼭짓점 같음 · 깊이 0 · 깊이 > 사면 높이 = 소단 없음) · test_b06_cross_design_mirror(소단 측점 더함) · test_b07_slope_geometry · test_berm_knee_progress(절토 끄면 고침) · test_54_26_slope_table09 · test_54_6_slope_container · test_b06_berm_review_info. +- 위험: 옛 되풀이 소단이 한 단으로 바뀜(재계산 때 면적 · 토량 바뀜) · 절토 소단 끄면 절토 면적 줄어듦 · 짝 파일 여유 적음(RD 618 · CD 606 · ED 555) · 측구가 성토 쪽인 단면의 기준 표고. +- 크기: 중간. +- ⬜ 절토 쪽 소단: (가) 끔(요구 = 성토 사면) / (나) 지금 되풀이 그대로 — 추천 (가). +- ⬜ 깊이 키: (가) 새 키 `depth_m` · 높이 칸 숨김 / (나) `height_m` 이름만 바꿔 씀(높이를 읽는 공용 코드에 깊이가 섞일 위험) — 추천 (가). +- ⬜ 깊이 기준: (가) 그쪽 노면 끝(길어깨 끝) 표고 / (나) 사면 시작점 — 추천 (가). +- ⬜ Y 조절 자리: (가) 좌측 패널 칸(구간 한 값) / (나) 횡단 카드 끌기(측점마다 · patch 새 키) — 추천 (가). + +## V13. 사면안정 옹벽 · 흙막이 「단」 — 유출 양식 재사용 +- 현재 칸: 흙막이 = 그룹 `gsoilgdetail`(DEF:3756 · 형태 · 길이 · 높이 · 전 · 후) + 숨은 `tiers`「단 수(다단)」(DEF:1629 · 표시 DEF:3542 끔) · 옹벽 = `gretaindetail`(DEF:3688 · `wall_form` · 길이 · 높이 · 전 · 후) · `tiers` 없음. 둘 다 명세(STRUCTURE_SPECS PIPE:107-114) 밖 — 정의 그룹 상자로 그림. 저장 = structures.json `options`. +- 형상: 다단은 이미 됨 — REV:248-270 이 `spec.tiers` 만큼 `buildExtrasAt`(배관과 같은 함수) · 짝 WAL:98 · WPY:76 이 tiers 나름. 단 조정값은 늘 `ZERO_ADJUST`(REV:257) → 단 형태 = 본 벽 형태 · 단 높이 = 기본 1.5 m(EXT:285-289 · `B06_Section_UI_Cross_Culvert_Const.ts`:166) · 단별 값 못 정함 · 단 수 칸이 화면에 없어 사실상 1단. +- 유출 양식(배관): 유출 상자 「추가 흙막이(단)」 숫자 칸 RCH:60-86(값 = 횡단 설계 patch `extra_wall_counts.outlet`) + 흙막이 상자 `SOIL_ADD`(GRD:113-121 · 형태 · 길이 · 높이 · 전 · 후 · 키 `soil_add_{n}_*` · 잇기 `gsoiladd{n}`) · 상자 수 = 지형이 세운 단(count 훅 DRF:345 · fields 훅 DRF:316-327) · 상자 값 → 단 조정 PPO:221-260(`extra{n-1}` 의 h · m). +- 고칠 자리(⬜ (가) 기준): + - GRD: 단 컨테이너 = SOIL_ADD 꼴 · 항목 형태 · 높이만(`guardItems()` 거름) · 옹벽 몫은 높이만(⬜). + - PIPE:107-114 에 `soil_guard` · `retaining_wall` 명세(선택 컨테이너 하나 · 본 칸은 정의 그룹 그대로) → 좌측 패널이 옵션 칸 아래 공용 폼 [추가] · [빼기] 상자(SPF:28-29 · FRM:171-183 · 앞선 예 `design_structure_road.ts` `ETC_SPEC`). + - WAL:74-106 · WPY:46-90(짝): `soil_add_{n}_form · _height` → `extras: [{form, height_m}]` · tiers = 1 + 상자 수(상자 없으면 옛 `tiers`). + - REV:249-257: 단 조정 = `{...ZERO_ADJUST, m: 형태, h: 높이}` · 자리(x · d)는 다단 자동 규칙 그대로. + - DEF: 두 종류 그룹에 `gsoiladd`(흙막이 n) — 조합 잇기 이름. +- 재사용: SOIL_ADD 칸 · 공용 명세 폼 선택 컨테이너(`design_structure_form.ts`:280-345) · `buildExtrasAt` · 그리기 · 3D(`tiers[].polygon`) · B07 그대로. +- 시험: T test_b06_structure_walls_mirror(extras 두 벌 같음) · 새 시험(tsc — 흙막이 2단 · 둘째 단 콘크리트 H2.0 → 둘째 벽 form · 높이) · test_54_26_slope_table09 · test_54_6_slope_container · test_54_27_soil_tier_field · test_b06_wall_shape_unified · test_52_17_single_container. +- 위험: 단별 수량은 이번 밖(B08 이 C군 다단을 안 셈 · 기록만) · 지형이 단을 못 세우면 상자보다 단이 적음(구간 벽은 측점마다 지형이 달라 배관처럼 상자 수를 맞출 수 없음) · 단 자리 조작(조정창) 없음 · 3D · B07 다단도 함께 바뀜. +- 크기: 중간. +- ⬜ 단 수 정하는 법: (가) 공용 상자 [추가] · [빼기](상자 수 = 추가 단) / (나) 배관 유출과 같은 「추가 흙막이(단)」 숫자 칸 + 상자 수 따라감(RCH `outletTiersRow` 를 저장 자리 받게 넓힘 · SPF 에 count · fields 훅) — 추천 (가). +- ⬜ 단 상자 칸: (가) 형태 · 높이 / (나) SOIL_ADD 그대로(길이 · 전 · 후는 그림에 안 쓰임) — 추천 (가). +- ⬜ 옹벽 단 형태: (가) 흙막이와 같은 형태 6 / (나) 높이만(형태 = 콘크리트) — 추천 (나). + +## 고칠 파일 목록 + +| 파일 | 줄 수 | 700 위험 | 요구 | +|---|---|---|---| +| `B05_Profile/B05_Profile_Structures_Schema.py` | 290 | 없음 | V7 ① | +| LAY · CV · COR | 389 · 690 · 675 | CV · COR 높음 — 바꾸기만 | V7 ② | +| NODE(번들) | — | 다시 빌드 | V7 ② · V13 | +| WAL ↔ WPY | 134 · 121 | 없음 | V7 ④ · V13 | +| BRM ↔ BPY · GEO ↔ GPY | 223 · 240 · 501 · 459 | 낮음 | V12 | +| CD ↔ ED · RD | 606 · 555 · 618 | 중간(+ 2~4) | V12 | +| PER · REF · `B06_Section_Api_Fetch.ts` · `B06_Section_Api_Types.ts` | 462 · 407 · 313 · 620 | 낮음(+ 1~3) | V12 | +| GRD · PIPE · REV | 158 · 114 · 392 | 없음 | V13 | +| DEF | 자료 | 해당 없음 | V7 ④ · V12 · V13 | + +- 요구 겹침: V7 ① 측점 심기 = V12 소단 · V13 단 모두 카드가 서는 조건 · V7 ② · V13 = REV · NODE 함께 · V12 · V13 = DEF 함께(M02 판). +- 안 고침: `B05_Profile/B05_Profile_UI_Structures_Panel.ts`(699줄 — 셋 다 필요 없음). diff --git a/docs/raw/요구_1010_2/조사_W_B06횡단도.md b/docs/raw/요구_1010_2/조사_W_B06횡단도.md new file mode 100644 index 000000000..2a3b93b8c --- /dev/null +++ b/docs/raw/요구_1010_2/조사_W_B06횡단도.md @@ -0,0 +1,99 @@ +# 조사 W — B06 횡단도(치수 칸 자리 · 측구 단추 비활성 · 측구 줄 줄임 · [상세] 축 고정 · 표 손질) (10-10 · 읽기 전용 조사 에이전트) + +약칭 `B06_Section/B06_Section_` + CV `UI_Cross_View.ts` · CTL `UI_Style_Cross_Controls.css` · CSX `UI_Style_Cross.css` · CSA `UI_Style_Cross_Areas.css` · CA `UI_Cross_Areas.ts` · CD `UI_Cross_Design.ts` · DP `UI_Cross_Design_Pick.ts` · CHR `UI_Cross_Card_Chrome.ts` · DT `UI_Cross_Detail.ts` · DTP `UI_Cross_Detail_Pick.ts` · CSD `UI_Style_Cross_Detail.css` · AX `UI_Cross_Axes.ts` · FR `UI_Cross_View_Frame.ts` · VZ `UI_Cross_View_Zoom.ts` · VM `UI_Cross_View_Metrics.ts` · SV `UI_Section_View.ts` · SVT `UI_Section_View_Types.ts` · DSY `UI_Page_Design_Sync.ts` · PER `UI_Page_Persist.ts` · CR `Cross_Refresh.ts` · AT `Api_Types.ts` · AF `Api_Fetch.ts` · SCH `Schema.py` · RD `Router_Design.py` · RCT `Router_CrossTable.py` / ZP `A00_Common/b_svg_zoom_pan.ts` · DVC `A00_Common/design/design_view_cross.ts` · BPS `A00_Common/b_page_state.ts` · CCD `common_util/common_util_cross_design.ts` · CT `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py` · RS `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · CQ `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py` · LB2 `ui_template/ui_template_locale_b2.ts` · CM `ui_template/ui_template_context_menu.ts` · DTB `ui_template/ui_template_data_table.css` · BE `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` · T `resources/tester/` + +## 0. 코드 지도 + +| 요구 | 손댈 층 | 계산 자리(5장) | 저장 | +|---|---|---|---| +| W14 치수 칸 = 줌 단추 왼쪽 | CTL 한 덩이 지움 | 화면만 | 없음 | +| W15 측구 · 좌우 단추 비활성 | 확인만(코드 없음) | — | — | +| W16 측구 줄 줄임 | CSX · CD(· LB2 · CHR) | 화면만 | 없음 | +| W17 [상세] 축 고정 · 표 글자 · 이름 칸 색 | DT · SV · SVT · DSY · CSD | 화면만(카드 축 함수 AX 재사용) | 없음 | +| W18 [상세] 행 · 열 더함 · 그래프 고르기 · 우클릭 | DT(+ 새 파일) · PER · CR ↔ RD 짝 목록 · SCH · AT(· RS) | 면적 = ③ 서버 단독(설계 면적 칸) · 길이 = ⬜ | 횡단 설계 patch 새 칸(`structure_note` 꼴) | + +## W14. 선 길이 정보창 → 줌 [+ − ⤢] 왼쪽 +- 현재: 보라 선을 누르면 치수 칸(`b06-cross-card__line-dims` · DP:329-331 · 글 두 줄 = 길이 · 수평 / 높이 · 구배 DP:216-224)이 줌 막대 맨 앞에 붙음(CV:676-677 `zoomBar.prepend(linePick.label)`) — 막대는 그림 칸 오른쪽 위 absolute(CTL:214-224). **그림 칸 폭 ≤ 620 px 이면 막대에서 떼어 면적표 아래 오른쪽으로 내림**(151-1 · CTL:242-259 `@container (max-width: 620px)` · top = 표 높이 변수). 카드 폭(조사 O 추정) 1열 1232 · 2열 608 · 3열(기본) 400 → **1열만 단추 왼쪽 · 2열부터 표 아래**. +- 겹침 셈(추정 · CROSS_PAD left 58): 치수 칸 ≈ 176 px(0.75rem · 「사면길이 12.34m · 수평 10.12m」) + 단추 66 px. 2열(608) = 막대 왼끝 ≈ 362 · 면적표 끝 ≈ 311 → **안 겹침**. 3열(400) = 막대 왼끝 ≈ 144 · 면적표 58~283 → 선을 고른 동안 표 오른쪽 ≈ 140 px(RR · BR · 계 칸) 가림. +- 고칠 자리: CTL:242-259 의 line-dims 두 규칙(absolute 내림 · `+ .zoom-btn` 왼선)과 막대 `overflow: visible` 지움 → 모든 폭에서 막대 안 왼쪽(지금 1열 꼴). (⬜ (나)면) CSA 에 `.b06-cross-card__chart-wrap:has(.b06-cross-card__line-dims:not([hidden])) .b06-cross-card__areas { opacity: … }` 한 규칙. +- 재사용: 지금 1열 꼴 그대로(DOM 손 안 댐 · CV 690줄 안 늘림). +- 시험: T test_137_design_pick:107-115(좁은 카드 내림 단정 → 「@container 안에 line-dims 없음」으로 고침) · test_173_areas_detail(표 높이 변수 무변 확인) · test_139_6_line_shift(무변). +- 위험: 3열 이상에서 고름 동안 면적표 일부 가림(막대 바탕 88% 불투명 · z-index 2) · `--b06-areas-height` 를 쓰는 다른 곳(CTL:10 · 15 조정창 자리)은 그대로. +- 크기: 작음(CSS 지우기만). +- ⬜ 3열 이상 겹침: (가) 그대로 가림(고름 동안만) / (나) 가림 + 그동안 면적표 흐림(CSS 한 규칙) / (다) 2열까지만 왼쪽(문턱 620 → 560) · 3열 이상은 지금처럼 표 아래 — 추천 (가)(기본 3열에서 요구대로 보임 · 지우기만). + +## W15. 측구 여부 · 좌우 방향 단추 비활성 — 확인 결과 +- 단추 = 카드 「측구 줄」(CD:396-467 · CHR:157-163). 비활성 = `button.disabled` + 듣개 안 닮(CD:164-166 · 198-200) + 묶음 `b06-design__seg--disabled`(흐림 0.4 · 손가락 금지 CSX:536-542) + 사유 툴팁. +- 단면유형(`section_mode`)은 지형 자동 판정(RD:351 `default_section_modes`) · **카드에 바꾸는 단추 없음**(MODE_OPTIONS = 이름표만 CD:83-94 · 알약 CHR:108-112). + +| 단추 | 비활성 조건(코드) | 툴팁 | 검증_랩탑메인 73 측점(DB SELECT) | +|---|---|---|---| +| 좌 · 우(노면 경사 = 측구 쪽) | 편측 성토(`left_cut` · `right_cut`) — `!needsDitch()` CD:352 · 404 | 「양측 절토·양측 성토 단면에서만…」 LB2:456-459 | right_cut **66 = 비활성**(「우」 보라 켜진 채 흐림) · both_cut 6 · both_fill 1 = 활성 | +| 측구(켬/끔) | 양측 성토(`both_fill`) — `!hasDitch` CD:390 · 417 | 「측구가 있는 단면(양성 제외)…」 LB2:467-470 | both_fill **1 = 비활성** · 72 활성 | +| (참고) 측구형식 일반/L형 | 암 아님 · 양성 · 측구 끔 CD:389 · 429 | LB2:463-466 · 471-474 | 73 모두 암 → both_fill 1 만 비활성 | +| (참고) 복합/단경사 | 암 아님 · 양성 CD:442 | LB2:463-466 | both_fill 1 만 비활성 | + +- 비활성이 **아닌** 까닭(코드 판정): 확정 장 · 잠금 · 저장 상태 · B05 연동 — 없음(조작 함수 늘 넘김 DVC:317-321 · 받는 쪽 막기 없음 DSY:60-108). 편측의 쪽은 절토 쪽 자동(CCD:234-239 짝 `_resolve_ditch_side`) — 설계 규칙. +- 사용자 눈에 비활성처럼 보일 까닭: ① 편측(90%)은 좌우가 흐린 채 늘 보임(E-6 「상시 노출 · 흐리게」 규칙) ② 측구 켬 · 끔 단추 글이 같은 「측구」(LB2:454-455) — 끔 = 회색 글 · 바탕색(CSX:551-559)이라 비활성과 구별 약함 ③ 좁은 카드에선 측구 단추가 「⋯」 안으로 옮겨짐(CD:451 · 476-503 · 숨김이지 비활성 아님) ④ 단면유형 알약은 좌 · 우 편측 모두 「편측 성토」(LB2:423-424) — 쪽은 흐린 좌우 단추로만 읽힘 ⑤ 요구 5(B05 3D 구슬 삭제) 뒤 쪽 고르기 = 이 단추뿐. + +## W16. 측구 줄 컴팩트 +- 현재: 한 줄 고정 · 넘치면 뒤에서부터 「⋯」(CSX:465-473 · 간격 16 px · CD:451-503). 차례 = [좌|우] [측구] [측구형식 일반|L형] [복합경사] [⋯] [상세](CD:467). 폭 ≈ 445 px(추정) > 3열 카드 400 → 복합경사 · 측구형식이 ⋯ 로 숨음(추정). 편측 66 측점은 흐린 좌우가 자리만 차지. +- 안: + - (가) **비활성은 숨김** — CSX:536 규칙을 `display: none` 으로 · 「측구형식」 글 지움(CD:422 legend 빈 글 · 툴팁으로) · 알약에 쪽 더함(「편측 성토(우)」 · LB2:423-424 두 줄 또는 CHR:110 한 줄). 편측 암 = [측구] [일반|L형] [복합경사] [상세] ≈ 280 px → 3열에서 ⋯ 없음. 장: 가장 작음 · W15 혼동 없앰. 단: E-6 뒤집음(사용자 확정 필요). + - (나) **측구 한 단추로 묶음** — [측구 우·일반 ▾] 하나 → 누르면 작은 판(⋯ 판 꼴 `details` CSX:475-521 재사용)에 켬/끔 · 좌/우 · 일반/L형(비활성 그대로 흐림 · 툴팁). 줄 = [측구 우·일반 ▾] [복합경사] [상세]. 장: 늘 한 칸 · 쪽 · 형식 글로 남음. 단: 바꾸려면 두 번 누름 · CD 새 판 ~40줄(527 → ~565) · 넘침 목록 고침. + - (다) **글 · 간격만 줄임** — 「복합경사/단경사」 → 「복합/단」 · 「측구형식」 지움 · 간격 16 → 8 px. 장: 뜻 그대로. 단: 60~80 px 만 줄고(추정) 흐린 단추 그대로. +- 재사용: 넘침 정리(CD:470-516 · 숨긴 칸 폭 0 이라 그대로 동작) · ⋯ 판 CSS. +- 시험: T 새 CSS · 소스 시험(비활성 규칙 · legend · 알약 글) · 측구 줄을 단정하는 기존 시험 없음(T grep — test_b06_cut_slope_user_input 은 바닥 줄만). +- 위험: (가) 비활성 사유 툴팁이 안 보임(숨김) · 좌측 사본 · B05 화면에 같은 클래스 없음(grep) · 알약 글 길어져 머리 줄 넘침. +- 크기: (가) 작음 · (나) 중간 · (다) 작음. +- ⬜ 줄이는 길: 추천 (가)(편측이 대부분이라 효과 큼 · W15 와 한 번에 풀림) — E-6 「상시 노출」 폐기 동의 필요. + +## W17. [상세] 모달 — 축 고정 팬 · 줌 · 표 글자 · 이름 칸 색 +- 현재 그림 구조: 카드 svg = 바탕 rect → **축 층** `g.b06-chart__axes`(CV:370-382 · 줌 바뀔 때마다 보이는 범위로 눈금 다시 그림 AX:44-131) → clip 그룹(고정) 안 **도형 층** `g.b06-chart__plot-layer`(CV:384-399) → 축선 · 축 이름(FR:60-81 · svg 직속). 카드 줌은 도형 층에만 transform + 축 다시 그림 + 십자 크기 되돌림(CV:628-640 · FR:83-89). +- 모달: 카드 함수로 칸 크기 새로 그림 → **복제**(DT:167-184 · 듣개 · 축 함수 잃음) → `attachDetailZoom` 이 **자식 전부를 g 하나로 감쌈**(DT:124-148 · 164-1 「축 · 눈금째 한 장」) → 팬 · 줌 모두 축 · 눈금 · 축선 · clip 까지 같이 움직임. 줌도 같은 원리(ZP:89-92 · 124-131 둘 다 같은 layer transform). +- 고칠 자리: + - SV:550-567 `detailChart` → svg 와 함께 축 옵션 반환(`crossPlotFromBase(fitPlotBase(base, h), h)` VM:84-115 → pixelsPerMeter · maxOffset · displayMax · elevationMid · exaggeration) · SVT:41 · DT:40 타입 한 줄 · DSY:52 그대로 통과. + - DT `attachDetailZoom`: 감싸기 지우고 layer = 복제의 `g.b06-chart__plot-layer` · `createCrossAxes(복제 g.b06-chart__axes, 옵션)` → onChange 에서 축 다시 그림 + 십자 `g.b06-chart__center` 크기 되돌림(FR:83-89 식 3줄). clip 은 고정이라 도형이 축 밖으로 안 넘침. 1배 팬 범위(content 3배 DT:143) 그대로. + - CSD:56-79 표: 글자 `--text-caption`(12px) → `--text-body-sm`(14px) · 이름 칸 `th` 바탕 = 데이터 표 머리색 `color-mix(in srgb, var(--color-accent) 6%, var(--color-surface))`(DTB:10) · 글 본문색. 빈 칸 규칙 CSD:101-105 `background: none` 이 이름 칸 바탕까지 지움 → `td[data-key][data-empty]` 로 좁힘(이름 칸은 글만 회색). +- 재사용: 카드 축 함수 AX 한 벌 · 공용 줌 ZP 무변 · 데이터 표 머리색 공식. +- 시험: T test_164_detail_zoom(`wrapped` 단정 → 「도형 층에만 transform · 축 층 transform 없음 · 팬 뒤 눈금 글 바뀜」 · helper_164 가짜 svg 에 두 층) · test_173_areas_detail · helper_173_detail_fit(반환 꼴 바뀜) · test_150_detail_pick · test_138_cross_detail(무변 확인) · 새 CSS 단정(14px · th 바탕 · 빈 칸 td 한정). +- 위험: SV 614줄 + ~6 · 표 커져 그림 칸 줄어듦(스크롤 없음 150-2 · 8줄 × ~4 px ≈ 32 px) · 강조 hover(0,3,0) · 고름(0,2,0)은 th 바탕(0,1,1)보다 셈 → 그대로 이김. +- 크기: 중간. +- ⬜ 표 글자: (가) 14px / (나) 16px(`--text-body` · 그림 칸 ≈ 60 px 더 줄어듦) — 추천 (가). + +## W18. [상세] 표 — 행 · 열 더함 · 칸 값 그래프에서 고르기 · 우클릭 재지정 +- 현재 표: B07 납품 양식 고정(머리 넷 쌍 + 본문 6줄 × 좌 · 중 · 우 CT:25-55) · 값 = 서버 `cross_quantity_sheet`(RCT:44-60 → RS:262-302 `_quantity_table` → CQ:102-128 `derived_cells` · 면적 = 설계 칸 그대로 CQ:47-57 · 사면 계열 = 설계선 유도 CQ:81-89) · **표시 전용 · 저장 없음**(RCT:5 · DT:257-282 열 때 POST). 칸 누름 = 그림 강조만(DTP:17-36 · 47-70). B07 도면 표 높이 = 줄 수 × 5 mm(CT:363-366) → 장 배치가 씀. +- 그래프에서 고를 수 있는 것(복제 svg · 듣개 없음 → `cad` 에 위임 듣개 하나): + - 면적 = 면적 띠 다각형 `g.b06-chart__area--{cut_soil|cut_rock|fill}`(CA:224-227) · 사토장 `.b06-chart__spoil-fill` → 출처 = 띠 키 → 값 = 설계 면적 칸(CQ AREA_KEYS 와 같은 키 → **서버가 [저장] 때 그대로 다시 읽음 · ③**). + - 길이 = 보라 판정 선 `.b06-chart__design-hit`(DP:314-321 · 직선 조각) → 화면 → m 환산 = `data-px-per-m` · `data-exaggeration`(CV:360-361) + 십자 x(0)(DTP:51-54 같은 꼴) → 길이 = `pieceMeasure`(DP:198-213 재사용). 원지반선 · 구조물 벽은 판정 선 없음(지금 못 고름). + - CSD:129-133 이 모달 판정 선 손가락을 지워 둠 → 고르기 상태에서만 되살림. +- 우클릭: 공용 `createMapContextMenu`(CM:82 · 한 번에 하나 규칙) — BE:227-231 꼴(document.body · 화면 고정 자리). 칸 메뉴 = 「그래프에서 고르기(면적)」「…(길이)」「자동값으로」「행 더함 · 열 더함 · 이 행/열 지움」 → 고르기 상태에서 그림 누름 = 그 칸 값. +- 저장(정본 규칙 — 그림 = 횡단 설계가 낳은 값 → **횡단 설계 patch**): 새 칸 `detail_table`(예 `{rows:[{label, cells:[{src:"area", key} | {src:"length", from, to} | {text}]}], overrides:{표키: src}}`) · `structure_note` 길 그대로 — 세션 초안 `readState/writeState("detail-table")`(BPS:447-470 · DT:50-76 꼴) → [저장] PER:390-395 옆 → SCH:200-201 옆 필드 → 재계산에도 살림 목록 **짝 둘** RD:155-156 · CR:90-91 → 타입 AT:472-473 · 618-619. 자동저장 없음. +- 고칠 자리: DT 320줄 → 표 손질 · 고르기 · 메뉴는 새 파일(`UI_Cross_Detail_Table.ts` 가칭) · DT 는 부르기만. (⬜ 덮어쓰기까지면) RS:292-302 `_quantity_table` 에 overrides 반영 한 곳(모달 · B07 도면 둘 다 이 함수 → 한 벌). +- 재사용: 공용 우클릭 메뉴 · `pieceMeasure` · DTP 판정 꼴 · 세션 초안 꼴 · 모달 · (안 (다)) M02 면적산출 시트 + 칸 잇기(`M02_MasterTemplete_AreaCellPick.ts` · `_AreaLink.ts` · `A00_Common/spreadsheet/spreadsheet_link_set.ts`). +- 시험: T 새 Node 시험(면적 출처 → 설계 칸 값 · 길이 = 두 끝 hypot · 우클릭 항목 · 세션 초안 → patch) · 새 pytest(RD ↔ CR 살림 목록 거울 · SCH 새 필드 · RCT 표 응답) · test_150_detail_pick · test_138_cross_detail · test_164_detail_zoom(무변 확인). +- 위험: 길이 출처를 두 끝 m 로 두면 설계선이 바뀐 뒤 어긋남(서버 검산에서 설계선 위 아님 → 값 비우고 「다시 고름」) · 판정 선 조각은 벽 가림을 뺀 「보이는 조각」(DP:127 · 174 · 브라우저 그림에 묶임 → 서버가 같은 조각을 못 만듦) · B07 표에 찍으면 표 높이 · 폭 · 장 배치 · 셀 id(uuid5 CT:3) 영향 · AT 620줄(+ ~10) · 짝 목록 한쪽만 고치면 재계산 때 칸 사라짐. +- 크기: 큼. +- ⬜ 행 · 열의 자리: (가) 모달 전용 「추가 표」(납품 양식 표 아래 · B07 도면엔 안 찍힘) / (나) 납품 양식 표 자체를 늘림(B07 도면까지 · CT 표 그리기 · 장 배치 고침) / (다) 공용 스프레드시트를 모달에 끼움(행열 · 메뉴 · 잇기 다 있음 · 측점마다 책 저장 = patch 큼) — 추천 (가). +- ⬜ 「열 · 행 추가」의 뜻: 그룹(깍기 · 측구 …) 안에 줄 더함 / 맨 끝에 새 항목 줄 · 새 열(비고 등) — 원문 모호 · 사용자 확인. +- ⬜ 기존 칸(깍기 토사 등)도 그래프 값으로 덮나: (가) 더한 칸만 / (나) 기존 칸 덮어쓰기 + 「자동값으로」 되돌림(RS 한 곳) — 추천 (나)(우클릭 재지정 원문과 맞음). +- ⬜ 길이 출처 검산: (가) 두 끝 m 저장 · 서버가 설계선 위인지 확인 후 hypot / (나) 값만 저장(5장 「브라우저 값 받아 적지 않음」 어긋남) — 추천 (가). + +## 고칠 파일 목록 + +| 파일 | 줄 수 | 700 위험 | 요구 | +|---|---|---|---| +| `B06_Section/B06_Section_UI_Style_Cross_Controls.css` | 471 | 없음(− ~15) | W14 | +| `B06_Section/B06_Section_UI_Style_Cross.css` | 628 | 낮음(± 2) | W16 | +| `B06_Section/B06_Section_UI_Cross_Design.ts` | 527 | 없음((가) ± 2 · (나) + ~40) | W16 | +| `B06_Section/B06_Section_UI_Cross_Card_Chrome.ts` · `ui_template/ui_template_locale_b2.ts` | 193 · — | 없음 | W16(알약 쪽) | +| `B06_Section/B06_Section_UI_Cross_Detail.ts` | 320 | 없음(+ ~15 · 표 손질은 새 파일) | W17 · W18 | +| `B06_Section/B06_Section_UI_Section_View.ts` | 614 | 중간(+ ~6) | W17 | +| `B06_Section/B06_Section_UI_Section_View_Types.ts` · `_UI_Page_Design_Sync.ts` | 62 · 236 | 없음 | W17 | +| `B06_Section/B06_Section_UI_Style_Cross_Detail.css` | 133 | 없음(+ ~8) | W17 · W18 | +| 새 `B06_Section/B06_Section_UI_Cross_Detail_Table.ts`(가칭) | 0 | 새 파일 | W18 | +| `B06_Section/B06_Section_UI_Page_Persist.ts` · `_Cross_Refresh.ts` · `_Api_Types.ts` | 462 · 407 · 620 | 낮음(AT + ~10) | W18 | +| `B06_Section/B06_Section_Schema.py` · `_Router_Design.py` · (⬜) `B07_DesignDetail/B07_DesignDetail_Router_Support.py` | 353 · 618 · 615 | 낮음 | W18 | + +- 안 고침: `B06_Section/B06_Section_UI_Cross_View.ts`(690줄 — W14 · W17 모두 손 안 댐) · `A00_Common/b_svg_zoom_pan.ts`(공용 · 무변) · `A00_Common/design/design_view_cross.ts`(680줄). +- 겹침: W17 · W18 = 같은 모달 파일(DT · CSD) · 같은 시험(test_150 · 138 · 164) → 한 창이 차례로 · W15 · W16 = 같은 측구 줄(W16 (가)가 W15 혼동을 풂). diff --git a/docs/raw/요구_1010_2/조사_X_B07횡단장.md b/docs/raw/요구_1010_2/조사_X_B07횡단장.md new file mode 100644 index 000000000..589a16eb1 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_X_B07횡단장.md @@ -0,0 +1,98 @@ +# 조사 X — B07 횡단 장 원지반 고르기 · 측점 한 벌 고르기 · 붉은 칸 보이기 · 표 배율 (10-10 · 읽기 전용 조사 에이전트) + +사용자 원문 = `사용자요구_1010_2.md` 19 · 20 · 21번. 지난 기록 = 169-2(격자선 인쇄 · 넘친 칸 붉은 틀) · 174-1(칸 아래 가운데 표 → 글 → 그림 · 배치 판 5 · b5442ccc4). + +| 약칭 | 파일 | +|---|---| +| Sheet | `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py` | +| Cad · Table | `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py` · `…_Engine_Cad_Table.py` | +| Support · Router | `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · `…_Router.py` | +| Page · Panels · Fetch | `B07_DesignDetail/B07_DesignDetail_UI_Page.ts` · `…_UI_Panels.ts` · `…_Api_Fetch.ts` | +| Struct · Overlay | `B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts` · `…_Cross_Overlay.ts` | +| owc | `B07_DesignDetail/openwebcad/src` | +| host | `ui_template/cad_host/cad_host.ts` | + +## 0. 코드 지도 + +| 항목 | 위치 | 역할 | +|---|---|---| +| 장 도면층 목록 | `Sheet:235-242`(원지반 `locked=True` `:236` · 도각 `:241`) | 장 레이어 선언 | +| 측점 도면층 목록 | `Cad:624-631`(원지반 잠금 `:625`) | 측점 한 장 · 장 블록 공통 | +| 측점 블록 만들기 | `Sheet:185-215` → `Cad:488-632` `build_cross_drawing` | 원지반 · 계획선 · 암 · 글 · 표 엔티티 | +| 쌓기(174) | `Cad:545-550`(표 + 글 + 8 mm) · `:572-584`(글 · 표) · `:586-593`(틀) | 표 아래선 = 칸 아래 + `CELL_TABLE_PAD_MM`(`Sheet:52`) | +| 넘침 판정 | `Sheet:209-213` | 틀이 칸 밖이면 `overflow_cells` | +| 칸 격자선 | `Sheet:153-172` | 도각 층 `b08-frame` · 색 `#8ea0b5`(`Cad:34`) · 인쇄 · DXF 나감 | +| 가이드(붉은 틀) | `Router:141-144` → `Page:335-340` → `owc/integration/guide-area.ts:105-125`(붉은 `#ff5252` `:28 · 122-123`) | CAD 겹판 · 저장 · DXF 안 섞임 | +| 고르기 걸름(잠금) | `owc/state.ts:220-221`(집기) · `:355-362`(밝힘) · `:365-371`(선택) · `owc/tools/select-tool.helpers.ts:141-160`(사각형) · `owc/helpers/calculate-angle-guides-and-snap-points.ts:33`(스냅) | 잠금 층 객체는 어느 길로도 안 잡힘 | +| 그룹 | `owc/helpers/entity-groups.ts:7-22` · `select-tool.helpers.ts:82-97` · 싣기 `owc/helpers/import-export-handlers/import-entities-from-json.ts:81-85` · 저장 `export-entities-to-json.ts:17-20` · 리본 GROUP · UNGROUP `owc/ribbon/ribbon.config.ts:116` | 한 개 집으면 같은 `groupId` 전부 선택 | +| 층 선택 단추 | `owc/components/LayerManager.tsx:35-38 · 133` | 층의 객체 전부 선택 | +| 화면 덧그림 | `Struct:63-97` · `Overlay:30-36 · 524-544` | 장의 설계선 · 구조물을 화면이 다시 그림(새 id · `groupId` 없음) | +| 표 크기 상수 | `Table:88-90`(폭 108 · 행 5 · 글 2.0) · 제목 글 ×1.15 `:183` · 글 줄 3.0 `:323` · 글 크기 `:351` · `cross_table_width/height` `:360-367` | 종이 mm | +| 배치 저장 | `Support:102`(판 5) · `:119-150`(읽기 · 검사 · 쓰기) · `Router:553-599` · `Fetch:182-185` | `cross_layout.json` = {default, sheets} | +| 배치 칸 화면 | `Panels:182-232` · 자리 `Page:155-197`(「모든 장」 `:194`) · 횡단도 묶음 맨 아래 `Panels:149` | 「배치 열 · 행 · [이 장] · [모든 장]」 | + +## 19. 원지반선 안 잡힘 · 측점 한 벌 고르기 + +현재: 장의 원지반 층 `b08-ground` = 잠금(`Sheet:236` · `Cad:625`) · 계획선 · 구조물 · 암 · 수량표 층은 안 잠김 · 도각(격자선 포함) 잠김. 엔티티에 `groupId` 없음 · 블록 없음 · 측점 구분은 id 씨앗(`{장id}:{측점}` `Sheet:188`)뿐. +원인(확정): 잠금 층은 집기 · 사각형 · 밝힘 · 선택 · 스냅 다섯 길에서 모두 빠짐(0 지도 「고르기 걸름」) — 원지반이 처음부터 「배경」으로 잠겨 나옴(측점 한 장 시절 규칙 · `owc/state.ts:216-219 · 356-357` 주석). 코드 흠 아님 — 장에서 옮기기가 생기며 맞지 않게 된 설정. +고칠 자리: +- 원지반 잠금 풀기 — `Sheet:236` · `Cad:625` 의 `locked=True` 지움(횡단만 · 종단 `Engine_Cad_Long.py:671` 그대로). +- 측점 한 벌 = `groupId` — `Sheet:214` 곁에서 그 측점 엔티티 전부(원지반 · 계획선 · 암 · 글 · 표)에 `groupId = uuid5(…, f"{seed_id}:group")` · `cross_placements` 에 `group_id` 실음. +- 화면 덧그림도 같은 묶음 — `Struct:25-35` `CrossPlacement.group_id?` · `:80-83` 새 엔티티에 `groupId` 붙임(안 붙이면 설계선 · 구조물만 빠짐). +- 격자선 · 도각 · 넘침 틀은 묶지 않음. +재사용: openwebcad 그룹 고르기 · 저장 · 싣기 그대로(새 CAD 코드 없음) · UNGROUP 리본 단추 · `uuid5` 씨앗 꼴. +시험: 새 `test_19x_b07_cross_group.py` — 장 2 × 2 에서 측점마다 `groupId` 하나 · 측점끼리 다름 · 격자선 · 도각엔 없음 · 원지반 층 `isLocked` 거짓 · placement `group_id` = 엔티티 값. `test_174` · `test_169_drawing_area_guide` · `test_b07_cross_sheet_pad` 그대로 통과 추정. 화면 덧그림은 `npm run typecheck`. +위험: 그룹이면 선 하나만 집기 불가(Ctrl · Shift 클릭도 그룹 확장 `select-tool.helpers.ts:91-97`) → 낱개 손질은 UNGROUP 뒤. 원지반이 마우스에 밝아지고 지워질 수 있음(09-01 「배경은 안 밝힘」 규칙을 횡단만 걷음). 확정 장은 저장본 층 · 그룹 그대로 — [수정](다시 만듦 `Page` reopen → `invalidateDesignDrawing`) 때 새 꼴. CAD 고침 없음 → 빌드 불필요 · 백엔드 재시작. +크기: 작음(파이썬 약 8줄 · 화면 약 4줄). +⬜ 19-1 측점 한 벌 묶는 법 — (a) `groupId`(종류 층 그대로 · DXF 층 이름 그대로 · 한 번 클릭 = 한 벌) / (b) 측점별 층(「No.3」 · 층 관리 선택 단추로 한 벌 · 대신 종류 층 사라짐 → DXF 층 이름 · 덧그림 설계선 걷기 `Struct:89` · 수량표 고침 막기 `owc/integration/aislo-drawing-bridge.ts:121 · 157` 이 층 id 에 기대어 깨짐) — 추천 (a)(원문 「도면층」은 한 벌 고르기 뜻으로 읽음). +⬜ 19-2 배치 판 — 올림(6 · 확정 장 전부 「옛 배치」 안내) / 그대로(배치 자리 같음 · [수정] 때 새 꼴) — 추천 그대로. + +## 20. 붉은 칸 틀 보이기 / 숨기기 + +현재: 「붉은 격자선」 = 넘친 칸 틀(`guide.marks` · `#ff5252` 2 px · `guide-area.ts:122-123`) — 4 × 4 칸(192.5 × 130 mm)에 표(108 × 40) + 글 + 8 mm 를 빼면 그림 높이 약 77 mm(실 7.7 m)라 칸 다수가 붉게 칠해져 격자처럼 보임(추정). CAD 겹판이라 저장 · DXF 에 안 섞임(`guide-area.ts:8`). 인쇄되는 칸 격자선은 회청색 `#8ea0b5` · 도각 층(`Sheet:169`) — 붉은 선 아님. 끄는 단추 없음 · 가이드는 싣기 때 한 번만 받음(`owc/integration/aislo-drawing-bridge.ts:243`). +고칠 자리: +- `owc/integration/guide-area.ts` — 모듈 값 `marksVisible`(싣기 때 지우지 않음) · `drawGuide` 의 붉은 틀 줄 `:122-123` 을 그 값으로 거름 · 다시 그리기 불필요(rAF 상시 `owc/main.tsx:128`). +- `owc/integration/aislo-drawing-bridge.ts:204-205` — 새 메시지 `aislo:b08:guide-marks {visible}` 한 줄(영역 메시지 꼴). +- `host:37-38 · 362` — `setGuideMarks(visible)` (`setGuideArea` 꼴 · `postAfterLoad`). +- `Panels:182-232` — 배치 칸 줄 끝에 ghost 단추 「붉은 칸 숨기기 / 보이기」(장일 때만 켬) · `Page:188-196` 에서 누름 → `cad.setGuideMarks` · 값은 모듈 변수(`reopenCrossSheet` 꼴 `Page:89`)로 다시 세운 화면에도 이어 줌. +재사용: 배치 칸 단추 `action` 꼴(`Panels:204-223`) · 영역 메시지 길 · 넘침 토스트(`Page:338-340` · 숨김이면 토스트도 뺄지 ⬜). +시험: `guide-area.test.ts` 에 「숨김 뒤 setGuide 해도 숨김 유지」 · `helper_153_b07_cross_layout.cjs` + `test_153_1` 에 단추 있음 · 장 아니면 꺼짐 · 누름 콜백 · `npm run typecheck`. +위험: 공용 `host` 고침 → M02 · B08 영향 없음(새 메서드만) · 알릴 일. `Page` 600줄(700 안). +빌드: CAD 고침 → `npm --prefix B07_DesignDetail/openwebcad run build` + 리로드. +크기: 작음(CAD 약 8줄 · 화면 약 15줄). +⬜ 20-1 단추가 끄는 것 — (a) 붉은 넘침 틀만 / (b) 붉은 틀 + 파란 점선 작도 영역 / (c) 인쇄되는 회청 칸선까지(칸선을 새 층 「칸 격자」로 떼어 층 끄기 · DXF 는 층 숨김을 안 봄 `Engine_Frame_Export.py:219-235` 이라 그대로 나감) — 추천 (a). +⬜ 20-2 자리 — 좌측 「횡단도」 묶음 안 배치 칸 줄(추천 · 원문 「횡단도 컨테이너 내부」) / CAD 리본. + +## 21. 표 · 글 배율(기본 1) + +현재: 표 = 폭 108 · 행 5 × 8행(40) · 글 2.0(`Table:88-90`) · 제목 ×1.15(`:183`) · 표 위 글 줄 3.0 · 글 2.0(`:323 · 351`) · 고정 상수. 그림 속 구조물 글자는 화면 `Overlay:36` 2.0. 배율 칸 · 저장 자리 없음. +고칠 자리: +- `Table` — `_cross_table_entities` · `_cross_note_entities` · `cross_note_height` · `cross_table_width` · `cross_table_height` 에 곁값 `scale=1.0`(폭 · 행 높이 · 글 · 줄 높이 곱함). +- `Cad:488-500` `build_cross_drawing(table_scale=1.0)` → 쌓기 `:549` · 표 `:578-584` · 틀 `:589-590` 에 같은 값 — 174 쌓기(표 아래선 고정 · 글 · 8 mm · 그림 위로)와 넘침 판정(`Sheet:210-213` 이 틀로 셈)이 **자동으로 같이 다시 셈**. 8 mm 간격 · `CELL_TABLE_PAD_MM` 은 그대로. +- `Sheet:124-150` 장 dict 에 `table_scale`(배치에서) · `:197-208` 넘김 · `section_block_size :67-101`(시험 전용)도 같은 곁값. +- `Support:119-136` `checked_cross_layout` 이 `table_scale` 지킴(지금은 모르는 칸을 버림) · 범위 검사 · `Router:553-557` 요청 칸 · `Fetch:182-185` 타입. +- `Panels:182-232` 숫자 칸 「표 배율」(step 0.1 · 기본 1) · `Page:194` [모든 장] 저장이 `{default, sheets: {}}` 로 배율을 지우지 않게 펼쳐 넣음. +재사용: `cross_layout.json` 한 파일 · 배치 칸 입력 꼴(`createInputField compact`) · 저장 → 화면 다시 세우기 길(`Page:173-187`). +시험: `test_174` 늘림 — 배율 1.5 에서 표 폭 162 · 높이 60 · 행 안 표 아랫선 같음 · 그림 아래 = 표(+글) 위 8 mm · 표가 칸보다 넓으면 `overflow_cells` · 배율 1 = 지금과 같은 수. `test_140:122`(배치 기본 dict 통째 비교) 기대값 고침. `test_153_1` 칸 하나 더. +위험: 확정 장은 저장본 크기 그대로(배치 바꿈은 담긴 측점이 바뀐 장만 풂 `Support:138-150`). 배율 ≥ 약 1.78 이면 4 × 4 칸 폭 넘침. `Cad` 632줄 · `Support` 615줄 · `Router` 599줄(700 안). 백엔드 재시작 · CAD 빌드 불필요. +크기: 중간 아래(파이썬 약 30줄 · 화면 약 12줄). +⬜ 21-1 값 하나 / 둘 — 표 · 글 한 배율(추천) / 표 · 글 따로. +⬜ 21-2 범위 · 단위 — 장 전부 한 값(추천) / 장마다 · 범위 0.5~2.0(추천). +⬜ 21-3 「텍스트」 — 표 + 표 위 글만(추천 · 서버 한 곳) / 그림 속 구조물 글자도(`Overlay:36` 에 placement 로 배율 실음). +⬜ 21-4 확정 장 — 그대로 + 안내(manifest 에 배율 적고 다르면 「옛 배치」 꼴 · 추천) / 배율 바꾸면 확정 장 전부 풂. + +## 고칠 파일 목록 + +- `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py` — 원지반 잠금 풂 · groupId · 배율 넘김 +- `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py` — 원지반 잠금 풂 · `table_scale` +- `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py` — 크기 함수 배율 +- `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · `B07_DesignDetail_Router.py` · `B07_DesignDetail_Api_Fetch.ts` — `table_scale` 저장 +- `B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts` — 덧그림 groupId +- `B07_DesignDetail/B07_DesignDetail_UI_Panels.ts` · `B07_DesignDetail_UI_Page.ts` — 붉은 칸 단추 · 배율 칸 +- `ui_template/cad_host/cad_host.ts` — `setGuideMarks` +- `B07_DesignDetail/openwebcad/src/integration/guide-area.ts` · `aislo-drawing-bridge.ts` — 붉은 틀 보이기 값 · 메시지(CAD build) +- 시험: 새 `test_19x_b07_cross_group.py` · `test_174_b07_cross_cell_stack.py` · `test_140_b07_cross_grid.py` · `test_153_1_b07_cross_layout.py`(+ helper) · `guide-area.test.ts` + +## 추천 차례 + +19(파이썬만 · 작음) → 21(파이썬 + 배치 칸) → 20(CAD 빌드 한 번) · 20 · 21 단추 · 칸은 같은 배치 칸 줄에 한 번에. diff --git a/docs/raw/요구_1010_2/조사_Y_B09탭박스내역.md b/docs/raw/요구_1010_2/조사_Y_B09탭박스내역.md new file mode 100644 index 000000000..3c006c5e1 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_Y_B09탭박스내역.md @@ -0,0 +1,156 @@ +# 조사 Y — B09 탭 두 층 · 박스보기 · 설계내역서 손 줄 레벨 (10-10 둘째 · 요구 22 · 23 · 29 · 30 · 읽기 전용 조사 에이전트) + +| 약칭 | 뜻 | +|---|---| +| `B09/` | `B09_Estimation/B09_Estimation_` | +| `B08/` | `B08_Quantity/B08_Quantity_` | +| `ui/` | `ui_template/ui_template_` | +| 손 줄 | 166-2 설계내역서 사용자 줄(`user.bill.rows` · 키 `hand:…`) | +| 「추정」 | 화면 실측 없이 코드 · CSS 셈으로 낸 판단 | + +## 0. 코드 지도 + +| 자리 | 파일:줄 | 줄 수 | +|---|---|---| +| 탭 키 · 이름 · 차례(평평한 한 줄 19) | `B09/UI_Tabs.ts:13-32 · 35-55` | 168 | +| 탭 줄 그리기 · 이동 · 묶음 탭 갈래 | `B09/UI_Shell.ts:151 · 182-254` | 332 | +| 탭 줄 CSS(sm 단추 · gap 4) | `B09/UI_Page.css:20-26` | 45 | +| B08 위 탭(md) · 보조 탭(sm) · 빈 보조 줄 | `B08/UI_Page.ts:30-44 · 56-83 · 90-100 · 118-134` · `B08/UI_Page.css:14-25` | 154 · 62 | +| 묶음 보기 단추 · 카드 · 표 갈래 | `B09/UI_Tabs_Groups.ts:31-35 · 165-207` | 207 | +| 공용 묶음 카드(요약 접기 · 강조) | `ui/group_cards.ts:71-80 · 187-247` · `.css:79-85 · 131-134` | 249 · 153 | +| 공용 표 강조 한 줄(`focusRow`) | `ui/data_table.ts:288-292` · `.css:148-152` | 294 · 174 | +| 설계내역서 표 · 손질 · 우클릭 | `B09/UI_Tabs_Bill.ts:23-81` · `B09/UI_Tabs_Bill_Edit.ts:72-242` | 81 · 242 | +| 설계내역서 셈(짝) | `B09/Calc_Bill.ts:28-43 · 237-266` ↔ `B09/Calc_Bill.py:33-52 · 246-275` | 299 · 310 | +| 손 줄 저장 다듬기 | `B09/Store.py:111-138` | 208 | + +## Y29 · Y30. 원가검토 탭 = 상위 · 하위 두 층 + +### 현재 탭 19 + 새 자리 + +| 차례 | 키 | 지금 이름 | 짜는 파일:줄 | 새 상위 · 하위 차례 | +|---|---|---|---|---| +| 1 | `check` | 연결 점검 | `B09/UI_Tabs.ts:139` | 상위 단독 | +| 2 | `cost` | 공사원가계산서 | `B09/UI_Tabs_Total.ts:245` | 상위 단독 | +| 3 | `total` | 총괄설계내역서 | `B09/UI_Tabs_Total.ts:81` | 상위 단독 | +| 4 | `bill` | 설계내역서 | `B09/UI_Tabs_Bill.ts:23` | 상위 단독 | +| 5 | `dg_list` | 일위대가목록표 | `B09/UI_Tabs_Labor.ts:45` | 일위대가 ① | +| 6 | `dg` | 일위대가표 | `B09/UI_Tabs_Labor.ts:78` | 일위대가 ② | +| 7 | `sg_list` | 단가산출근거목록표 | `B09/UI_Tabs_Sangeun.ts:39` | 단가산출 ① | +| 8 | `sg` | 단가산출근거 | `B09/UI_Tabs_Sangeun.ts:76` | 단가산출 ② | +| 9 | `basis` | 환율및기초자료 | `B09/UI_Tabs_Base.ts:63` | 상위 단독(사용자 목록에 없음) | +| 10 | `machine` | 중기목록표 | `B09/UI_Tabs_Base.ts:237` | 중기 ① | +| 11 | `machine_blocks` | 중기사용료 | `B09/UI_Tabs_Base.ts:263` | 중기 ② | +| 12 | `material` | 재료비목록표 | `B09/UI_Tabs_Base.ts:210` | 재료 ① | +| 13 | `labor` | 노무비목록표 | `B09/UI_Tabs_Base.ts:45` | 노무 ① | +| 14 | `expense` | 경비목록표 | `B09/UI_Tabs_Base.ts:52` | 중기 ④ | +| 15 | `compare` | 자재단가대비표 | `B09/UI_Tabs_Base.ts:116` | 재료 ②(사용자 글 「자재단가목록표」) | +| 16 | `sum_material` | 재료비수량금액집계표 | `B09/UI_Tabs_Summary.ts:57` | 재료 ③ | +| 17 | `sum_labor` | 노무비수량금액집계표 | `B09/UI_Tabs_Summary.ts:58` | 노무 ② | +| 18 | `sum_expense` | 경비수량금액집계표 | `B09/UI_Tabs_Summary.ts:59` | 중기 ⑤(166-5 · 자원 0 → 안내 한 줄) | +| 19 | `sum_machine` | 중기시간금액집계표 | `B09/UI_Tabs_Summary.ts:61` | 중기 ③ | + +- 새 상위 10 = 연결 점검 · 공사원가계산서 · 총괄설계내역서 · 설계내역서 · 일위대가 · 단가산출 · 환율및기초자료 · 중기 · 재료 · 노무(지금 차례 그대로 묶음 첫 자리). +- 이름 짝 — 다른 것은 「자재단가목록표」 ↔ `compare` 「자재단가대비표」 하나. 사용자 25 · 26 · 28번 글은 「대비표」. +- 없는 탭 — 없음. 중기시간금액집계표 · 경비수량금액집계표 · 노무비수량금액집계표 · 재료비수량금액집계표 넷 모두 있음(`B09/UI_Shell.ts:82-85`). +- 하위 차례가 엑셀 평평 차례와 다른 곳 = 중기(경비목록표 · 집계표 둘이 중기 뒤로) · 재료(대비표가 재료비목록표 뒤). + +### B08 양식(본뜰 꼴) + +- 위 탭 = `createButton` 기본 md(`B08/UI_Page.ts:122-126` · size 없음) · 보조 탭 = sm 줄 `div.b08-quantity__tabs`(`:57-75`) · 보조 탭 없는 탭 = 보이지 않는 sm 단추 한 줄로 높이만 채움(170-4 · `:90-100` · CSS `:22-25`) → 탭 바꿀 때 몸통이 안 튐. +- CSS = `.b08-quantity__tabs` gap 8 · 아래 8 · 밑줄 1px(`B08/UI_Page.css:14-20`) — B08 파일 안 클래스 · `ui/` 공용 탭 양식은 없음(`tablist` 쓰는 곳 = B08 · B09 · M02 DF · `ui/cad_host/cad_host_tabs.ts`(CAD 장 탭 · 결 다름)). +- B09 지금 = 한 줄 sm 19개(`B09/UI_Shell.ts:184-197`) · gap 4(`B09/UI_Page.css:23`). + +### 고칠 자리 + +- `B09/UI_Tabs.ts` — `TABS`(엑셀 차례 · 탭 이름 · 표 caption · 연결 점검 「탭」 칸)는 그대로 · 새 `TAB_GROUPS: [상위 키, 이름, 하위 TabId[]][]` + `groupOf(tab)` 더함(+20줄). +- `B09/UI_Shell.ts:184-197` — 위 줄 = 상위 10(md) · 아래 줄 = 고른 상위의 하위(sm) · 하위 하나뿐인 상위 = 빈 보조 줄(B08 170-4 꼴) · 위 탭 누름 = 그 묶음에서 마지막 본 하위(이 화면 안 Map) 없으면 첫 하위 · `show(tab, focus)` 는 TabId 그대로 받아 상위를 `groupOf` 로 정함 → 이동(`goto` `:205-207` · 연결 점검 줄 · 목록표 → 상세)이 묶음을 넘어도 그대로. +- `B09/UI_Page.css:20-26` — 위 줄 gap 8(B08 값) · 보조 줄 · 빈 줄 규칙 둘(B08 css 본뜸 · +10줄). + +### 재사용 · 기억 영향 + +- `createButton` md · sm · `role=tab` · `aria-selected` · `data-b09-tab`(그대로 하위 키 · 상위엔 `data-b09-group`). +- 탭 기억 — 지금 없음: `current = "check"`(`B09/UI_Shell.ts:151`) · 해시 · sessionStorage 안 씀(`location.hash` 는 라우터만 `A00_Common/router.ts:122`) → 들어올 때마다 「연결 점검」. 두 층으로 바꿔도 같음. +- 보기 기억 `b09-group-view` · `b09-group-cols`(`A00_Common/b_page_state.ts:70-71`)는 하위 키(dg · machine_blocks · sg) 그대로 → 영향 없음. + +### 시험 · 위험 · 크기 + +- 시험 — `test_144_b09_skeleton.py:44-49`(`TABS` 엑셀 차례 · 안 바꾸면 통과) · 새 판정(상위 10 차례 · 하위 묶음 = 사용자 30번 차례 · 19 키 빠짐 · 겹침 없음 · `groupOf` · 묶음 넘는 이동 = 상위 켜짐 · 단독 상위 = 빈 보조 줄). +- 위험 — 위 탭 md 10개 + 보조 줄 = 탭 머리 높이 ≈ +36px(추정 · sm 줄 하나 더) · 「연결 점검」 「탭」 칸은 하위 이름만(상위 안 보임). +- 크기 — TS +45 · CSS +10 · Shell 332 → 360. +- ⬜ Y30-a 「자재단가목록표」 = `compare` 이름을 바꿈? — 추천 그대로 「자재단가대비표」(엑셀 탭 이름 · test_144 · 25 · 26 · 28번 글). +- ⬜ Y30-b 「환율및기초자료」 자리 — 추천 지금 차례(단가산출과 중기 사이 상위 단독). +- ⬜ Y30-c 위 탭 다시 누름 = 마지막 본 하위(가 · 추천 · 저장 안 함) / 늘 첫 하위(나 · B08 꼴). + +## Y22. 「호표별 보기」 → 「박스보기」 · 접기 없앰 · 목록표 고른 호표 = 붉은 외곽선 + +### 현재 + +- 단추 글 = `viewLabel`(`B09/UI_Tabs_Groups.ts:32-33` · 「호표별 보기」 ↔ 「표로 보기」) · 묶음 탭 = dg · machine_blocks · sg(`:27`). +- 접기 = 묶음 12 넘으면 요약 보기(`ui/group_cards.ts:71 · 80 · 187-189 · 218-225 · 236-237` · CSS `.is-closed` `:79-85`) · 공용 카드 쓰는 곳 = B09 하나(`B09/UI_Tabs_Groups.ts:192`). +- 목록표 → 상세 — 일위대가목록표 `link` = `{tab: "dg", key: 블록 id}`(`B09/UI_Tabs_Labor.ts:68`) · 산근목록표 = `{tab: "sg", key}`(`B09/UI_Tabs_Sangeun.ts:61`) · 중기목록표 → 중기사용료도 같은 꼴(`B09/UI_Tabs_Base.ts:253`) → `show(tab, key)`(`B09/UI_Shell.ts:205-207`) → focus = 블록 머리 줄 번호(`B09/UI_Tabs_Groups.ts:189`). +- 박스 강조 지금 = 머리 띠 한 줄만 `is-focus`(accent 8% 바탕 · `ui/group_cards.ts:241-247` · CSS `:131-134`). +- 표 강조 지금 = 머리 줄 한 줄 `focusRow`(accent 밑줄 2px · `ui/data_table.ts:288-292` · CSS `:148-152`) · 표는 줄 28px 창 그리기(`ui/data_table.ts:239`) → 줄 class 는 `rowClass` 로만 오래 감. + +### 고칠 자리 + +- `B09/UI_Tabs_Groups.ts:33` — 「호표별 보기」 → 「박스보기」. +- `ui/group_cards.ts` — 요약 보기 지움(`GROUP_SUMMARY_OVER` · `isSummary` · `is-closed` · [모두 펼침 · 접기] · 머리 누름 펼침) · 이동해 온 키 = 그 박스(`article`)에 `is-pick`(몸 줄이면 그 줄 `is-focus` 도 유지) · CSS = B06 고름 꼴 `border-color: var(--color-danger); box-shadow: 0 0 0 2px var(--color-danger)`(`B06_Section/B06_Section_UI_Style_Cross.css:219-222`) · `.is-closed` 규칙 지움. +- 표 보기 — `B09/UI_Tabs_Groups.ts:204-205` · `B09/UI_Shell.ts:208` `makeTable(extra?)` 에 줄 class 곁값 → 고른 블록(키 `|` 앞머리 같은 줄 구간) = `is-pick` + 첫 줄 `is-pick-top` · 끝 줄(합계 · 총계) `is-pick-end` · CSS(`B09/UI_Page.css`) = 안쪽 그림자 4면 danger 2px(첫 칸 왼 · 끝 칸 오른 · 위 · 아래) · 머리 줄 accent 밑줄은 `is-pick` 안에서 덮음 · 스크롤 = `focusRow(머리)` 그대로. +- 공용 표(`ui/data_table.ts`)는 안 고침(`rowClass` 곁값으로 끝남). + +### 재사용 · 시험 · 위험 · 크기 + +- 재사용 — `--color-danger`(밝음 #c0392b · 어두움 #e8695a · `ui/theme.css:40 · 323`) · 164-2 「표 강조 = 붉은 한 벌」 · B06 카드 고름 CSS · `rowIndex` · 묶음 키 규칙(`B09/UI_Tabs_Groups.ts:136`). +- 시험 — `test_157_2_b09_sangeun_views.py:69` · `test_171_3_b09_group_tabs.py:165`(글 「박스보기」) · `test_171_2_group_cards.py:42-50 · 114-136`(요약 판정 → 「13 묶음도 접힘 없음 · 머리 누름 = 이동 없음 · 고른 박스 `is-pick`」) · 새 판정(표 보기 블록 구간 class · 첫 · 끝 줄 · 다른 블록 없음). +- 위험 — 묶음이 많은 탭(산근 수십) = 다 펼쳐져 길어짐(요구대로 · 박스 찾기는 목록표 누름 → 스크롤) · OS 어두움 자동 블록엔 danger 어두운 값 없음(R12 공용 빈틈 · 범위 밖). +- 크기 — 공용 TS −25 +6 · CSS −7 +4 · Groups +12 · Shell +3 · B09 CSS +12. +- ⬜ Y22-a 글 = 「박스보기」(사용자 글 그대로 · 추천) / 「박스 보기」 · 맞은편 「표로 보기」 유지. + +## Y23. 설계내역서 손 줄 레벨 · 상위 합계 · 연결(우클릭 + 단추) + +### 현재 + +- 줄 꼴 = 제목(`kind: "title"` · 고정 공종 나무 14 `B09/Calc_Bill.ts:28-43`) · 항목(`kind: "item"`) 둘뿐 · 레벨 칸 없음 · 공종 번호 깊이 = 2(1 → 1.1) · 합계 = 항목이 자기 공종 + 부모 하나에 더함(`B09/Calc_Bill.ts:240-249` ↔ `.py:250-260`) · 짝 맞음(⚠ 머리 주석 · 거울 `test_166_2_b09_bill_hand.py:125-135` 전체 같음 판정). +- 화면 레벨 표시 = 항목 이름 앞 빈칸 둘뿐(`B09/UI_Tabs_Bill.ts:28`) · 제목 굵게 없음(`rowClass` = `is-hand` 만 `:74`) · 1 토공사 · 1.1 준비공이 같은 꼴. +- 손 줄 저장 = `user.bill.rows[] {key hand:… · section · after · name · spec · unit · qty · ref}`(`B09/UI_Tabs_Bill_Edit.ts:7 · 87` · 다듬기 `B09/Store.py:111-138` · 저장 `user_values.json`) · 자리 = `after` 줄 뒤(`B09/Calc_Bill.ts:147-152`) · 손 줄은 늘 항목(수량 × 단가). +- 연결 = 우클릭 [연결 ▸ 일위대가 호표 · 단가산출 호표 · M01 로직](`B09/UI_Tabs_Bill_Edit.ts:194-206`) · 고르기 창 = `openModal` + 공용 `createFindList`(`:116-151`) · 로직 = M02 `openLogicPicker` + `openLaborInput`(`:154-183`) — 이미 있음 · 단추는 없음. +- 다른 고르기 창 — B08 [선택] = `openItemPicker`(구조물 조합 고름 · `B08/UI_Link.ts:46`) · 호표가 아니라 조합 → 이번엔 안 맞음. + +### 고칠 자리 + +- 저장 꼴 — 손 줄 곁값 둘: `kind`(`"group"` = 묶음 줄 · 없음 = 항목) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래). 묶음 줄 = 수량 · 연결 없음(이름만). +- 셈(짝 ① · `B09/Calc_Bill.ts` ↔ `.py` 함께) — 묶음 줄 = `itemRow` 안 거침(「연결 없음」 거짓 목록 안 생김) · 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합(`:240-249`)은 항목(잎)만 → 원가계산서 `sums` 그대로 · 줄마다 `level` 내보냄(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1 …) · `parent` 못 찾으면 공종 바로 아래(옛 저장도 그대로 읽힘). +- `B09/Store.py:122-130` — `kind` · `parent`(`hand:` 시작만) 남김 · 묶음 줄 `qty` · `ref` = None. +- `B09/UI_Tabs_Bill_Edit.ts` — 우클릭 더함: [아래에 하위 줄 추가](묶음 줄 · 자리 = 마지막 자손 뒤) · [묶음 줄로 · 항목으로] · [들여쓰기 → · 내어쓰기 ←](= `parent` 바꿈) · 지울 때 자식은 지운 줄 부모로(`:92-99` `after` 꼴). +- `B09/UI_Tabs_Bill.ts` — 이름 앞 빈칸 대신 `rowClass` = `is-lv{n}`(+ `is-hand`) · CSS(`B09/UI_Page.css`) Lv1 = 16px 굵게 · Lv2 = 14px 굵게 · Lv3 = 14px · Lv4+ = 13px · 첫 칸 들여쓰기 1em × (레벨 − 1) · 묶음 손 줄 = 굵게. +- 단추 — 손 줄 「비고」 칸 앞 새 좁은 칸 「연결」(글 「연결 ▾」 · 연결됨 = 호표 번호) · 누름(`onCell`) = 우클릭과 같은 [연결 ▸] 메뉴를 그 칸 자리에 엶(`ctxMenu.open` · 칸 `getBoundingClientRect`) · 이 칸은 `link` null(이동 안 함). + +### 재사용 · 계산 자리 + +- `pickHopyo`(dg · sg 목록 · `createFindList`) · `openLogicPicker` · `openLaborInput` · 공용 우클릭 메뉴(`ui/context_menu.ts:130` `open(x, y, items)` · 겹 메뉴) · `rowClass` 여러 class(빈칸으로 이음 · `ui/data_table.ts:164`) · 집계표는 `kind !== "item"` 건너뜀(`B09/Summary.py:53`) → 묶음 줄 안전 · 산근 손 로직(`B09/Sangeun.py:477-489`)은 `ref.tab == "logic"` 만 → 묶음 줄 안 걸림. +- 계산 자리 = ① 파이썬 · TS 짝(이미 짝 · 조작 중 브라우저 · [저장] 때 서버 정본) · 서버 단독 아님(조작 즉시 합계 보여야 함) · 새 계산 없음 — 합 규칙만 더함. + +### 시험 · 위험 · 크기 + +- 시험 — `test_166_2_b09_bill_hand.py`(자리 · 거울 · 저장 다듬기) 손봄 + 새 판정(묶음 줄 합 = 자손 합 · 겹 묶음 · 공종 합 · `sums` 안 바뀜 · 빈 줄 n · 부모 없음 = 공종 아래 · 옛 꼴 그대로 · `level` 값 · TS 거울 · 다듬기 `kind` · `parent`) · `test_145_3_b09_bill.py`(rows 에 `level` 더해짐 → 판정 손봄 여부 확인) · `test_145_5`(집계표 · 묶음 줄 빼고 셈). +- 위험 — ① 표가 줄 28px 고정 창 그리기 → 글자 16px 넘으면 줄 높이 어긋남(16px 까지) ② 1 토공사 굵게 · 크게로 지금 화면 꼴이 다 바뀜 ③ `after` 차례와 `parent` 가 어긋나면(자식이 부모 앞) 합은 맞고 들여쓰기만 이상 → 끼울 때 마지막 자손 뒤 규칙 ④ 둘 짝 한쪽만 고치면 거울 깨짐. +- 크기 — Calc_Bill ts 299 → 335 · py 310 → 350 · Bill_Edit 242 → 300 · Bill 81 → 95 · Store +5 · CSS +12. +- ⬜ Y23-a 저장 꼴 = `parent` 키(가 · 추천 · 차례가 흔들려도 합 맞음) / `level` 수(나 · 엑셀 윤곽 꼴 · 차례로 부모 짐작). +- ⬜ Y23-b 손 줄로 새 1단 공종(예 「5 부대공」) 허용? — 추천 안 함(고정 공종 안에서만 · 원가계산서 직접비 1~4 합 규칙 그대로) · 필요하면 직접비에 넣을지 따로 정함. +- ⬜ Y23-c 손 묶음 깊이 한도 — 추천 2(묶음 안 묶음까지 · Lv5) · 글자 13px 바닥. +- ⬜ Y23-d 자동 줄(B08 수량 줄)도 손 묶음 아래로 옮김? — 추천 안 함(자동 줄은 공종 고정 · 손 줄만). + +## 고칠 파일 목록 + +| 파일 | 지금 | 뒤(추정) | 700 | 요구 | +|---|---|---|---|---| +| `B09_Estimation/B09_Estimation_UI_Tabs.ts` | 168 | 190 | 없음 | 29 · 30 | +| `B09_Estimation/B09_Estimation_UI_Shell.ts` | 332 | 365 | 없음 | 29 · 30 · 22 | +| `B09_Estimation/B09_Estimation_UI_Page.css` | 45 | 80 | 없음 | 29 · 30 · 22 · 23 | +| `B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts` | 207 | 220 | 없음 | 22 | +| `ui_template/ui_template_group_cards.ts` · `.css` | 249 · 153 | 230 · 150 | 없음 | 22 | +| `B09_Estimation/B09_Estimation_Calc_Bill.ts` · `.py` | 299 · 310 | 335 · 350 | 없음 | 23(짝) | +| `B09_Estimation/B09_Estimation_Store.py` | 208 | 213 | 없음 | 23 | +| `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` | 242 | 300 | 없음 | 23 | +| `B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts` | 81 | 95 | 없음 | 23 | +| 시험 — `test_144_b09_skeleton` · `test_157_2` · `test_171_2` · `test_171_3` · `test_166_2` · `test_145_3` · `test_145_5` + 새 시험 | — | — | — | 22 · 23 · 29 · 30 | diff --git a/docs/raw/요구_1010_2/조사_ZA_관급.md b/docs/raw/요구_1010_2/조사_ZA_관급.md new file mode 100644 index 000000000..0253d7e2e --- /dev/null +++ b/docs/raw/요구_1010_2/조사_ZA_관급.md @@ -0,0 +1,78 @@ +# 조사 ZA — 관급(관급자재) 구현 여부 + +요구 27 · 조사만(코드 안 고침) · 기준 = 지금 `main_laptop_1` 작업 트리 · 상태 = 됨 / 일부 / 없음. + +## 1. 한눈 결론 + +- **원가 계산 끝단은 됨** — 관급자재대 = 순자재대 + 조달수수료 → 천원 올림 · 도급공사비 밖 · 총공사비에 더함 · 일반관리비 · 이윤 · 부가세 밑수에서 빠짐 · 안전관리비 A/B 비교까지 실무 엑셀(기번3) 숫자와 같음. +- **관급 표시는 B09 자재단가대비표 한 곳뿐** — 「관급 · 별도」 칸 누름으로 바꿈 · 기본 관급은 파형강관 하나. +- **M02 자재구분(관급 · 사급)은 저장 · 화면만** — B09 가 안 읽음 → 마스터 · 사본에서 「관급」으로 골라도 원가에 안 이어짐. +- **같은 자재가 두 줄(B08 총괄 줄 · 일위대가 안 자재 줄)** — 관급 표시를 따로 해야 함 · 한쪽만 하면 두 번 셈 또는 사라짐 · 커플링밴드는 기본 관급 아님 → 내역서에서 빠짐. +- **엑셀 내보내기 없음** · 조달수수료율 기본값 없음(빈칸이면 관급자재대 · 총공사비가 빈칸). + +## 2. 흐름 단계 표 + +| 단계 | 상태 | 지금 어떻게 | 근거 파일:줄 | 빈틈 | +|---|---|---|---|---| +| M02 자재구분 칸 | 일부 | 일위대가 자재 줄 · 로직 자재 줄에 「관급 · 사급」 드롭다운 · 빈 칸 = 사급(162-1) · 금액 무관 | M02_Labor_Doc.py:49-54 · :222-224 · M02_MasterTemplete_Labor_Api.ts:143 · :150-151 · M02_MasterTemplete_Labor_MatMarks.ts:3 | 저장만 · `mark_of` 를 시험 말고 부르는 곳 없음 | +| 프로젝트 사본(구조물) 자재구분 | 일부 | 사본 일위대가에 그대로 실림(검증 프로젝트에 「관급」 표시 실제 있음 · 아래 5) | storage …/d2245e3b…/templates/structures/pipe@263.21/일위대가.json | B09 가 안 읽음 | +| M01 자재품목 「관급」 | 됨(단가 출처) | 조달청 관급 단가 열(30,711 줄 중 11,278 줄 값) · 고르기 창에 「관급」 꼬리표 | M01_MasterData_Store.py:490 · :503 · M01_MasterData_UI_Pick.ts:85 | 이건 「관급 가격」 출처 · 「누가 사나」 표시와 다른 뜻 | +| B05 · B06 배관 | 없음 | 관 배치 · 관경만 · 관급 개념 없음 | (해당 코드 없음) | 필요 없음 — B09 기본값이 맡음 | +| B08 구조물 집계표 | 없음 | 관급 표시 칸 없음 · 커플링 열은 B09 내역서 구조물 줄에서 뺌(「관급자재대라」) | B09_Estimation_Bill_Items.py:8 · :47 | 뺀 커플링이 관급자재대에도 안 들어감(아래 B09 기본값) | +| B08 자재 탭(총괄) | 일부 | 파형강관 · 커플링 할증 0(관급 뜻) · 관급 표시 칸 없음 · 안내 글만 | B08_Quantity_Material_Calc.py:99 · :119 · :349-354 · B08_Quantity_UI_Material.ts:55 | 표시는 B09 에서 | +| B09 자재단가대비표 「관급 · 별도」 | 됨 | 칸 누름 = 없음 → 관급 → 별도 · 사용자 값 저장(user_values.materials[키].supply) | B09_Estimation_UI_Tabs_Base.ts:108 · :155 · B09_Estimation_Calc_Base.py:38 · :161 · B09_Estimation_Store.py:151-152 | — | +| B09 기본 관급 | 일부 | B08 총괄 `pipe_` 줄만 관급 기본 | B09_Estimation_Prices.py:231-232 | 커플링밴드(M01 이름도 「(관급)」) · 시멘트 · 모래 등 기본 없음 · M02 표시 안 씀 | +| B09 재료비목록표 관급 칸 | 됨 | 「관급 · 별도」 열 보임 · 총액 = 수량 × 적용 단가 | B09_Estimation_Calc_Base.py:181-189 · B09_Estimation_UI_Tabs_Base.ts:224 | B08 파형강관 줄은 M01 짝 없음 → 관급 단가 수기 입력 | +| 일위대가(대가) 안 관급 자재 | 됨 | 관급 · 별도 표시 자재 = 대가 안 0원(두 번 안 셈) | B09_Estimation_Calc_Labor.py:46-47 | 수량 없는 MT 줄이면 관급자재대에도 안 감 → 사라짐 | +| 단가산출근거(산근) 안 관급 자재 | 없음 | 표시와 무관하게 적용 단가 그대로 셈 | B09_Estimation_Sangeun.py:155 · :414 | 대가와 규칙 어긋남 | +| 설계내역서 관급자재대 단락 | 됨 | 「관급」 표시 + B08 수량 있는 자재 → 관급자재대(제외금액) · 조달수수료 줄 · 순공사비 계에서 뺌 | B09_Estimation_Calc_Bill.py:47-50 · :98-118 · :217-245 · B09_Estimation_UI_Tabs_Bill.ts:48 | 수량 원천 = B08 총괄 줄뿐 | +| 조달수수료 | 일부 | ROUNDDOWN(순자재대 × 율 ÷ 100, 0) · 율 = 사용자 칸(빈칸 시작) | B09_Estimation_Calc_Bill.py:221-245 · B09_Estimation_Calc_Total.py:68-70 · :206-210 | 화면 안내 「빈칸 = 0」(UI_Tabs_Total.ts:203) 인데 셈은 빈칸 → 관급자재대 · 총공사비 빈칸 | +| 원가계산서 · 총괄 관급자재대 | 됨 | ROUNDUP(순자재대 + 수수료, −3) · 도급공사비 밖 · 총공사비 = 도급 + 관급 | B09_Estimation_Calc_Total.py:204-211 · B09_Estimation_UI_Tabs_Total.ts:169-176 · :241 | — | +| 일반관리비 · 이윤 · 부가세 | 됨 | 밑수에 관급 없음(순공사원가 · 다 − 재료비 · 공급가액) | B09_Estimation_Calc_Total.py:196-203 | — | +| 산업안전보건관리비 | 됨 | A = (재료비 + 직노 + 관급 ÷ 1.1) × 율 + 기초액 · B = ((재료비 + 직노) × 율 + 기초액) × 1.2 · 작은 쪽 | B09_Estimation_Calc_Total.py:59-60 · :146-150 · :186-192 | — | +| 추정가격(요율 구간) | 일부 | 손 입력 칸만 | B09_Estimation_UI_Tabs_Total.ts:32 · :131 | 「관급 빼고」 안내 · 자동 후보 없음 | +| 견적가(자재 견적) | 됨(관급 무관) | 대비표 「견적」 열 · 관급 자재도 같은 칸으로 단가 | B09_Estimation_Calc_Base.py:150-158 | — | +| 엑셀 내보내기 | 없음 | B09 엑셀 내보내기 코드 없음 | (해당 코드 없음) | 관급 포함 전체 없음 | +| 옛 자리(안 씀) | — | 프로젝트 설정 `material_supply` · 화면 글 키 넷(관급·사급 탭) — 옛 코드는 tmp/old_code 로 감 | common_util_project_settings.py:100-108 · ui_template_locale_b2.ts:857-863 · :912 · :918 | 정리 거리 | + +## 3. 기준 대조 + +| 기준(근거 문서) | 지금 구현 | 판정 | +|---|---|---| +| 관급자재대 = 총원가 밖 별도 · 총공사비에 합함 — 지식DB 원가계산_체계.md:30 · :40-41 · 산림과임업기술(임도) 5. 설계.md:558 · :581 | Calc_Total.py:204-211 | 맞음 | +| 일반관리비 · 이윤 · 부가세에 관급 안 듦 — 예정가격작성기준 원가 구성 · 원가계산_체계.md 트리 | Calc_Total.py:196-203 | 맞음 | +| 안전관리비 = 관급 포함 값 · 관급 뺀 값 × 1.2 중 작은 값 이상 — 건설업 산업안전보건관리비 계상 및 사용기준 현행_20250212.md:18 · :44 · 제비율 적용기준 현행_20260413.md:125 | Calc_Total.py:186-192 · ÷ 1.1 은 실무 엑셀 꼴(관급가 부가세 포함) · M01 요율 규칙 메모와 같음 | 맞음(÷ 1.1 = 실무 근거) | +| 조달수수료 = 순자재대 × 율 원 버림 · 관급자재대 천원 올림 — 조달수수료 고시 현행_20250701.md(단가계약 0.54 %) · 단수처리_규칙.md:28 · :30 | Calc_Bill.py:222 · Calc_Total.py:205-210 · 기번3 69,474,220 + 375,160 → 69,850,000 같음(PLAN 145-3) | 맞음 · 율 기본값은 사용자 협의 거리 | +| 관급 자재 = 내역 단가 0원 · 관급자재대로 따로 — 자재단가_적용.md:32 | 대가 맞음(Calc_Labor.py:46-47) · 산근 다름(Sangeun.py:414) | 일부 | +| 관급 · 사급 구분 = 발주기관 결정 · 프로그램은 자재별 표시 받아 두 갈래 집계 — 자재단가_적용.md:36 | B09 대비표 칸 됨 · M02 표시 안 이어짐 · 같은 자재 두 줄 | 일부 | +| 관급 흔한 품목 = 시멘트 · 모래 · 자갈 · 깬돌 · 파쇄암 · 파형강관(일부) — 자재단가_적용.md:32 · 원가_기초데이터_원천.md:166 | 기본 관급 = 파형강관만 · 커플링밴드 빠짐 | 다름(기본값 좁음 · 확정은 사용자 몫) | +| 추정가격 = 관급자재 부분 뺀 금액 — 국가계약법 시행령 현행_20260603.md:69(제7조 1호) · 지방계약법 시행령 현행_20260603.md:87 | 손 입력만 · 안내 없음 | 일부 | +| 공사손해보험료 = (총공사원가 + 관급) × 율 — 예정가격작성기준 현행_20260825.md:219 | 비목 자체 없음 | 없음(임도 실무 엑셀 비목 밖 · 참고) | +| 관급자재 관리비(경비 세비목) — 예정가격작성기준 현행_20260825.md:202 | 없음 | 없음(실무 엑셀 밖 · 참고) | +| 산재 · 고용 적용 = (도급 + 관급) 부가세 빼고 2천만 원 이상 — 제비율 적용기준 현행_20260413.md:104 | 문턱 검사 없음 | 없음(작은 공사만 해당) | + +## 4. 빈틈 목록과 고칠 거리 + +| # | 빈틈 | 고칠 거리 | 크기 | +|---|---|---|---| +| 1 | 같은 자재가 B08 총괄 줄(`b08:cement`)과 대가 안 MT 줄(`MT004188`)로 따로 — 관급 표시를 한쪽만 하면 두 번 셈(대가 정가 + 관급자재대) 또는 사라짐(대가 0원 · 관급자재대 줄 없음) | 관급 표시를 자재 한 벌로 묶음(총괄 줄 ↔ MT 짝) · 또는 관급 수량 = 대가 역전개 총수량(Summary.py:50 이미 셈) | 중 | +| 2 | M02 · 사본 「자재구분 관급」이 B09 에 안 이어짐 | B09 입력 짤 때(Prices.build_inputs) 사본 자재표시 「관급」 → 그 자재 기본 supply 관급 · 두 사본이 다르게 고른 자재 규칙은 사용자 협의 | 중 | +| 3 | 산근 안 관급 자재 정가 셈 | 대가와 같은 규칙(관급 · 별도 = 0원) · 파이썬 한 곳(서버 단독) | 소 | +| 4 | 커플링밴드 기본 관급 아님 → 구조물 줄에서도 빠지고 관급자재대에도 안 감 | Prices.py:232 기본 관급에 `coupling_` 더함 | 소 | +| 5 | 조달수수료율 빈칸 = 안내는 0 · 셈은 빈칸 | 안내 글을 셈에 맞추거나 빈칸 = 0 처리 · 기본 0.54 % 여부는 사용자 협의 | 소 | +| 6 | B08 파형강관 · 커플링 줄 M01 짝 없음 → 관급 단가 수기 입력 | B08 총괄 줄 ↔ M01 「파형강관 · 커플링밴드(관급)」 규격 짝 → 관급 단가 자동 | 중 | +| 7 | 추정가격 손 입력 · 관급 제외 안내 없음 | 칸 안내 한 줄(「관급 · 부가세 뺀 금액」) · 자동 후보(공급가액)는 나중 | 소 | +| 8 | 엑셀 내보내기 없음 | B09 탭 전체 내보내기(관급자재대 단락 · 총괄 줄 포함) — 별도 일감 | 대 | +| 9 | 안 쓰는 옛 자리 | `material_supply` 설정 칸 · 화면 글 키 넷 정리 | 소 | +| 10 | 공사손해보험료 · 관급자재 관리비 · 산재 · 고용 2천만 원 문턱 | 실무 엑셀에 없으면 둘 필요 없음 · 필요 여부 사용자 확인만 | 참고 | + +## 5. 검증 자료 확인(읽기만) + +- storage 검증 프로젝트 `d2245e3b…` 사본 `pipe@263.21` 일위대가 — 콘크리트 로직 자재 시멘트(MT004188) · 모래(MT003816) = 「관급」 표시 · B09 는 이 표시 안 봄. +- 모든 프로젝트에 B09 `user_values.json` 없음 → B09 「관급 · 별도」 칸을 저장한 이력 없음 · `price_book.json` 은 한 곳(MT 6 줄 · 관급 단가 빈칸). +- 시스템 임시 폴더 스크립트(순수 셈 함수만 부름) 결과: + - 파형강관 Φ1000 8 m(기본 관급 · 단가 100,000) → 관급자재대 단락 800,000 · 순공사비 계 0(빠짐 맞음). + - 커플링밴드(표시 없음 · 수량 2) → 설계내역서 어디에도 없음. + - 시멘트 MT(관급 표시 · B08 수량 없음) → 대가 단가 0원 · 관급자재대 줄도 없음(사라짐). + - 조달수수료율 빈칸 → 관급자재대 빈칸 · 율 0.54 % → 800,000 + 4,320 → 805,000(천원 올림). +- 엑셀 대조(PLAN 145-3 · 145-4 검증완료) — 관급 69,474,220 + 0.54 % 375,160 → 69,850,000 · 도급 1,132,029,000 + 관급 = 총공사비 1,201,879,000 · 안전 A 16,586,996 · B 18,494,700 같은 값. diff --git a/docs/raw/요구_1010_2/조사_Z_B09자재표.md b/docs/raw/요구_1010_2/조사_Z_B09자재표.md new file mode 100644 index 000000000..b47b7507b --- /dev/null +++ b/docs/raw/요구_1010_2/조사_Z_B09자재표.md @@ -0,0 +1,135 @@ +# 조사 Z — B09 재료비목록표 줄 출처 · 자재단가대비표 열 · 적용 드롭다운 · 수량 열 (10-10 둘째 · 24 · 25 · 26 · 28 · 읽기 전용 조사 에이전트) + +| 줄임 | 뜻 | +|---|---| +| `B09/` | `B09_Estimation/B09_Estimation_` | +| `ui/` | `ui_template/ui_template_` | +| 대비표 | 탭 `compare` 「자재단가대비표」(`B09/UI_Tabs.ts:50`) = 사용자 글 「지제단가대비표」 · 「자재단가목록표」(30번) | +| 목록표 | 탭 `material` 「재료비목록표」(`B09/UI_Tabs.ts:47`) | +| 사본 | 프로젝트에 연결된 구조물 조합 사본(`linked_snapshots`) | +| 실측 | 스크래치 스크립트(시스템 임시 폴더) · storage 읽기 + DB SELECT(B08 총괄) · 저장 안 함 | + +## 0. 코드 지도 + +| 자리 | 파일:줄 | 줄 수 | +|---|---|---| +| 수요 키 = 사본 로직을 돌릴 때 `get` 가로채기 | `B09/Linked.py:178-207` · `:210-220` | 73 | +| 설계내역서 수기 줄 수요(같은 꼴) | `B09/Sangeun.py:496-517` | 563 | +| 수요 → 자재 줄 · B08 총괄 줄 덧붙임 · MT 같으면 수량만 | `B09/Prices.py:134-172` · `:213-235` · `:238-268`(합침 :252-259) | 268 | +| GET 길 · 수요 · 입력 · 셈 | `B09/Router.py:84-89` · `:92-117` | 147 | +| 대가 블록 — 조합 자재 줄(MT 코드) | `B09/Labor_Source.py:265-269` | 340 | +| 대가 재료 줄 단가 = 목록표 키 찾기 | `B09/Calc_Labor.py:44-47` | 210 | +| 대비표 셈(최저 · pick › applied › 최저) · 목록표 셈 | `B09/Calc_Base.py:133-178` · `:181-189` ↔ `.ts:119-158` · `:160-171`(거울 `test_144` · `test_166_4`) | 335 · 309 | +| 대비표 · 목록표 화면 | `B09/UI_Tabs_Base.ts:108-208` · `:210-235` | 326 | +| 사용자 값 다듬기 `materials{applied · supply · quote · quote_page · pick}` | `B09/Store.py:144-160` | 208 | +| 집계표 역전개(설계내역서 × 호표) · 「목록표에 없음」 | `B09/Summary.py:47-66` · `:73-92` · `:158-160` | 160 | +| 자재대 · 관급자재대 항목(수량 = 입력 qty) | `B09/Calc_Bill.py:98-118` ↔ `.ts:88-` | 310 · 299 | +| 공용 표(줄 class 만 · 칸 class 없음) | `ui/data_table.ts:40-63` · `:129-178` · `:201-226` | 294 | +| 표 짜기 곁값 · Shell 표 만들기 · 칸 풍선 듣기 | `B09/UI_Tabs.ts:75-92` · `B09/UI_Shell.ts:207-238` | 168 · 332 | +| 공용 우클릭 메뉴 · B09 공유 한 벌 | `ui/context_menu.ts:23 · 71` · `B09/UI_Tabs_Bill_Edit.ts:222-242` | 181 · 242 | + +## Z24. 재료비목록표 = 연결된 일위대가의 재료 줄 + +### 현재 — 줄 넷 갈래 + +| 갈래 | 출처(파일:줄) | 수량 | 단가 | +|---|---|---|---| +| ① 사본 로직이 **건드린** MT · MO | `Linked.py:187-196`(`snapshot.get` 가로채기 = 고르기 후보 훑기까지) | 빈칸 | M01 출처 → 최저 | +| ② 기계 연료(경유 · 휘발유) | `Prices.py:155-165` | 빈칸 | 유가 | +| ③ B08 총괄자재 줄 `b08:키`(파형강관 · 커플링 · 사본 자재 · 로직 재료) | `Prices.py:213-235` | B08 합계(할증 뒤) | 빈칸(출처 없음) | +| ④ ③ 키가 ① 의 MT 와 같음 | `Prices.py:255-259` | B08 합계를 얹음 | M01 | + +- 수량 · 총액 = 166-3 꼴(`Calc_Base.py:181-189`) — B08 줄만 수량 · M01 줄만 단가 → 두 쪽이 엇갈려 총액이 거의 다 빈칸. +- 비고 칸 = `origin`(「M01」 · 「B08」 · 「유가」 · `UI_Tabs_Base.ts:225`) — 어느 대가 · 산근이 쓰는지 안 보임. + +### 실측(세 프로젝트) + +| 프로젝트 | 줄 | 대가 · 산근 · 중기가 씀 | 아무도 안 씀 | B08 줄(단가 빈칸) | 수량 있는 줄 | +|---|---|---|---|---|---| +| 검증_랩탑메인 226fc805(사본 1) | 6 | 3(잡석 · 경유 · 휘발유) | 0 | 3 — 파형강관 Φ1200 7 · 커플링 0 · **합판 b08:MT000053 7** | 3 | +| 검증_sub1 7e420ed6(사본 14) | 125 | 10 | **110** | 5 — 파형강관 둘 · 커플링 둘 · **부직포 b08:MT023984** | 5 | +| 엄상준_검증 d2245e3b(사본 10) | 141 | 17 | **117** | 7 — 파형강관 둘 · 커플링 둘 · 채움 콘크리트 · **레미콘 b08:MT004253 · 부직포 b08:MT023984** | 9 | + +- 안 쓰는 줄 = 같은 이름 지역 갈래가 줄줄이(합판(내수) 서울~제주 19 · 시멘트 전국~제주 · 외송각재 · 어닐링철선) — 쓰인 줄은 그중 하나. + +### 원인 + +1. **후보 줄까지 수요** — `Linked.used_keys`(`Linked.py:187-196`) · `Sangeun.hand_resource_keys`(`:505-514`)가 로직 실행 중 `get` 을 부른 키를 모두 담음 → 재료 고르기(`mf.mp.priced` · `Sangeun.py:401`)가 훑은 후보까지 들어옴(머리 주석 `Linked.py:6-7` 「고르기 후보 전부는 안 씀」과 어긋남 · 추정 = 실측 지역 갈래 꼴). +2. **사본 일위대가 자재 줄 빠짐** — 조합 자재(`Labor_Source.py:265-269` · 사본 `자재` 코드)는 로직이 아니라 수요에 안 듦 → 대가 재료 줄 키 `MT000053` 이 목록표에 없음 → 대가 단가 빈칸(`Calc_Labor.py:45-47`) · 집계표 「목록표에 없음」(`Summary.py:89`) · 같은 자재가 B08 줄 `b08:MT000053`(단가 없음)으로만 보임(조사 ZA 「같은 자재 두 줄」과 같은 뿌리). +3. **수량 원천이 B08 하나** — 산근 · 대가가 쓰는 자재는 수량 빈칸. + +### 고칠 자리 + +- 원인 2 — `Linked.used_keys` 끝에 사본 `doc["자재"]` 코드 더함(`M02_Item_Source.py:161` 꼴 · +3줄) → MT 줄 = M01 단가 · B08 합계는 `Prices.py:255-259` 가 그 줄에 얹음(두 줄 → 한 줄). +- 원인 1 — `Router.payload`(`:101-106`) 두 번 셈: 1차 셈 → 쓰는 키 = 호표 구성 재료 키(`Summary.hopyo_lines` · 빈 키 뺌) ∪ 연료 키 ∪ B08 줄 ∪ 관급 · 별도 표시 줄 → `inputs["materials"]` 거름 → 2차 셈(+12줄 · 거르기 함수 `Summary.py` +8줄). 화면 짝은 거른 입력을 그대로 받음(TS 안 고침). 실측 셈 0.6 초(엄상준) → 두 번 ≈ 1.2 초. +- 비고 → 「쓰는 곳」(대가 n호표 · 산근 n호표 · 중기 · 내역 자재대) — `UI_Tabs_Base.ts:210-235` 에서 `sheets.daega_blocks` · `sg` · `machine_blocks` 줄 `ref.key` · 설계내역서 자재 항목을 키로 모음(표시 잇기 · 셈 아님 · +20줄) · 안내 글(:232) 고침. +- 수량 — ⬜ 24-a 따라. + +- 재사용 — 산근 목록 「쓰는 구조물」 꼴(`Sangeun.py:292-295` · `UI_Tabs_Sangeun.ts:42`) · `hopyo_lines` · 줄 누름 → 대비표 이동(`UI_Tabs_Base.ts:233`). +- 시험 — `test_166_3`(B08 줄 · MT 합침 판정 손봄) · `test_144_b09_skeleton.py:107`(compare 키 차례) · 새 시험(후보 줄 거름 · 사본 자재 MT 가 수요에 듦 · 대가 재료 줄 단가 채워짐 · 쓰는 곳 글) · `test_145_2` · `test_145_5` 통과 확인. +- 위험 — ① 두 번 셈 = GET · PUT 느려짐(1초 남짓) ② 대비표 번호(no)가 바뀜 → 내역 자재 항목 비고 「자재 n」(`Calc_Bill.py:113`)도 따라 바뀜(같은 셈이라 어긋남 없음) ③ 산근 재료 줄 키 빈칸(sub1 · 엄상준에 있음 · 고르기 못 한 줄)은 그대로 빈칸 ④ 단가 책(`price_book.json`)엔 후보 줄이 남음(쓰지 않음 · 무해). +- 크기 — py 25줄 · TS 25줄 · 시험 손봄 둘 + 새 시험 하나. +- ⬜ 24-a 수량 열 — ① 지금(B08 합계 · 산근 재료 빈칸) ② 역전개 총수량(재료비수량금액집계표와 같은 값 · 1차 셈 집계를 입력 `material_qty` 로 넣어 2차 · Calc_Base 짝 `material_sheet` 수량 원천만 바꿈 · 거울) ③ 수량 · 총액 열 뺌(엑셀 꼴 · 수량 × 단가 = 재료비수량금액집계표) — 추천 ②(모든 줄이 일위대가 사슬 한 원천 · 166-3 총액 열 유지 · B08 합계는 할증 포함이라 값이 다를 수 있음 → 안내 글). +- ⬜ 24-b B08 줄(파형강관 · 커플링 · 로직 재료) — 남김(내역 자재대 · 관급 원천) 추천. + +## Z25. 대비표 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」 · 견적서 → 견적 아래 + +### 지금 열(`UI_Tabs_Base.ts:146-157`) + +| 차례 | 머리 1 | 머리 2 | 값 | +|---|---|---|---| +| 1~4 | — | 번호 · 품명 · 규격 · 단위(앞 2 고정) | | +| 5~16 | 관급 · 물가정보 · 물가자료 · 유통물가 · 거래가격 · 유가 | 단가 · **면** | `prices[s]` · `pages[s]`(M01 면수 · 유가 = 기준일 · 관급 면 = 늘 빈칸 `Prices.py:90`) | +| 17~18 | 견적 | 단가(수기) · **면**(수기 `quote_page`) | | +| 19 | — | 최저 | | +| 20~21 | 적용 | 적용 단가(수기) · 출처(누름 = 돌림) | | +| 22 · 23 | — | 관급 · 별도 · **견적서**(「n 파일」 누름 = 모달) | | + +- 고칠 자리 — `UI_Tabs_Base.ts:151` 를 갈래로: 견적 밖 = `[money(p:s, "단가", [s]), text(g:s, "출처", 64, [s])]` · 견적 = `[money(p:견적, "단가", ["견적"]), text("files", "견적서", 72, ["견적"])]` · `:156` 견적서 열 지움 · `:161` editable 에서 `g:견적` 뺌 · `:166-170` 면 고침 갈래 지움 · 안내 글(:160) 고침. 머리 두 줄 묶음 = 공용 `group`(`ui/data_table.ts:96-124`) 그대로. +- 재사용 — 공용 열 묶음 · 견적서 모달(`UI_Tabs_Base_Quote.ts`) · `onCell` files 갈래(:185-194) 그대로. +- 시험 — `test_171_1_manual_input_label.py:17`(안내 글 「견적 단가 · 면 = 수기 입력」 판정 → 새 글) + 새 판정(열 키 · 머리 이름 · 견적 아래 files). +- 위험 — 적용 묶음에도 「출처」(applied_from)가 있어 머리 글이 겹침(묶음 머리로 구분됨) · 셈 · 저장 안 고침(`quote_page` 는 저장에 남음 · `test_166_4:26 · 59-63`). +- 크기 — TS 10줄. +- ⬜ 25-a 늘 빈 관급 「출처」 열 — 그대로 둠(지시 = 값 유지) 추천 · 빼면 열 하나 줄음. + +## Z26. 적용 단가 = 칸 누름 드롭다운 · 고른 출처 칸 강조 · 기본 최저 + +- 현재 — 적용 단가 = 수기 칸(`UI_Tabs_Base.ts:161 · 171-178` → `applied` · `pick` 지움) · 출처 칸 누름 = 값 있는 출처 돌림(`:110-114 · 195-200` → `pick`) · 저장 `user_values.json` `materials[키]{applied · pick}`(`Store.py:149-158` · `pick` ∈ SOURCES 만) · 셈 차례 pick › applied › 최저(`Calc_Base.py:153-158` ↔ `.ts:134-139` · 거울 짝) · 최저 = 0 · 빈칸 뺀 최소 · 같으면 앞 칸 · 견적 포함(`Calc_Base.py:133-140` ↔ `.ts:119-`) → **기본 최저는 이미 됨**. +- 공용 표 빈틈 — 칸마다 class 없음(줄 `rowClass` 만 · `ui/data_table.ts:62 · 169-171`) · 칸 누름 콜백에 자리(좌표) 없음(`B09/UI_Shell.ts:219-222`) · 고르는 칸 꼴 없음(글 칸만 `:145-151`). +- 고칠 자리 + - `ui/data_table.ts` — 곁값 `cellClass?: (줄, 열 키) => string | null` 하나(옵션 :40-63 · `rowHtml` td class :169-171 · 넘김 :213-221 · +6줄 · 안 주면 그대로). + - `B09/UI_Tabs.ts:75-92` — `TabTable.cellClass` · `choices?: (줄, 열 키) => MapContextMenuItem[]` 두 칸. + - `B09/UI_Shell.ts:207-238` — `cellClass` 넘김 · `choices` 있으면 표 root click 듣기(칸 풍선 듣기 :229-237 꼴 · 칸 `getBoundingClientRect` 아래에 메뉴) · 메뉴 = `B09/UI_Tabs_Bill_Edit.ts:222-232` 공유 한 벌을 내보내 씀(`openMenuAt(x, y, items)` · +6줄). + - `B09/UI_Tabs_Base.ts` buildCompare — `applied` editable 뺌 · `choices`(applied 칸) = 값 > 0 출처마다 「물가정보 12,000 · 최저」 + 견적 + 「최저(자동)」 · 고름 = `pick` 넣고 `applied` 지움 · 「최저(자동)」 = 둘 다 지움 · `cellClass` = `p:{pick ?? 최저 출처}` 칸 `is-picked` · 출처 칸 돌림(:195-200 · `nextPick`) 지움. + - `B09/UI_Page.css` — `.b09-estimation td.is-picked` 바탕 = `color-mix(var(--color-accent) …)`(`ui/data_table.css:9` 토큰 꼴 · 테마 따라감). +- 셈 · 저장 — 안 고침(짝 그대로 · 옛 `applied` 값도 그대로 읽음 · 드롭다운 「최저(자동)」이 지움). +- 재사용 — 공용 우클릭 메뉴(한 번에 하나 · 바깥 누름 닫힘) · 공용 열 묶음 · `setUser` 즉시 다시 셈(네트워크 없음). +- 시험 — `test_144` · `test_166_4`(셈 그대로 통과) + 새 시험(공용 표 `renderDataTableHtml` cellClass 칸 class · 안 주면 글 같음 · buildCompare choices 항목 · 고름 → pick · 자동 → 지움 · 강조 칸 = pick ?? 최저 출처 · applied 칸 editable 아님). +- 위험 — ① 출처 없는 줄(B08 파형강관 · 커플링 · 로직 재료 · 번호판)은 견적 칸으로만 단가를 넣음 → 안내 글에 적음 ② `ui/data_table.ts` 공용(다른 페이지 영향 없음 · 옵션만) ③ 메뉴는 click 으로 열림 — 공용 닫힘은 pointerdown 캡처라 순서 맞음. +- 크기 — TS 45줄 · CSS 4줄 · 시험 하나. +- ⬜ 26-a 수기 적용 단가 — 없앰(견적 = 유일한 수기 출처) 추천 · 남기려면 드롭다운에 「수기 입력…」 항목. +- ⬜ 26-b 「출처」 칸 누름 돌림 — 없앰(드롭다운 하나) 추천. + +## Z28. 대비표 수량 열 + +- 현재 — 대비표 화면에 **수량 열 없음**(`UI_Tabs_Base.ts:146-157` · 모든 갈래 같음). `qty` 는 셈 줄에만 — 대비표 줄 `qty`(`Calc_Base.py:175` ↔ `.ts:155`) → 목록표 수량 · 총액(`Calc_Base.py:183-188` ↔ `.ts:160-171`) · 자재대 항목 수량은 입력에서 따로(`Calc_Bill.py:100` ↔ `.ts:89-90`). 엑셀 내보내기 없음 · 합계 줄 없음(대비표). +- 고칠 자리 — 화면은 할 일 없음. 셈 정리를 원하면 `material_sheet` 가 입력 qty 를 직접 읽게(짝 둘 · 거울 · `test_166_3`) — 보이는 변화 없음. +- 위험 — 없음. +- 크기 — 0(정리 시 py · TS 각 3줄). +- ⬜ 28-a 사용자가 본 「수량」 = 목록표 수량 열(166-3)일 수 있음 — 대비표는 그대로 · 목록표는 Z24-a 로 정함 추천. + +## 고칠 파일 목록 + +| 파일 | 지금 줄 | 고친 뒤(추정) | 700 위험 | 요구 | +|---|---|---|---|---| +| `B09_Estimation/B09_Estimation_Linked.py` | 73 | 76 | 없음 | Z24 | +| `B09_Estimation/B09_Estimation_Router.py` | 147 | 160 | 없음 | Z24 | +| `B09_Estimation/B09_Estimation_Summary.py` | 160 | 168 | 없음 | Z24 | +| `B09_Estimation/B09_Estimation_Calc_Base.py` · `.ts` | 335 · 309 | 339 · 313 | 없음 | Z24-a ②(거울) | +| `B09_Estimation/B09_Estimation_UI_Tabs_Base.ts` | 326 | 365 | 없음 | Z24 · Z25 · Z26 | +| `B09_Estimation/B09_Estimation_UI_Tabs.ts` | 168 | 172 | 없음 | Z26 | +| `B09_Estimation/B09_Estimation_UI_Shell.ts` | 332 | 345 | 없음 | Z26(조사 Y 탭 묶음과 겹침) | +| `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` | 242 | 248 | 없음 | Z26 | +| `B09_Estimation/B09_Estimation_UI_Page.css` | 45 | 49 | 없음 | Z26 | +| `ui_template/ui_template_data_table.ts` | 294 | 300 | 없음 | Z26(공용) | +| 시험 — `test_166_3` · `test_144_b09_skeleton` · `test_171_1` 손봄 + 새 시험 둘(Z24 · Z25/26) | — | — | — | | diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 6cc24ba67..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": "소단 높이", @@ -3333,6 +3343,8 @@ "inlet.before": "기준측점 전 (m)", "inlet.after": "기준측점 후 (m)", "inlet.animal": "동물이동형", + "inlet.animal=true": "유", + "inlet.animal=false": "무", "outlet.structure": "구조", "outlet.structure=기슭막이": "흙막이", "outlet.form": "형태", @@ -3561,6 +3573,7 @@ "weep_hole_area_m2": false }, "berm": { + "height_m": false, "length_m": false, "before_m": false, "after_m": false, @@ -3795,7 +3808,7 @@ "이름": "상세 내용", "옵션": [ "width_m", - "height_m" + "depth_m" ], "형상": {} } diff --git a/resources/tester/helper_115_8_corridor_loft.cjs b/resources/tester/helper_115_8_corridor_loft.cjs index a99c2d037..c122162db 100644 --- a/resources/tester/helper_115_8_corridor_loft.cjs +++ b/resources/tester/helper_115_8_corridor_loft.cjs @@ -57,6 +57,7 @@ const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid")); const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg")); +const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin")); const HALF = 3; const ROAD_Z = 100; @@ -257,7 +258,7 @@ function gapEdges(tris, out, original) { return { gaps, maxGapLen }; } -/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ +/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 SEAM_M 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */ function lostVertices(tris, before, out) { const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]); const border = new PSLG.SegGrid(2); @@ -274,7 +275,7 @@ function lostVertices(tris, before, out) { for (let v = 0; v < before.length / 3; v += 1) { const x = before[v * 3]; const z = before[v * 3 + 2]; - if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue; + if (region.contains(x, z) || border.passesNear(x, z, SEAM_M)) continue; if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1; } return lost; @@ -450,6 +451,8 @@ function daylightPoints(out, bounds) { let sectionErr = 0; for (const side of [1, -1]) { for (let s = 0.05; s <= 12; s += 0.05) { + // 비탈 끝(s 9) 바깥 이음 띠(SEAM_M + 격자 0.5 m)는 지형 굴곡을 긴 삼각형으로 펴므로 뺀다(176-1). + if (s > 9 && s < 9 + SEAM_M + 0.5) continue; const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20 if (z === null) continue; sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94))); diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index dc221cc88..c80f3c94c 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -162,4 +162,30 @@ out.notSheet = { values: inputs().map((n) => n.value), }; +// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용] +const scales = []; +const toastStart = toasts.length; +slot.replaceChildren( + buildCrossLayoutControls([4, 4], null, () => {}, { + scale: { value: 1, onApply: (value) => scales.push(value) }, + }), +); +const scaleInput = inputs()[2]; +out.scale = { + labels: byClass(slot, "ui-field__label").map((n) => n.textContent), + input: { + value: scaleInput.value, + min: scaleInput.attrs.min, + max: scaleInput.attrs.max, + step: scaleInput.attrs.step, + }, + buttons: buttons().map((n) => n.textContent), +}; +scaleInput.value = "1.5"; +buttons()[2].click(); +scaleInput.value = "2.5"; +buttons()[2].click(); +out.scale.applied = scales; +out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index ada95dc88..75bdba7bb 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -11,11 +11,28 @@ 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: () => {} }, "../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 +69,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 +104,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 +158,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_173_7_inlet_animal.cjs b/resources/tester/helper_173_7_inlet_animal.cjs index 609c9c8db..89f9a38be 100644 --- a/resources/tester/helper_173_7_inlet_animal.cjs +++ b/resources/tester/helper_173_7_inlet_animal.cjs @@ -1,5 +1,5 @@ -/* 집수정 「동물이동형」 체크 칸(PLAN 173-7) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. - * 빈 값 = 체크 · 끄면 "false" · 다시 켜면 "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. +/* 집수정 「동물이동형」 칸(PLAN 173-7 · 177-2 고르기 유 / 무) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다. + * 빈 값 = 유("true" 적힘) · 무 = "false" · 다시 유 = "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌. * 짝 = `test_173_7_inlet_animal.py` · 화면 그릇 흉내는 `helper_54_10_soil_tiers.cjs` 꼴(+ 듣개). */ const fs = require("fs"); @@ -121,32 +121,44 @@ const { createSpecForm } = loadTs( const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); const form = createSpecForm(null, {}); +// 177-2 — 체크박스 → 고르기(유 / 무) · 저장 글 "true" / "false" 그대로 const box = () => form.root .querySelectorAll("label") .find((l) => l.dataset.specItem === "inlet.animal") - ?.querySelector("input") ?? null; + ?.querySelector("select") ?? null; const animal = () => form.read().inlet_basin_animal ?? null; -const toggle = (on) => { - const input = box(); - input.checked = on; - input.fire("change"); +const pick = (v) => { + const select = box(); + select.value = v; + select.fire("change"); +}; +const shown = () => { + const select = box(); + return select + ? { + value: select.value, + options: select.children.map((o) => [o.value, o.textContent]), + saved: animal(), + } + : null; }; const out = {}; form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정" }); -out.blank = { checked: box()?.checked ?? null, saved: animal() }; -toggle(false); -out.off = { checked: box().checked, saved: animal() }; -toggle(true); -out.on = { checked: box().checked, saved: animal() }; +out.blank = shown(); +pick("false"); +out.off = shown(); +pick("true"); +out.on = shown(); form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정", inlet_basin_animal: "false", }); -out.storedOff = { checked: box().checked, saved: animal() }; +out.storedOff = shown(); form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "기슭막이" }); out.soil = { shown: box() !== null, saved: animal() }; +out.checkbox = form.root.querySelectorAll("input").some((i) => i.type === "checkbox"); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_176_1_seam.cjs b/resources/tester/helper_176_1_seam.cjs new file mode 100644 index 000000000..31138097d --- /dev/null +++ b/resources/tester/helper_176_1_seam.cjs @@ -0,0 +1,263 @@ +/* PLAN 176-1 단위검증 헬퍼 — B05 3D 성토 비탈 끝 이음 띠(SEAM_M) · 성토 덩이 끝 뜸 줄이기. + * 직선 노선 80 m · 측점 0 · 20 · 40 성토(노면 100 · 지반 94 · 1:1.5) · 60 · 80 절토(지반 104 · 1:1). + * 지반에 노선 방향 사인 물결(진폭 1 m · 파장 10 m · 측점 자리 0) → 성토 비탈 끝이 한도까지 뜸. + * 트랜스파일 틀 · 격자 지형 · 틈 셈은 helper_172_1_loft_wrinkle.cjs 와 같다. 결과는 표준출력 JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three")); +const LIBS = { + three: THREE, + delaunator: require(path.join(NM, "delaunator", "index.js")), + "three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")), + "@kninnug/constrainautor": { + __esModule: true, + default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (LIBS[request]) return LIBS[request]; + if (request.startsWith("@ui/") || request.startsWith("@config/")) { + return new Proxy({}, { get: () => () => undefined }); + } + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft")); +const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); +const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); +const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg")); + +const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin")); + +const HALF = 3; +const ROAD_Z = 100; +const LENGTH = 80; +const STATIONS = [0, 20, 40, 60, 80]; +const AMP = 1; +const WAVE_M = 10; +const r6 = (v) => Math.round(v * 1e6) / 1e6; + +/** 측점 단면 — 성토(지반 94 · 1:1.5) 또는 절토(지반 104 · 1:1). */ +function station(chainage) { + const fill = chainage <= 40; + const ground = fill ? 94 : 104; + const toe = HALF + Math.abs(ROAD_Z - ground) * (fill ? 1.5 : 1); + const pts = [ + [-40, ground], + [-toe, ground], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [toe, ground], + [40, ground], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: ground }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: chainage, + center_y: 0, + center_z: ground, + frame: { left_xy: [0, 1] }, + samples, + design: { + section_mode: fill ? "both_fill" : "both_cut", + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +const BOUNDS = { x: [-10, 90], y: [-30, 30], z: [85, 115] }; +const OX = (BOUNDS.x[0] + BOUNDS.x[1]) / 2; +const OY = (BOUNDS.y[0] + BOUNDS.y[1]) / 2; +const OZ = (BOUNDS.z[0] + BOUNDS.z[1]) / 2; +const X0 = -10; +const X1 = 90; +const Y0 = -30; +const Y1 = 30; +const STEP = 0.5; + +function gridTerrain(heightAt) { + const nx = Math.round((X1 - X0) / STEP) + 1; + const ny = Math.round((Y1 - Y0) / STEP) + 1; + const positions = new Float32Array(nx * ny * 3); + for (let j = 0; j < ny; j += 1) { + for (let i = 0; i < nx; i += 1) { + const v = j * nx + i; + const x = X0 + i * STEP; + const y = Y0 + j * STEP; + positions[v * 3] = x - OX; + positions[v * 3 + 1] = heightAt(x, y) - OZ; + positions[v * 3 + 2] = -(y - OY); + } + } + const index = []; + for (let j = 0; j < ny - 1; j += 1) { + for (let i = 0; i < nx - 1; i += 1) { + const a = j * nx + i; + if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); + else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(new Uint8Array(nx * ny * 4).fill(200), 4, true)); + geometry.setIndex(index); + return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); +} + +const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`; +const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`; +const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z)); + +/** 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝 · 구멍 둘레가 아닌 것(틈). */ +function gapCount(root, out) { + const pslg = out.design.pslg; + const tagAt = new Map(); + for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]); + const onFrame = (p) => { + const x = p[0] + OX; + const y = -p[2] + OY; + return Math.abs(x - X0) < 1e-3 || Math.abs(x - X1) < 1e-3 || Math.abs(y - Y0) < 1e-3 || Math.abs(y - Y1) < 1e-3; + }; + const uses = new Map(); + root.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const p = child.geometry.getAttribute("position"); + const idx = child.geometry.getIndex(); + const count = idx ? idx.count : p.count; + for (let t = 0; t < count; t += 3) { + const tri = [0, 1, 2].map((k) => { + const v = idx ? idx.getX(t + k) : t + k; + return [p.getX(v), p.getY(v), p.getZ(v)]; + }); + const [a, b, c] = tri; + if (Math.abs((b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2])) < 1e-9) continue; + for (let k = 0; k < 3; k += 1) { + const ka = key(...tri[k]); + const kb = key(...tri[(k + 1) % 3]); + const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + const entry = uses.get(e) ?? { n: 0, a: tri[k], b: tri[(k + 1) % 3] }; + entry.n += 1; + uses.set(e, entry); + } + } + }); + let gaps = 0; + for (const { n, a, b } of uses.values()) { + if (n !== 1) continue; + const ta = tagAt.get(pkey(a[0], a[2])); + const tb = tagAt.get(pkey(b[0], b[2])); + if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue; + if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue; + if (onFrame(a) && onFrame(b)) continue; + gaps += 1; + } + return gaps; +} + +/** 성토 비탈 끝 둘레 점 · 이음 띠 경사 · 덩이 끝 뜸 · 틈. */ +function run() { + const base = (x) => (x <= 40 ? 94 : x >= 60 ? 104 : 94 + (x - 40) / 2); + const terrain = gridTerrain((x) => base(x) + AMP * Math.sin((2 * Math.PI * x) / WAVE_M)); + const sections = STATIONS.map(station); + const build = buildCorridor(sections, [ + { x: 0, y: 0 }, + { x: LENGTH, y: 0 }, + ]); + build.sections = sections; + build.structures = []; + const split = new TerrainBandSplit(terrain, { minX: -1e4, maxX: 1e4, minZ: -1e4, maxZ: 1e4 }); + const heightIndex = new TerrainHeightIndex(terrain); + const out = loftTerrain(split, build, BOUNDS, heightIndex); + const pslg = out.design.pslg; + const groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ; + + // 성토 비탈 끝(설계 둘레 · 성토 꼬리표) — 측마다 높이 − 지형 · 덩이 끝(가장 큰 x) 점. + const outer = new Set(out.design.outer.map(([x, z]) => pkey(x, z))); + const rim = new Set(); + const ends = { 1: { x: -Infinity, off: 0 }, [-1]: { x: -Infinity, off: 0 } }; + let midOff = 0; + for (let i = 0; i < pslg.count; i += 1) { + if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(pkey(pslg.x[i], pslg.z[i]))) continue; + rim.add(pkey(pslg.x[i], pslg.z[i])); + const x = pslg.x[i] + OX; + const y = -pslg.z[i] + OY; + const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y)); + if (x > 5 && x < 35) midOff = Math.max(midOff, off); + const end = ends[y > 0 ? 1 : -1]; + if (x > end.x) Object.assign(end, { x, off }); + } + + // 이음 띠 — 성토 비탈 끝 점을 쓰는 원지반 색 삼각형의 경사(°). + const mesh = out.root.getObjectByName("terrain-tin-mesh"); + const p = mesh.geometry.getAttribute("position"); + const keep = mesh.userData.keepColor; + const slopes = []; + for (let t = 0; t < p.count / 3; t += 1) { + if (keep[t * 3]) continue; + const v = [0, 1, 2].map((k) => [p.getX(t * 3 + k), p.getY(t * 3 + k), p.getZ(t * 3 + k)]); + if (!v.some(([x, , z]) => rim.has(pkey(x, z)))) continue; + // 지반 바탕이 평탄한 성토 구간만(지형 자체 경사 ≤ 32°) — 40~60 m 오르막은 지형이 45° 넘음. + const cx = (v[0][0] + v[1][0] + v[2][0]) / 3 + OX; + if (cx < 2 || cx > 38) continue; + const u = [0, 1, 2].map((k) => v[1][k] - v[0][k]); + const w = [0, 1, 2].map((k) => v[2][k] - v[0][k]); + const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]; + const len = Math.hypot(...n); + if (len < 1e-9) continue; + slopes.push((Math.acos(Math.abs(n[1]) / len) * 180) / Math.PI); + } + slopes.sort((a, b) => a - b); + return { + seam: SEAM_M, + stats: out.stats, + gaps: gapCount(out.root, out), + rimPoints: rim.size, + seamTris: slopes.length, + seamP99: r6(slopes[Math.floor(0.99 * (slopes.length - 1))] ?? 0), + midOff: r6(midOff), + endX: [r6(ends[1].x), r6(ends[-1].x)], + endOff: r6(Math.max(ends[1].off, ends[-1].off)), + }; +} + +process.stdout.write(JSON.stringify(run())); 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/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 104b84165..45027fdd4 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -58,6 +58,7 @@ class E { get className() { return [...this.cls].join(" "); } set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); } append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } } + replaceChildren(...xs) { this.children = []; this.append(...xs); } prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } } addEventListener(t, f) { (this.on[t] ??= []).push(f); } setAttribute(k, v) { this.attrs[k] = String(v); } diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index 0e88ef6c9..0e17f6d7e 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -106,14 +106,13 @@ def test_카드_배선_하나만() -> None: assert ".b06-chart__design-hit.is-active {" in css -def test_치수_두_줄_좁은_카드는_표_아래로() -> None: - """151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 좁은 카드(3열 등)는 표 높이 아래로 내려 표를 안 덮음.""" +def test_치수_두_줄_늘_단추_왼쪽() -> None: + """151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 179-1 — 좁은 카드도 표 아래로 안 내림(늘 [+ − ⤢] 왼쪽).""" css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") dims = css.split(".b06-cross-card__line-dims {", 1)[1].split("}", 1)[0] assert "white-space: pre-line;" in dims and "text-align: right;" in dims - assert "container-type: inline-size;" in css - narrow = css.split("@container (max-width: 620px) {", 1)[1] - assert "top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));" in narrow + assert "position:" not in dims + assert "@container" not in css def test_폴리곤_자르기는_공용_한_벌() -> None: diff --git a/resources/tester/test_140_b07_cross_grid.py b/resources/tester/test_140_b07_cross_grid.py index f8fb20882..c2905d7e8 100644 --- a/resources/tester/test_140_b07_cross_grid.py +++ b/resources/tester/test_140_b07_cross_grid.py @@ -119,7 +119,11 @@ def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None: root, longitudinal_path = _project(tmp_path, 20) - assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본 + assert read_cross_layout(root) == { + "default": [4, 4], + "sheets": {}, + "table_scale": 1.0, + } # 파일 없으면 기본 ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4 manifest = _read_manifest(root) diff --git a/resources/tester/test_153_1_b07_cross_layout.py b/resources/tester/test_153_1_b07_cross_layout.py index 58275b487..edc706067 100644 --- a/resources/tester/test_153_1_b07_cross_layout.py +++ b/resources/tester/test_153_1_b07_cross_layout.py @@ -61,3 +61,12 @@ def test_장_아니면_이_장_꺼짐(out): assert not_sheet["disabled"] == [True, False] assert not_sheet["defaults"] == [[4, 4]] assert not_sheet["values"] == ["4", "4"] + + +def test_표_배율_칸(out): + scale = out["scale"] + assert scale["labels"] == ["배치 열", "행", "표 배율"] + assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"} + assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"] + assert scale["applied"] == [1.5] + assert scale["toasts"] == ["error"] # 2.5 = 범위 밖 diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index 81dd451f8..b8cbb03d4 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -1,7 +1,7 @@ """157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」. - 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음) -- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) +- 단추 = 보기 바꿈(글 「박스보기」 ↔ 「표로 보기」 · 181-2) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본) - 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip) """ @@ -66,7 +66,7 @@ def test_157_2_단추_바꿈(): "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," " flipView('table'), flipView(flipView('table'))]));" ) - assert out == ["호표별 보기", "표로 보기", "cards", "table"] + assert out == ["박스보기", "표로 보기", "cards", "table"] def test_157_2_기억_실패_때_카드(): diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 04b48be77..c8b59ecfc 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 @@ -102,3 +122,27 @@ def test_빈칸_누름_없음_이름_칸_첫_키(): 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 + + +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_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index ec00b9bfe..cba6989d6 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -223,7 +223,7 @@ console.log(JSON.stringify(out)); assert out["title"] == ["아래에 줄 추가"] assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"] assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-", - "줄 지우기"] # fmt: skip + "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨 assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄" assert out["qty"] == {} # hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로 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_171_2_group_cards.py b/resources/tester/test_171_2_group_cards.py index 679ea754d..3a5fe8211 100644 --- a/resources/tester/test_171_2_group_cards.py +++ b/resources/tester/test_171_2_group_cards.py @@ -2,7 +2,7 @@ - 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계 - 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음) -- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) +- 가로 열 수 1~4(기본 2 · 고르면 알림) · 접기 없음(181-2) · 이동해 온 박스 = `is-pick`(붉은 외곽선) · 머리 누름 = 이동 - 157-2 산근 카드 파일 = 이 양식으로 갈음(지움) """ @@ -43,11 +43,9 @@ def test_도우미_열수_요약_풍선(): out = _run( "\nconsole.log(JSON.stringify({" " cols: [0, 1, 2, 3, 4, 5, '3', null, undefined].map(clampColumns)," - " summary: [12, 13].map(isSummary)," " tip: [partsTip([1000, 0, null]), partsTip([null, null, null]), partsTip(null)] }));" ) assert out["cols"] == [2, 1, 2, 3, 4, 2, 2, 2, 2] - assert out["summary"] == [False, True] assert out["tip"] == ["노무 1,000 · 재료 0 · 경비 -", None, None] @@ -91,7 +89,7 @@ def test_카드_머리_몸_합계_줄(): assert out["foot"] == ["노무 100", "재료 20", "경비 3", "계 123", "빈 줄 1"] assert out["link"] == [True, False] assert out["cols"] == "2" # 기본 2 - assert out["summary"] == ["1", "2", "3", "4"] # 12 이하 = 요약 단추 없음 + assert out["summary"] == ["1", "2", "3", "4"] # 가로 열 수 단추뿐(요약 · 접기 단추 없음) def test_가로_열수_고름_알림_누름_이동(): @@ -111,29 +109,35 @@ def test_가로_열수_고름_알림_누름_이동(): assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link) -def test_묶음_12_넘으면_요약_보기(): +def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선(): out = _run( "\nconst root = make(13, { focus: 52 });" "\nconst cards = root.all('.ui-group-card');" - "\nconst closed = () => cards.map((c) => c.classList.contains('is-closed'));" - "\nconst first = closed();" "\nconst focused = root.querySelector('[data-at=\"52\"]');" + "\nconst first = cards.map((c) => c.classList.contains('is-pick'));" "\ncards[0].querySelector('.ui-group-card__head').click();" - "\nconst opened = closed()[0];" - "\nconst btn = (label) => root.all('.ui-btn').find((b) => b.textContent === label);" - "\nbtn('모두 펼침').click(); const all = closed();" - "\nbtn('모두 접기').click(); const none = closed();" - "\nconsole.log(JSON.stringify({ first, opened, all, none, picks," + "\nconsole.log(JSON.stringify({ first, picks," + " closed: cards.map((c) => c.classList.contains('is-closed'))," + " buttons: root.all('.ui-btn').map((b) => b.textContent)," " foot: cards.every((c) => c.querySelector('.ui-group-card__foot'))," - " focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));" + " focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));" ) - # 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤 - assert out["first"] == [True] * 4 + [False] + [True] * 8 + # 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤 + assert out["first"] == [False] * 4 + [True] + [False] * 8 assert out["focus"] == [True, True] - assert out["opened"] is False # 머리 누름 = 펼침 - assert out["picks"] == [] # 요약 보기 머리 누름은 이동 아님 - assert out["all"] == [False] * 13 and out["none"] == [True] * 13 - assert out["foot"] is True # 접어도 합계 줄은 보임(몸만 숨김) + assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음 + assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로) + assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음 + assert out["foot"] is True + + +def test_고른_박스_없으면_외곽선_없음(): + out = _run( + "\nconst root = make(3);" + "\nconsole.log(JSON.stringify(root.all('.ui-group-card').map(" + "(c) => c.classList.contains('is-pick'))));" + ) + assert out == [False, False, False] def test_양식_글자_간격_숫자(): @@ -146,6 +150,8 @@ def test_양식_글자_간격_숫자(): "font-size: inherit;", ] assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게 + assert "is-closed" not in css and "is-closed" not in (ROOT / COMMON).read_text("utf-8") + assert "border-color: var(--color-danger);" in css # 고른 박스 = B06 고름 꼴(181-2) assert "spacing-16" not in css and "card-padding" not in css # 공용 카드 16px 끼움 없음 assert "grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));" in css assert "text-align: right;" in css diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 98d98d0d2..cd5dd8e83 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -151,9 +151,10 @@ def test_화면_보기_바꿈_누름_이동(): "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" + "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nbar.children[0].click();" "\nconst [bar2, view2] = renderGroupPanel(o);" - "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws," + "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," " label2: bar2.children[0].textContent, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, @@ -161,8 +162,9 @@ def test_화면_보기_바꿈_누름_이동(): assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조 + assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) assert out["redraws"] == 1 - assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3] + assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} diff --git a/resources/tester/test_173_7_inlet_animal.py b/resources/tester/test_173_7_inlet_animal.py index b8539c476..ccb323207 100644 --- a/resources/tester/test_173_7_inlet_animal.py +++ b/resources/tester/test_173_7_inlet_animal.py @@ -1,4 +1,4 @@ -"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참. +"""173-7 · 177-2 집수정 「동물이동형」 칸 — 고르기 유 / 무 · 저장 키 inlet_basin_animal("true"/"false") · 빈 값 = 유. 공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`) + 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`). @@ -28,14 +28,18 @@ def out() -> dict: return json.loads(done.stdout) -def test_빈_값은_체크_저장_안_함(out: dict) -> None: - assert out["blank"] == {"checked": True, "saved": None} +YU_MU = [["true", "유"], ["false", "무"]] -def test_끄면_false_다시_켜면_true(out: dict) -> None: - assert out["off"] == {"checked": False, "saved": "false"} - assert out["on"] == {"checked": True, "saved": "true"} - assert out["storedOff"] == {"checked": False, "saved": "false"} +def test_빈_값은_유_실제_값으로_적힘(out: dict) -> None: + # 177-2 — 필수 상자 빈 고르기 칸 = 기본값을 실제 값으로(54-43) · 옛 「저장 안 함」 뒤집음 + assert out["blank"] == {"value": "true", "options": YU_MU, "saved": "true"} + + +def test_무면_false_다시_유면_true(out: dict) -> None: + assert out["off"] == {"value": "false", "options": YU_MU, "saved": "false"} + assert out["on"] == {"value": "true", "options": YU_MU, "saved": "true"} + assert out["storedOff"] == {"value": "false", "options": YU_MU, "saved": "false"} def test_흙막이엔_칸_없음(out: dict) -> None: 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_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py new file mode 100644 index 000000000..9b98c4cb1 --- /dev/null +++ b/resources/tester/test_175_precombo_mats.py @@ -0,0 +1,149 @@ +"""M02 일위대가 사전조합 자재목록(PLAN 175) — 구조물 일위대가와 같은 자재목록. + +175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가 +고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400). +175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄 +재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감. +""" + +import json +import os +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속선 · 에폭시) +NET_ITEM = "MT006697" +WIRE = "MT000194" +MAT = "MT000008" # 자재품목 · 물가정보 83000 +STONE = "GF000219" # 원/㎡ +SYS = {"층": "system", "주인": None} + + +def _save(folder, doc): + with store.at(folder): + key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) + + +def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811 + detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail + assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail + assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail + assert "delete out.자재" not in detail and "delete out.열" in detail + + +def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811 + line = {"로직": NET, "고름": {"4": NET_ITEM}, "자재표시": {"3": {"자재구분": "관급"}}} + mats = [ + {"코드": MAT, "수량": {"손": 2}, "운반비": "미포함", "이름": "틀린 이름"}, + {"코드": WIRE, "수량": None}, + ] + _, got = _save(world["system"], {"담은로직": [line], "자재": mats}) + one = got["담은로직"][0] + assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}} + assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)] + assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름" + # 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘) + _, empty = _save(world["system"], {"담은로직": [{"로직": NET}]}) + assert empty["자재"] == [] + + +def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F811 + with pytest.raises(store.StoreError) as e: + _save(world["system"], {"담은로직": [{"로직": NET}], "자재": [{"코드": WIRE}]}) + assert e.value.status == 400 and WIRE in str(e.value.detail) + with pytest.raises(store.StoreError) as e: + _save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]}) + assert e.value.status == 400 + + +# ── 175-2 셈 ────────────────────────────────────────────────────────── +def _price(code): + whole = m01.loaded()[1] + return Decimal(str(labor.material_price(whole, code, None)["기준단가"])) + + +def _world_combo(world): # noqa: F811 + """STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값).""" + lines = [ + {"로직": STONE, "수량": {"손": 1}}, + {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, + ] + mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}] + with store.at(world["system"]): + key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ + "이름" + ] + stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) + return key, stone + _price(MAT) * 2 + + +def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(world): # noqa: F811 + key, want = _world_combo(world) + with store.at(world["system"]): + doc = labor.clean( + {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]} + ) + one = labor.calc(doc, {})["줄"][0] + assert one["기준"]["계"] == pytest.approx(float(want)) + assert one["반영"]["계"] == pytest.approx(float(want * 3)) + mats = [x for x in one["안"] if x.get("자재")] + assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈 + assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT))) + # 목록 기준금액(51-4)도 같은 펼침 + rows = [{"종류": "precombo", "이름": key}] + refs.summaries(world["system"], rows) + assert rows[0]["기준금액"] == pytest.approx(float(want)) + + +def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811 + from B08_Quantity.B08_Quantity_Router_Material import _ref_mats + + key, _ = _world_combo(world) + with store.at(world["system"]): + outer = refs.create( + "precombo", + {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + )["이름"] + got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) + mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] + assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음 + + +def test_B09_겹_대가에_자재_줄(world): # noqa: F811 + from B09_Estimation.B09_Estimation_Labor_Source import Builder + + key, _ = _world_combo(world) + ref = {**SYS, "키": key} + build = Builder() + bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {}) + mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"] + assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)] + assert mats[0]["name"] and mats[0]["unit"] + + +def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(world): # noqa: F811 + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + + key, want = _world_combo(world) + doc = {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 1}}], "자재": []} + with store.at(world["system"]): + rows = source.labor_doc_rows(labor.clean(doc)) + mats = refs.frozen_materials(rows["참조사본"]) + assert {MAT, WIRE, NET_ITEM} <= set(mats) + assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read() + bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats)) + (world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본 + got = prices.calc(rows, bundle, {}, {}, None)["줄"][0] + assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9) diff --git a/resources/tester/test_176_1_seam.py b/resources/tester/test_176_1_seam.py new file mode 100644 index 000000000..6de7ecf0d --- /dev/null +++ b/resources/tester/test_176_1_seam.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""PLAN 176-1 — B05 3D 성토 비탈 끝 깨짐(172 뒤) 고침 검사. + +도우미(helper_176_1_seam.cjs) — 직선 80 m · 성토(0~40 m) → 절토(60~80 m) · 지반 사인 물결(진폭 1 m). +비탈 끝 바깥 SEAM_M(1.5 m) 안 지형 점 빼서 잇는 띠 완만 · 성토 덩이 끝 3 m 안 뜸 0 까지 줄임. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_176_1_seam.cjs")], + capture_output=True, + text=True, + timeout=240, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_seam_band_gentle(r): + # 성토 비탈 끝을 쓰는 원지반 삼각형 경사 p99 < 45°(1 mm 띠 = 54.7°) + assert r["seam"] == 1.5 + assert r["seamTris"] > 100, r + assert r["seamP99"] < 45, r + + +def test_run_end_lift_tapered(r): + # 성토 덩이 가운데는 한도까지 뜸(물결이 실제로 재어짐) · 절토로 바뀌는 끝은 0 근처 + assert r["midOff"] > 0.4, r + assert r["endX"][0] > 45 and r["endX"][1] > 45, r + assert r["endOff"] < 0.02, r + + +def test_no_gap_no_failed_constraint(r): + assert r["gaps"] == 0, r + assert r["stats"]["failed"] == 0, r diff --git a/resources/tester/test_176_2_view_defaults.py b/resources/tester/test_176_2_view_defaults.py new file mode 100644 index 000000000..9bc03950b --- /dev/null +++ b/resources/tester/test_176_2_view_defaults.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""PLAN 176-2 — B05 3D 흑백 지형 · 등고선 처음 값 false(단추 · 뷰어 두 곳 다).""" + +import os +import re + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") + + +def _read(name): + with open(os.path.join(ROOT, "B05_Profile", name), encoding="utf-8") as f: + return f.read() + + +def test_panel_buttons_start_off(): + src = _read("B05_Profile_UI_Panel_View.ts") + assert re.search(r'toggleButton\("등고선", false,', src) + assert re.search(r'toggleButton\("흑백 지형", false,', src) + + +def test_viewer_starts_off(): + src = _read("B05_Profile_UI_Viewer.ts") + assert "let surfaceGrayscale = false;" in src + # 등고선 그룹은 만들자마자 숨김(적재 때 선은 그대로 만들고 단추로 켬) + assert re.search( + r"const contours = new THREE\.Group\(\);\s*\n\s*contours\.visible = false;", src + ) diff --git a/resources/tester/test_176_3_no_uphill_lamp.py b/resources/tester/test_176_3_no_uphill_lamp.py new file mode 100644 index 000000000..3f1b7a61c --- /dev/null +++ b/resources/tester/test_176_3_no_uphill_lamp.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""PLAN 176-3 — B05 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) · 클릭 · 처리기 지움. + +저장 길(세션 uphill → uphill_overrides)과 B06 이 쓰는 종단 병합 함수는 남김. +""" + +import os + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") + + +def _read(*parts): + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def test_markers_have_no_lamp(): + src = _read("B05_Profile", "B05_Profile_UI_Markers.ts") + for gone in ("uphillStationId", "onUphillPick", "0xf97316", "UPHILL_LAMP", "lampHit"): + assert gone not in src, gone + + +def test_page_has_no_lamp_handler(): + src = _read("A00_Common", "design", "design_view_profile.ts") + assert "onUphillPick" not in src + assert "uphillOverrides" not in _read("B05_Profile", "B05_Profile_UI_Page_Stations.ts") + + +def test_save_path_and_merge_kept(): + # 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로 + page = _read("A00_Common", "design", "design_view_profile.ts") + assert "persistUphillOverrides" in page + assert "uphill_overrides" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts") + assert "def _merge_uphill_overrides_into_longitudinal" in _read( + "B05_Profile", "B05_Profile_Router_Confirm.py" + ) diff --git a/resources/tester/test_177_1_summary_unassigned_bg.py b/resources/tester/test_177_1_summary_unassigned_bg.py new file mode 100644 index 000000000..bafa3cf4f --- /dev/null +++ b/resources/tester/test_177_1_summary_unassigned_bg.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +CSS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +def _rule(sel: str) -> str: + src = open(CSS, encoding="utf-8").read() + m = re.search(re.escape(sel) + r"\s*\{([^}]*)\}", src) + assert m, sel + return m.group(1) + + +def test_미연결_배경_테두리_경고색_글자색_없음(): + body = _rule(".b05-structure__summary-name:not(.is-assigned)") + assert re.search(r"border-color:\s*var\(--color-warning\)", body) + assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body) + assert not re.search(r"(^|[;\s])color:", body) + + +def test_테마_토큰_밝음_어두움_둘다(): + th = open(os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8").read() + assert len(re.findall(r"--color-warning:\s*#", th)) >= 2 diff --git a/resources/tester/test_177_2_inlet_animal_select.py b/resources/tester/test_177_2_inlet_animal_select.py new file mode 100644 index 000000000..c77e1f35c --- /dev/null +++ b/resources/tester/test_177_2_inlet_animal_select.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""PLAN 177-2 — 동물이동형 = 고르기(유 / 무) · 체크박스 꼴(bool) 지움 · 화면 값은 helper_173_7.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +DESIGN = ROOT / "A00_Common" / "design" + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_7_inlet_animal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_체크박스_없음(out: dict) -> None: + assert out["checkbox"] is False + + +def test_bool_꼴_지움() -> None: + spec = (DESIGN / "design_structure_spec.ts").read_text("utf-8") + form = (DESIGN / "design_structure_form.ts").read_text("utf-8") + pipe = (DESIGN / "design_structure_pipe.ts").read_text("utf-8") + assert '"bool"' not in spec and '"bool"' not in form and '"bool"' not in pipe + assert "createCheckbox" not in form 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_179_1_line_dims_left.py b/resources/tester/test_179_1_line_dims_left.py new file mode 100644 index 000000000..7d9406081 --- /dev/null +++ b/resources/tester/test_179_1_line_dims_left.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _src(rel: str) -> str: + return open(os.path.join(ROOT, rel), encoding="utf-8").read() + + +def test_좁은_카드_내림_규칙_없음(): + css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") + assert "@container" not in css + # 치수 칸 규칙은 기본 꼴 · 숨김(+ 옆 단추) 뿐 — 절대 자리 · 왼선 지움 규칙 없음 + assert re.findall(r"\.b06-cross-card__line-dims[^{,;]*\{", css) == [ + ".b06-cross-card__line-dims {", + ".b06-cross-card__line-dims[hidden] {", + ".b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {", + ] + + +def test_치수_칸은_줌_막대_맨_앞(): + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "zoomBar.prepend(linePick.label);" in view + zoom = ( + _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") + .split(".b06-cross-card__zoom {", 1)[1] + .split("}", 1)[0] + ) + assert "display: flex;" in zoom and "position: absolute;" in zoom diff --git a/resources/tester/test_179_2_ditch_row_compact.py b/resources/tester/test_179_2_ditch_row_compact.py new file mode 100644 index 000000000..c1c0866ba --- /dev/null +++ b/resources/tester/test_179_2_ditch_row_compact.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""PLAN 179-2 — 측구 줄 컴팩트(W16 (가)): 막힌 단추 숨김 · 「측구형식」 글 지움(툴팁) · 알약에 편측 쪽(소스 시험).""" + +import os +import re + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def _src(rel: str) -> str: + return open(os.path.join(ROOT, rel), encoding="utf-8").read() + + +def test_막힌_묶음은_숨김(): + css = _src("B06_Section/B06_Section_UI_Style_Cross.css") + body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1) + assert "display: none;" in body + assert "opacity" not in body + + +def test_측구형식_글_없이_툴팁(): + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + seg = cd.split("const ditchTypeSeg = segment(", 1)[1].split(");", 1)[0] + assert seg.lstrip().startswith('"",') + assert 'ditchTypeSeg.title = L("B06_Design_DitchType_Legend");' in cd + + +def test_넘침_셈은_숨긴_칸_폭_간격_뺌(): + cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts") + assert "overflow -= width ? width + barGapPx : 0;" in cd + + +def test_알약에_편측_쪽(): + lb = _src("ui_template/ui_template_locale_b2.ts") + assert 'B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)"' in lb + assert 'B06_Design_Mode_RightCut: ["편측 성토(우 절토)"' in lb diff --git a/resources/tester/test_180_2_b07_cross_scale.py b/resources/tester/test_180_2_b07_cross_scale.py new file mode 100644 index 000000000..3fc8dec6e --- /dev/null +++ b/resources/tester/test_180_2_b07_cross_scale.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +"""180-2 B07 횡단 장 — 표 · 표 위 글 배율(모든 장 한 값 · 0.5~2.0 · 기본 1). + +못박는 것 + - 배율 1.5 = 표 폭 162 · 높이 60 · 글 줄 높이 × 1.5 · 표 아래선은 칸 아래 + 여백 그대로. + - 그림 아래 끝 = 글(없으면 표) 위 8 mm(간격은 배율 안 탐) · 넓은 표가 칸을 넘으면 overflow_cells. + - 배율 1 = 배율 칸 없는 배치와 같은 엔티티. + - cross_layout.json 이 배율을 지킴 · 범위 밖 = 거절 · 배율만 바꾸면 확정 장 안 풂. + - 지금과 다른 배율로 확정된 장 = 그대로 + stale_scale 안내. +""" + +import json +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + CELL_TABLE_PAD_MM, + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + usable_area, + use_project_templates, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_SHEET_LAYOUT, + _cross_sheet_plan, + _read_drawing, + _read_manifest, + _write_manifest, + read_cross_layout, + write_cross_layout, +) + +NOTE = {"structure_note": "L형 측구\n집수정 보강"} + + +def _source(chainage: float, lo: int = -5, hi: int = 5) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.05 * x} + for x in range(lo, hi + 1) + ], + } + + +def _drawing(layout: dict, designs: list[dict | None]) -> tuple[dict, dict]: + use_project_templates(None) + sources = [_source(i * 20.0) for i in range(len(designs))] + sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0] + sections = [ + { + "chainage": s["chainage_m"], + "source": s, + "design": design, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": "No.0", + } + for s, design in zip(sources, designs, strict=True) + ] + return sheet, build_cross_sheet(sheet, sections) + + +def _tables(drawing: dict) -> list[dict]: + return [e for e in drawing["entities"] if e["type"] == "Table"] + + +def test_배율_15_표_크기와_쌓기() -> None: + sheet, drawing = _drawing({"default": [2, 2], "table_scale": 1.5}, [NOTE, None]) + assert sheet["table_scale"] == 1.5 + _w, h = usable_area() + tables = _tables(drawing) + for table, slot in zip(tables, sheet["slots"], strict=True): + shape = table["shapeData"] + assert sum(shape["columnWidths"]) == pytest.approx(162.0) + assert sum(shape["rowHeights"]) == pytest.approx(60.0) + bottom = shape["origin"]["y"] - sum(shape["rowHeights"]) + assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로 + grounds = [ + entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + ] + # 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐 + tops = [t["shapeData"]["origin"]["y"] for t in tables] + assert grounds[0][1] == pytest.approx(tops[0] + 9.0 + 8.0) + assert grounds[1][1] == pytest.approx(tops[1] + 8.0) + + +def test_배율_1_은_지금과_같음() -> None: + _s, plain = _drawing({"default": [2, 2]}, [NOTE, None]) + _s, one = _drawing({"default": [2, 2], "table_scale": 1.0}, [NOTE, None]) + assert one["entities"] == plain["entities"] + assert sum(_tables(one)[0]["shapeData"]["columnWidths"]) == pytest.approx(108.0) + + +def test_넓은_표_넘침() -> None: + w, _h = usable_area() + # 4 × 4 칸 폭 192.5 — 표 108 × 2.0 = 216 이 칸을 넘음 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 2.0}, [None]) + assert w / 4 < 216.0 + assert len(drawing["overflow_cells"]) == 1 + _s, drawing = _drawing({"default": [4, 4], "table_scale": 1.0}, [None]) + assert drawing["overflow_cells"] == [] + + +def _project(tmp_path: Path, count: int) -> tuple[Path, Path]: + root = tmp_path / "project" + cross_dir = root / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + longitudinal_path.parent.mkdir(parents=True) + stations = [] + for i in range(count): + chainage = i * 20 + stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0}) + (cross_dir / f"cross_{chainage:05d}m.json").write_text( + json.dumps(_source(float(chainage))), "utf-8" + ) + longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8") + return root, longitudinal_path + + +def test_배치_파일_배율_저장_범위(tmp_path: Path) -> None: + root, longitudinal_path = _project(tmp_path, 5) + ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)] + manifest = _read_manifest(root) + manifest["drawings"] = {i: {"confirmed": True} for i in ids} + _write_manifest(root, manifest) + # 배율만 바꿈 — 담긴 측점 그대로라 확정 장 안 풂 + assert ( + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": 1.3}) == [] + ) + assert read_cross_layout(root)["table_scale"] == 1.3 + assert _read_manifest(root)["drawings"][ids[0]]["confirmed"] is True + assert _cross_sheet_plan(root, longitudinal_path)[0]["table_scale"] == 1.3 + for bad in (0.4, 2.1, "1.2", True): + with pytest.raises(ValueError): + write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": bad}) + assert read_cross_layout(root)["table_scale"] == 1.3 + + +def test_옛_배율_확정_장_안내(tmp_path: Path) -> None: + stage = tmp_path / "B07_DesignDetail" + (stage / "drawings").mkdir(parents=True) + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []} + (stage / "drawings" / "cross_s00000m.json").write_text(json.dumps(saved), encoding="utf-8") + entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT} + manifest = {"drawings": {"cross_s00000m": entry}} + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + def read() -> dict: + _k, _l, drawing, confirmed, _t = _read_drawing( + tmp_path, tmp_path / "x.json", "cross_s00000m" + ) + assert confirmed + return drawing + + assert "stale_scale" not in read() # 배율 적힘 없음 = 1 · 지금도 1 + (stage / "cross_layout.json").write_text( + json.dumps({"default": [4, 4], "sheets": {}, "table_scale": 1.5}), encoding="utf-8" + ) + assert read().get("stale_scale") is True + entry["table_scale"] = 1.5 + (stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert "stale_scale" not in read() 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_180_b07_cross_group.py b/resources/tester/test_180_b07_cross_group.py new file mode 100644 index 000000000..4feffb1a1 --- /dev/null +++ b/resources/tester/test_180_b07_cross_group.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""180-1 B07 횡단 장 — 원지반선 고르기 · 측점 한 벌 groupId. + +못박는 것 + - 횡단 장 · 측점 한 장의 원지반 층은 안 잠김(종단은 그대로 잠김). + - 측점마다 groupId 하나(원지반 · 계획선 · 표 · 글 전부) · 측점끼리 다름. + - 칸 격자선 · 도각엔 groupId 없음. + - cross_placements 의 group_id = 그 측점 엔티티 값(화면 덧그림이 같은 묶음). +""" + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + FRAME_LAYER_ID, + GROUND_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates + + +def _source(chainage: float) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x} + for x in range(-5, 6) + ], + } + + +def _design_line() -> list[dict]: + return [{"offset_m": x, "elevation_m": 100.0} for x in (-3.0, 0.0, 3.0)] + + +def _drawing() -> dict: + use_project_templates(None) + chainages = [0.0, 20.0, 40.0, 60.0] + sheet = plan_cross_sheets(chainages, {"default": [2, 2]})[0] + sections = [ + { + "chainage": c, + "source": _source(c), + "design_line": _design_line(), + "design": {"structure_note": "L형 측구"}, + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": f"No.{int(c // 20)}", + } + for c in chainages + ] + return build_cross_sheet(sheet, sections) + + +def _ground_locked(drawing: dict) -> bool: + return next(layer for layer in drawing["layers"] if layer["id"] == GROUND_LAYER_ID)["isLocked"] + + +def test_원지반_층_안_잠김() -> None: + assert _ground_locked(_drawing()) is False + single = build_cross_drawing(_source(0.0), "cross_00000m", _design_line(), None, None, "") + assert _ground_locked(single) is False + + +def test_측점마다_groupId_하나() -> None: + drawing = _drawing() + placements = drawing["cross_placements"] + assert len(placements) == 4 + group_ids = [p["group_id"] for p in placements] + assert len(set(group_ids)) == 4 # 측점끼리 다름 + grouped = [e for e in drawing["entities"] if "groupId" in e] + assert {e["groupId"] for e in grouped} == set(group_ids) + for group_id in group_ids: + members = [e for e in grouped if e["groupId"] == group_id] + layers = {e["layerId"] for e in members} + types = {e["type"] for e in members} + assert GROUND_LAYER_ID in layers and "Table" in types and "Text" in types + # 격자선 · 도각은 묶지 않음 + assert all("groupId" not in e for e in drawing["entities"] if e["layerId"] == FRAME_LAYER_ID) + assert all(e["layerId"] != FRAME_LAYER_ID for e in grouped) + + +def test_groupId_결정적() -> None: + first = [p["group_id"] for p in _drawing()["cross_placements"]] + assert first == [p["group_id"] for p in _drawing()["cross_placements"]] diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py new file mode 100644 index 000000000..c1e288330 --- /dev/null +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -0,0 +1,104 @@ +"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. + +- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" + +# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 +EXPECTED = [ + ["연결 점검", ["check"]], + ["공사원가계산서", ["cost"]], + ["총괄설계내역서", ["total"]], + ["설계내역서", ["bill"]], + ["일위대가", ["dg_list", "dg"]], + ["단가산출", ["sg_list", "sg"]], + ["환율및기초자료", ["basis"]], + ["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]], + ["재료", ["material", "compare", "sum_material"]], + ["노무", ["labor", "sum_labor"]], +] + + +def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," + " of: TABS.map(([t]) => groupOf(t)[0]) }));", + ) + assert out["groups"] == EXPECTED + flat = [t for _, tabs in out["groups"] for t in tabs] + assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19 + # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 + firsts = list(dict.fromkeys(out["of"])) + assert firsts == [g[0] for g in EXPECTED] + assert len(firsts) == 10 + + +def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): + out = run( + [TABS, BAR], + """ +const picks = []; +const bars = createTabBars((t) => picks.push(t)); +const view = () => ({ + top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'], + b.dataset.b09Group, b.dataset.b09Tab ?? null]), + sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]), + blank: bars.sub.classList.contains('b09-estimation__tabs--blank'), + role: bars.sub.attrs.role, +}); +bars.draw('dg'); +const a = view(); +bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.sub.children[0].click(); // 일위대가목록표 +bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 +const b = view(); +bars.draw('sum_material'); +bars.draw('dg'); +bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.draw('cost'); +const c = view(); +bars.top.children[0].click(); // 연결 점검 = 단독 위 탭 +bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 +console.log(JSON.stringify({ a, b, c, picks })); +""", + ) + a, b, c = out["a"], out["b"], out["c"] + assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [ + "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + ] # fmt: skip + assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5 + assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") + assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] + assert not a["blank"] and a["role"] == "tablist" + assert b["sub"] == [ + ["재료비목록표", "false", "material"], + ["자재단가대비표", "true", "compare"], + ["재료비수량금액집계표", "false", "sum_material"], + ] # fmt: skip + assert b["top"][8][2] == "true" and b["top"][4][2] == "false" + # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 + assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 + assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None + assert out["picks"] == ["dg_list", "sum_material", "machine", "check"] + + +def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "createTabBars((tab) => show(tab))" in shell and "bars.draw(tab);" in shell + assert "body.append(bars.top, bars.sub, panel);" in shell + css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert ".b09-estimation__tabs--blank > .ui-btn" in css and "visibility: hidden" in css diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py new file mode 100644 index 000000000..05d14ce34 --- /dev/null +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -0,0 +1,76 @@ +"""181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. + +- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` +- 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 + +ROWS = [ # 호표 A(3줄) · 호표 B(4줄) · 호표 C(1줄) + {"key": "A"}, {"key": "A|1"}, {"key": "A|합계"}, + {"key": "B"}, {"key": "B|1"}, {"key": "B|2"}, {"key": "B|총계"}, + {"key": "C"}, +] # fmt: skip + + +def test_구간_첫_끝_줄(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst rows = {json.dumps(ROWS)};" + "\nconst show = (focus) => { const r = pickRange(rows, focus);" + " return [r, rows.map((_, i) => pickClass(r, i))]; };" + "\nconsole.log(JSON.stringify({ a: show('A|1'), b: show('B'), c: show('C')," + " none: show(undefined), unknown: show('Z|1') }));", + ) + assert out["a"][0] == [0, 2] + assert out["a"][1] == [ + "is-pick is-pick-top", "is-pick", "is-pick is-pick-end", None, None, None, None, None, + ] # fmt: skip + assert out["b"][0] == [3, 6] + assert out["b"][1][3:7] == [ + "is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", + ] # fmt: skip + assert out["b"][1][:3] == [None] * 3 # 다른 호표 없음 + assert out["c"][0] == [7, 7] and out["c"][1][7] == "is-pick is-pick-top is-pick-end" + assert out["none"] == [None, [None] * 8] and out["unknown"] == [None, [None] * 8] + + +def test_표_보기_줄_class_넘김(): + out = run( + [COMMON, TABS, GROUPS], + f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};" + "\nlet extra = null;" + "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," + " makeTable: (e) => { extra = e; return { root: document.createElement('table')," + " focusRow: () => {} }; } };" + "\nchosen.set('dg', 'table');" + "\nrenderGroupPanel(o);" + "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", + ) + assert out == ( + [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end"] + [None] + ) + + +def test_글_접기_없음_CSS_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + common = (ROOT / COMMON).read_text("utf-8") + for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): + assert gone not in common + assert 'box?.classList.add("is-pick")' in common + page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8") + for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"): + assert sel in page + assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "extra?: (index: number) => string | null" in shell diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py new file mode 100644 index 000000000..775a9e234 --- /dev/null +++ b/resources/tester/test_182_b09_bill_level.py @@ -0,0 +1,302 @@ +"""182 B09 설계내역서 손 줄 레벨 — 묶음 줄 · 부모 · 상위 합계 · 거울 · 저장 왕복 · 우클릭 레벨 메뉴. + +- 손 줄 `kind: "group"` = 묶음 줄(수량 · 연결 없음) · `parent` = 같은 공종 묶음 손 줄 키 +- `level` = 제목(1 · 2) · 줄 = 공종 깊이 + 1 + 손 묶음 조상 수 · 부모 못 찾음 · 다른 공종 · 돎 = 공종 바로 아래 +- 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합 · `sums` = 항목만(원가계산서 그대로) +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +ITEMS = [ + {"key": "earth:1", "section": "1.1", "name": "규준틀", "unit": "개소", "qty": 14, + "ref": {"tab": "dg_list", "key": "B00194"}}, + {"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None}, + {"key": "haul:1", "section": "3", "name": "운반", "unit": "㎥", "qty": 2, + "ref": {"tab": "sg_list", "key": "D01341"}}, +] # fmt: skip +HOPYO = { + "B00194": {"labor": 21936, "material": 3885, "expense": 0}, + "D01341": {"labor": 1116, "material": 360, "expense": 463}, +} +INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO} +BOOKS = { + "daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30, + "sum": 1230}], +} # fmt: skip +DG = {"tab": "dg", "key": "DG:a"} + + +def _hand(key, after, section="1.2", **more): + row = {"key": key, "section": section, "after": after, "name": key[5:], "spec": "", + "unit": "㎡", "qty": None, "ref": None} # fmt: skip + return {**row, **more} + + +HAND = [ + _hand("hand:g1", "earth:4", kind="group"), + _hand("hand:a", "hand:g1", parent="hand:g1", qty=10, ref=DG), + _hand("hand:g2", "hand:a", kind="group", parent="hand:g1"), + _hand("hand:b", "hand:g2", parent="hand:g2", qty=2, ref=DG), + _hand("hand:c", "hand:b", parent="hand:g2", qty=1), # 연결 없음 = 빈 줄 + _hand("hand:x", "hand:c", parent="hand:없음", qty=1, ref=DG), # 부모 없음 → 공종 아래 + _hand("hand:y", "haul:1", "3", parent="hand:g1", qty=1, ref=DG), # 다른 공종 부모 + _hand("hand:p", "earth:1", "1.1", kind="group", parent="hand:q"), # 돌기 + _hand("hand:q", "hand:p", "1.1", kind="group", parent="hand:p"), +] +USER = {"bill": {"rows": HAND}} +#: 같은 손 줄 · 묶음 없이(묶음 줄 빼고 부모 지움) — 공종 합 · sums 비교 밑 +FLAT = { + "bill": { + "rows": [ + {k: v for k, v in h.items() if k != "parent"} for h in HAND if h.get("kind") != "group" + ] + } +} + + +def _rows(sheets): + return {row["key"]: row for row in sheets["bill"]["rows"]} + + +def _node(source: str) -> str: + # 표준입력으로 줌 — 「-e」 는 윈도 명령 줄 길이(32k)를 넘음 + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=source, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return result.stdout + + +def _ts(names: list[str]) -> str: + return "".join( + IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) + for n in names + ) + + +def test_레벨_차례_부모(): + sheets = compute_estimation(INPUTS, USER, None, BOOKS) + got = [(r["key"], r["level"]) for r in sheets["bill"]["rows"] if r["section"] == "1.2"] + assert got == [ + ("sec:1.2", 2), ("earth:4", 3), ("hand:g1", 3), ("hand:a", 4), ("hand:g2", 4), + ("hand:b", 5), ("hand:c", 5), ("hand:x", 3), ("earth:5", 3), + ] # fmt: skip + rows = _rows(sheets) + assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1 + assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == ( + 2, + 2, + None, + ) + assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2" + # 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래) + assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 3) + assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4) + # 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦 + g1 = rows["hand:g1"] + assert (g1["kind"], g1["qty"], g1["ref"], g1["hand"]) == ("group", None, None, True) + assert not {g["key"] for g in sheets["gaps"]} & {"hand:g1", "hand:g2", "hand:p", "hand:q"} + + +def test_묶음_금액_자손_합_공종_합_그대로(): + sheets = compute_estimation(INPUTS, USER, None, BOOKS) + rows = _rows(sheets) + g2, g1 = rows["hand:g2"], rows["hand:g1"] + assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60) + assert (g2["blank"], g2["note"]) == (1, "빈 줄 1") + assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄) + assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == "" + # 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈) + flat = compute_estimation(INPUTS, FLAT, None, BOOKS) + titles = [r for r in sheets["bill"]["rows"] if r["kind"] == "title"] + assert titles == [{**r} for r in flat["bill"]["rows"] if r["kind"] == "title"] + assert sheets["bill"]["sums"] == flat["bill"]["sums"] + assert sheets["bill"]["totals"] == flat["bill"]["totals"] + assert rows["sec:1.2"]["sum"] == 19390 + 12300 + 2460 + 1230 # earth:4 + a + b + x + + +def test_거울_TS_가_같은_값(): + calc = _ts(["Calc_Base", "Calc_Bill"]) + for user in (USER, FLAT, {}): + body = ( + f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" + f"\nconst u = {json.dumps(user, ensure_ascii=False)};" + f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));" + ) + assert json.loads(_node(calc + body)) == compute_estimation(INPUTS, user, None, BOOKS) + + +def test_저장_왕복_kind_parent(tmp_path): + raw = { + "bill": { + "rows": [ + {**HAND[0], "qty": 5, "ref": DG}, # 묶음 줄 = 수량 · 연결 버림 + HAND[1], + {**HAND[3], "parent": "x:1", "kind": "zz"}, # hand: 아닌 부모 · 모르는 꼴 버림 + ] + } + } + clean = store.write_user(tmp_path, raw) + assert store.read_user(tmp_path)["bill"] == clean["bill"] + g1, a, b = clean["bill"]["rows"] + assert (g1["kind"], g1["qty"], g1["ref"], "parent" in g1) == ("group", None, None, False) + assert (a["parent"], "kind" in a, a["ref"]) == ("hand:g1", False, DG) + assert "parent" not in b and "kind" not in b + # 다시 읽은 값으로 셈 = 원래 값으로 셈 + back = store.read_user(tmp_path) + assert _rows(compute_estimation(INPUTS, back, None, BOOKS))["hand:g1"]["sum"] == 12300 + + +MENU_JS = """ +const rows = __ROWS__; +const start = __USER__; +let user = start; +const ctx = { sheets: {}, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; +const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label); +const at = (key) => rows.findIndex((r) => r.key === key); +const labels = (key) => billMenu(ctx, rows, at(key)).map(name); +const run = (key, label) => { + user = start; + billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1](); + return user.bill.rows; +}; +const out = { + g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"), + b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"), +}; +out.child = run("hand:g2", "아래에 하위 줄 추가").at(-1); +out.below = run("hand:g1", "아래에 줄 추가").at(-1); +out.belowB = run("hand:b", "아래에 줄 추가").at(-1); +out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x"); +out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2"); +out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key)); +out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x"); +out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key)); +console.log(JSON.stringify(out)); +""" + + +def test_우클릭_레벨_메뉴_TS(): + rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"] + body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace( + "__USER__", json.dumps(USER, ensure_ascii=False) + ) + out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit"]) + body)) + level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} + pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 + assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"] + assert "연결" not in out["g1"] and pick("g1") == ["항목으로 바꾸기"] + assert pick("a") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음 + assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내 + assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음 + assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1 + assert pick("auto") == [] and "줄 지우기" not in out["auto"] + assert {k: out["child"][k] for k in ("section", "after", "parent")} == { + "section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip + assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝 + assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2") + assert out["indent"]["parent"] == "hand:g1" + assert "parent" not in out["outdent"] # g1 이 맨 위 → 공종 아래 + assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [ + ("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip + assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == ( + "group", + None, + None, + ) + # g1 지움 — 자식은 공종 아래로 · 바로 뒤 줄은 g1 자리(earth:4 뒤)로 + assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [ + ("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip + # 들여쓴 x = g1 금액에 듦(셈 짝 그대로) + user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}} + rows2 = _rows(compute_estimation(INPUTS, user, None, BOOKS)) + assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230) + + +LINK_JS = """ +const opened = []; +const picked = {}; +globalThis.createMapContextMenu = () => ({ + element: { isConnected: true, style: {} }, + open: (x, y, items) => opened.push({ x, y, items }), +}); +globalThis.requestAnimationFrame = () => 0; +globalThis.createFindList = () => ({ + search: { value: "", addEventListener() {}, focus() {} }, + hint: {}, + root: {}, + setRows: (rows) => (picked.rows = rows), +}); +globalThis.openModal = (o) => { + picked.title = o.title; + o.mount({ append() {} }, () => (picked.closed = true)); +}; +let user = __USER__; +const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {}, + get user() { return user; }, setUser: (u) => (user = u) }; +const listeners = {}; +const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true }; +attachRowMenu(root, () => []); +const cell = { getBoundingClientRect: () => ({ left: 10, bottom: 40 }) }; +listeners.click({ target: { closest: () => cell } }); +const table = buildBill(ctx); +const at = (key) => table.rows.findIndex((r) => r.key === key); +const name = (x) => (x === "-" ? "-" : x[0]); +const out = { + columns: table.columns.map((c) => c.key).slice(-2), + connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect), + link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")], + cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))), + edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")], +}; +table.onCell(at("hand:a"), "connect"); +table.onCell(at("hand:g1"), "connect"); +table.onCell(at("earth:4"), "connect"); +table.onCell(at("hand:c"), "connect"); +out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); +opened[1].items[0][1](); // hand:c → 일위대가 호표 고르기 창 +picked.rows[0].onPick(); +out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)]; +out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; +console.log(JSON.stringify(out)); +""" + + +def test_연결_단추_칸_메뉴_고르기_창_TS(): + sheets = compute_estimation(INPUTS, USER, None, BOOKS) + body = ( + LINK_JS.replace("__USER__", json.dumps(USER, ensure_ascii=False)) + .replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False)) + .replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False)) + ) + src = _ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + out = json.loads(_node(src + body)) + assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸 + assert out["connect"] == ["대가3 ▾", "연결 ▾", "", ""] # 손 항목 줄만 · 연결됨 = 호표 번호 + assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 + assert out["cls"] == [ + "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip + assert out["edit"] == [True, False] # 묶음 줄 = 이름만 + picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] + # 누른 칸 아래(left · bottom) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함 + assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]] + assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 + assert out["ref"] == DG 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_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_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py index e10707d17..e788663a8 100644 --- a/resources/tester/test_m02_labor_mat_marks.py +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -125,7 +125,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸(): assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts") detail = _src("M02_MasterTemplete_Labor_Detail.ts") - assert "mark: lib ? undefined : markLogicMat" in detail + assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail # 사전조합도(175-1) assert "mark: markMat" in detail # 서버 허용 값 = 화면 허용 값 api = _src("M02_MasterTemplete_Labor_Api.ts") 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_group_cards.css b/ui_template/ui_template_group_cards.css index a2e6f033f..3fa11762a 100644 --- a/ui_template/ui_template_group_cards.css +++ b/ui_template/ui_template_group_cards.css @@ -47,6 +47,12 @@ border-radius: var(--radius-cards); } +/* 목록표에서 고른 호표 — B06 횡단 카드 고름과 같은 붉은 외곽선(181-2) */ +.ui-group-card.is-pick { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + .ui-group-card__head { display: flex; flex-wrap: wrap; @@ -76,14 +82,6 @@ overflow-x: auto; } -.ui-group-card.is-closed .ui-group-card__body { - display: none; -} - -.ui-group-card.is-closed .ui-group-card__head { - cursor: pointer; -} - .ui-group-card__lines { width: 100%; border-collapse: collapse; diff --git a/ui_template/ui_template_group_cards.ts b/ui_template/ui_template_group_cards.ts index 086ce3552..65923f767 100644 --- a/ui_template/ui_template_group_cards.ts +++ b/ui_template/ui_template_group_cards.ts @@ -5,8 +5,9 @@ * 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) / * 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선) * 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽) - * 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기) - * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조 + * 접기 없음 — 묶음 모두 펼친 박스 + * 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선 + * (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`) * ========================================================================== */ import "./ui_template_group_cards.css"; @@ -68,7 +69,6 @@ export interface GroupCardsOptions { actions?: HTMLElement[]; } -export const GROUP_SUMMARY_OVER = 12; export const GROUP_COLUMNS = [1, 2, 3, 4] as const; export const PART_LABELS = ["노무", "재료", "경비"] as const; @@ -76,9 +76,6 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const; export const clampColumns = (value: unknown): number => GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2; -/** 요약 보기 — 묶음 12 넘음 */ -export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER; - const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 }); const plain = (value: number | null | undefined): string => value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value); @@ -173,7 +170,7 @@ function card(g: GroupCard, o: Required return box; } -/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */ +/** 묶음 카드 판 — 머리 띠(가로 열 수 · 부르는 쪽 단추) + 카드 격자 */ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain }; const root = document.createElement("div"); @@ -184,9 +181,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.style.setProperty("--group-cols", String(columns)); const cards = opts.groups.map((g) => card(g, fmt)); grid.append(...cards); - const summary = isSummary(opts.groups.length); - const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open); - if (summary) cards.forEach((c) => setOpen(c, false)); const bar = document.createElement("div"); bar.className = "ui-group-cards__bar"; @@ -215,14 +209,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { }); pick.append(...colButtons); bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`)); - if (summary) { - const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open)); - bar.append( - span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"), - createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }), - createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }), - ); - } const tail = document.createElement("div"); tail.className = "ui-group-cards__actions"; tail.append(...(opts.actions ?? [])); @@ -232,18 +218,15 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement { grid.addEventListener("click", (event) => { const el = (event.target as Element).closest("[data-at]"); if (!el || !grid.contains(el)) return; - const box = el.closest(".ui-group-card"); - if (summary && box && el.classList.contains("ui-group-card__head")) - return setOpen(box, box.classList.contains("is-closed")); opts.onPick?.(Number(el.dataset.at)); }); if (opts.focus !== undefined && opts.focus >= 0) { const el = grid.querySelector(`[data-at="${opts.focus}"]`); const box = el?.closest(".ui-group-card"); - if (box) setOpen(box, true); + box?.classList.add("is-pick"); el?.classList.add("is-focus"); - requestAnimationFrame(() => el?.scrollIntoView({ block: "center" })); + requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" })); } return root; } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e84c27cf3..a97c57165 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -420,8 +420,8 @@ export const ui_locales_b2 = { B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"], B06_Design_Ground_Blasting: ["발파암", "Blasting rock"], B06_Design_Mode_Legend: ["단면유형", "Section type"], - B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"], - B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"], + B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)", "One-side fill (left cut)"], + B06_Design_Mode_RightCut: ["편측 성토(우 절토)", "One-side fill (right cut)"], B06_Design_Mode_BothCut: ["양측 절토", "Both cut"], B06_Design_Mode_BothFill: ["양측 성토", "Both fill"], B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],