diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 013fe8db..4f4e5f58 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -179,6 +179,15 @@ "default": 2.5, "required": false, "phase": "detail" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": false, + "phase": "detail" } ] }, @@ -202,6 +211,15 @@ "default": 2.0, "required": false }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "body_height_m", "label": "본체 높이", @@ -779,6 +797,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -787,6 +823,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -870,6 +915,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -878,6 +941,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -1026,6 +1098,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -1034,6 +1124,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -1139,6 +1238,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -1147,6 +1264,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "length_m", "label": "길이", @@ -1192,6 +1318,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -1200,6 +1344,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "area_m2", "label": "면적", @@ -1308,6 +1461,24 @@ "required": false, "phase": "detail" }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": ["품셈", "실무 관행"], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": ["180", "210"], + "default": null, + "required": false, + "phase": "detail" + }, { "key": "face_slope_ratio", "label": "전면 기울기 (1:n 의 n)", @@ -1316,6 +1487,15 @@ "required": false, "phase": "detail" }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": ["기초유", "기초버림"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "height_m", "label": "높이", diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 45a7dab6..7fbca2a6 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -233,6 +233,9 @@ export interface CulvertSet { side?: string; /** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */ tiers?: number; + /** 기초 축 — "기초유" | "기초버림"(실무 정본 탭 제목). **비어 있으면 터파기를 안 그린다** + * — 고르기 전에는 근거가 없다(2026-09-09 사용자 확정 ④). */ + foundation?: string | null; } /** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 865df085..5f18bbd2 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -213,6 +213,12 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: "pipe_kind": str(kind) if kind else None, "diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3), "min_cover_m": MIN_PIPE_COVER_M, + # 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로 + # 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것. + "foundation": ( + str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip() + or None + ), "inlet": _side_spec(values, defaults, "inlet"), "outlet": _side_spec(values, defaults, "outlet"), } @@ -263,6 +269,8 @@ def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]: "hidden_pipe": True, "side": str(values.get("side") or "양쪽"), "tiers": int(_number(values.get("tiers"), 1.0) or 1), + # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. 비어 있으면 안 그린다. + "foundation": (str(values.get("foundation") or "").strip() or None), "pipe_kind": None, "diameter_m": 0.3, "min_cover_m": 0.0, diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index d4b3a70d..637ebcec 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -73,6 +73,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. "side": options.get("side"), + # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. + # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). + "foundation": options.get("foundation"), "tiers": options.get("tiers"), "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 94c24efe..37865f31 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -23,6 +23,11 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; +import { + appendPipeTrench, + appendWallTrench, + foundationChoice, +} from "./B06_Section_UI_Cross_Excavation"; import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 @@ -162,6 +167,12 @@ export function appendCulvertOverlay( const revetShapes = new Map(); /** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */ const basinShapes: SVGPolygonElement[] = []; + // 터파기는 구조물보다 **먼저** 그린다 — 파선이 벽 밑에 깔려야 도면처럼 보인다. + // 저장 제원 `foundation`(기초유/기초버림)이 비어 있으면 그리지 않는다 — 근거가 없다. + const wallFoundation = foundationChoice(culvert.foundation); + for (const { wall } of wallsInOrder) { + appendWallTrench(layer, wall, wallFoundation, x, toDisplayY); + } for (const { wall, key: wallKey } of wallsInOrder) { // 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②). const revetShape = polygon( @@ -272,6 +283,26 @@ export function appendCulvertOverlay( const inletFinal = pushOut(pipeCorners.inlet, -1); const outletFinal = pushOut(pipeCorners.outlet, 1); + // 관 터파기 — 표값 폭으로 관 위 지반선에서 관 바닥까지 수직(KCS 44 40 10 그림 3.2-1). + if (!hidePipe) { + const invert = Math.min( + pipeCorners.inlet.bottom.elevation, + pipeCorners.outlet.bottom.elevation, + ); + const centerOffset = (pipeCorners.inlet.bottom.offset + pipeCorners.outlet.bottom.offset) / 2; + appendPipeTrench( + layer, + { + centerOffsetM: centerOffset, + invertM: invert, + topM: invert + diameter + culvert.min_cover_m, + diameterMm: Math.round(diameter * 1000), + }, + x, + toDisplayY, + ); + } + const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); const tip = diff --git a/B06_Section/B06_Section_UI_Cross_Excavation.ts b/B06_Section/B06_Section_UI_Cross_Excavation.ts new file mode 100644 index 00000000..d7d1120a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Excavation.ts @@ -0,0 +1,119 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Excavation.ts + * 횡단도에 **터파기 선**을 얹는다 — 관·벽 두 갈래(2026-09-09 사용자 확정 ④). + * + * 왜 있나 — 「터파기가 현재 횡단도에서 표현이 안 된다」가 사용자 지적이었다. 수량을 + * 세기 전에 **도면에 그것이 보여야** 한다. + * + * ⚠ 근거의 급이 다르므로 **노티스를 갈라 적는다** — 값은 `common_util_excavation` 한 곳에서 + * 온다(파이썬 짝과 거울 시험으로 묶여 있다). 여기서는 **그리기만** 한다. + * 관 : 법정 시방서 표(KCS 44 40 10 그림 3.2-1) + * 벽 : 법정 근거 없음 — 실무 정본 식(구조도 기슭막이 xls) + * ⚠ **비탈을 주지 않는다 — 수직으로 그린다.** 정본 식에 기울기 항이 없다. + * ========================================================================== */ + +import { + BASIS_PIPE, + BASIS_WALL, + WALL_BLINDING_DEPTH_M, + WALL_BLINDING_WIDTH_M, + WALL_FOUNDATION_DEPTH_M, + WALL_FOUNDATION_WIDTH_M, + WALL_TRENCH_CLEARANCE_M, + pipeTrenchWidthM, +} from "@util/common_util_excavation"; +import type { WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** 「기초유」인가 — 저장 제원 `foundation` 의 값. 비어 있으면 **그리지 않는다**(근거 없음). */ +export function foundationChoice(value: unknown): "기초유" | "기초버림" | null { + const text = typeof value === "string" ? value.trim() : ""; + if (text === "기초유") return "기초유"; + if (text === "기초버림") return "기초버림"; + return null; +} + +function trenchPath(layer: SVGElement, corners: Array<[number, number]>, tooltip: string): void { + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("points", corners.map(([px, py]) => `${px},${py}`).join(" ")); + line.setAttribute("class", "b06-chart__excavation"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = tooltip; + line.append(title); + layer.append(line); +} + +/** + * 관 터파기 — **표값 폭**으로 관 위 지반선에서 관 바닥까지 수직으로 판다. + * 표에 없는 관경이면 **아무것도 그리지 않는다**(폭을 지어내지 않는다). + */ +export function appendPipeTrench( + layer: SVGElement, + input: { + /** 관 중심 offset(m). */ + centerOffsetM: number; + /** 관 바닥(invert) 표고. */ + invertM: number; + /** 터파기 윗면 표고 — 관 위 지반(또는 노면)선. */ + topM: number; + diameterMm: number | null | undefined; + }, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): number | null { + const width = pipeTrenchWidthM(input.diameterMm); + if (width === null || !(input.topM > input.invertM)) return null; + const half = width / 2; + const left = input.centerOffsetM - half; + const right = input.centerOffsetM + half; + trenchPath( + layer, + [ + [x(left), toDisplayY(input.topM)], + [x(left), toDisplayY(input.invertM)], + [x(right), toDisplayY(input.invertM)], + [x(right), toDisplayY(input.topM)], + ], + `관 터파기 폭 ${width.toFixed(2)}m · 깊이 ${(input.topM - input.invertM).toFixed(2)}m\n` + + `Φ${Math.round(Number(input.diameterMm))}㎜ — ${BASIS_PIPE}`, + ); + return width; +} + +/** + * 벽 터파기 — 벽 바닥선 아래로 **수직**. 폭은 바닥 폭 + 여유 0.2, 깊이는 기초 몫. + * `foundation` 이 비어 있으면 **그리지 않는다** — 사용자가 고르기 전에는 근거가 없다. + */ +export function appendWallTrench( + layer: SVGElement, + wall: WallLayout, + foundation: "기초유" | "기초버림" | null, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): { widthM: number; depthM: number } | null { + if (foundation === null) return null; + const hasFoundation = foundation === "기초유"; + const depth = hasFoundation ? WALL_FOUNDATION_DEPTH_M : WALL_BLINDING_DEPTH_M; + // 폭: 기초 몫은 정본이 정한 값(0.9 / 0.7), 다만 벽 바닥이 그보다 넓으면 그 폭 + 여유. + const baseWidth = Math.abs(wall.bottomFront.offset - wall.bottomBack.offset); + const minWidth = hasFoundation ? WALL_FOUNDATION_WIDTH_M : WALL_BLINDING_WIDTH_M; + const width = Math.max(minWidth, baseWidth + (hasFoundation ? WALL_TRENCH_CLEARANCE_M : 0)); + const center = (wall.bottomFront.offset + wall.bottomBack.offset) / 2; + const top = Math.max(wall.bottomFront.elevation, wall.bottomBack.elevation); + const bottom = top - depth; + const left = center - width / 2; + const right = center + width / 2; + trenchPath( + layer, + [ + [x(left), toDisplayY(top)], + [x(left), toDisplayY(bottom)], + [x(right), toDisplayY(bottom)], + [x(right), toDisplayY(top)], + ], + `${foundation} 터파기 폭 ${width.toFixed(2)}m · 깊이 ${depth.toFixed(2)}m (수직)\n` + + `품셈에 규정이 없어 실무 관행으로 정한 값 — ${BASIS_WALL}`, + ); + return { widthM: width, depthM: depth }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 5c45ff3d..d6f4578c 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -37,6 +37,7 @@ import { type WallAdjust, type WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -53,6 +54,8 @@ export interface RevetmentSpec { height_m?: number | null; /** 사용자가 고른 설치 측 — "좌" | "우". */ side?: string | null; + /** 기초 축 — "기초유" | "기초버림". **비어 있으면 터파기를 안 그린다**(근거 없음). */ + foundation?: string | null; /** 단 수(다단). 1이면 단일 벽. */ tiers?: number | null; /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ @@ -89,6 +92,8 @@ export interface RevetmentLayout { top: RevetPoint; /** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */ walls: WallLayout[]; + /** 기초 축 — 저장 칸과 **같은 글자**(`foundation`). 터파기 그림이 이 값으로 갈린다. */ + foundation: string | null; /** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */ fillSegments: OutletFillSegment[]; /** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */ @@ -304,6 +309,7 @@ export function computeRevetmentLayout( requestedTiers, top: jt, walls, + foundation: typeof spec.foundation === "string" ? spec.foundation.trim() || null : null, fillSegments: extras.segments, designTrim, appliedAdjust: { x: appliedX, d: appliedDrop, h: adjust?.h ?? null, m: null }, @@ -359,6 +365,11 @@ export function appendRevetmentOverlay( ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); } + // 터파기는 벽보다 **먼저** — 파선이 벽 밑에 깔린다. 기초 축이 비어 있으면 안 그린다. + const trenchFoundation = foundationChoice(layout.foundation); + for (const wall of layout.walls) { + appendWallTrench(svg, wall, trenchFoundation, x, y); + } const drawn: SVGPolygonElement[] = []; layout.walls.forEach((wall, index) => { const keyId = index === 0 ? "own" : `own-extra${index - 1}`; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d6728a14..535a53c9 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -379,3 +379,12 @@ stroke-dasharray: 4 3; stroke-linejoin: round; } + +/* 터파기 선(2026-09-09 사용자 확정 ④) — **파선**으로 그린다. 구조물 실체가 아니라 + 「여기를 판다」는 표시이고, 근거의 급(법정 표 / 실무 관행)은 말풍선에 적힌다. */ +.b06-chart__excavation { + fill: none; + stroke: #8a5c3c; + stroke-dasharray: 4 3; + stroke-width: 1; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 8f0adb09..8511073f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -274,6 +274,7 @@ export function putStandardSheetSpec( stone_supply: string | null; back_len_cm: string | null; face_slope_ratio: string | null; + foundation: string | null; }, ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { return requestJson(`/projects/${projectId}/standard-sheets/spec`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py index 8ce117a6..a1ee36a8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py @@ -11,12 +11,14 @@ ⚠ **수량이 쓰는 그 상수로 그린다** — `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY` 를 직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5장). - 상부 두께 = 0.45 + 0.10·H 하부 두께 = 0.45 + 0.40·H + 상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H − 1.0) 터파기 폭 = 평균두께 + 0.2 되메우기 두께 = 0.2 - ⓘ 이 상수는 **뒷면이 수직인 1:0.3 벽**을 뜻한다 — 하부−상부 = 0.30·H 이고 전면이 뒤로 - 기우는 양도 0.3·H 라 딱 맞는다. 기울기가 0.3 이 아니면 그 차이만큼 **뒷면이 기운다**. - 지어낸 것이 아니라 상수에서 따라 나오는 결과라 그대로 그린다. + ⚠ **뒷길이가 두께에 들어간다**(확정 2차 ②, 실무 구조물도 식). 뒷길이가 다르면 벽이 + 두꺼워지고 그림도 그만큼 넓어진다 — 예전 식(0.45+0.10H / 0.45+0.40H)은 뒷길이를 + 아예 안 봐서 35㎝ 든 45㎝ 든 같은 그림이 나왔다. + ⚠ 뒷길이를 안 정한 장은 두께를 못 낸다 — 그때는 **그리지 않는다**(0 으로 때우면 + 거짓 그림이 된다). ⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서 몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다. @@ -69,12 +71,30 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: ) -def section_points(height_m: float, slope_ratio: float) -> list[tuple[float, float]]: - """벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다.""" - top_t = STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_top_coeff"] * height_m - bottom_t = ( - STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_bottom_coeff"] * height_m +def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]: + """(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다.""" + top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"] + bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max( + height_m - STONE_MASONRY["thickness_height_base_m"], 0.0 ) + return top_t, bottom_t + + +def back_length_cm(sheet: dict[str, Any]) -> float | None: + """장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다.""" + options = sheet.get("options") or {} + raw = options.get("back_len_cm") or options.get("stone_back_length_cm") + try: + return float(raw) if raw not in (None, "") else None + except (TypeError, ValueError): + return None + + +def section_points( + height_m: float, slope_ratio: float, back_cm: float +) -> list[tuple[float, float]]: + """벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다.""" + top_t, bottom_t = wall_thickness(height_m, back_cm) lean = slope_ratio * height_m return [ (0.0, 0.0), @@ -101,7 +121,10 @@ def build_figure( if height_m <= 0: return [], 0.0 - options = sheet.get("options") or {} + back_cm = back_length_cm(sheet) + if back_cm is None: + # 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다. + return [], 0.0 slope, slope_note = slope_of(sheet) scale = SCALE_MM_PER_M ox, oy = origin @@ -109,11 +132,8 @@ def build_figure( def mm(point: tuple[float, float]) -> tuple[float, float]: return (ox + point[0] * scale, oy + point[1] * scale) - points = section_points(height_m, slope) - top_t = STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_top_coeff"] * height_m - bottom_t = ( - STONE_MASONRY["thickness_base_m"] + STONE_MASONRY["thickness_bottom_coeff"] * height_m - ) + points = section_points(height_m, slope, back_cm) + top_t, bottom_t = wall_thickness(height_m, back_cm) average_t = (top_t + bottom_t) / 2.0 dig_width = average_t + STONE_MASONRY["excavation_extra_m"] backfill_t = STONE_MASONRY["backfill_thickness_m"] @@ -177,9 +197,7 @@ def build_figure( ), ("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"), ] - back = options.get("back_len_cm") or options.get("stone_back_length_cm") - if back: - labels.append((f"뒷길이 ℓ₃ = {int(back)}㎝", (dig_width + 0.16, height_m * 0.72), "left")) + labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}㎝", (dig_width + 0.16, height_m * 0.72), "left")) for index, (text, point, align) in enumerate(labels): x, y = mm(point) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py index 71b43431..67bc203d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py @@ -25,7 +25,18 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Schema import StructureInstance #: 표준도에서 받는 칸 — `키 → (이름, 검사)`. 여기 없는 칸은 표준도가 안 만진다. -EDITABLE_KEYS: tuple[str, ...] = ("stone_kind", "stone_supply", "back_len_cm", "face_slope_ratio") +EDITABLE_KEYS: tuple[str, ...] = ( + "stone_kind", + "stone_supply", + "back_len_cm", + "face_slope_ratio", + "foundation", +) + +#: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`). +#: ⚠ **물량과 그림이 같은 칸을 본다** — 터파기 기초 몫이 0.5×(0.7+0.2)=0.45 대 +#: 0.1×(0.7+0.0)=0.07 ㎥/m 로 갈리고, 횡단도 터파기 선도 이 값으로 그려진다. +FOUNDATION_CHOICES: tuple[str, ...] = ("기초유", "기초버림") #: 품셈 13-4-3·13-4-4 [주]① 의 일곱 규격. 그 밖의 값은 계수가 없어 물량이 안 선다. BACK_LENGTH_CHOICES: tuple[int, ...] = (25, 30, 35, 45, 55, 60, 75) @@ -39,6 +50,7 @@ FIELD_LABELS: dict[str, str] = { "stone_supply": "조달", "back_len_cm": "뒷길이", "face_slope_ratio": "전면 기울기", + "foundation": "기초", } @@ -95,6 +107,15 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: "물량이 서지 않습니다." ) + if "foundation" in spec: + found = spec.get("foundation") + cleaned["foundation"] = str(found) if found not in (None, "") else None + if cleaned["foundation"] and cleaned["foundation"] not in FOUNDATION_CHOICES: + notes.append( + f"기초 「{cleaned['foundation']}」는 정본에 없는 갈래입니다 " + f"(있는 것: {' · '.join(FOUNDATION_CHOICES)})." + ) + if "face_slope_ratio" in spec: ratio, note = _clean_slope(spec.get("face_slope_ratio")) cleaned["face_slope_ratio"] = ratio diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py index 32159a2c..86c64b71 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Standard.py @@ -70,6 +70,7 @@ class StandardSheetSpecRequest(BaseModel): stone_supply: str | None = None back_len_cm: int | str | None = None face_slope_ratio: float | str | None = None + foundation: str | None = None @router.put("/{project_id}/standard-sheets/spec") diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts index e1923b59..0badf6ce 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts @@ -15,6 +15,8 @@ const STONE_KINDS = ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"] as const; const SUPPLIES = ["채집", "구입"] as const; const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const; +/** 정본 xls 탭 제목 그대로 — 터파기 기초 몫 0.45 대 0.07 을 가르는 축. */ +const FOUNDATIONS = ["기초유", "기초버림"] as const; /** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */ export interface StandardSheetSpec { @@ -31,6 +33,7 @@ export interface StandardSpecResult { stone_supply: string | null; back_len_cm: string | null; face_slope_ratio: string | null; + foundation: string | null; } /** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */ @@ -106,6 +109,7 @@ export function buildStandardSpecPanel( const kind = select(STONE_KINDS, options.stone_kind, "— 안 정함 —"); const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —"); const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —"); + const foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —"); const slope = document.createElement("input"); slope.className = "b07-spec__input"; @@ -118,6 +122,7 @@ export function buildStandardSpecPanel( field("돌 종류", kind), field("조달", supply, "비우면 「캔다」로 봅니다."), field("뒷길이 (㎝)", back, "품셈 일곱 규격 밖이면 물량이 서지 않습니다."), + field("기초", foundation, "터파기 몫이 갈립니다 — 기초유 0.45 · 기초버림 0.07 ㎥/m."), field( "전면 기울기 1:n", slope, @@ -157,6 +162,7 @@ export function buildStandardSpecPanel( stone_supply: supply.value || null, back_len_cm: back.value || null, face_slope_ratio: slope.value.trim() || null, + foundation: foundation.value || null, }), ); } catch (error) { diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 26c8353d..e6d72e12 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -104,6 +104,7 @@ def build_handoff( ground_classes: list[str] | None = None, ground_methods: dict[str, str | None] | None = None, concrete_placing_method: str | None = None, + bench_cut_depth_m: float | None = None, ) -> dict[str, Any]: """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" table = mapping or load_mapping() @@ -112,7 +113,7 @@ def build_handoff( methods = {key: value for key, value in (ground_methods or {}).items() if value} if summary_table: - rows, misses = _earthwork_rows(summary_table, table, methods) + rows, misses = _earthwork_rows(summary_table, table, methods, bench_cut_depth_m) work_items.extend(rows) unmatched.extend(misses) if haul_table: diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 0b19c353..4f0d1e42 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -89,10 +89,17 @@ def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, ) +#: 면적(㎡)으로 서지만 **길이를 곱해 ㎥ 로 내보내는** 공종 — 지금은 층따기뿐이다. +#: ⭐ 2026-09-09 사용자 확정 2차 ① — 「면적이 정본이고 부피는 사용자가 지정한 길이를 곱해 쓴다」. +#: ⚠ 면적을 없애지 않는다(횡단도 하단 표가 면적을 쓴다) — **㎥ 를 덧붙이는 것**이다. +AREA_TIMES_DEPTH_GROUPS = {"층따기"} + + def _earthwork_rows( summary_table: dict[str, Any], mapping: WorkItemMapping, methods: dict[str, str | None], + bench_cut_depth_m: float | None = None, ) -> tuple[list[dict[str, Any]], list[str]]: """토공집계표 줄을 내역 줄로 옮긴다. @@ -117,7 +124,12 @@ def _earthwork_rows( amount = float(row.get("amount") or 0.0) mismatch = _basis_mismatch(entry, unit) if code else None spec_detail = "" - if mismatch is not None: + # 층따기 — 길이가 들어오면 **면적 × 길이**로 ㎥ 를 내 품셈 단가를 그대로 쓴다. + depth = float(bench_cut_depth_m or 0.0) + if group in AREA_TIMES_DEPTH_GROUPS and unit == "㎡" and depth > 0: + spec_detail = f"면적 {amount:,.2f}㎡ × 길이 {depth:g}m" + unit, amount, mismatch = "㎥", amount * depth, None + elif mismatch is not None: spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" unit, amount = mismatch[0], 0.0 if code is None and not is_subtotal: diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index ab920281..d9e20814 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -38,6 +38,14 @@ from dataclasses import dataclass, field from typing import Any, Iterable from common_util.common_util_structure_face_role import structure_face_role +from common_util.common_util_excavation import ( + BASIS_WALL, + WALL_BLINDING_DEPTH_M, + WALL_FOUNDATION_DEPTH_M, + WALL_TRENCH_CLEARANCE_M, + wall_trench_area_m2, + wall_trench_width_m, +) from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( BASIS_DERIVED, BASIS_OBSERVED, @@ -67,6 +75,17 @@ STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { } DEFAULT_BACK_LENGTH_CM = 45 +#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지, +#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」). +#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은 +#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음). +#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다. +STONE_WEIGHT_SOURCE = "uljin_library" +STONE_WEIGHT_BASIS = ( + "⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)." + " 품셈·교본에는 돌중량표가 없음" +) + #: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. #: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. @@ -92,6 +111,20 @@ def load_stone_kind_table() -> dict[str, Any]: return {} +#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨). +#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면 +#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다. +#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다. +#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다. +COEFF_BASIS_KEY = "stone_coeff_basis" +COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"} + + +def wants_practice_coefficients(options: dict[str, Any]) -> bool: + """계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열).""" + return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE + + def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. @@ -105,14 +138,21 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, return {}, "" if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") - if not kind: + practice = wants_practice_coefficients(options) + if not kind or practice: fallback = table.get("fallback") or {} + message = ( + f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —" + " 기본은 품셈 열입니다(확정 ⑨)" + if practice and kind + else str(fallback.get("message") or "") + ) return { "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), "kind": "", - }, str(fallback.get("message") or "") + }, message if kind not in (table.get("kinds") or []): return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) @@ -139,9 +179,18 @@ STONE_MASONRY = { # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 - "thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께 - "thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H) - "thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H) + # ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본 + # 네 탭에서 찾아 실측과 소수점까지 대조). + # 상부 두께 = 뒷길이(ℓ3) + 0.30 + # 하부 두께 = 상부 + 0.30 × (H − 1.0) + # ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로 + # 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다. + # ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한 + # 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다. + # ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다. + "thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30 + "thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0) + "thickness_height_base_m": 1.0, # 그 식의 기준 높이 # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 @@ -150,8 +199,9 @@ STONE_MASONRY = { # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) - "excavation_extra_m": 0.2, # 터파기 폭 여유 - "backfill_thickness_m": 0.2, # 되메우기 두께 + # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 + # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). + # 여기서 다시 두면 그림과 물량이 갈린다. } # 성분이 어디로 가는가 — 이중계상을 막는 표시. @@ -163,9 +213,12 @@ STONE_MASONRY = { #: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. #: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 #: 폭과 동일**하게 한다」. -#: ⚠ 우리에게 **잡석다짐 폭이 아직 없다**(기초잡석은 품셈에 공종만 있고 두께·폭이 없음). -#: 그래서 **터파기 폭(평균두께 + 여유 0.2m)을 잠정으로** 쓰고 근거 문구에 그 사실을 적는다. -#: 잡석다짐 폭이 정해지면 이 한 줄만 바꾸면 된다. +#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」 +#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」). +#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다. +#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느 +#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 +#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다. #: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). #: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 #: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 @@ -186,6 +239,31 @@ STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchase #: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. COLLECTED_STONE_KEY = "collected_stone_deduction_m3" +#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」). +#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다. +#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다. +FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa" +FILL_CONCRETE_MPA_DEFAULT = "210" +FILL_CONCRETE_MPA_CHOICES = ("180", "210") + + +def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]: + """(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다.""" + value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip() + if value in FILL_CONCRETE_MPA_CHOICES: + return value, f"강도 {value} — 저장 제원에서 고른 값" + if value: + return ( + FILL_CONCRETE_MPA_DEFAULT, + f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에" + " 없어 기본으로 섬", + ) + return ( + FILL_CONCRETE_MPA_DEFAULT, + f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)", + ) + + BLINDING_THICKNESS_M = 0.10 #: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). #: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. @@ -210,6 +288,11 @@ DESTINATION = { # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 # `material` 만 모으므로 여기 섞이지 않는다. "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) + # ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에 + # 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에 + # 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다. + # ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.** + "입적": "reference", } # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. @@ -233,6 +316,8 @@ class Component: # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. basis_kind: str = BASIS_DERIVED source: str = "" + #: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다. + spec: str = "" @dataclass(slots=True) @@ -359,21 +444,42 @@ def wants_blinding(options: dict[str, Any]) -> bool: return True -def _blinding_component(base_width_m: float, length_m: float, options: dict[str, Any]): - """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.""" +def _blinding_component( + base_width_m: float, + length_m: float, + options: dict[str, Any], + width_basis: str = "", +): + """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`. + + 폭은 **잡석다짐 폭**(KCS 34 50 05)이고, 그 값은 확정 ⑪ 로 **구조물 하단 길이**다. + 부르는 쪽이 그 폭과 까닭(`width_basis`)을 함께 넘긴다. + """ if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: return None + basis = ( + f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" + " · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)" + ) + if width_basis: + basis = f"{basis} = {width_basis}" return Component( "버림콘크리트", "㎥", base_width_m * length_m * BLINDING_THICKNESS_M, DESTINATION["버림콘크리트"], - f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" - " · 두께는 KDS 44 90 00(100㎜) · ⚠ 폭은 잡석다짐 폭(KCS 34 50 05)이라야 하나" - " 그 값이 아직 없어 **터파기 폭을 잠정**으로 씀", + basis, ) +#: 기초잡석(품셈 12-25)이 아직 못 서는 까닭 — **폭은 있고 두께가 없다.** +RUBBLE_BASE_BLOCKED = ( + "기초잡석(12-25) 물량이 안 섬 — 폭은 하단 길이 {width:.2f}m 로 섰으나 **두께가 원문에 " + "없음**(품셈 12-25 는 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 " + "다른 구조물 값을 옮겨 쓰지 않음" +) + + #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. @@ -418,8 +524,9 @@ BOULDER_MASONRY = { # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. "sheet_check_factor_at_0_3": 1.04, - "excavation_extra_m": 0.2, # 터파기 폭 여유 - "backfill_thickness_m": 0.2, # 되메우기 두께 + # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 + # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). + # 여기서 다시 두면 그림과 물량이 갈린다. } @@ -460,7 +567,6 @@ def boulder_masonry( # ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다. # 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다. slope_ratio = _num(options.get("face_slope_ratio"), 0.3) - constants = BOULDER_MASONRY face_area = height_m * length_m # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). masonry_area = face_area * math.hypot(1.0, slope_ratio) @@ -481,30 +587,23 @@ def boulder_masonry( upper_cm = float(str(diameter).split("~")[-1]) thickness = upper_cm / 100.0 # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. - blinding = _blinding_component(thickness + constants["excavation_extra_m"], length_m, options) + blinding = _blinding_component( + thickness, + length_m, + options, + f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용", + ) if blinding is not None: components.append(blinding) - excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m - backfill = height_m * constants["backfill_thickness_m"] * length_m components.extend( - [ - Component( - "터파기", - "㎥", - excavation, - DESTINATION["터파기"], - f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이" - " 품셈에 없어 돌쌓기 방식을 준용", - ), - Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), - Component( - "잔토처리", - "㎥", - excavation - backfill, - DESTINATION["잔토처리"], - "터파기 − 되메우기", - ), - ] + _trench_components( + height_m, + length_m, + thickness, + options, + notes, + "⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용", + ) ) notes.append( @@ -525,6 +624,66 @@ def boulder_masonry( return components, notes +def _trench_components( + height_m: float, + length_m: float, + thickness: float, + options: dict[str, Any], + notes: list[str], + extra_basis: str = "", +) -> list[Component]: + """터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`). + + ⚠ 여기서 식을 다시 짜지 않는다 — 화면(B06 횡단도)이 그리는 단면과 같은 코드라야 + 그림과 물량이 안 갈린다. 평균두께는 이쪽이 낸 값을 **넘겨 준다**(저쪽은 두께를 모름). + ⚠ 「기초유 / 기초버림」은 정본 탭 제목이고 **기초분 0.45 냐 0.07 이냐**를 가른다. + 안 고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 — 한쪽으로 찍으면 임의값이 + 금액으로 굳는다(기초분이 통째로 0.45㎥/m 다). + """ + foundation = str(options.get("foundation") or "").strip() + if foundation in ("기초유", "기초버림"): + has_foundation = foundation == "기초유" + area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation) + base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M + base_basis = f"기초분({foundation}) + " + else: + width = wall_trench_width_m(thickness) + area = None if width is None else height_m * width + base_depth = 0.0 + base_basis = "" + notes.append( + "⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움" + ) + if area is None: + notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음") + return [] + excavation = area * length_m + backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m + basis = ( + f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장" + f" · {BASIS_WALL}" + ) + if extra_basis: + basis = f"{basis} · {extra_basis}" + return [ + Component("터파기", "㎥", excavation, DESTINATION["터파기"], basis), + Component( + "되메우기", + "㎥", + backfill, + DESTINATION["되메우기"], + f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장", + ), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + + def stone_masonry( height_m: float, length_m: float, @@ -588,10 +747,16 @@ def stone_masonry( # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). masonry_area = face_area * math.hypot(1.0, slope_ratio) - thickness = ( - (constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m) - + (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m) - ) / 2.0 + # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** + top_thickness = back_cm / 100.0 + constants["thickness_top_add_m"] + bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max( + height_m - constants["thickness_height_base_m"], 0.0 + ) + thickness = (top_thickness + bottom_thickness) / 2.0 + thickness_basis = ( + f"실무 구조물도 식 — 상부 {top_thickness:.2f}(뒷길이 {back_cm}㎝ + 0.30) ·" + f" 하부 {bottom_thickness:.2f}(상부 + 0.30×(H−1))" + ) volume = face_area * thickness # 입적 components = [ @@ -602,6 +767,15 @@ def stone_masonry( DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}", ), + # 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라 + # 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다. + Component( + "입적", + "㎥", + volume, + DESTINATION["입적"], + f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}", + ), ] # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 @@ -635,7 +809,8 @@ def stone_masonry( "ton", masonry_area * stone_ton, DESTINATION["야면석"], - f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", + f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}", + source=STONE_WEIGHT_SOURCE, ) ) @@ -651,19 +826,22 @@ def stone_masonry( "㎥", rubble, DESTINATION["막자갈"], - f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)" + f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌) · {thickness_basis}" + (f" · {kind_label}" if kind_label else ""), ) ) if wet: + mpa, mpa_basis = fill_concrete_mpa(options) components.append( Component( "채움콘크리트", "㎥", masonry_area * _num(table["fill_concrete_m3_per_m2"]), DESTINATION["채움콘크리트"], - f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", + f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + f" · {mpa_basis}", + spec=f"{mpa}", ) ) components.append( @@ -717,28 +895,22 @@ def stone_masonry( ) # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. - base_width = thickness + constants["excavation_extra_m"] - blinding = _blinding_component(base_width, length_m, options) + # 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다. + base_width = bottom_thickness + blinding = _blinding_component( + base_width, + length_m, + options, + f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」", + ) if blinding is not None: components.append(blinding) + notes.append(RUBBLE_BASE_BLOCKED.format(width=base_width)) + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). - excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m - backfill = height_m * constants["backfill_thickness_m"] * length_m components.extend( - [ - Component( - "터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장" - ), - Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), - Component( - "잔토처리", - "㎥", - excavation - backfill, - DESTINATION["잔토처리"], - "터파기 − 되메우기", - ), - ] + _trench_components(height_m, length_m, thickness, options, notes, thickness_basis) ) return components, notes @@ -1001,6 +1173,7 @@ def build_table( "basis": component.basis, "basis_kind": component.basis_kind, "source": component.source, + "spec": component.spec, } for component in item.components ], diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 5ccf4c94..442ae047 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -217,11 +217,13 @@ class QuantitySettingsBody(BaseModel): # ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고 # 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다. ancillary_counts: dict[str, float] | None = None + # 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①). + bench_cut_depth_m: float | None = None #: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. #: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. -NULLABLE_SETTING_KEYS = ("topsoil_thickness_m",) +NULLABLE_SETTING_KEYS = ("topsoil_thickness_m", "bench_cut_depth_m") @router.put("/{project_id}/quantity/settings") diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 9a3058f1..339fde83 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -203,6 +203,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse: ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)}, # 타설 방식 — 안 정했으면 기본값으로 서되 그 사실을 `placing_notes` 가 알린다. concrete_placing_method=concrete_placing_method(settings)[0], + # 층따기 길이 — 면적 × 이 값으로 ㎥ 를 낸다(확정 2차 ①). 안 넣었으면 막히고 사유가 감. + bench_cut_depth_m=settings.get("bench_cut_depth_m"), ) handoff["summary"] = summarize(handoff) handoff["skipped_structures"] = skipped diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 1b67957e..165ed43c 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -138,6 +138,10 @@ function culvertSet( pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind), diameter_m: pipeDiameterM(diameterMm), min_cover_m: MIN_PIPE_COVER_M, + // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 + // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 + // 확인되면 그때 나눌 것. + foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation), inlet: sideSpec(options, defaults, "inlet"), outlet: sideSpec(options, defaults, "outlet"), }; @@ -174,6 +178,8 @@ function revetSet(values: Record): CulvertSetSpec { hidden_pipe: true, side: String(values.side ?? "양쪽"), tiers: Math.trunc(num(values.tiers, 1.0) || 1), + // 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. 비어 있으면 안 그린다. + foundation: text(values.foundation), pipe_kind: null, diameter_m: 0.3, min_cover_m: 0.0, diff --git a/common_util/common_util_excavation.py b/common_util/common_util_excavation.py new file mode 100644 index 00000000..d5535eee --- /dev/null +++ b/common_util/common_util_excavation.py @@ -0,0 +1,118 @@ +"""터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.** + +⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_excavation.ts`. + 거울 시험: `tmp/tests/test_excavation_mirror.py` (고칠 때 같이 돌릴 것). + 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다. + +① 관 — **법정 시방서에 표가 있다** + `KCS 44 40 10 배수관(23.01) §3.2(4) 그림 3.2-1`. + 「설계도서에 따로 표기되지 않았을 때에는 그림 3.2-1 터파기 기준 폭에 따라야 한다」 + ⇒ 기본값으로 쓰고 사용자가 덮어쓰는 구조가 원문과 맞다. + ⚠ **관경 + 2b 로 계산하지 않는다** — 표가 관 두께·작업여유를 이미 담고 있다 + (Ø800 은 800+2×300=1400 이 아니라 **1600**). + 여유폭 b 는 같은 그림의 값: 보통지반 0.30 m · 연약지반 0.50 m(한쪽). + +② 벽(돌쌓기·기슭막이) — **법정 근거 없음. 실무 정본 식이 있다** + 법령·교본은 「설계도서 지시 폭·기울기·깊이」까지만 정한다 + (지식DB `01_임도/02_상세설계/구조물/구조물_기초_토공.md` §1 — 전수 확인). + 실무 정본 `original/구조물도/기슭막이/04.구조도(기슭막이).xls` 7탭: + + 터파기 = 기초 0.5 × (0.7 + 0.2) × 1.0 = 0.45 (버림만 있는 탭은 0.1 × 0.7 = 0.07) + + 비탈 H × (평균두께 + 0.2) × 1.0 + + ⇒ **여유폭은 「+0.2」**(평균두께에 더함), **기초는 깊이 0.5 · 폭 0.9**. + ⇒ 정본 탭 제목이 **「기초유 / 기초버림」**이다 — 기초를 안 두는 것이 아니라 + **버림으로 얕게 두는 것**이라 뜻이 다르다. 그 축이 곧 **0.45 냐 0.07 이냐**를 가른다. + ⚠ **비탈을 주지 않는다 — 정본이 수직으로 센다.** 기울기 항이 식에 아예 없다. + 임의로 기울기를 주면 그림과 물량이 어긋난다. + +⚠ 값을 지어내지 않는다 — 여기 있는 수는 전부 위 두 출처의 것이고, 사용자가 덮어쓰면 + 그 값이 이긴다. 화면에는 **근거의 급**(법정 표 / 실무 식)을 노티스로 드러낸다. +""" + +from __future__ import annotations + +#: 관경(㎜) → 터파기 폭(㎜). `KCS 44 40 10` 그림 3.2-1 표 그대로. +PIPE_TRENCH_WIDTH_MM: dict[int, int] = { + 300: 700, + 350: 800, + 400: 900, + 450: 1000, + 500: 1100, + 600: 1200, + 700: 1400, + 800: 1600, + 900: 1700, + 1000: 1800, + 1100: 2000, + 1200: 2100, + 1300: 2200, + 1400: 2400, + 1500: 2500, +} + +#: 한쪽 여유폭(m) — 같은 그림. 표에 없는 관경을 사용자가 넣었을 때의 안내용이다. +PIPE_TRENCH_CLEARANCE_NORMAL_M = 0.30 +PIPE_TRENCH_CLEARANCE_SOFT_M = 0.50 + +#: 벽 터파기 — 실무 정본 식의 수(위 문서 참조). +WALL_TRENCH_CLEARANCE_M = 0.2 # 평균두께에 더하는 여유 +WALL_FOUNDATION_DEPTH_M = 0.5 # 기초 깊이 +WALL_FOUNDATION_WIDTH_M = 0.9 # 기초 폭 (0.7 + 0.2) +WALL_BLINDING_DEPTH_M = 0.1 # 기초 없이 버림만 있을 때의 깊이 +WALL_BLINDING_WIDTH_M = 0.7 # 그때의 폭 (여유폭 0.0 — 버림 탭에는 여유가 없다) + +#: 근거의 급 — 화면 노티스가 이 값을 그대로 쓴다. +BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값" +BASIS_WALL = "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) 기본값 — 확인 후 쓸 것" + + +def pipe_trench_width_m(diameter_mm: float | int | None) -> float | None: + """관경(㎜)에 대응하는 터파기 폭(m). 표에 없으면 `None`(지어내지 않는다).""" + if diameter_mm is None: + return None + try: + key = int(round(float(diameter_mm))) + except (TypeError, ValueError): + return None + width = PIPE_TRENCH_WIDTH_MM.get(key) + return None if width is None else width / 1000.0 + + +def wall_trench_area_m2( + height_m: float | None, + average_thickness_m: float | None, + *, + has_foundation: bool, +) -> float | None: + """벽 터파기 단면적(㎡/m). 높이나 평균두께가 없으면 `None`. + + 실무 정본 식 그대로: `기초분 + H × (평균두께 + 0.2)`. + `has_foundation=False` 면 기초분이 **버림 몫(0.1 × 0.7)**으로 줄어든다. + """ + if height_m is None or average_thickness_m is None: + return None + try: + height = float(height_m) + thickness = float(average_thickness_m) + except (TypeError, ValueError): + return None + if height <= 0 or thickness <= 0: + return None + base = ( + WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M + if has_foundation + else WALL_BLINDING_DEPTH_M * WALL_BLINDING_WIDTH_M + ) + return base + height * (thickness + WALL_TRENCH_CLEARANCE_M) + + +def wall_trench_width_m(average_thickness_m: float | None) -> float | None: + """벽 터파기 폭(m) — 평균두께 + 여유 0.2. 값이 없으면 `None`.""" + if average_thickness_m is None: + return None + try: + thickness = float(average_thickness_m) + except (TypeError, ValueError): + return None + return None if thickness <= 0 else thickness + WALL_TRENCH_CLEARANCE_M diff --git a/common_util/common_util_excavation.ts b/common_util/common_util_excavation.ts new file mode 100644 index 00000000..5452b7b5 --- /dev/null +++ b/common_util/common_util_excavation.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * common_util_excavation.ts + * 터파기 단면 — 관·벽 두 갈래. **근거의 급이 다르므로 갈라 둔다.** + * + * ⚠⚠ 파이썬 짝과 **한 벌**이다 — `common_util/common_util_excavation.py`. + * 거울 시험: `tmp/tests/test_excavation_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것. + * 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽 수가 같아야 한다. + * + * ① 관 — 법정 시방서에 표가 있다: `KCS 44 40 10 배수관(23.01) §3.2(4) 그림 3.2-1`. + * 「설계도서에 따로 표기되지 않았을 때에는 … 기준 폭에 따라야 한다」 + * ⚠ **관경 + 2b 로 계산하지 않는다** — 표가 관 두께·작업여유를 이미 담고 있다. + * ② 벽 — 법정 근거 없음. 실무 정본 식(`구조도(기슭막이).xls` 7탭): + * 터파기 = 기초 0.5 × 0.9 (기초버림이면 0.1 × 0.7) + H × (평균두께 + 0.2) + * ⚠ **비탈을 주지 않는다 — 정본이 수직으로 센다.** 축 이름은 「기초유 / 기초버림」이다. + * ========================================================================== */ + +/** 관경(㎜) → 터파기 폭(㎜). KCS 44 40 10 그림 3.2-1 표 그대로. */ +export const PIPE_TRENCH_WIDTH_MM: Record = { + 300: 700, + 350: 800, + 400: 900, + 450: 1000, + 500: 1100, + 600: 1200, + 700: 1400, + 800: 1600, + 900: 1700, + 1000: 1800, + 1100: 2000, + 1200: 2100, + 1300: 2200, + 1400: 2400, + 1500: 2500, +}; + +/** 한쪽 여유폭(m) — 같은 그림. 표에 없는 관경을 넣었을 때의 안내용. */ +export const PIPE_TRENCH_CLEARANCE_NORMAL_M = 0.3; +export const PIPE_TRENCH_CLEARANCE_SOFT_M = 0.5; + +/** 벽 터파기 — 실무 정본 식의 수. */ +export const WALL_TRENCH_CLEARANCE_M = 0.2; +export const WALL_FOUNDATION_DEPTH_M = 0.5; +export const WALL_FOUNDATION_WIDTH_M = 0.9; +export const WALL_BLINDING_DEPTH_M = 0.1; +export const WALL_BLINDING_WIDTH_M = 0.7; + +/** 근거의 급 — 화면 노티스가 이 문구를 그대로 쓴다. */ +export const BASIS_PIPE = "법정 표(KCS 44 40 10 그림 3.2-1) — 설계도서에 따로 없으면 이 값"; +export const BASIS_WALL = + "법정 근거 없음 · 실무 정본 식(구조도 기슭막이 xls) 기본값 — 확인 후 쓸 것"; + +/** 관경(㎜)에 대응하는 터파기 폭(m). 표에 없으면 `null`(지어내지 않는다). */ +export function pipeTrenchWidthM(diameterMm: number | null | undefined): number | null { + if (diameterMm === null || diameterMm === undefined) return null; + const key = Math.round(Number(diameterMm)); + if (!Number.isFinite(key)) return null; + const width = PIPE_TRENCH_WIDTH_MM[key]; + return width === undefined ? null : width / 1000; +} + +/** + * 벽 터파기 단면적(㎡/m). 높이·평균두께가 없으면 `null`. + * `hasFoundation=false` 면 기초분이 버림 몫(0.1 × 0.7)으로 줄어든다. + */ +export function wallTrenchAreaM2( + heightM: number | null | undefined, + averageThicknessM: number | null | undefined, + hasFoundation: boolean, +): number | null { + if (heightM === null || heightM === undefined) return null; + if (averageThicknessM === null || averageThicknessM === undefined) return null; + const height = Number(heightM); + const thickness = Number(averageThicknessM); + if (!Number.isFinite(height) || !Number.isFinite(thickness)) return null; + if (height <= 0 || thickness <= 0) return null; + const base = hasFoundation + ? WALL_FOUNDATION_DEPTH_M * WALL_FOUNDATION_WIDTH_M + : WALL_BLINDING_DEPTH_M * WALL_BLINDING_WIDTH_M; + return base + height * (thickness + WALL_TRENCH_CLEARANCE_M); +} + +/** 벽 터파기 폭(m) — 평균두께 + 여유 0.2. 값이 없으면 `null`. */ +export function wallTrenchWidthM(averageThicknessM: number | null | undefined): number | null { + if (averageThicknessM === null || averageThicknessM === undefined) return null; + const thickness = Number(averageThicknessM); + if (!Number.isFinite(thickness) || thickness <= 0) return null; + return thickness + WALL_TRENCH_CLEARANCE_M; +} diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 8ab40ad4..921d9d8e 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -109,6 +109,12 @@ def default_settings() -> dict[str, Any]: # ⇒ `ceil(연장÷500)` 을 확정 산식으로 쓰지 않고 **개소를 받는다.** # 비워 두면 물량을 안 낸다(0 으로 때우지 않음). "ancillary_counts": {}, + # 층따기 길이(깊이, m) — ⭐ 2026-09-09 사용자 확정 2차 ①. + # ⚠ **면적이 정본**이고 부피는 **면적 × 이 길이**로 낸다. 그러면 품셈 ㎥ 단가를 + # 그대로 쓸 수 있다(단위 불일치가 풀림). + # ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음). + # 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다. + "bench_cut_depth_m": None, "dataset_versions": {}, }, "estimation": { diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index a8b4156f..bb8f95fe 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -75,9 +75,10 @@ "group": "층따기", "work_item_code": "FP-09-18", "master_name": "층따기", - "basis_unit": "㎡", - "basis_source": "⭐ 2026-09-09 사용자 확정 ⑦ — 층따기 수량 단위는 **㎡**(실무 관행). 밑수는 **원지반 표면의 경사길이**(B06 `design.bench_cut_length_m`)를 평균단면적법으로 면적화한 값이다. ⚠ 품셈 9-18 [주]는 `Q1 = 3600×q×K×f×E/㎝ = ㎥/시간` 이라 **공식은 체적 기준**이다 — 단의 높이·폭이 설계도서 값이라 체적을 지어낼 수 없어 면적으로 간다. ⇒ **받는 쪽(B09)이 ㎡ 단가를 세워야 한다**(㎥ 단가를 그대로 곱하면 금액이 틀림).", - "unit_note": "앞서 2026-09-08 에는 이 어긋남을 「막힘」으로 두어 금액이 안 섰다(410만원 자리). 사용자 확정으로 ㎡ 가 정본이 되었으므로 막지 않는다." + "basis_unit": "㎥", + "basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 공식이 체적 기준이다. ⭐ 2026-09-09 사용자 확정 2차 ① 로 **면적(㎡)을 정본으로 두고 사용자가 넣은 길이를 곱해 ㎥ 를 낸다** — 그러면 품셈 ㎥ 단가를 그대로 쓸 수 있다(단위 불일치가 풀림).", + "mismatch_reason": "층따기 길이(깊이)가 아직 입력되지 않았습니다 — 산출 조건에서 넣으면 면적 × 길이로 물량이 섭니다(면적은 이미 섰습니다).", + "mismatch_kind": "input_missing" }, { "group": "면고르기",