diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index b7592b7ef..a58f42145 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -330,7 +330,7 @@ export function createFacilityOptionsForm( if (c.id === "inlet" && item.id === "structure" && inletIsAuto()) { const drawn = drawnNow(); return drawn - ? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" } + ? { value: drawn.basin ? "집수정" : "흙막이", tag: "자동" } : { value: "", tag: "자동(지형 규칙)" }; } // 빈 고르기 칸 = 「선택 안 함」 그대로(54-39 · 기본값 꼬리 없음) @@ -412,7 +412,7 @@ export function createFacilityOptionsForm( // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). - const extraGroup = group("추가 기슭막이"); + const extraGroup = group("추가 흙막이"); const extraSlot = document.createElement("div"); extraSlot.className = "b05-structure__adjust-slot"; extraGroup.body.append(extraSlot); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 0a326b5b7..a8d14e9e3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -25,7 +25,7 @@ export const INLET_KINDS: ReadonlyArray<{ type: "기슭막이" | "집수정"; structure: InletStructureKind; }> = [ - { label: "기슭막이", type: "기슭막이", structure: "revet" }, + { label: "흙막이", type: "기슭막이", structure: "revet" }, { label: "집수정", type: "집수정", structure: "auto" }, { label: "자동(규칙)", type: "기슭막이", structure: "auto" }, { label: "집수정 I형", type: "집수정", structure: "I" }, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 605621048..78c1bff9a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -289,6 +289,8 @@ export interface FordSet { /** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */ export interface BoxSet { type: "box"; + /** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */ + chainage_m?: number; /** 사용자 입력 내공(유로) 폭·높이(m). */ inner_width_m: number; inner_height_m: number; @@ -561,6 +563,9 @@ export interface CrossSectionPatch { cut_slope_ratio_user?: number; /** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */ pipe_length_m?: number; + /** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */ + pipe_slope_pct?: number; + box_ground_slope_pct?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index b1171532f..0cfea5cf6 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel): # 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로 # 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`). pipe_length_m: float | None = Field(default=None, ge=0) + # B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값 + # (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫. + pipe_slope_pct: float | None = None + box_ground_slope_pct: float | None = None class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 34ff50281..aa6b89415 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -66,6 +66,8 @@ _AREA_KEYS = ( "cut_soil_area_m2", "cut_rock_area_m2", "pipe_length_m", + "pipe_slope_pct", + "box_ground_slope_pct", ) diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index b647cdb67..4a5b91695 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross import type { CrossSection } from "./B06_Section_Api_Fetch"; import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; +import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom"; import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const"; +import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; import { @@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = { }; /** - * 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`. + * 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`. * * ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고 * **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다. */ -function pipeOwnerChainage( - section: CrossSection, +function ownerChainage( + target: number | undefined, sections: readonly CrossSection[], ): number | null { - const target = section.culvert?.chainage_m; if (typeof target !== "number") return null; let best: number | null = null; for (const item of sections) { @@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [ "cut_soil_area_m2", "cut_rock_area_m2", "pipe_length_m", + // B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음. + "pipe_slope_pct", + "box_ground_slope_pct", ] as const; +/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */ +function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null { + const groundAt = groundInterpolator(section.samples); + const inlet = box.sides.find((side) => side.wingRole === "inlet"); + const outlet = box.sides.find((side) => side.wingRole === "outlet"); + const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0; + if (!groundAt || !inlet || !outlet || !(run > 0)) return null; + return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100; +} + /** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */ function areaInputOf(section: CrossSection, layouts: StoredLayouts) { const trim = trimOfLayouts(layouts); @@ -195,18 +209,26 @@ function areaRowOf( // B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷). // ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이 // 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다. - const ownerChainage = pipeOwnerChainage(section, sections); + const isOwner = (target: number | undefined): boolean => { + const owner = ownerChainage(target, sections); + return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M; + }; // 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음. const pipeOwner = - !!section.culvert && - !section.culvert.body && - (ownerChainage === null || - Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M); - const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined; - const pipeRow: Record | null = - typeof pipeLengthM === "number" && pipeLengthM > 0 - ? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) } + !!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m); + const pipe = pipeOwner ? layouts.culvert?.pipe : undefined; + const ownRow: Record = { chainage_m: section.chainage_m }; + if (pipe && pipe.lengthM > 0) { + ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4)); + ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4)); + } + // 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙). + const boxSlope = + layouts.box && isOwner(section.box?.chainage_m) + ? boxGroundSlopePct(section, layouts.box) : null; + if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4)); + const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null; const areaInput = areaInputOf(section, layouts); if (!areaInput) return pipeRow; const design = layouts.design; @@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] const rows: ExtraWallRow[] = []; for (const section of sections) { if (!section.culvert) continue; - const owner = pipeOwnerChainage(section, sections); + const owner = ownerChainage(section.culvert.chainage_m, sections); if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue; const layouts = computeStoredLayouts(section, sections); const culvert = layouts?.culvert; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 30ef832d1..28fcf846a 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -47,14 +47,13 @@ const EXTRA_ROWS = [ ".b06-structure-panel__struct--span", ].join(", "); -/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · - * 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */ +/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) · + * 단 수 · 옵션 · 집수정 내공(24-4a). */ const SIDE_ROWS = [ ".b06-structure-panel__struct--readout", ".b06-structure-panel__struct--tier", ".b06-structure-panel__struct--options", ".b06-structure-panel__struct--basin-size", - ".b06-structure-panel__struct--wall-height", ].join(", "); /** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29). @@ -64,12 +63,11 @@ const PIPE_INLET_ROWS = [ ".b06-structure-panel__struct--basin-size", ].join(", "); const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size"; -const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"; /** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 - * 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */ + * 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */ function rowsFor(slot: HTMLElement): string | null { - if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW; + if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null; if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS; return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS; } diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index b3fdefb2b..88d0db750 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -43,9 +43,11 @@ export interface BodyGuardBase { trim: CulvertDesignTrim; } +/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */ export function bodyGuardBase( section: CrossSection, samples: SectionSample[], + defaults = false, ): BodyGuardBase | null { const body = section.culvert?.body; const adjust = section.design; @@ -74,9 +76,10 @@ export function bodyGuardBase( return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim }; } if (body === "ford" && section.ford) { + const stored = defaults ? null : adjust?.ford_adjust; const layout = computeFordLayout(section, samples, { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) }, + inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, + outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, }); if (!layout) return null; // 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑 @@ -143,16 +146,21 @@ export function bodyGuardAxes( export type BodyAxes = { x: number; d: number; through: number; outward: number }; /** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로 - * 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */ + * 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. + * 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 · + * 수량 L 은 그린 관 길이 올림 그대로). */ export function fordPipeFit( - base: BodyGuardBase | null, + section: CrossSection, + samples: SectionSample[], culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } }, - axesOf: (role: Role) => BodyAxes | null, + axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null, ): [BodyAxes | null, BodyAxes | null] { const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")]; - if (!base || culvert.body !== "ford") return axes; + if (culvert.body !== "ford") return axes; + const base = bodyGuardBase(section, samples, true); + if (!base) return axes; const roles = ["inlet", "outlet"] as const; - const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null)); + const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null)); const tips = roles.map((role, i) => { const end = base.ends[role]; const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0; @@ -163,8 +171,8 @@ export function fordPipeFit( const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z); const gap = Math.ceil(length - 1e-6) - length; const at = guarded[1] ? 1 : 0; - const fit = guarded[at]; - if (!fit || gap < 1e-6) return axes; + const fit = axes[at]; + if (!guarded[at] || !fit || gap < 1e-6) return axes; axes[at] = { ...fit, x: fit.x + gap }; return axes; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 10039ccdb..35bbbbbe0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -124,14 +124,14 @@ export function appendCulvertOverlay( segment.points, `유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit - ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" + ? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` + (layout.fillSlope.roadWideningM > 0.01 ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` : "") + (layout.fillSlope.structureRequired - ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" + ? " · ⚠ 흙막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" : ""), ); } @@ -148,7 +148,7 @@ export function appendCulvertOverlay( ? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m` : `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit - ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" + ? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); @@ -192,7 +192,7 @@ export function appendCulvertOverlay( "b06-chart__culvert-revet", // 높이 표기 = 순수 높이(바닥~상단, 근입 0.5 포함). 추가 기슭막이도 같은 기준으로 // 통일했다(2026-08-23 배관 벽 → 2026-08-29 추가 벽). - `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${( + `${roleLabel(wall.role)} 흙막이 ${wall.form ?? ""} H=${( wall.height + REVET_EMBED_DEPTH_M ).toFixed(1)}m` + `(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` + @@ -221,8 +221,8 @@ export function appendCulvertOverlay( basin.reason === "cut" ? "절토측 유입 — 집수정 기본(성토사면 없음)" : basin.reason === "manual" - ? "사용자 선택 집수정(규칙상 기슭막이 자리)" - : "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본"; + ? "사용자 선택 집수정(규칙상 흙막이 자리)" + : "유입측 성토사면 3m 이하 — 흙막이 대신 집수정 기본"; for (const part of basin.parts) { const shape = polygon( part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), @@ -344,7 +344,7 @@ export function appendCulvertOverlay( const hit = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-revet-hit", - `${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`, + `${roleLabel(wall.role)} 흙막이 선택 — 카드 하단 ◀/▶로 자리 조절`, ); hit.addEventListener("click", (event) => { event.stopPropagation(); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index d815290ac..1d59b2136 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -172,8 +172,8 @@ export function computeCulvertLayout( const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); // 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙. - const bodyAxesOf = (role: "inlet" | "outlet") => { - const end = body?.ends[role]; + const bodyAxesOf = (role: "inlet" | "outlet", base = body) => { + const end = base?.ends[role]; const [spec, info] = role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo]; if (!end) return null; @@ -186,7 +186,7 @@ export function computeCulvertLayout( thickness: REVET_THICKNESS_M, }); }; - const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf); + const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf); const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust => axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust; const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index c33ac5554..1d21de895 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -30,7 +30,7 @@ import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_C /** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */ export interface FordWallAdjust { - /** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */ + /** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */ heightM: number | null; /** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */ lateralM: number; @@ -103,7 +103,8 @@ export interface FordLayout { adjust: FordAdjust; } -/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */ +/** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 · + * 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */ function minWallHeight(ford: FordSet): number { return ford.diameter_m + ford.min_cover_m; } @@ -149,15 +150,6 @@ export function computeFordLayout( const inletOnLeft = uphill === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); - // 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점. - const bedElevation = (() => { - let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m)); - for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) { - low = Math.min(low, groundAt(offset)); - } - return low; - })(); - const buildSide = (role: FordWallRole): FordSideLayout | null => { const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft; const edge = onLeft ? edges.left : edges.right; @@ -166,8 +158,7 @@ export function computeFordLayout( const wing = role === "inlet" ? ford.wing_in : ford.wing_out; const wanted = role === "inlet" ? adjust.inlet : adjust.outlet; const lateralM = Math.max(wanted.lateralM, 0); - const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m); - const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford)); + const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford)); // 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다. const anchor: OffsetPoint = { offset: edge.offset_m, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 3e0f4cab2..edf4b76f7 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -6,8 +6,8 @@ * `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조, * **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화). * - * 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽 - * 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a). + * 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) · + * 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a). * 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다. * ========================================================================== */ @@ -19,7 +19,6 @@ import { buildPanelShell, dpadButton, dragReadout, - makeButton, makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; @@ -29,8 +28,6 @@ export interface FordPanelDeps { adjustFor: (role: FordWallRole) => FordWallAdjust; /** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */ heightFor: (role: FordWallRole) => number; - /** 높이 ±0.1m. */ - nudgeHeight: (role: FordWallRole, deltaM: number) => void; /** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */ nudge: (role: FordWallRole, screenDeltaM: number) => void; /** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */ @@ -54,7 +51,7 @@ const STEP_M = 0.1; /** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. * 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a). - * `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다. + * `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3). * 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle { const shell = buildPanelShell(() => deps.close()); @@ -75,26 +72,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): if (current) run(current); }; - // 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a). - const heightRow = makeRow("측벽 높이"); - heightRow.row.classList.add("b06-structure-panel__struct--wall-height"); - const heightLabel = heightRow.row.querySelector(".b06-structure-panel__label")!; - const heightValue = document.createElement("span"); - heightValue.className = "b06-structure-panel__hval"; - heightRow.controls.append( - makeButton( - "−", - "측벽 높이 0.1m 낮추기", - act((role) => deps.nudgeHeight(role, -STEP_M)), - ), - heightValue, - makeButton( - "+", - "측벽 높이 0.1m 높이기", - act((role) => deps.nudgeHeight(role, STEP_M)), - ), - ); - // 십자 조작 세트 — **창 맨 아래**(템플릿 규약). const moveRow = makeRow("이동"); moveRow.controls.classList.add("b06-structure-panel__buttons"); @@ -126,7 +103,9 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): ), ); - root.append(value, opts?.dock ? heightRow.row : moveRow.row); + // 측벽 높이 줄 없음 — 세월교 바닥은 노면 − (관경 + 토피) 기본(107-3) + root.append(value); + if (!opts?.dock) root.append(moveRow.row); const arrows = opts?.dock ? { attach: (): void => undefined, detach: (): void => undefined } @@ -153,9 +132,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): `높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : ""); moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`; slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`; - heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`; - // 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에 - heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이"; } return { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 0ad30e724..d44abb5d7 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -407,14 +407,13 @@ export function buildStructurePanel( return { button, set }; }; const LINK_TITLE = - "옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다."; + "옆 측점 흙막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다."; /** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */ - const LINK_LOCKED_TITLE = - "경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다."; + const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 흙막이 Z를 하나로 묶는다."; const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on)); const gradeToggle = makeToggle( "경사", - "종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.", + "종단경사 반영(3D 전용) — 켜면 3D 흙막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.", (on) => deps.setFollowGrade(on), ); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 55bc589db..c6f8a34f7 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -213,6 +213,7 @@ export function createCrossSectionCard( box, isCardSelected: () => isSelected, clearArea: () => { + if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1) if (activeArea === null) return; activeArea = null; setBandActive(null); @@ -235,10 +236,10 @@ export function createCrossSectionCard( setChipActive(null); } if (activeRevet !== null) { - // 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저 - // 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 - // 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의 - // 면적 강조를 이미 비운다. + bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1) + // 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이 + // 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21 + // 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다. if (wasSelected) onAreaSelect?.(section.station_id, null); else onSelect(section.station_id); } diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 30159b342..881dd3c8c 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -13,7 +13,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford"; export interface FordPanelContext { section: CrossSection; ford: FordControl; - /** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */ + /** 마지막 계산의 측벽 높이(m) — 읽기 줄. */ heightFor: (role: FordWallRole) => number; /** 마지막 계산의 바닥판 길이(m). */ slabLengthM: () => number; @@ -31,8 +31,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps { return { adjustFor: (role) => ford.adjustFor(chainage)[role], heightFor: context.heightFor, - nudgeHeight: (role, deltaM) => - ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }), nudge: (role, screenDeltaM) => ford.update(chainage, role, { lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role), diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 45faf329c..bf422f71b 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -109,6 +109,8 @@ export interface FordControlDeps { queuePipeOptions: (chainageM: number, patch: Record) => void; round1: (value: number) => number; clampMove: (value: number) => number; + /** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */ + releaseWall: (chainageM: number) => void; } export interface FordControls { @@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { }, selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null, select: (chainageM, role) => { + if (role) deps.releaseWall(chainageM); fordSelections.set(chainageM.toFixed(2), role); }, }; @@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): { }, selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null, select: (chainageM, role) => { + if (role) deps.releaseWall(chainageM); selections.set(chainageM.toFixed(2), role); }, }; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index d8c1f1b7e..c75854fb8 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls }, select: (chainageM, key) => { revetSelection = key ? { at: chainageM.toFixed(2), key } : null; + // 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1) + const { ford, box } = bodyControls; + if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null); + if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null); revetOffsetControl.onSelectionChange?.(); }, syncApplied: (chainageM, role, applied) => { @@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls queuePipeOptions: culvertOptions.queue, round1, clampMove, + releaseWall: (chainageM) => { + if (revetSelection?.at !== chainageM.toFixed(2)) return; + revetSelection = null; + revetOffsetControl.onSelectionChange?.(); + }, }, deps.sessionKey, ); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 1b5a20121..301272a01 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -19,6 +19,7 @@ import type { HaulEquipmentLimit, SectionDetailResponse, } from "./B06_Section_Api_Fetch"; +import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; @@ -197,6 +198,26 @@ export function createSectionView( }; if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights; + /** + * 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1). + * 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다. + */ + let spanMarks: ReadonlyArray = []; + let spanMarkId: string | null = null; + const inSpan = (section: CrossSection): boolean => { + const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null; + const { start_m: start, end_m: end } = mark ?? {}; + if (start == null || end == null || start === end) return false; + const at = section.chainage_m; + return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01; + }; + const syncSpanHighlights = (): void => { + for (const section of currentDetail?.cross_sections ?? []) + document + .getElementById(`cross-${section.station_id}`) + ?.classList.toggle("b06-cross-card--span", inSpan(section)); + }; + /** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */ let stationSelectListener: ((stationId: string | null) => void) | null = null; @@ -269,8 +290,8 @@ export function createSectionView( section: CrossSection, forcedHeightPx?: number, plotBase?: CrossPlotBase | null, - ): HTMLElement => - createCrossSectionCard( + ): HTMLElement => { + const card = createCrossSectionCard( section, section.station_id === selectedStationId, currentExaggeration, @@ -296,6 +317,9 @@ export function createSectionView( plotBase, cutSlope, ); + card.classList.toggle("b06-cross-card--span", inSpan(section)); + return card; + }; /** * 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면 @@ -483,7 +507,11 @@ export function createSectionView( ); } }, - selectMark: (structureId) => profile.selectMark(structureId), + selectMark: (structureId) => { + spanMarkId = structureId; + syncSpanHighlights(); + profile.selectMark(structureId); + }, setStationSelectListener(listener) { stationSelectListener = listener; }, @@ -491,6 +519,8 @@ export function createSectionView( structureEdit = edit; }, setStructureMarks(structures, types) { + spanMarks = structures; + syncSpanHighlights(); profile.setMarks(structures, types); // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 // (`__corridorBuild` 등과 같은 용도). diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index af1dd0afb..0c9b34125 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -175,6 +175,12 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent); } +/* 구간형 구조물(노면공 등)을 고르면 시작~종료 안 모든 카드(106-1) — 가까운 한 장의 선택 강조보다 옅게. */ +.b06-cross-card--span:not(.b06-cross-card--selected) { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent); +} + /* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */ .b06-cross-card > header { display: flex; diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 103b6a67e..aa12da9dc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -180,7 +180,10 @@ export interface PlanKind { id: string; name: string; pieces: string[]; + /** 양식 — plan(평면도 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). */ + layout: PlanKindLayout; } +export type PlanKindLayout = "plan" | "watershed"; export interface PlanMark { chainage_m: number; refuge: boolean; @@ -202,10 +205,11 @@ export function addPlanKind( projectId: string, name: string, pieces: string[], + layout: PlanKindLayout = "plan", ): Promise { return requestJson(`/projects/${projectId}/plan-kinds`, { method: "POST", - body: JSON.stringify({ name, pieces }), + body: JSON.stringify({ name, pieces, layout }), }); } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py index 8fea242bf..78d718bd3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py @@ -110,33 +110,31 @@ def ip_blocks( def _place(blocks: list[dict[str, Any]], width_mm: float) -> None: - """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄.""" + """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 → 오른쪽 · 위 → 아래(첫 줄 맨 위). + + 묶음 전체는 도곽 왼쪽 아래 기준 — 줄이 늘면 위로 쌓이고 마지막 줄이 맨 아래다. + """ block_w = sum(_COLUMNS) per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w)) + lines = -(-len(blocks) // per_line) for order, block in enumerate(blocks): line, column = divmod(order, per_line) - top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H + top = _MARGIN + (lines - 1 - line) * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H block["origin"] = [_MARGIN + column * block_w, top] block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS -def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: - """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" - min_x, min_y = box[0], box[1] - width = sum(_COLUMNS) / MM - area = unary_union( - [ - rect( - min_x + b["origin"][0] / MM, - min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, - min_x + b["origin"][0] / MM + width, - min_y + b["origin"][1] / MM, - ) - for b in blocks - ] - ) +def clip_lines( + pieces: dict[str, dict[str, Any]], + rects: list[tuple[float, float, float, float]], + names: tuple[str, ...], +) -> None: + """상자들(사업지 m · 최소 x · y · 최대 x · y) 밑 선 조각 `names` 를 상자 밖으로 잘라 낸다.""" + if not rects: + return + area = unary_union([rect(*bounds) for bounds in rects]) inside = prep(area) - for piece in _CLIPPED: + for piece in names: lines = (pieces.get(piece) or {}).get("lines") if not lines: continue @@ -155,6 +153,22 @@ def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: pieces[piece]["lines"] = kept +def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: + """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" + min_x, min_y = box[0], box[1] + width = sum(_COLUMNS) / MM + rects = [ + ( + min_x + b["origin"][0] / MM, + min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, + min_x + b["origin"][0] / MM + width, + min_y + b["origin"][1] / MM, + ) + for b in blocks + ] + clip_lines(pieces, rects, _CLIPPED) + + def ip_table_piece( pieces: dict[str, dict[str, Any]], curves: list[dict[str, Any]], diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index a88402caa..072a4dc33 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -75,7 +75,7 @@ LEGACY_PIECES: dict[str, tuple[str, ...]] = { _OUTLINE = LEGACY_PIECES["slope_outline"] _TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius") -# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. +# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다. DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( {"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]}, { @@ -100,6 +100,8 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( "name": "용지도", "pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"], }, + # 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장). + {"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"}, ) _COLORS = { @@ -127,7 +129,7 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { "emd": (2, [8, 4]), "sgg": (3, [14, 5, 3, 5]), } -_PARCEL_FONT = 1.8 +PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값 TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 @@ -518,7 +520,7 @@ def _piece_entities( "rect", layer=layer, color=color, - font=_PARCEL_FONT, + font=PARCEL_FONT, ) ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 9c0e8e38c..776394703 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -39,14 +39,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, MASS_HAUL_ID, - WATERSHED_ID, _cross_sheet_plan, _drawing_list, _invalidate_drawing, _read_drawing, _read_json, _store_confirmed_drawing, - watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source @@ -322,12 +320,6 @@ async def get_design_drawing( async with pool.acquire() as connection: section = await get_longitudinal_section(connection, project_id, route_id) source_design = ((section or {}).get("data") or {}).get("mass_haul") - elif drawing_id == WATERSHED_ID: - # 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다. - context, reason = await load_drainage_context(project_id) - if context is None: - return JSONResponse(status_code=404, content={"status": "error", "message": reason}) - source_design = await asyncio.to_thread(watershed_source, context) elif drawing_id == CROSS_STANDARD_ID: # 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값). pool = get_db_pool() diff --git a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py index 051b9622e..10e823bf9 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_PlanKinds.py @@ -34,6 +34,8 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) class PlanKindRequest(BaseModel): name: str pieces: list[str] = Field(default_factory=list) + # 양식 — plan(평면도 · 조각 고름) · watershed(유역도). 추가에서만 쓰고 업데이트는 그대로 둔다. + layout: str = "plan" class PlanMark(BaseModel): @@ -79,10 +81,10 @@ async def get_plan_kinds(project_id: UUID) -> Any: @router.post("/{project_id}/plan-kinds") async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any: - """종류 추가 — 조각을 골라 이름을 붙인다.""" + """종류 추가 — 양식 · 조각을 골라 이름을 붙인다.""" try: root = await _project_root(project_id) - await asyncio.to_thread(add_kind, root, request.name, request.pieces) + await asyncio.to_thread(add_kind, root, request.name, request.pieces, request.layout) return await asyncio.to_thread(_state, root) except ValueError as exc: return _error(400, exc) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 31fe66b3b..55d141e11 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -76,6 +76,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( PLAN_KIND_ID, + WATERSHED_LAYOUT, plan_kind_items, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( @@ -92,7 +93,6 @@ _CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" -WATERSHED_ID = "watershed" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" @@ -156,7 +156,6 @@ def _drawing_list( (COVER_ID, "cover", "표지"), (CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), - (WATERSHED_ID, "watershed", "유역도(배수 유역도)"), ): drawings.append( DesignDrawingItem( @@ -164,12 +163,6 @@ def _drawing_list( kind=kind, label=label, confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), - # 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다. - **( - {"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"} - if drawing_id == WATERSHED_ID - else {} - ), ) ) # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④. @@ -405,7 +398,7 @@ def _read_drawing( # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. if saved.get("format") == DRAWING_FORMAT: - if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): + if drawing_id in (COVER_ID, MASS_HAUL_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" @@ -451,36 +444,22 @@ def _read_drawing( if not isinstance(stored_design, dict): raise FileNotFoundError("계획평면도 자료가 없습니다.") label = str(stored_design.get("label") or drawing_id) - return ( - "plan", - label, - build_plan_kind_drawing( - drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} - ), - False, - None, - ) - - if drawing_id == WATERSHED_ID: - # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). - if not isinstance(stored_design, dict): - raise FileNotFoundError("배수 유역 산출물이 없습니다.") - longitudinal = _read_json(longitudinal_path) - interval = infer_station_interval(longitudinal.get("stations") or []) - return ( - "watershed", - "유역도(배수 유역도)", - build_watershed_drawing( + if stored_design.get("layout") == WATERSHED_LAYOUT: + # 유역도 양식 — watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). + longitudinal = _read_json(longitudinal_path) + drawing = build_watershed_drawing( drawing_id, stored_design.get("route_xy") or [], stored_design.get("basins") or [], stored_design.get("contours") or [], stored_design.get("streams") or [], - interval, - ), - False, - None, - ) + infer_station_interval(longitudinal.get("stations") or []), + ) + else: + drawing = build_plan_kind_drawing( + drawing_id, label, stored_design["box"], stored_design.get("pieces") or {} + ) + return "plan", label, drawing, False, None if _LONG_ID.fullmatch(drawing_id): source = _read_json(longitudinal_path) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py index 00303a402..6cb837830 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -20,7 +20,6 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$") _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") # 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. MASS_HAUL_ID = "mass_haul" -WATERSHED_ID = "watershed" COVER_ID = "cover" # ⚠ 빈-도각 목록이 여기 있었다 — 계획평면도·표준 횡단면도·용지도·표준도 일곱 줄. diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py index 36d81aca4..879af2b05 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -19,16 +19,18 @@ from shapely.validation import make_valid from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PARCEL_FONT from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg") +# 필지 정보 상자 밑에서 잘라 낼 선 조각 — 배경이 글을 가리지 않게(109-4). +LABEL_CLIPPED = ("contour", "stream", "parcel", "emd", "sgg") # B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). PARCEL_FILE = "연속지적도_bounds.geojson" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" -PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm) ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) _GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) _RINGS = 5 # 후보 자리 고리 수 @@ -187,6 +189,17 @@ def place_labels( return items +def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]: + """필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기.""" + rects = [] + for item in items: + width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT) + half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + cx, cy = item["center"] + rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h)) + return rects + + def landuse_pieces( project_root: Path, crs: str, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 5d4037c23..1cfe716d7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -1,12 +1,14 @@ """B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장). 저장 자리(프로젝트 저장소 `B07_DesignDetail/`): - plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩 + plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout} — 파일 하나씩 plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m) plan_marks.json 사용자가 정한 대피소 · 사토장 자리 -`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때 -기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다. +`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를 +쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 +없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도 +고치고 지울 수 있다. 조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경) 에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다. """ @@ -24,7 +26,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _structure_label, plan_area_mm, @@ -47,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _sheet_box, map_background, plan_stations, + watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _design_root, @@ -55,7 +58,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _write_manifest, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LABEL_CLIPPED, LANDUSE_PIECES, + label_rects, landuse_pieces, ) from common_util.common_util_json import atomic_write_json @@ -65,6 +70,8 @@ from config.config_system import DRAWING_SCALE_PLAN KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$") PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$") _NAME_MAX = 40 +WATERSHED_LAYOUT = "watershed" +_LAYOUTS = ("plan", WATERSHED_LAYOUT) _REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"} _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측) @@ -80,16 +87,24 @@ def _kinds_dir(project_root: Path) -> Path: return _design_root(project_root) / "plan_kinds" -# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). -_LANDUSE_MARK = "_landuse_added" +# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). +_ADDED_ONCE = ("landuse", "watershed") -def _add_landuse_once(folder: Path) -> None: - """93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다.""" - mark = folder / _LANDUSE_MARK - if mark.exists(): +def _document(kind: dict[str, Any], order: int) -> dict[str, Any]: + return { + "name": kind["name"], + "pieces": list(kind["pieces"]), + "order": order, + "layout": kind.get("layout", "plan"), + } + + +def _add_defaults_once(folder: Path) -> None: + """기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다.""" + pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()] + if not pending: return - landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse") documents = [] for path in folder.glob("*.json"): try: @@ -97,21 +112,23 @@ def _add_landuse_once(folder: Path) -> None: except (OSError, ValueError): continue names = {str(document.get("name")) for document in documents} - if not (folder / "landuse.json").exists() and landuse["name"] not in names: - order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1 - atomic_write_json( - folder / "landuse.json", - {"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order}, - ) - mark.touch() + order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1 + for kind_id in pending: + kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id) + if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names: + atomic_write_json(folder / f"{kind_id}.json", _document(kind, order)) + order += 1 + (folder / f"_{kind_id}_added").touch() def list_kinds(project_root: Path) -> list[dict[str, Any]]: - """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류.""" + """종류 목록 [{id, name, pieces, layout}] — 차례대로. 파일이 없으면 기본 종류.""" folder = _kinds_dir(project_root) if not folder.is_dir(): - return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS] - _add_landuse_once(folder) + return [ + {"layout": "plan", **kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS + ] + _add_defaults_once(folder) rows: list[tuple[int, str, dict[str, Any]]] = [] for path in folder.glob("*.json"): if not KIND_ID.fullmatch(path.stem): @@ -123,67 +140,76 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]: stored = set() for piece in document.get("pieces") or []: stored.update(LEGACY_PIECES.get(piece, (piece,))) - pieces = [piece for piece in PIECE_IDS if piece in stored] + layout = document.get("layout") if document.get("layout") in _LAYOUTS else "plan" + pieces = [] if layout == WATERSHED_LAYOUT else [p for p in PIECE_IDS if p in stored] rows.append( ( int(document.get("order") or 0), path.stem, - {"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces}, + { + "id": path.stem, + "name": str(document.get("name") or path.stem), + "pieces": pieces, + "layout": layout, + }, ) ) return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])] def _materialize(project_root: Path) -> list[dict[str, Any]]: - """기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번.""" + """기본 종류를 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번.""" kinds = list_kinds(project_root) folder = _kinds_dir(project_root) if not folder.is_dir(): for order, kind in enumerate(kinds): - atomic_write_json( - folder / f"{kind['id']}.json", - {"name": kind["name"], "pieces": kind["pieces"], "order": order}, - ) - (folder / _LANDUSE_MARK).touch() + atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order)) + for kind_id in _ADDED_ONCE: + (folder / f"_{kind_id}_added").touch() return kinds -def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]: +def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, list[str]]: text = str(name or "").strip() if not text or len(text) > _NAME_MAX: raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.") + if layout not in _LAYOUTS: + raise ValueError("올바르지 않은 양식입니다.") + if layout == WATERSHED_LAYOUT: + return text, [] # 유역도 양식은 그림이 정해져 있다 — 조각을 고르지 않음 chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])] if not chosen: raise ValueError("얹을 정보를 하나 이상 고르세요.") return text, chosen -def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]: - text, chosen = _checked(name, pieces) +def add_kind( + project_root: Path, name: str, pieces: list[str], layout: str = "plan" +) -> dict[str, Any]: + text, chosen = _checked(name, pieces, layout) kinds = _materialize(project_root) if any(kind["name"] == text for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") - kind_id = f"k{uuid.uuid4().hex[:8]}" - atomic_write_json( - _kinds_dir(project_root) / f"{kind_id}.json", - {"name": text, "pieces": chosen, "order": len(kinds)}, - ) - return {"id": kind_id, "name": text, "pieces": chosen} + kind = {"id": f"k{uuid.uuid4().hex[:8]}", "name": text, "pieces": chosen, "layout": layout} + atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds))) + return kind def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]: - """종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다.""" + """종류 이름 · 조각을 고친다(차례 · 양식은 그대로). 그 종류의 확정본은 풀어 다시 그린다.""" if not KIND_ID.fullmatch(kind_id): raise ValueError("올바르지 않은 종류 id 입니다.") - text, chosen = _checked(name, pieces) kinds = _materialize(project_root) + current = next((kind for kind in kinds if kind["id"] == kind_id), None) + path = _kinds_dir(project_root) / f"{kind_id}.json" + if current is None or not path.is_file(): + raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") + text, chosen = _checked(name, pieces, current["layout"]) if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds): raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}") - path = _kinds_dir(project_root) / f"{kind_id}.json" - if not path.is_file(): - raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.") order = int(_read_json(path).get("order") or 0) - atomic_write_json(path, {"name": text, "pieces": chosen, "order": order}) + kind = {"id": kind_id, "name": text, "pieces": chosen, "layout": current["layout"]} + atomic_write_json(path, _document(kind, order)) manifest = _read_manifest(project_root) released = False for drawing_id, entry in manifest["drawings"].items(): @@ -193,7 +219,7 @@ def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) released = True if released: _write_manifest(project_root, manifest) - return {"id": kind_id, "name": text, "pieces": chosen} + return kind def delete_kind(project_root: Path, kind_id: str) -> None: @@ -449,6 +475,8 @@ def build_pieces( pieces.update( landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route]) ) + if "parcel_info" in wanted: + clip_lines(pieces, label_rects(pieces["parcel_info"]["items"]), LABEL_CLIPPED) if "ip_table" in wanted: pieces["ip_table"] = ip_table_piece( pieces, _load_design_curves(project_root), route, box, chunk, total @@ -477,6 +505,9 @@ def plan_kind_source( ) if kind is None: raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.") + if kind["layout"] == WATERSHED_LAYOUT: + # 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장. + return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)} number = int(match.group(2)) if match.group(2) else 1 pieces, box, chunk, total = build_pieces( context, longitudinal, longitudinal_path, designs, number, kind["pieces"] @@ -494,6 +525,16 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di sheets = plan_chunks(plan_stations(longitudinal)) items = [] for kind in list_kinds(project_root): + if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음 + items.append( + { + "id": f"plan_{kind['id']}", + "label": kind["name"], + "plan_kind": kind["id"], + "plan_kind_name": kind["name"], + } + ) + continue title = plan_title(kind["name"]) for chunk in sheets: single = len(sheets) <= 1 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 3f49b784d..fc2dc82dd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -29,7 +29,7 @@ export const DRAWING_GROUPS: readonly { idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로. + // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도 · 사용자 추가)를 한 묶음으로. { label: "평면도", kind: "plan" }, { label: "종단도", kind: "longitudinal" }, // 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5). @@ -89,7 +89,7 @@ export function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { if (group.kind === "plan") { // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. - const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed"); + const plans = drawings.filter((item) => item.kind === "plan"); panel.append(buildPlanSection(plans, drawingButton, plan)); continue; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index d1dd90109..2af96dcbe 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -4,11 +4,11 @@ * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. - * 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. + * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; -import { showToast } from "@ui/ui_template_elements"; +import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, @@ -17,6 +17,7 @@ import { savePlanMarks, updatePlanKind, type DesignDrawingItem, + type PlanKindLayout, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; @@ -51,15 +52,31 @@ async function run(action: () => Promise, controls: PlanKindsControls): } } -/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ +/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ +const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ + { value: "plan", text: "평면도" }, + { value: "watershed", text: "유역도" }, +]; + +/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); - const pick = el("select", "b07-plan-tools__kind"); - pick.append( - ...state.kinds.map((kind) => new Option(kind.name, kind.id)), - new Option("새 종류", ""), - ); + const pick = createSelectField({ + options: [ + ...state.kinds.map((kind) => ({ value: kind.id, text: kind.name })), + { value: "", text: "새 종류" }, + ], + compact: true, + ariaLabel: "종류", + onChange: () => show(), + }); + const layout = createSelectField({ + options: LAYOUTS, + compact: true, + ariaLabel: "양식", + onChange: () => showPieces(), + }); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; @@ -69,27 +86,35 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme pieceList.append(...boxes.map((box) => box.root)); const action = el("button", "b07-plan-tools__button"); action.type = "button"; + // 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다. + const showPieces = (): void => { + pieceList.hidden = layout.select.value === "watershed"; + }; const show = (): void => { - const kind = state.kinds.find((item) => item.id === pick.value); + const kind = state.kinds.find((item) => item.id === pick.select.value); name.value = kind?.name ?? ""; boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); + // 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만. + layout.select.value = kind?.layout ?? "plan"; + layout.select.disabled = Boolean(kind); action.textContent = kind ? "업데이트" : "추가"; + showPieces(); }; - pick.addEventListener("change", show); show(); action.addEventListener("click", () => { const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); - const kindId = pick.value; + const kindId = pick.select.value; const label = name.value.trim(); + const chosen = layout.select.value as PlanKindLayout; void run( () => kindId ? updatePlanKind(controls.projectId, kindId, label, pieces) - : addPlanKind(controls.projectId, label, pieces), + : addPlanKind(controls.projectId, label, pieces, chosen), controls, ); }); - form.append(pick, name, pieceList, action); + form.append(pick.root, layout.root, name, pieceList, action); return form; } @@ -173,8 +198,7 @@ export function buildPlanSection( makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), ); head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); - // × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404). - if (controls && sheets[0].kind === "plan") { + if (controls) { const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = `「${name}」 종류 지우기`; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index fcc59ec17..a9257b90a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -279,7 +279,10 @@ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); } -.b07-plan-tools__kind, +.b07-plan-tools__pieces[hidden] { + display: none; +} + .b07-plan-tools__name { min-width: 0; } diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index 8a8931ab9..311c4e4e0 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -1,7 +1,8 @@ """B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96). 표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값. -측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음. +측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사는 B06 횡단 경사(110) · +관 종류 · n 은 안 이음. 근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본). """ @@ -17,6 +18,7 @@ from config.config_system_design import ( DRAINAGE_RUNOFF_COEFFICIENT, DRAINAGE_TC_MIN_MINUTES, ) +from M02_MasterTemplete.M02_Table_Fill import _nearest #: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준 FACTORS = (2.0, 1.5, 1.2) @@ -66,6 +68,11 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = { } +#: 장 → B06 횡단 정본 경사 키(110) — 관 = 관 바닥 두 끝 경사 · 박스 = 박스 두 끝 원지반 경사 +#: (본체는 늘 수평) · 세월교 · 물넘이는 장 기본값(B05 세월교 높이 계산과 같은 값) +SLOPE_KEYS = {"pipe": "pipe_slope_pct", "box": "box_ground_slope_pct"} + + def _positive(value: Any) -> float | None: """양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸).""" try: @@ -75,9 +82,29 @@ def _positive(value: Any) -> float | None: return number if number > 0 else None -def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]: - """B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션.""" +def bed_slopes(designs: list[dict[str, Any]] | None) -> dict[str, dict[float, float]]: + """B06 횡단 설계 `[{chainage_m, design}]` → 장별 `{측점: 경사 %}` — 0 % 이하는 안 담음(빈칸).""" + found: dict[str, dict[float, float]] = {sheet: {} for sheet in SLOPE_KEYS} + for row in designs or []: + design = row.get("design") or {} + for sheet, key in SLOPE_KEYS.items(): + pct = _positive(design.get(key)) + if pct: + found[sheet][round(float(row.get("chainage_m") or 0.0), 3)] = pct + return found + + +def _linked( + sheet: str, + options: dict[str, Any], + basin: dict[str, Any] | None, + slope_pct: float | None = None, +) -> dict[str, Any]: + """연결값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션 · + 하상경사 S = 1 / 분모 는 B06 경사(110 · 0 % 이하면 빈칸 · 손 입력).""" values: dict[str, Any] = {} + if slope_pct: + values["slope_denominator"] = round(100 / slope_pct, 3) if basin is not None: area_m2 = _positive(basin.get("area_m2")) values["area_km2"] = area_m2 / 1e6 if area_m2 else None @@ -94,13 +121,17 @@ def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) - def station_rows( - points: list[Any], basins: list[dict[str, Any]] | None = None + points: list[Any], + basins: list[dict[str, Any]] | None = None, + designs: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford). basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은 - `carry_indices`(±0.05 m) · 안 주면 둘 다 None. + `carry_indices`(±0.05 m) · 안 주면 둘 다 None. designs(B06 횡단 설계) 경사는 관 지점과 + 가장 가까운 횡단 측점(±0.5 m · 관 연장과 같은 짝)에서. """ + slopes = bed_slopes(designs) points = sorted(points, key=lambda p: p.chainage_m) picks = ( carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins]) @@ -114,7 +145,8 @@ def station_rows( row["linked"] = row["b05"] = None if basins is not None: basin = basins[pick] if pick is not None else None - row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin) + slope = _nearest(slopes.get(sheet, {}), p.chainage_m) + row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin, slope) if basin is not None: row["b05"] = { "design_flow_m3s": basin.get("design_flow_m3s"), diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index d51f3294c..dbfb95982 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -1,12 +1,13 @@ """B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12). - GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값 + GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 · B06 연결값 + 기본값 · 선택지 + 공사지(지역 · R24) + 저장본 POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro) PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀 -측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) — -화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음. +측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) · +하상경사는 B06 횡단 설계(관 경사 · 박스 원지반 경사 · 110) — 화면이 빈칸만 채움 · 관 종류 · n 은 +잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음. """ from __future__ import annotations @@ -19,6 +20,11 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id +from B06_Section.B06_Section_Repository import ( + get_confirmed_route_context, + get_cross_section_designs, + get_workflow_route_context, +) from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B08_Quantity.B08_Quantity_Engine_Hydro import compute from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows @@ -81,7 +87,15 @@ async def _project_root(project_id: UUID) -> Path: @router.get("/{project_id}/quantity/hydro") async def hydro_sheet(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root)) + # 설계 = B08 집계표와 같은 길(확정 노선 먼저) · 노선 전이면 경사 없이 + async with get_db_pool().acquire() as connection: + context = await get_confirmed_route_context(connection, project_id) + context = context or await get_workflow_route_context(connection, project_id) + designs = ( + await get_cross_section_designs(connection, int(context["route_id"])) if context else [] + ) + points = read_pipe_points_file(pipe_points_path_in(root)) + stations = station_rows(points, read_basins(root), designs) project = await get_project_by_id(str(project_id)) or {} return { "stations": stations, diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index 04cd6efbf..a1d5ee66e 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -7,7 +7,8 @@ * 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12) * 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만 * 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」. - * B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2). + * 연결값과 같은 칸은 이름 옆 작은 원천 표 — 「B05」(풍선 「B05 분석」) · 하상경사는 「B06」 + * (풍선 「B06 횡단 경사」 · 110) · Qd · T 옆에 B05 값 참고(96-2). * * 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게. * ========================================================================== */ @@ -78,7 +79,7 @@ export interface HydroOptions { site: HydroSite; } -/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */ +/** 연결(96 · 110) — 칸 값(B05 배수유역 · 하상경사는 B06 횡단) · 비교용 설계유량 · 도달시간(분) */ export interface HydroLink { values: Partial>; b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null; @@ -236,7 +237,7 @@ export function buildHydroSheet( link: HydroLink = { values: {}, b05: null }, ): HydroSheet { const site = options.site; - /** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */ + /** 연결된 칸의 원천 표(「B05」 · 「B06」) — 값이 연결값과 같을 때만 보임 */ const marks = new Map(); const markLinked = (key: Key): void => { const mark = marks.get(key); @@ -263,9 +264,12 @@ export function buildHydroSheet( input.value = value == null ? "" : String(value); const linked = link.values[key]; if (linked != null) { - input.title = `B05 분석 ${linked}`; - const mark = node("span", "b08-hydro__hint", "B05"); - mark.title = "B05 분석"; + // 원천 표 — 하상경사는 B06 횡단 경사(관 바닥 · 박스 원지반 두 끝 · 110) · 그 밖은 B05 + const [source, title] = + key === "slope_denominator" ? ["B06", "B06 횡단 경사"] : ["B05", "B05 분석"]; + input.title = `${title} ${linked}`; + const mark = node("span", "b08-hydro__hint", source); + mark.title = title; mark.dataset.b08HydroLinked = key; marks.set(key, mark); } diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 9df1bb0cb..2270275bb 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]: return out +def seed_picks(labor: Any) -> dict[str, Any]: + """일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1).""" + doc = labor.get("줄") if isinstance(labor, dict) else None + return { + str(line["로직"]): dict(line["고름"]) + for line in (doc or {}).get("담은로직") or [] + if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict) + } + + def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: - """원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면 - LookupError.""" + """원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` · + SS 줄이 없으면 LookupError.""" files = build_source(item) - return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}} + labor = files.get(LABOR_FILE) + return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)} def source_files(files: dict[str, Any]) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index ccd9c7af0..5f219ca70 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 - materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []] + doc = (labor or {}).get("줄") or {} + materials = [str(m.get("코드")) for m in doc.get("자재") or []] + materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 + str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values() + ] wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 9640e799d..1eef8d95f 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,7 +1,7 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?} -| {참조: {층, 주인, 키}, 별칭, 메모, 수량}], +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, +담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). @@ -14,11 +14,15 @@ 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. +로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) · +고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 +같은 품목 두 번은 [저장] 때 400. """ from __future__ import annotations import json +import re from decimal import Decimal, InvalidOperation from typing import Any @@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비 MATERIAL_HEAD = "자재|" _MAT_COLS = ("코드", "이름", "규격", "단위") ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음 +_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력} def _bad(text: str) -> store.StoreError: @@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]: raise _bad(f"{where} · 입력값이 묶음 아님") memo, alias = one.get("메모"), _alias(one.get("별칭"), where) rate = _rate(one.get("켜쌓기율"), code, where) + picks = _picks(one.get("고름"), logic, whole, where) lines.append( { "로직": code, @@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]: "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), **({"켜쌓기율": rate} if rate is not None else {}), + **({"고름": picks} if picks else {}), } ) mats, codes = [], set() @@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None: return alias or None +def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None: + """고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None.""" + if not value: + return None + if not isinstance(value, dict): + raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}") + rows = logic.get("호표") or [] + out = {} + for at, key in value.items(): + n = int(at) if str(at).isdigit() else -1 + if not 0 <= n < len(rows) or rows[n].get("종류") != "재료": + raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님") + try: + ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None + except mf.FormulaError: + ok = False + if not ok: + raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음") + out[str(n)] = str(key) + return out + + def _rate(value: Any, code: str, where: str) -> int | float | None: """켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠).""" if value is None or value == "": @@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: "분모": pill.get("단위"), "배": pill.get("배") or 1, } - lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))}) + got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) + lines.append({**line, **got}) mats = [ { "코드": one["코드"], @@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: return settle(_json({"줄": lines, "자재": mats}), doc, have) -def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]: - """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` · - 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" +def run_line( + whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None +) -> dict[str, Any]: + """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진 + `choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" try: logic = whole.logic(code) auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} - got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) + with mf.mp.choosing(picks): + got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"기준": None, "까닭": str(e) or type(e).__name__} if "결과" in got: return {"결과": got["결과"], "기준": None, "까닭": None} # 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4) - return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None} + money = {k: got[k] for k in (*COSTS, "계")} + mats = logic_mats(whole, logic, got["줄"], picks) + return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None} + + +def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]: + """로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직 + 재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이 + 바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`).""" + if isinstance(logic.get("흐름"), dict): + return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임 + names = {str(spec.get("이름")) for spec in _specs(logic)} + out = [] + for n, item in enumerate(logic.get("호표") or []): + if item.get("종류") != "재료" or n >= len(lines): + continue + line, goods = lines[n], lines[n].get("품목") + try: + row = (whole.get(goods["키"]) if goods else None) or {} + except mf.FormulaError: + row = {} + cond = item.get("요소") + texts = cond.values() if isinstance(cond, dict) else [cond] + tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)} + out.append( + { + "차례": n, + "이름": item.get("이름") or "", + "단위": item.get("단위") or "", + **{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")}, + "품목": goods, + "가격": {slot: row.get(slot) for slot in mm.SLOTS}, + "고름": str(n) in (picks or {}), + "걸림": sorted(tied if tied <= names else names) if tied else [], + } + ) + return out def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]: @@ -421,6 +490,12 @@ def _json(value: Any) -> Any: def server(doc: Any) -> dict[str, Any]: - """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림).""" + """[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) · + 사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈).""" got = clean(doc) - return {**got, "계산값": calc(got)} + out = calc(got) + 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_LogicMats_Model.ts b/M02_MasterTemplete/M02_Labor_LogicMats_Model.ts new file mode 100644 index 000000000..8da43bf83 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_LogicMats_Model.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * M02_Labor_LogicMats_Model.ts + * 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) · + * 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`). + * 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼). + * ========================================================================== */ + +/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */ +export interface LogicMatItem { + 키: string; + 이름?: string | null; + 규격?: string | null; + 단위?: string | null; + 값칸?: string | null; +} + +/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */ +export interface LogicMat { + 차례: number; + 이름: string; + 단위: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; + 까닭?: string | null; + 품목: LogicMatItem | null; + /** M01 자재품목 값 열 다섯(빈 값 null) */ + 가격?: Record; + 고름: boolean; + /** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */ + 걸림: string[]; +} + +/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */ +export function releaseTied< + T extends { 입력값: Record; 고름?: Record }, +>(row: T, before: Record, mats: readonly LogicMat[]): T { + if (!row.고름) return row; + const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); + const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]); + const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k])); + for (const mat of mats) + if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)]; + if (!Object.keys(row.고름).length) delete row.고름; + return row; +} + +/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */ +export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set { + return new Set( + (lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))), + ); +} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 7ccecac25..9c5228fe3 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -370,7 +370,9 @@ class Expand: line = (doc.get("담은로직") or [{}])[0] code = str(line.get("로직") or "") - got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율")) + got = labor.run_line( + self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름") + ) times = labor._number(doc.get("배")) or Decimal(1) base = got.get("기준") why = got.get("까닭") or (None if base else "돈 아닌 로직") @@ -397,7 +399,9 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율")) + got = labor.run_line( + self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index e40258c2b..79f3171c7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -53,6 +53,16 @@ font-size: var(--text-caption); } +/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */ +.m02-labrow__logic-mat .m02-labrow__money { + color: var(--color-text-secondary); +} + +.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key { + color: var(--color-accent); + font-weight: 600; +} + .m02-labrow__money, .m02-labrow__num { text-align: right; @@ -90,10 +100,10 @@ white-space: nowrap; } -/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */ +/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */ .m02-labrow__order { display: inline-flex; - align-items: center; + flex-direction: column; gap: 2px; } @@ -116,14 +126,37 @@ box-shadow: inset 0 2px 0 var(--color-primary); } -/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */ +/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */ +.m02-labrow tr.is-dragging { + opacity: 0.5; +} + +.m02-labrow tr.is-dragunder td { + box-shadow: inset 0 -2px 0 var(--color-primary); +} + +/* 손잡이(102-1) — 차례 칸 앞 */ +.m02-labrow__no { + white-space: nowrap; +} + +/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */ .m02-labrow__tools { display: inline-flex; - flex-direction: column; - align-items: flex-start; + align-items: stretch; gap: var(--btn-group-gap); } +.m02-labrow__tools > .ui-btn-group { + align-items: stretch; +} + +/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */ +.m02-labrow__book { + display: block; + font-size: var(--text-caption); +} + /* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ .m02-labrow .m02-labrow__optrow td { padding-top: 0; @@ -396,6 +429,20 @@ grid-column: 1 / -1; max-width: none; } + + /* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */ + .m02-labrow tr.is-dragover td, + .m02-labrow tr.is-dragunder td { + box-shadow: none; + } + + .m02-labrow tr.is-dragover { + box-shadow: inset 0 2px 0 var(--color-primary); + } + + .m02-labrow tr.is-dragunder { + box-shadow: inset 0 -2px 0 var(--color-primary); + } } /* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 67b1d3931..47275de84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -131,6 +131,8 @@ export interface LaborRow { 수량: LaborQty; /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */ 켜쌓기율?: number | null; + /** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */ + 고름?: Record; } export interface LaborMat { 코드: string; @@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 수량: r.수량 ?? null, ...(r.참조 ? { 참조: r.참조 } : {}), ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), + ...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 54e3b3e2e..2024f610a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -7,6 +7,7 @@ * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import type { LogicMat } from "./M02_Labor_LogicMats_Model"; export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; @@ -36,6 +37,8 @@ export interface CalcLine { 결과?: unknown; /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ 줄?: LogicLine[]; + /** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */ + 재료?: LogicMat[]; /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · * 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */ 이름?: string | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index cf2d81160..9c63bd843 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -7,6 +7,8 @@ * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 + * · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) + + * 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · * 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) · @@ -57,9 +59,12 @@ import { import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; +import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; @@ -103,6 +108,8 @@ export function mountLaborDetail( const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; + /** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */ + let last: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; @@ -180,29 +187,43 @@ export function mountLaborDetail( }; dgBox.addEventListener("toggle", drawDg); + // 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번 const drawLogic = (): void => - logicHost.replaceChildren( - logicTable( - { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, - // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) - edit && !promote - ? { - open: pick, - option: openRow, - ref: openRef, - qty: openQty, - move: reorder, - remove: removeRow, - alias: setAlias, - } - : { open: pick, option: openRow, ref: openRef }, + afterRowDrag(logicHost, () => + logicHost.replaceChildren( + logicTable( + { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, + // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) + edit && !promote + ? { + open: pick, + option: openRow, + ref: openRef, + qty: openQty, + move: reorder, + remove: removeRow, + alias: setAlias, + } + : { open: pick, option: openRow, ref: openRef }, + ), ), ); const draw = (): void => { drawLogic(); matHost.replaceChildren( matTable( - { mats: cur.자재, calc, fail, cells: cells() }, + { + mats: cur.자재, + calc, + fail, + cells: cells(), + lead: logicMatRows( + cur.담은로직, + calc ?? last, + !calc && !fail, + edit ? { swap: swapMat, reset: resetMat } : undefined, + ), + }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); @@ -222,7 +243,7 @@ export function mountLaborDetail( // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; - calc = got; + calc = last = got; pills = new Map( (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), ); @@ -343,6 +364,25 @@ export function mountLaborDetail( cur.자재.splice(i, 1); changed(); } + /** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */ + function swapMat(i: number, at: number, key: string): void { + const own = cur.자재.find((m) => m.코드 === key); + if (own) + return showToast( + tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`), + "error", + ); + const row = cur.담은로직[i]; + row.고름 = { ...row.고름, [String(at)]: key }; + changed(); + } + function resetMat(i: number, at: number): void { + const row = cur.담은로직[i]; + if (!row.고름) return; + delete row.고름[String(at)]; + if (!Object.keys(row.고름).length) delete row.고름; + changed(); + } function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; @@ -359,7 +399,9 @@ export function mountLaborDetail( const same = JSON.stringify(values) === JSON.stringify(row.입력값) && next.켜쌓기율 === (row.켜쌓기율 ?? null); + const before = row.입력값; row.입력값 = values; + releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1) row.메모 = next.메모; if (next.켜쌓기율 === null) delete row.켜쌓기율; else row.켜쌓기율 = next.켜쌓기율; @@ -434,6 +476,11 @@ export function mountLaborDetail( variant: "filled", onClick: () => pickMaterial((m) => { + if (insideKeys((calc ?? last)?.줄).has(m.코드)) + return showToast( + tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), + "error", + ); if (cur.자재.some((x) => x.코드 === m.코드)) return showToast( tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts new file mode 100644 index 000000000..90cea47f5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_LogicMats.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_LogicMats.ts + * 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸 + * (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞. + * · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂) + * · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음) + * · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable"; +import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal"; +import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { LogicMat } from "./M02_Labor_LogicMats_Model"; + +export interface LogicMatHandlers { + /** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */ + swap: (row: number, at: number, key: string) => void; + /** [기본] — 그 줄 고름 풂 */ + reset: (row: number, at: number) => void; +} + +const num = (v: number | null): string => + v === null + ? "—" + : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */ +export function logicMatRows( + rows: LaborRow[], + calc: LaborCalc | null, + wait: boolean, + on?: LogicMatHandlers, +): HTMLElement[] { + return rows.flatMap((row, r) => { + const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row)); + return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on)); + }); +} + +function matRow( + row: LaborRow, + r: number, + mat: LogicMat, + wait: boolean, + on?: LogicMatHandlers, +): HTMLElement { + const item = mat.품목; + const name = item?.이름 ?? mat.이름; + const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!item?.값칸 && item.값칸 === slot; + const v = mat.가격?.[slot]; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), + attrs: { "data-label": slot, "data-price-slot": slot }, + }); + }; + const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum")); + money.title = tc("LogicMat_Inside"); + const tools = on + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + el("div", { + className: "ui-btn-group", + children: [ + createButton({ + label: tc("LogicMat_Swap"), + variant: "ghost", + onClick: (e) => { + e.stopPropagation(); + openPickTable({ + kind: "재료", + title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`, + seed: headWord(name), + alt: [headWord(mat.이름)], + isCurrent: (x) => String(x["키"]) === item?.키, + onPick: (x) => on.swap(r, mat.차례, String(x["키"])), + }); + }, + }), + ...(mat.고름 + ? [ + createButton({ + label: tc("LogicMat_Reset"), + variant: "ghost", + onClick: (e) => { + e.stopPropagation(); + on.reset(r, mat.차례); + }, + }), + ] + : []), + ], + }), + ], + }), + ] + : []; + return el("tr", { + className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`, + attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` }, + children: [ + el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: name, attrs: { title: name } }), + el("small", { + className: "m02-labrow__key", + text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner, + attrs: { title: owner }, + }), + ...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []), + ], + }), + el("td", { + className: "m02-labrow__spec", + text: item?.규격 ?? "", + attrs: { title: item?.규격 ?? "" }, + }), + el("td", { + className: "m02-labrow__qty", + text: wait ? "…" : num(mat.수량), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { + className: "m02-labrow__unit", + text: mat.단위, + attrs: { "data-label": tc("Labor_Unit") }, + }), + ...PRICE_SLOTS.map(priceCell), + money, + ...tools, + ], + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts new file mode 100644 index 000000000..f2061978d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RowDrag.ts + * 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길). + * 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) · + * 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` · + * 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번). + * ========================================================================== */ + +/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */ +export const DRAG_THRESHOLD_PX = 6; +const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]"; +const HEAD = "tr[data-labor-logic]"; + +let active = false; +const queued = new Map void>(); + +/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */ +export function afterRowDrag(host: Element, draw: () => void): void { + if (active) return void queued.set(host, draw); + queued.delete(host); + draw(); +} + +/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */ +export const pastThreshold = (dx: number, dy: number): boolean => + Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX; + +/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */ +const headOf = (tr: Element | null | undefined): Element | null => { + let t = tr ?? null; + while (t && !t.matches(HEAD)) t = t.previousElementSibling; + return t; +}; + +/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */ +const groupOf = (head: Element): Element[] => { + const out = [head]; + for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling) + out.push(t); + return out; +}; + +/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */ +export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void { + let id: number | null = null; + let x = 0; + let y = 0; + let moved = false; + let from = -1; + let to = -1; + let marks: Element[] = []; + const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)]; + const unmark = (): void => { + for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder"); + marks = []; + }; + const mark = (els: Element[], cls: string): void => { + for (const e of els) e.classList.add(cls); + marks.push(...els); + }; + + const end = (drop: boolean): void => { + const pid = id; + id = null; + if (pid === null || !moved) return; + moved = false; + if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid); + unmark(); + root.style.userSelect = ""; + root.style.cursor = ""; + active = false; + // 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로 + const swallow = (e: MouseEvent): void => { + e.stopPropagation(); + e.preventDefault(); + }; + window.addEventListener("click", swallow, { capture: true, once: true }); + requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true })); + if (drop && to >= 0 && to !== from) move(from, to); + const later = [...queued.values()]; + queued.clear(); + for (const draw of later) draw(); + }; + + root.addEventListener("pointerdown", (e) => { + if (e.button !== 0 || moved) return; + const t = e.target as Element; + if (t.closest(BLOCK)) return; + const head = headOf(t.closest("tbody > tr")); + from = head ? heads().indexOf(head) : -1; + if (from < 0) return; + id = e.pointerId; + x = e.clientX; + y = e.clientY; + to = -1; + }); + + root.addEventListener("pointermove", (e) => { + if (id !== e.pointerId) return; + if (!moved) { + if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님 + if (!pastThreshold(e.clientX - x, e.clientY - y)) return; + moved = true; + active = true; + // 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음 + root.setPointerCapture(e.pointerId); + root.style.userSelect = "none"; + root.style.cursor = "grabbing"; + window.getSelection()?.removeAllRanges(); + } + e.preventDefault(); + unmark(); + const list = heads(); + if (list[from]) mark(groupOf(list[from]), "is-dragging"); + const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr")); + to = over ? list.indexOf(over) : -1; + if (!over || to < 0 || to === from) return; + // 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감) + if (to < from) mark([over], "is-dragover"); + else mark(groupOf(over).slice(-1), "is-dragunder"); + }); + + root.addEventListener("pointerup", () => end(true)); + root.addEventListener("pointercancel", () => end(false)); + root.addEventListener("lostpointercapture", () => end(false)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 262c577cd..8c4d4f164 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -23,6 +23,7 @@ import type { LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; +import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; @@ -47,6 +48,9 @@ export const kindOf = (row: Pick): string => { ); }; +/** 품셈 책(102-3) — 출처 첫 낱말 */ +const BOOK = /^(건설|산림)품셈/; + const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); @@ -121,7 +125,7 @@ function qtyCell( } /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ -function moneyCell( +export function moneyCell( base: number | null | undefined, applied: number | null | undefined, wait: boolean, @@ -148,9 +152,6 @@ function moneyCell( } /** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */ -/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */ -let dragFrom: number | null = null; - const titled = (td: HTMLElement): HTMLElement => { for (const c of td.children) c.setAttribute("title", c.textContent ?? ""); return td; @@ -182,7 +183,7 @@ export interface RowHandlers { ref?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; - /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */ + /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */ move?: (from: number, to: number) => void; remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ @@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; const note = [values.text, layer].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); + const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0]; const small = ( label: string, run: () => void, @@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement run(); }, }); - // 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기) + // 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`) const arrow = (mark: string, label: string, to: number): HTMLElement => { const b = small(mark, () => move?.(i, to)); b.classList.add("m02-labrow__arrow"); @@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const g = el("span", { className: "m02-labrow__grip", text: "⠿", - attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) }, - }); - g.addEventListener("click", (e) => e.stopPropagation()); - g.addEventListener("dragstart", (ev) => { - dragFrom = i; - ev.dataTransfer?.setData("text/plain", String(i)); - if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move"; - }); - g.addEventListener("dragend", () => { - dragFrom = null; + attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) }, }); + g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음 return g; }; const sel = data.selected === id ? " is-selected" : ""; @@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel, attrs: { "data-labor-logic": id }, children: [ - el("td", { className: "m02-labrow__no", text: String(i + 1) }), - el("td", { className: "m02-labrow__kind", text: kindOf(row) }), + el("td", { + className: "m02-labrow__no", + children: move ? [grip(), String(i + 1)] : [String(i + 1)], + }), + el("td", { + className: "m02-labrow__kind", + children: [ + kindOf(row), + // 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말) + ...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []), + ], + }), titled( el("td", { className: "m02-labrow__name", @@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("div", { className: "m02-labrow__order", children: [ - grip(), arrow("▲", tc("Up"), i - 1), arrow("▼", tc("Down"), i + 1), ], @@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ], }); if (on) tr.addEventListener("click", () => on.open(i)); - if (move) { - tr.addEventListener("dragover", (ev) => { - if (dragFrom === null || dragFrom === i) return; - ev.preventDefault(); - tr.classList.add("is-dragover"); - }); - tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover")); - tr.addEventListener("drop", (ev) => { - ev.preventDefault(); - tr.classList.remove("is-dragover"); - const from = dragFrom; - dragFrom = null; - if (from !== null && from !== i) move(from, i); - }); - } const subs = sel ? subRows(line, wait, tools) : []; if (!note) return [tr, ...subs]; // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 @@ -509,13 +497,16 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), ]; - return data.rows.length - ? table(heads, trs, foot) - : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); + if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); + const out = table(heads, trs, foot); + if (move) attachRowDrag(out, move); + return out; } export interface MatTableData { mats: LaborMat[]; + /** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */ + lead?: HTMLElement[]; calc: LaborCalc | null; fail?: string | null; cells?: ReadonlyMap; @@ -609,7 +600,8 @@ export function matTable(data: MatTableData, on?: Omit): HT `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; - return data.mats.length - ? table(heads, trs, []) + const lead = data.lead ?? []; + return data.mats.length || lead.length + ? table(heads, [...lead, ...trs], []) : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index abdc2f6bc..1af48b9f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -110,6 +110,16 @@ const TEXT = { Labor_Spec: ["규격", "Spec"], Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"], Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], + // 로직 자재(103-1) + Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"], + LogicMat_Swap: ["바꾸기", "Change"], + LogicMat_Reset: ["기본", "Default"], + LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"], + LogicMat_Inside: [ + "로직 단가 안 금액 — 합계에 다시 안 더함", + "Inside the logic price — not added again", + ], + LogicMat_Picked: ["바꾼 품목", "Changed item"], In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index d5ec07a01..0f7f4e689 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -20,6 +20,7 @@ import { type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc"; +import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH onRow ? { open: logicOpen } : undefined, ), el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }), - matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined), + matTable( + { mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) }, + onRow ? { open: matOpen } : undefined, + ), ); }; diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index d7293bb76..2b5b5bbf4 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -160,7 +160,7 @@ }, { "key": "inlet_revet_form", - "label": "유입 기슭막이 형태", + "label": "유입 흙막이 형태", "input": "select", "choices": [ "돌쌓기(찰)", @@ -176,7 +176,7 @@ }, { "key": "inlet_revet_length_m", - "label": "유입 기슭막이 길이", + "label": "유입 흙막이 길이", "input": "number", "unit": "m", "default": 10, @@ -184,7 +184,7 @@ }, { "key": "inlet_revet_before_m", - "label": "유입 기슭막이 기준측점 전", + "label": "유입 흙막이 기준측점 전", "input": "number", "unit": "m", "default": 5, @@ -193,7 +193,7 @@ }, { "key": "inlet_revet_after_m", - "label": "유입 기슭막이 기준측점 후", + "label": "유입 흙막이 기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -202,7 +202,7 @@ }, { "key": "inlet_revet_height_m", - "label": "유입 기슭막이 높이", + "label": "유입 흙막이 높이", "input": "number", "unit": "m", "default": 1.5, @@ -223,7 +223,7 @@ }, { "key": "outlet_revet_form", - "label": "유출 기슭막이 형태", + "label": "유출 흙막이 형태", "input": "select", "choices": [ "돌쌓기(찰)", @@ -240,7 +240,7 @@ }, { "key": "outlet_revet_length_m", - "label": "유출 기슭막이 길이", + "label": "유출 흙막이 길이", "input": "number", "unit": "m", "default": 10, @@ -248,7 +248,7 @@ }, { "key": "outlet_revet_before_m", - "label": "유출 기슭막이 기준측점 전", + "label": "유출 흙막이 기준측점 전", "input": "number", "unit": "m", "default": 5, @@ -257,7 +257,7 @@ }, { "key": "outlet_revet_after_m", - "label": "유출 기슭막이 기준측점 후", + "label": "유출 흙막이 기준측점 후", "input": "number", "unit": "m", "default": 5, @@ -266,7 +266,7 @@ }, { "key": "outlet_revet_height_m", - "label": "유출 기슭막이 높이", + "label": "유출 흙막이 높이", "input": "number", "unit": "m", "default": 1.5, @@ -277,7 +277,7 @@ }, { "key": "revet_foundation", - "label": "기슭막이 기초", + "label": "흙막이 기초", "input": "select", "choices": [ "기초유", @@ -317,7 +317,7 @@ }, { "key": "revet_foundation", - "label": "기슭막이 기초", + "label": "흙막이 기초", "input": "select", "choices": [ "기초유", diff --git a/resources/tester/helper_107_ford_free.cjs b/resources/tester/helper_107_ford_free.cjs new file mode 100644 index 000000000..c8c132c23 --- /dev/null +++ b/resources/tester/helper_107_ford_free.cjs @@ -0,0 +1,156 @@ +/* 107 세월교 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_107_ford_free.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임 +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const out = {}; + +// 107-1 — 세월교 · 박스를 고르면 같은 측점 벽 고름을 풂(푸는 것은 안 부름) +const { createBodyControls } = loadTs(B06("B06_Section_UI_Page_Ford_Controls.ts")); +const released = []; +const bodies = createBodyControls( + { + sectionAt: () => undefined, + patchCachedDesign: () => undefined, + refreshCard: () => undefined, + queuePipeOptions: () => undefined, + round1: (v) => Math.round(v * 10) / 10, + clampMove: (v) => v, + releaseWall: (chainageM) => released.push(chainageM), + }, + () => null, +); +bodies.ford.control.select(173.09, "inlet"); +bodies.ford.control.select(173.09, null); +bodies.box.control.select(352.14, "left"); +out.released = released; + +// 107-2 — 원지반을 바꾼 사본: 세월교 바닥 = 노면 − (관경 + 토피) · 박스 본체 · 끝 · 날개 · 흙막이 자리 그대로 +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts")); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const at = (c) => sections.find((x) => Math.abs(x.chainage_m - c) < 0.6); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; +const pts = (list) => (list ?? []).map((p) => [r3(p.offset), r3(p.elevation)]); +/** 원지반 사본 — [from, to] 안 샘플을 dz 만큼 옮김. */ +const shiftGround = (x, dz, from = -Infinity, to = Infinity) => { + for (const p of x.samples) + if (p.offset_m >= from && p.offset_m <= to && p.elevation_m != null) p.elevation_m += dz; +}; +const GROUNDS = { + base: () => undefined, + bedDown2: (x) => shiftGround(x, -2, -2, 2), + allUp05: (x) => shiftGround(x, 0.5), + allDown1: (x) => shiftGround(x, -1), +}; +const walls = (culvert) => + (culvert?.walls ?? []).filter((w) => w.role !== "extra").map((w) => pts(w.points)); +function fordShape(base, edit, ground) { + const x = clone(base); + edit(x); + ground(x); + const { ford, culvert } = computeStoredLayouts( + x, + sections.map((y) => (y === base ? x : y)), + ); + return { + sides: ford.sides.map((side) => ({ + role: side.role, + parts: side.parts.map((part) => pts(part.points)), + slabTop: r3(side.slabTopElevation), + // 노면(벽 상단) − 바닥판 상면 = 관경 + 최소 토피 + depth: r3(side.top.elevation - side.slabTopElevation), + })), + pipeCover: r3(ford.ford.diameter_m + ford.ford.min_cover_m), + slab: pts(ford.slabBridge), + pipe: pts(ford.pipe.outer), + walls: walls(culvert), + }; +} +function boxShape(base, edit, ground) { + const x = clone(base); + edit(x); + ground(x); + const { box, culvert } = computeStoredLayouts( + x, + sections.map((y) => (y === base ? x : y)), + ); + return { + top: pts(box.topSlab), + bottom: pts(box.bottomSlab), + barrel: pts(box.barrel), + sides: box.sides.map((side) => ({ + role: side.role, + offset: r3(side.offset), + top: r3(side.topElevation), + bottom: r3(side.bottomElevation), + apron: pts(side.apron), + })), + walls: walls(culvert), + }; +} +const FORD = at(173.087); +const BOX = at(352.14); +const keep = () => undefined; +const inletWing = (x) => { + x.culvert.inlet = { role: "inlet", structure: "" }; +}; +// 날개벽 박스 — 흙막이 없이 양쪽 날개벽(에이프런 1.414 m) +const noGuard = (x) => { + delete x.culvert; + for (const wing of [x.box.wing_in, x.box.wing_out]) + Object.assign(wing, { installed: true, slab_extend_m: 1.414 }); +}; +const run = (shape, base, edit) => + Object.fromEntries(Object.entries(GROUNDS).map(([k, g]) => [k, shape(base, edit, g)])); +out.ford = { guards: run(fordShape, FORD, keep), inletWing: run(fordShape, FORD, inletWing) }; +out.box = { guards: run(boxShape, BOX, keep), bare: run(boxShape, BOX, noGuard) }; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_80_ford_pipe.cjs b/resources/tester/helper_80_ford_pipe.cjs index 4957556cc..5e8833494 100644 --- a/resources/tester/helper_80_ford_pipe.cjs +++ b/resources/tester/helper_80_ford_pipe.cjs @@ -107,6 +107,28 @@ const out = { }), }; +// 107-1 — 유입 날개벽(측벽) 좌우 · 상하를 옮겨도 유출 흙막이 자리 그대로(관 1 m 맞춤 = 기본 자리 기준) +const outletWallAt = (inlet) => { + const section = clone(FORD); + section.culvert.inlet = { role: "inlet", structure: "" }; + section.design.ford_adjust = { inlet: { heightM: null, ...inlet } }; + const { ford, culvert } = computeStoredLayouts( + section, + sections.map((s) => (s === FORD ? section : s)), + ); + const wall = culvert.walls.find((w) => w.role === "outlet"); + const [right, left] = ford.pipe.outer.slice(2); + return { + back: r3(wall.bottomBack.offset), + label: ford.label.text, + lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)), + }; +}; +out.inletMoves = [0, 0.1, 0.2, 0.3, 0.5, 1.0].map((lateralM) => + outletWallAt({ lateralM, slopeM: 0 }), +); +out.inletSlopes = [0.1, 0.5].map((slopeM) => outletWallAt({ lateralM: 0, slopeM })); + // 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로 const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6); function boxFit(edit) { diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs index 3a1e864a9..5cb2a0ec8 100644 --- a/resources/tester/helper_91_ground_free.cjs +++ b/resources/tester/helper_91_ground_free.cjs @@ -68,7 +68,7 @@ const layoutsOf = (s, base) => sections.map((x) => (x === base ? s : x)), ); -// ① 91-2 세월교 바닥 — 자동은 하상을 따라감 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도 +// ① 91-2 · 107-2 세월교 바닥 — 기본 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도 const FORD = at(173.087); function fordFloor(edit) { const s = clone(FORD); diff --git a/resources/tester/helper_b06_adjust_single_form.cjs b/resources/tester/helper_b06_adjust_single_form.cjs index 726b0e7a8..6e97bc8ed 100644 --- a/resources/tester/helper_b06_adjust_single_form.cjs +++ b/resources/tester/helper_b06_adjust_single_form.cjs @@ -186,7 +186,6 @@ for (const [label, key, kind] of [ const fordDeps = { adjustFor: () => ({ heightM: null, lateralM: 0, slopeM: 0 }), heightFor: () => 1.5, - nudgeHeight: () => {}, nudge: () => {}, nudgeSlope: () => {}, reset: () => {}, diff --git a/resources/tester/helper_m02_labor_logic_mats.cjs b/resources/tester/helper_m02_labor_logic_mats.cjs new file mode 100644 index 000000000..3219be1fe --- /dev/null +++ b/resources/tester/helper_m02_labor_logic_mats.cjs @@ -0,0 +1,15 @@ +/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(JSON.stringify(box.exports.releaseTied(input.row, input.before, input.mats))); diff --git a/resources/tester/helper_m02_labor_rowdrag.cjs b/resources/tester/helper_m02_labor_rowdrag.cjs new file mode 100644 index 000000000..70706a7ea --- /dev/null +++ b/resources/tester/helper_m02_labor_rowdrag.cjs @@ -0,0 +1,210 @@ +/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 — + * 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "M02_MasterTemplete", + "M02_MasterTemplete_Labor_RowDrag.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +/* ---- 가짜 DOM ---- */ +const one = (node, sel) => { + const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim()); + if (!m) throw new Error(`셀렉터 모름: ${sel}`); + return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs); +}; +const matches = (node, sel) => + sel.split(",").some((part) => { + const [up, me] = part.includes(">") ? part.split(">") : [null, part]; + return one(node, me) && (!up || (node.parent && one(node.parent, up))); + }); +class Node { + constructor(tag, attrs = {}, parent = null) { + this.tag = tag; + this.attrs = attrs; + this.parent = parent; + this.kids = []; + this.on = {}; + this.style = {}; + const set = new Set(); + this.classList = { + add: (...c) => c.forEach((x) => set.add(x)), + remove: (...c) => c.forEach((x) => set.delete(x)), + contains: (c) => set.has(c), + list: () => [...set], + }; + if (parent) parent.kids.push(this); + } + matches(sel) { + return matches(this, sel); + } + closest(sel) { + for (let n = this; n; n = n.parent) if (n.matches(sel)) return n; + return null; + } + get previousElementSibling() { + const k = this.parent.kids; + return k[k.indexOf(this) - 1] ?? null; + } + get nextElementSibling() { + const k = this.parent.kids; + return k[k.indexOf(this) + 1] ?? null; + } + querySelectorAll(sel) { + const out = []; + const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c))); + walk(this); + return out; + } + addEventListener(type, fn) { + (this.on[type] ??= []).push(fn); + } +} + +/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */ +const root = new Node("div"); +const tbody = new Node("tbody", {}, new Node("table", {}, root)); +const tr = (attrs) => new Node("tr", attrs, tbody); +const rows = [ + tr({ "data-labor-logic": "A" }), + tr({ "data-labor-logic": "B" }), + tr({ "data-labor-opt": "B" }), + tr({ "data-labor-sub": "" }), + tr({ "data-labor-sub": "" }), + tr({ "data-labor-logic": "C" }), + tr({ "data-labor-logic": "D" }), +]; +const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]); +const td = rows.map((_, r) => cell(r)); +const btn = new Node("button", {}, cell(1)); +const input = new Node("input", { "data-labor-alias": "" }, cell(1)); +const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1))); +const grip = new Node("span", { "data-labor-grip": "0" }, td[0]); +const Y = (r) => r * 20 + 10; + +let capture = null; +root.hasPointerCapture = (id) => capture === id; +root.setPointerCapture = (id) => (capture = id); +root.releasePointerCapture = () => (capture = null); + +const winClick = []; +let frames = []; +global.window = { + addEventListener: (t, fn) => winClick.push(fn), + removeEventListener: (t, fn) => { + const i = winClick.indexOf(fn); + if (i >= 0) winClick.splice(i, 1); + }, + getSelection: () => ({ removeAllRanges() {} }), +}; +global.requestAnimationFrame = (fn) => frames.push(fn); +global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null }; + +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +/* ---- 부르기 ---- */ +const moves = []; +m.attachRowDrag(root, (from, to) => moves.push([from, to])); +const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e })); +const down = (target, y) => + fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target }); +const mv = (y, dx = 0) => + fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y }); +/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */ +const click = () => { + let stopped = false; + [...winClick].forEach((fn) => + fn({ stopPropagation: () => (stopped = true), preventDefault() {} }), + ); + winClick.length = 0; // once + return !stopped; +}; +const frame = () => { + const f = frames; + frames = []; + f.forEach((fn) => fn()); +}; +const classes = () => rows.map((r) => r.classList.list().sort().join(" ")); + +const out = {}; +out.threshold = [ + [5, 0], + [6, 0], + [0, -6], + [5, 5], + [-7, 2], +].map(([dx, dy]) => m.pastThreshold(dx, dy)); + +// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기) +down(td[0], Y(0)); +mv(Y(0) + 5); +fire("pointerup", {}); +out.small = { moves: moves.length, clickPasses: click(), capture }; + +// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴 +let drawn = 0; +const host = {}; +down(td[0], Y(0)); +mv(Y(0) + 7); +m.afterRowDrag(host, () => drawn++); +m.afterRowDrag(host, () => drawn++); // 마지막 것만 +mv(Y(5)); +out.during = { drawn, capture, classes: classes(), select: root.style.userSelect }; +fire("pointerup", {}); +out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() }; +frame(); +out.afterFrame = { listeners: winClick.length }; + +// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림 +down(td[4], Y(4)); +mv(Y(0)); +out.fromSub = { classes: classes() }; +fire("pointerup", {}); +out.fromSub.moves = moves.splice(0); +click(); +frame(); + +// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선 +down(td[0], Y(0)); +mv(Y(2)); +out.downMark = classes(); +fire("pointerup", {}); +out.downMark.push(JSON.stringify(moves.splice(0))); +click(); +frame(); + +// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함 +for (const [name, t] of [ + ["button", btn], + ["input", input], + ["qty", qty], +]) { + down(t, Y(1)); + mv(Y(6)); + fire("pointerup", {}); + out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() }; +} + +// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로) +down(grip, Y(0)); +mv(Y(0) + 8); +fire("pointerup", {}); +out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() }; +frame(); + +// 7) 끄는 중이 아니면 바로 그림 +let now = 0; +m.afterRowDrag(host, () => now++); +out.idleDraw = now; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py new file mode 100644 index 000000000..eef9f9cea --- /dev/null +++ b/resources/tester/test_106_span_highlight_wording.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""PLAN 106 — 구간형 구조물 범위 강조 · B05/B06 화면 글 「흙막이」. + +• 106-1 구조물을 고르면 시작~종료 안 횡단 카드 전부에 `--span` 클래스 · 카드를 다시 만들어도 유지 +• 106-2 로케일 · 툴팁 · 정의 label 에 「기슭막이」 없음(독립=D군 · 사방 근거 글 빼고) · 저장 값 · choices 그대로 +""" + +import json +import os +import re + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts") + + +def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다(): + assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard + assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW) + assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW) + assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW + + +def test_범위_판정은_시작_종료가_다른_구간형만(): + assert "start == null || end == null || start === end" in VIEW + + +def test_범위_강조_CSS는_선택_강조를_덮지_않는다(): + css = _read("B06_Section", "B06_Section_UI_Style_Cross.css") + assert ".b06-cross-card--span:not(.b06-cross-card--selected)" in css + + +def test_로케일에_기슭막이는_독립_한_곳뿐(): + locale = _read("ui_template", "ui_template_locale_b2.ts") + korean = [ + line + for line in locale.splitlines() + if "기슭막이" in line and not line.strip().startswith(("//", "/*")) + ] + assert len(korean) == 1 and "B06_Cross_Revet_Own" in korean[0] + + +def test_정의_label은_흙막이_choices_값은_그대로(): + definition = json.loads(_read("resources", "master_template", "구조물정의.json")) + labels, choices = [], [] + + def walk(node): + if isinstance(node, dict): + if isinstance(node.get("label"), str): + labels.append(node["label"]) + if node.get("key") in ("inlet_type", "outlet_type"): + choices.append(node["choices"]) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(definition) + assert not [label for label in labels if "기슭막이" in label and label != "기슭막이"] + assert ["기슭막이", "집수정"] in choices and ["기슭막이"] in choices diff --git a/resources/tester/test_107_ford_free.py b/resources/tester/test_107_ford_free.py new file mode 100644 index 000000000..0872cb6eb --- /dev/null +++ b/resources/tester/test_107_ford_free.py @@ -0,0 +1,74 @@ +"""107 세월교 — 유입 · 유출 따로 · 원지반 제약 삭제 · 유입 날개벽 수평 여유. + +화면 TS 를 그 자리에서 돌린 값(`helper_107_ford_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +HELPER = Path(__file__).parent / "helper_107_ford_free.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_세월교_박스를_고르면_같은_측점_벽_고름을_풂(out: dict) -> None: + assert out["released"] == [173.09, 352.14] + + +def test_벽을_고르면_세월교_박스_고름을_풂() -> None: + station = _read("B06_Section/B06_Section_UI_Page_Station_Controls.ts") + assert ( + "if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);" + in station + ) + assert ( + "if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);" + in station + ) + view = _read("B06_Section/B06_Section_UI_Cross_View.ts") + # 카드 안 — 벽을 고르면 본체 창을 닫고 · 본체를 고르면 벽 창을 닫음 + assert "bodies.clearSelection(); // 세월교 · 박스 고름을 풂" in view + assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view + + +@pytest.mark.parametrize("variant", ["guards", "inletWing"]) +def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: str) -> None: + """107-2 — 바닥 = 노면 − (관경 + 최소 토피) · 하상 −2 m · 전체 +0.5 / −1 m 사본도 같은 모양.""" + runs = out["ford"][variant] + for name, shape in runs.items(): + assert shape == runs["base"], name + base = runs["base"] + assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"]) + + +@pytest.mark.parametrize("variant", ["guards", "bare"]) +def test_박스_원지반을_바꿔도_본체_끝_날개_흙막이_그대로(out: dict, variant: str) -> None: + runs = out["box"][variant] + for name, shape in runs.items(): + assert shape == runs["base"], name + if variant == "bare": + assert all(side["apron"] for side in runs["base"]["sides"]) + else: + assert len(runs["base"]["walls"]) == 2 diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py new file mode 100644 index 000000000..27e57b27d --- /dev/null +++ b/resources/tester/test_110_b08_bed_slope.py @@ -0,0 +1,131 @@ +"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사. + +관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) · +관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로 +실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로. +""" + +from __future__ import annotations + +import copy +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402 + DEFAULTS, + SHEET_DEFAULTS, + station_rows, +) +from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 + +FIXTURES = Path(__file__).parent / "fixtures" +NODE_MODULES = ROOT / "config" / "node_modules" +needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + +BASINS = [ + {"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9, + "relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0} + for c in (85.05, 173.09, 300.0, 400.0, 500.0) +] # fmt: skip +POINTS = [ + PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}), + PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}), + PipePoint(chainage_m=300.004, facility="box_culvert", + options={"body_width_m": 2.0, "body_height_m": 1.5}), + PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}), + PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), +] # fmt: skip +DESIGNS = [ + # 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안) + {"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}}, + # 세월교에 경사 키가 있어도 안 이음(장 기본값) + {"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}}, + {"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}}, + {"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸 + {"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음 +] + + +def _rows() -> dict[float, dict]: + return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)} + + +def test_관_경사가_하상경사_칸으로() -> None: + assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20 + + +def test_박스는_원지반_경사() -> None: + assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50 + + +def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None: + rows = _rows() + assert "slope_denominator" not in rows[400.0]["linked"] + assert "slope_denominator" not in rows[500.0]["linked"] + assert DEFAULTS["slope_denominator"] is None # 손 입력 자리 + + +def test_세월교는_장_기본값_그대로() -> None: + assert "slope_denominator" not in _rows()[173.09]["linked"] + assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0 + + +def test_연결값으로_판정이_나옴() -> None: + row = _rows()[85.052] + out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"}) + assert out["ok"] in (True, False) + assert out["slope_bed"] == pytest.approx(0.05) + + +def test_저장된_측점_null_이_연결값을_덮지_않음() -> None: + """화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜.""" + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + assert "v != null || !(k in linkedOf(station))" in ui + assert "...station.linked," in ui + + +def test_패치가_받는다() -> None: + patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5) + assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5) + + +def _server_rows(sections: list[dict]) -> dict[float, dict]: + from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE + from common_util.common_util_node_bundle import run_bundle_json + + output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}}) + assert output, "서버 Node 번들 실행 실패" + return {round(r["chainage_m"], 3): r for r in output["areas"]} + + +@needs_node +def test_서버가_관_경사를_관_길이와_같이_냄() -> None: + sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8")) + row = _server_rows(sections["cross_sections"])[85.052] + assert row["pipe_length_m"] > 0 + # 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프) + assert row["pipe_slope_pct"] >= 0 + + +@needs_node +def test_서버가_박스_원지반_경사를_주인_측점에만() -> None: + data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8")) + sections = data["cross_sections"] + box_at = next(s for s in sections if s.get("box")) + # 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림 + neighbour = next(s for s in sections if s["chainage_m"] == 340) + neighbour["box"] = copy.deepcopy(box_at["box"]) + rows = _server_rows(sections) + assert isinstance(rows[352.14]["box_ground_slope_pct"], float) + assert "box_ground_slope_pct" not in rows[340] + # 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음 + assert all("pipe_slope_pct" not in r for r in rows.values()) diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index cd8102698..934e4b7ea 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -164,12 +164,9 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼(): assert groups(type_id, "", None) == [] -def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음(): +def test_B06_세월교_박스_칸은_받는_줄_없음(): + """107-3 — 측벽 높이 줄 지움.""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert ( - "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" - in dock - ) - assert 'WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"' in dock + assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8") assert "return isNone(c, state[c.id][0])" in lists diff --git a/resources/tester/test_80_ford_pipe.py b/resources/tester/test_80_ford_pipe.py index f101a302f..c3c208fca 100644 --- a/resources/tester/test_80_ford_pipe.py +++ b/resources/tester/test_80_ford_pipe.py @@ -4,6 +4,7 @@ """ import json +import math import subprocess import sys from pathlib import Path @@ -74,3 +75,11 @@ def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None: assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx( fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3 ) + + +def test_유입_측벽을_옮겨도_유출_흙막이_자리_그대로(fit: dict) -> None: + """107-1 — 관 1 m 맞춤은 기본 자리 기준 · 사용자 이동이 반대쪽 흙막이를 안 밈 · L 은 올림.""" + backs = {case["back"] for case in fit["inletMoves"] + fit["inletSlopes"]} + assert len(backs) == 1, fit["inletMoves"] + for case in fit["inletMoves"] + fit["inletSlopes"]: + assert case["label"].endswith(f"L{math.ceil(case['lengthM'] - 1e-6)}m") diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 243dd1005..821be3e19 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -29,9 +29,9 @@ def fit() -> dict: return json.loads(done.stdout) -def test_세월교_바닥_자동은_하상을_따라감(fit: dict) -> None: - for before, after in zip(fit["fordAuto"], fit["fordAutoBedDown"]): - assert after["slabTop"] < before["slabTop"] - 1.0 +def test_세월교_바닥_기본도_원지반을_바꿔도_그대로(fit: dict) -> None: + """107-2 — 기본 바닥 = 노면 − (관경 + 토피) · 하상을 안 따라감(옛 91-2 「자동은 하상」 뒤집음).""" + assert fit["fordAuto"] == fit["fordAutoBedDown"] def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) -> None: @@ -41,9 +41,11 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) assert side["wallBottom"] == pytest.approx(side["slabTop"] - 0.3, abs=1e-3) -def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None: +def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None: + """107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출).""" dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock + assert "sideSlots?.wingOutlet) return null" in dock + assert "wall-height" not in dock def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None: diff --git a/resources/tester/test_96_b08_hydro_linked.py b/resources/tester/test_96_b08_hydro_linked.py index ffa10618d..62fd9c4ce 100644 --- a/resources/tester/test_96_b08_hydro_linked.py +++ b/resources/tester/test_96_b08_hydro_linked.py @@ -68,6 +68,7 @@ def test_측점마다_연결값() -> None: def test_잇지_않는_칸() -> None: + # 하상경사는 B06 횡단 경사가 있을 때만 이음(110 · test_110_b08_bed_slope) — 설계 없이는 빈칸 for row in station_rows(POINTS, BASINS): assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"]) diff --git a/resources/tester/test_b06_adjust_single_form.py b/resources/tester/test_b06_adjust_single_form.py index 682ce6482..1d0d98fad 100644 --- a/resources/tester/test_b06_adjust_single_form.py +++ b/resources/tester/test_b06_adjust_single_form.py @@ -41,7 +41,7 @@ def test_폼과_같은_값은_사본_줄로_만들지_않는다(out) -> None: assert out["dock"]["outlet"] == ["options", "readout", "tier"] # 집수정 내공 줄은 화면에서 숨김(54-40 · 값 · 그림 그대로) assert out["dock"]["basin"] == ["options", "readout"] - assert out["dock"]["ford"] == ["readout", "wall-height"] + assert out["dock"]["ford"] == ["readout"] # 측벽 높이 줄 지움(107-3) assert out["dock"]["box"] == ["readout"] @@ -86,7 +86,6 @@ def test_사본_줄은_모두_폼_칸으로_옮겨_붙는다() -> None: "tier", "options", "basin-size", - "wall-height", "form", "height", "span", diff --git a/resources/tester/test_b06_design_key_split.py b/resources/tester/test_b06_design_key_split.py index b41698357..e30b220df 100644 --- a/resources/tester/test_b06_design_key_split.py +++ b/resources/tester/test_b06_design_key_split.py @@ -40,6 +40,9 @@ _COMPUTED_FIELDS = { # 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` → # `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다. "pipe_length_m", + # B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리. + "pipe_slope_pct", + "box_ground_slope_pct", } diff --git a/resources/tester/test_b06_pipe_length.py b/resources/tester/test_b06_pipe_length.py index f49d38916..6ad4cb70b 100644 --- a/resources/tester/test_b06_pipe_length.py +++ b/resources/tester/test_b06_pipe_length.py @@ -52,7 +52,7 @@ def test_면적을_못_내는_측점에서도_길이는_낸다(): """설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다.""" source = _LAYOUTS.read_text(encoding="utf-8") assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다" - assert "layouts.culvert?.pipe?.lengthM" in source + assert "layouts.culvert?.pipe" in source and "pipe.lengthM" in source def test_이름이_한_벌이다(): @@ -99,8 +99,8 @@ def test_소유_측점에만_싣는다(): **하나만 고르는 것**은 그대로라 두 번 세지 않는다. """ layouts = _LAYOUTS.read_text(encoding="utf-8") - assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다" - assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다" + assert "ownerChainage" in layouts, "소유 측점을 안 가리고 있다" + assert "isOwner(section.culvert.chainage_m)" in layouts, "관이 놓인 자리를 안 읽고 있다" py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_b07_landuse_parcel_info.py b/resources/tester/test_b07_landuse_parcel_info.py index 2ed7bb7f0..600704445 100644 --- a/resources/tester/test_b07_landuse_parcel_info.py +++ b/resources/tester/test_b07_landuse_parcel_info.py @@ -3,6 +3,7 @@ ① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선 ② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번 ③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침 +④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4) """ import json @@ -11,6 +12,7 @@ from pathlib import Path from shapely.geometry import LineString, Point from shapely.geometry import box as box_polygon +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( @@ -18,9 +20,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( layer_id, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LABEL_CLIPPED, PARCEL_FONT, ROUTE_CLEAR_M, clip_parcels, + label_rects, place_labels, ) @@ -161,3 +165,44 @@ def _polyline(entity: dict) -> LineString: c["shapeData"]["endPoint"] for c in children ] return LineString([(p["x"], p["y"]) for p in points]) + + +def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None: + features = [ + ( + { + "pnu": f"P{i}{j}", + "jibun": f"{i}{j}-1", + "jimok": "임야", + "parea": 9999, + "owner_nm": "홍길동", + }, + _polygon(_square(i * 100.0, j * 100.0, 100.0)), + ) + for i in range(4) + for j in range(3) + ] + parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX) + route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)] + items = place_labels(parcels, route, BOX) + rects = label_rects(items) + assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음 + # 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감 + across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)] + pieces = { + "contour": {"lines": [list(line) for line in across]}, + "stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]}, + "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, + "emd": {"lines": [list(line) for line in across]}, + "sgg": {"lines": [list(line) for line in across]}, + "centerline": {"lines": [[list(p) for p in route]]}, + } + clip_lines(pieces, rects, LABEL_CLIPPED) + inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects] + for piece in LABEL_CLIPPED: + for points in pieces[piece]["lines"]: + line = LineString(points) + assert all(line.intersection(area).length < 1e-6 for area in inner), piece + # 상자 밖은 남음 · 노선은 그대로 + assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400 + assert pieces["centerline"]["lines"] == [[list(p) for p in route]] diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index 43355d261..3df0d6ff8 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -8,6 +8,8 @@ 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 ⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층 ⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 +⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 · + 용지도 상자 안 선 0 """ import json @@ -19,6 +21,7 @@ import pytest from B06_Section.B06_Section_Engine_Design import compute_cross_design from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ( + _place, curve_values, dms, ip_blocks, @@ -38,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( add_kind, delete_kind, list_kinds, + plan_kind_items, read_marks, slope_records, update_kind, @@ -139,6 +143,7 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None: "대피소", "라이다", "용지도", + "배수유역도", ] added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) assert added["pieces"] == ["centerline", "station"] @@ -148,14 +153,28 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None: add_kind(root, "빈 것", []) delete_kind(root, "terrain") # 기본 넷도 지움 again = list_kinds(root) # 다시 읽어 같음 - assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"] + assert [k["name"] for k in again] == [ + "노선배치도", + "대피소", + "라이다", + "용지도", + "배수유역도", + "내 평면도", + ] assert again[-1]["pieces"] == ["centerline", "station"] with pytest.raises(FileNotFoundError): delete_kind(root, "terrain") for kind in list(again): delete_kind(root, kind["id"]) assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음) - assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"} + assert {k["id"] for k in DEFAULT_KINDS} == { + "terrain", + "route", + "layout", + "lidar", + "landuse", + "watershed", + } def test_refuge_marks_same_spot(tmp_path: Path) -> None: @@ -202,7 +221,7 @@ def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: outline = {"road_edge", "fill_outline", "cut_outline"} # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로) for kind in DEFAULT_KINDS: - if kind["id"] != "landuse": + if kind["id"] not in ("landuse", "watershed"): assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] folder = _kinds_dir(root) folder.mkdir(parents=True) @@ -227,6 +246,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None: "id": "terrain", "name": "지형2", "pieces": ["contour", "centerline"], + "layout": "plan", } assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로 drawings = _read_manifest(root)["drawings"] @@ -315,9 +335,9 @@ def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None: json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" ) kinds = list_kinds(root) - assert [k["id"] for k in kinds][-1] == "landuse" - assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] - assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번 + assert [k["id"] for k in kinds][-2:] == ["landuse", "watershed"] # 늘어난 기본 종류 둘 + assert kinds[-2]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] + assert len(list_kinds(root)) == 6 # 두 번 읽어도 한 번 delete_kind(root, "landuse") assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남 @@ -344,3 +364,79 @@ def test_landuse_pieces_become_layers() -> None: if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" ] assert labels == ["1-1", "임야", "국"] + + +def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None: + """배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장.""" + root = tmp_path / "project" + watershed = next(k for k in list_kinds(root) if k["id"] == "watershed") + assert watershed == { + "id": "watershed", + "name": "배수유역도", + "pieces": [], + "layout": "watershed", + } + # 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장 + stations = [ + {"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20) + ] + items = plan_kind_items(root, {"stations": stations}) + terrain = [item for item in items if item["plan_kind"] == "terrain"] + mine = [item for item in items if item["plan_kind"] == "watershed"] + assert len(terrain) > 1 + assert mine == [ + { + "id": "plan_watershed", + "label": "배수유역도", + "plan_kind": "watershed", + "plan_kind_name": "배수유역도", + } + ] + + +def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None: + """× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로.""" + root = tmp_path / "project" + delete_kind(root, "watershed") + assert "watershed" not in [k["id"] for k in list_kinds(root)] + assert "watershed" not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남 + added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림 + assert added["layout"] == "watershed" and added["pieces"] == [] + again = next(k for k in list_kinds(root) if k["id"] == added["id"]) + assert again == added + assert any( + item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []}) + ) + update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트 + assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed" + with pytest.raises(ValueError): + add_kind(root, "이상한 양식", ["contour"], "nope") + + +def test_watershed_kind_added_once_to_folder_with_landuse(tmp_path: Path) -> None: + """용지도 표시가 있는 옛 폴더 — 배수유역도만 한 번 더해짐 · 지우면 안 살아남.""" + root = tmp_path / "project" + folder = _kinds_dir(root) + folder.mkdir(parents=True) + for order, kind in enumerate(DEFAULT_KINDS[:5]): + (folder / f"{kind['id']}.json").write_text( + json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" + ) + (folder / "_landuse_added").touch() + assert [k["id"] for k in list_kinds(root)][-1] == "watershed" + delete_kind(root, "watershed") + assert [k["id"] for k in list_kinds(root)][-1] == "landuse" + + +def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None: + """한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽.""" + blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)] + block_w = 7.0 + 6.0 + 23.0 + _place(blocks, 2 * 5.0 + 3 * block_w + 1.0) + origins = [b["origin"] for b in blocks] + tops = [o[1] for o in origins] + assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위 + assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오 + assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽 + assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음) + assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로 diff --git a/resources/tester/test_m02_labor_logic_mats.py b/resources/tester/test_m02_labor_logic_mats.py new file mode 100644 index 000000000..e2c39830e --- /dev/null +++ b/resources/tester/test_m02_labor_logic_mats.py @@ -0,0 +1,162 @@ +"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음. + +- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록 +- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) · + 고름 없으면 지금 값 +- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400 +- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node) +- 프로젝트 사본 씨앗 = 문서 고름 +""" + +import json +import os +import subprocess + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Snapshot as snap +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_structure import write_sample_table + +HERE = os.path.dirname(os.path.abspath(__file__)) +NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시) +BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림) +BARE = "GC000994" # 재료 줄 없는 돈 로직 +WIRE = "MT000194" +NET_ITEM = "MT006697" + + +def _whole(): + return m01.loaded()[1] + + +def _doc(**extra): + return {"열": "rv", "담은로직": [], "자재": [], **extra} + + +def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록(): + got = labor.run_line(_whole(), NET, {}) + assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [ + (3, NET_ITEM), + (4, WIRE), + (5, "MT007705"), + ] + assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"]) + assert labor.run_line(_whole(), BARE, {})["재료"] == [] + + +def test_조건에_든_입력은_걸림(): + mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]} + assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9)) + assert mats[9]["걸림"] == [] + + +def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값(): + plain = labor.run_line(_whole(), NET, {}) + again = labor.run_line(_whole(), NET, {}, None, None) + assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀) + picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE}) + row = picked["재료"][0] + assert (row["품목"]["키"], row["고름"]) == (WIRE, True) + assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"] + gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"] + assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap) + assert picked["기준"]["노무비"] == plain["기준"]["노무비"] + + +def test_합_알약에_로직_자재_금액_다시_안_더함(): + doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}])) + got = labor.calc(doc, {}) + line = got["줄"][0] + assert line["재료"] and got["자재"] == [] + assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"]) + assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2) + assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)] + + +def test_고름_정리_틀리면_400(): + got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}])) + assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1] + for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]): + with pytest.raises(store.StoreError) as e: + labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}])) + assert e.value.status == 400 + + +def test_사용자_자재가_로직_자재와_같으면_저장_400(): + with pytest.raises(store.StoreError) as e: + labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}])) + assert e.value.status == 400 and WIRE in str(e.value.detail) + # 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음 + with pytest.raises(store.StoreError): + labor.server( + _doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}]) + ) + ok = labor.server( + _doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}]) + ) + assert ok["자재"][0]["코드"] == WIRE + + +@pytest.fixture() +def system(tmp_path, monkeypatch): + write_sample_table(tmp_path) + monkeypatch.setattr(store, "FOLDER", tmp_path) + store.create_structure("rv") + return tmp_path + + +def test_저장_뒤_고름_남고_계산값에_반영(system): + have = store.read("labor", "rv") + sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]} + store.write("labor", "rv", have["판"], sent) + doc = store.read("labor", "rv")["문서"] + assert doc["담은로직"][0]["고름"] == {"3": WIRE} + assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE + + +def test_단가승격도_고름을_따름_없으면_지금_값(): + expand = refs.Expand(_whole()) + plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1}) + picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1}) + assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"] + assert picked["기준"]["재료비"] < plain["기준"]["재료비"] + + +def test_사본_씨앗은_문서_고름(): + labor_file = { + "줄": { + "담은로직": [ + {"로직": NET, "고름": {"3": WIRE}}, + {"로직": BARE}, + {"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""}, + ] + } + } + assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}} + assert snap.seed_picks(None) == {} + + +def _release(row: dict, before: dict, mats: list) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")], + input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def test_입력이_바뀌면_걸린_고름만_풂_화면_짝(): + mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"]) + picks = {"4": "MT007895", "9": WIRE} + row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks} + assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로 + assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만 + row = {**row, "고름": {"4": "MT007895"}} + assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움 diff --git a/resources/tester/test_m02_labor_rowdrag.py b/resources/tester/test_m02_labor_rowdrag.py new file mode 100644 index 000000000..684f97ed1 --- /dev/null +++ b/resources/tester/test_m02_labor_rowdrag.py @@ -0,0 +1,85 @@ +"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글. + +끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 · +6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸. +배치(102-1) · 품셈 글(102-3) = 소스 꼴. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_문턱_6px(r): + assert r["threshold"] == [False, True, True, False, True] + + +def test_작게_움직이면_누름(r): + assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None} + + +def test_끌어_놓으면_순서만_누름_삼킴(r): + assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음 + assert r["during"]["capture"] == 1 and r["during"]["select"] == "none" + assert r["during"]["classes"][0] == "is-dragging" + assert r["during"]["classes"][5] == "is-dragunder" + assert r["drop"]["moves"] == [[0, 2]] + assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번 + assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로 + assert set(r["drop"]["classes"]) == {""} + assert r["afterFrame"]["listeners"] == 0 + assert r["idleDraw"] == 1 + + +def test_열린_줄_하위_줄째(r): + assert r["fromSub"]["moves"] == [[1, 0]] + assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4 + assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]" + + +@pytest.mark.parametrize("name", ["button", "input", "qty"]) +def test_단추_입력칸에선_시작_안_함(r, name): + assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True} + + +def test_손잡이_제자리(r): + assert r["gripSelf"] == {"moves": 0, "clickPasses": False} + + +def test_배치_손잡이_차례칸_앞_단추칸(): + rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8") + assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows + assert 'draggable: "true"' not in rows and "dragFrom" not in rows + assert "if (move) attachRowDrag(out, move);" in rows + css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8") + order = css[css.index(".m02-labrow__order {") :] + assert "flex-direction: column;" in order[: order.index("}")] + assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8") + assert "afterRowDrag(logicHost, () =>" in detail + + +def test_구분열_품셈_글(): + rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8") + assert "const BOOK = /^(건설|산림)품셈/;" in rows + assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows + assert 'className: "m02-labrow__book"' in rows diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 95257df30..31cbf9c36 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -120,7 +120,7 @@ out.splitAfter = ctl("inlet", "after").value; await set("inlet", "after", "4"); out.splitLength = ctl("inlet", "length").value; - // 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나 + // 흙막이 = 유출 「추가 흙막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나 out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length; out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length; spec().querySelector('[data-spec-add="chute_add"]').click(); diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js index 1e56458ec..81d0b900c 100644 --- a/resources/tester/ui_checks/53-1_횡단_고르기.js +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -239,9 +239,9 @@ sec() .querySelectorAll("button") .forEach((b) => flat(b) === "리셋" && b.click()); - // 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로) + // 다단 벽(추가 흙막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로) out.tier = null; - for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + for (const card of cards().filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) { if (/추가\(성토부\)/.test(card.innerHTML)) { out.tier = card.id; break; @@ -306,7 +306,7 @@ } // ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ── await clearCard(); - const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML)); + const pipeCard = targets.find((c) => /유입 집수정|유출 흙막이/.test(c.innerHTML)); if (pipeCard) { click(pipeCard.querySelector("svg")); await w(1400); @@ -423,12 +423,12 @@ judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state()); }; // 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음 - for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + for (const c of targets.filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) { await clearCard(); click(uniqueHits(document.getElementById(c.id))[0]); await w(1400); if (!ctlOf("outlet.form")) continue; - await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t)); + await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 흙막이 콘크리트/.test(t)); break; } const fordCard = targets.find((c) => /련/.test(titles(c).join("|"))); diff --git a/resources/tester/ui_checks/54-10_다단_흙막이.js b/resources/tester/ui_checks/54-10_다단_흙막이.js index 1e1a7c197..1e74472b1 100644 --- a/resources/tester/ui_checks/54-10_다단_흙막이.js +++ b/resources/tester/ui_checks/54-10_다단_흙막이.js @@ -1,10 +1,10 @@ /* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너 * 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18) * 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)" - * look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이) + * look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 흙막이」 칸 · 다단 벽 그림(형태 · 높이) * wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look * set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look - * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - + * tier+ / tier- → 유출 「추가 흙막이(단)」 + / - * 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안) */ (async () => { @@ -71,7 +71,7 @@ ), ), extraGroupShown: [...document.querySelectorAll("legend")].filter( - (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + (l) => visible(l) && l.textContent.startsWith("추가 흙막이"), ).length, tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, walls: extraShapes(), diff --git a/resources/tester/ui_checks/54-11_흙막이_집계.js b/resources/tester/ui_checks/54-11_흙막이_집계.js index e6f94bb91..66dd384fb 100644 --- a/resources/tester/ui_checks/54-11_흙막이_집계.js +++ b/resources/tester/ui_checks/54-11_흙막이_집계.js @@ -3,7 +3,7 @@ * 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)" * look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림 * prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look - * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look + * tier+ / tier- → 유출 「추가 흙막이(단)」 + / - → [저장] → look * 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 · * 다시 2 에 줄 · 상자 값 되살아남 */ diff --git a/resources/tester/ui_checks/54-13_그림값_표시.js b/resources/tester/ui_checks/54-13_그림값_표시.js index 07f6a2918..198d14ba3 100644 --- a/resources/tester/ui_checks/54-13_그림값_표시.js +++ b/resources/tester/ui_checks/54-13_그림값_표시.js @@ -108,10 +108,10 @@ ["inlet", "유입"], ["outlet", "유출"], ]) { - const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`)); + const wall = pic.walls.find((t) => t.startsWith(`${role} 흙막이`)); if (side === "inlet" && basin) continue; const f = head0(fields[`${side}.form`]); - if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`)) + if (!wall || flat(wall) !== flat(`${role} 흙막이 ${f}`)) bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`); } const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000); diff --git a/resources/tester/ui_checks/54-1_배관_좌측패널.js b/resources/tester/ui_checks/54-1_배관_좌측패널.js index 5d303019e..27e4f954b 100644 --- a/resources/tester/ui_checks/54-1_배관_좌측패널.js +++ b/resources/tester/ui_checks/54-1_배관_좌측패널.js @@ -1,11 +1,11 @@ /* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) · * 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 · - * 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N + * 흙막이 컨테이너 수 = 유출 「추가 흙막이(단)」 N * 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5) * 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)" * look → 지금 화면 배관 폼 수치(아무것도 안 바꿈) * basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함) - * tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수 + * tier+ / tier- → B06 유출 벽을 골라 「추가 흙막이(단)」 + / - 한 번 · 흙막이 컨테이너 수 * 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 · * 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after · * soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수 @@ -86,7 +86,7 @@ outletText: box("outlet")?.textContent.includes("자동 자리"), tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, extraGroupShown: [...document.querySelectorAll("legend")].filter( - (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + (l) => visible(l) && l.textContent.startsWith("추가 흙막이"), ).length, }; }; diff --git a/resources/tester/ui_checks/54-3_배관_횡단그림.js b/resources/tester/ui_checks/54-3_배관_횡단그림.js index 71ac4f824..d2218c8c3 100644 --- a/resources/tester/ui_checks/54-3_배관_횡단그림.js +++ b/resources/tester/ui_checks/54-3_배관_횡단그림.js @@ -120,7 +120,7 @@ for (const form of forms) { await set(side, "form", form); const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) => - (p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`), + (p.querySelector("title")?.textContent ?? "").startsWith(`${word} 흙막이`), ); const hatch = poly.nextElementSibling?.nextElementSibling; rows.push({ diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 81a587eac..bc98b3039 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -242,16 +242,16 @@ export const ui_locales_b2 = { B06_Cross_Width_Inc: ["표시 반폭 1m 늘리기", "Widen view width by 1m"], B06_Cross_Width_Reset: ["전역 반폭으로 초기화", "Reset to global width"], B06_Cross_Revet_Select: [ - "기슭막이 선택 — 화살표로 자리 조절", + "흙막이 선택 — 화살표로 자리 조절", "Select revetment — nudge with arrows", ], - B06_Cross_Revet_Label: ["기슭막이", "Revetment"], + B06_Cross_Revet_Label: ["흙막이", "Revetment"], B06_Cross_Revet_Close: ["조정창 닫기", "Close adjuster"], B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"], B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"], - B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"], - B06_Cross_Revet_Inlet: ["기슭막이(유입)", "Revetment (inlet)"], - B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"], + B06_Cross_Revet_Reset: ["흙막이 자동 자리로 초기화", "Reset revetment to solved position"], + B06_Cross_Revet_Inlet: ["흙막이(유입)", "Revetment (inlet)"], + B06_Cross_Revet_Outlet: ["흙막이(유출)", "Revetment (outlet)"], /* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */ B06_Cross_Revet_Own: ["기슭막이(독립)", "Revetment (standalone)"], /* 세월교 · BOX암거 유입 · 유출 흙막이 조정창(79b). */ @@ -259,7 +259,7 @@ export const ui_locales_b2 = { B06_Cross_Guard_Outlet: ["흙막이(유출)", "Retaining wall (outlet)"], B06_Cross_Struct_Label: ["구조물 형식", "Structure type"], // 조정창 제목은 "기슭막이(…)" 한 형식으로 통일한다(2026-08-29 사용자). {n} = 단 번호. - B06_Cross_Revet_Extra: ["기슭막이(추가 {n})", "Revetment (extra {n})"], + B06_Cross_Revet_Extra: ["흙막이(추가 {n})", "Revetment (extra {n})"], B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"], B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"], B06_Cross_Revet_Limit_Up: [ @@ -283,7 +283,7 @@ export const ui_locales_b2 = { B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"], B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"], B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"], - B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"], + B06_Cross_Extra_Count: ["추가 흙막이(단)", "Extra revetments"], B06_Cross_Extra_Add: ["한 단 추가", "Add one tier"], B06_Cross_Extra_Equalize: [ "다단 등간격 배치 — 사면 구간을 같게", @@ -291,17 +291,17 @@ export const ui_locales_b2 = { ], B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"], B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"], - B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"], + B06_Cross_Struct_Revet: ["흙막이+배관", "Revetment + pipe"], B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"], B06_Cross_Struct_L: ["집수정 ㄴ형", "Catch basin — L"], B06_Cross_Struct_U: ["집수정 ㄷ형", "Catch basin — U"], B06_Cross_Struct_InletBasin: ["유입 집수정", "Inlet catch basin"], B06_Cross_Revet_Limit_Inward: [ - "기슭막이 안쪽 한계 — 노견(도로) 안쪽으로는 들어갈 수 없습니다.", + "흙막이 안쪽 한계 — 노견(도로) 안쪽으로는 들어갈 수 없습니다.", "Revetment inner limit — cannot move inside the road shoulder.", ], B06_Cross_Revet_Limit_Outward: [ - "기슭막이 바깥 한계 — 조정 범위를 벗어나 더 밀 수 없습니다.", + "흙막이 바깥 한계 — 조정 범위를 벗어나 더 밀 수 없습니다.", "Revetment outer limit — cannot push beyond the adjustment range.", ], B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], @@ -345,7 +345,7 @@ export const ui_locales_b2 = { /* 성토사면 경사길이 — 구조물이 사면을 끊기 전 **본래** 길이(2026-09-03 사용자 지시). */ B06_Cross_FillSlope: ["성토사면", "Fill slope"], B06_Cross_FillSlope_Tip: [ - "노견 끝(그 측에 측구가 있으면 측구 바깥)부터 성토 사면이 원지반과 만나는 곳까지의 경사길이입니다. 기슭막이·집수정이 사면을 끊기 전 본래 길이이며, 양측 성토면 좌·우를 함께 적습니다. 5m를 넘으면 옹벽·석축 등이 필요합니다(성토_비탈면 §2). 「≥」는 사면이 계산 반폭 안에서 원지반과 만나지 못해 거기까지만 잰 하한값입니다.", + "노견 끝(그 측에 측구가 있으면 측구 바깥)부터 성토 사면이 원지반과 만나는 곳까지의 경사길이입니다. 흙막이·집수정이 사면을 끊기 전 본래 길이이며, 양측 성토면 좌·우를 함께 적습니다. 5m를 넘으면 옹벽·석축 등이 필요합니다(성토_비탈면 §2). 「≥」는 사면이 계산 반폭 안에서 원지반과 만나지 못해 거기까지만 잰 하한값입니다.", "Slant length of the fill slope, from the shoulder edge (outside the ditch if that side has one) to where it meets existing ground. It is the original slope before any revetment or catch basin cuts it short, and both sides are shown when the section is filled on both. Over 5 m a retaining structure is required. A leading ≥ marks a lower bound: the slope never meets ground within the computed half-width, so only that much could be measured.", ], B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"],