Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
|
||||
export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1)
|
||||
import { PIPE_MATCH_M } from "./design_selection";
|
||||
|
||||
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
pipeDrawn,
|
||||
pipeExtraCount,
|
||||
pipeLengthM,
|
||||
pipeLengthText,
|
||||
} from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
hasLinks,
|
||||
@@ -339,7 +340,7 @@ export function createFacilityOptionsForm(
|
||||
auto: (_c, item) => {
|
||||
if (item.id !== "length") return "";
|
||||
const length = pipeLengthM(chainage);
|
||||
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length));
|
||||
return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length);
|
||||
},
|
||||
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
|
||||
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
|
||||
|
||||
@@ -147,7 +147,7 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number
|
||||
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
|
||||
* 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고
|
||||
* 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림.
|
||||
* ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */
|
||||
* ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */
|
||||
export function fordPipeFit(
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
|
||||
@@ -21,7 +21,8 @@ import type {
|
||||
PipeEnd,
|
||||
WallLayout,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendPipeInfoText, outlineBox } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { pipeLengthText } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
|
||||
import {
|
||||
appendChuteBand,
|
||||
@@ -305,8 +306,11 @@ export function appendCulvertOverlay(
|
||||
|
||||
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
|
||||
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
|
||||
const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`;
|
||||
// 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림)
|
||||
if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null);
|
||||
const tip =
|
||||
`관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` +
|
||||
pipeInfo +
|
||||
` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` +
|
||||
` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` +
|
||||
` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`;
|
||||
|
||||
@@ -13,6 +13,14 @@ import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
* 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */
|
||||
export const MIN_PIPE_COVER_M = 0.5;
|
||||
|
||||
/** 관 길이 정수(사사오입 · 0.5 올림 · 225-1) — 저장 값은 실제 길이(0.1 m) 그대로.
|
||||
* ⚠ 파이썬 짝 `M02_Table_Fill.pipe_lengths_from_designs`(집계 값 · 거울 시험 test_225_1). */
|
||||
export const pipeLengthWhole = (lengthM: number): number => Math.floor(lengthM + 0.5);
|
||||
|
||||
/** 관 길이 표시 「8 m (7.6)」(225-1 — 카드 글 · 좌측 패널). */
|
||||
export const pipeLengthText = (lengthM: number): string =>
|
||||
`${pipeLengthWhole(lengthM)} m (${lengthM.toFixed(1)})`;
|
||||
|
||||
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
|
||||
export const REVET_THICKNESS_M = 0.45;
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Culvert_Fit.ts
|
||||
* 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리.
|
||||
*
|
||||
* · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로
|
||||
* 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐).
|
||||
* · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽
|
||||
* 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는
|
||||
* 차는 표기 올림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
FILL_SLOPE_RATIO_MAX,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_HEIGHT_STEP_M,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
export interface OutletFitInput {
|
||||
/** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */
|
||||
shifted: boolean;
|
||||
edge: { offset_m: number; elevation_m: number };
|
||||
outward: number;
|
||||
axis: () => OffsetPoint;
|
||||
thickness: number;
|
||||
anchor: OffsetPoint;
|
||||
/** 계산용 높이(순수 − 근입). */
|
||||
height: number;
|
||||
/** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */
|
||||
lineInvertAt: (offset: number) => number;
|
||||
slopeAt: (
|
||||
offset: number,
|
||||
thickness: number,
|
||||
height: number,
|
||||
) => { ratio: number; lengthM: number };
|
||||
cutLength: () => number;
|
||||
/** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */
|
||||
rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean;
|
||||
}
|
||||
|
||||
/** 맞춤 뒤 벽 자리 · 높이. */
|
||||
export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } {
|
||||
const target = Math.ceil(c.cutLength() - 1e-6);
|
||||
let anchor = c.anchor;
|
||||
let height = c.height;
|
||||
const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio;
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const gap = target - c.cutLength();
|
||||
if (Math.abs(gap) < 0.005) break;
|
||||
const axis = c.axis();
|
||||
const moved = anchor.offset + axis.offset * gap;
|
||||
// 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저.
|
||||
if ((moved - c.edge.offset_m) * c.outward < 0) break;
|
||||
if (c.shifted) {
|
||||
// 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금)
|
||||
if (!Number.isFinite(ratio0)) break;
|
||||
const invert = anchor.elevation + axis.elevation * gap;
|
||||
const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m);
|
||||
const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M;
|
||||
const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M;
|
||||
if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break;
|
||||
const next = stepped - REVET_EMBED_DEPTH_M;
|
||||
if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break;
|
||||
anchor = { offset: moved, elevation: invert };
|
||||
height = next;
|
||||
if (!c.rebuild(anchor, height, target)) break;
|
||||
continue;
|
||||
}
|
||||
const movedSlope = c.slopeAt(moved, c.thickness, height);
|
||||
// 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23).
|
||||
if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break;
|
||||
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
|
||||
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
|
||||
if (
|
||||
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 &&
|
||||
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
|
||||
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
|
||||
) {
|
||||
anchor = { offset: moved, elevation: c.lineInvertAt(moved) };
|
||||
if (!c.rebuild(anchor, height, target)) break;
|
||||
continue;
|
||||
}
|
||||
// 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다).
|
||||
break;
|
||||
}
|
||||
return { anchor, height };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
MIN_PIPE_COVER_M,
|
||||
pipeLengthWhole,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
@@ -57,7 +58,6 @@ import {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut";
|
||||
import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit";
|
||||
import {
|
||||
buildCulvertTiers,
|
||||
drawnFillPoints,
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import {
|
||||
cutLength,
|
||||
outletSlopeFactory,
|
||||
fillSlopeOf,
|
||||
groundInterpolator,
|
||||
designInterpolator,
|
||||
@@ -116,7 +115,7 @@ export function computeCulvertLayout(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null;
|
||||
return p ? { shape: p.basin?.shape ?? null, lengthM: pipeLengthWhole(p.pipe.lengthM) } : null;
|
||||
};
|
||||
const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
|
||||
const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin);
|
||||
@@ -284,13 +283,9 @@ export function computeCulvertLayout(
|
||||
const rise0 = outletAnchor.elevation - inlet.elevation;
|
||||
const length0 = Math.hypot(run0, rise0);
|
||||
if (!(length0 > 0.5)) return null;
|
||||
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정).
|
||||
let lengthM = Math.ceil(length0 - 1e-6);
|
||||
const scale = lengthM / length0;
|
||||
const outlet: OffsetPoint = {
|
||||
offset: inlet.offset + run0 * scale,
|
||||
elevation: inlet.elevation + rise0 * scale,
|
||||
};
|
||||
// 관 길이 = 그려진 실제 길이(0.1 m · 225-1 — 정수 올림 연장 없음 · 표시 · 집계만 반올림).
|
||||
let lengthM = Math.round(length0 * 10) / 10;
|
||||
const outlet: OffsetPoint = { ...outletAnchor };
|
||||
const span = Math.abs(outletAnchor.offset - inlet.offset);
|
||||
let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0;
|
||||
|
||||
@@ -418,8 +413,7 @@ export function computeCulvertLayout(
|
||||
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
|
||||
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
|
||||
let outletWallAnchor = outletAnchor;
|
||||
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이
|
||||
// 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다.
|
||||
// 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5).
|
||||
const outletZeroAnchor = slopeZeroAnchor(
|
||||
outletInfo.edge,
|
||||
outletInfo.outward,
|
||||
@@ -427,10 +421,6 @@ export function computeCulvertLayout(
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
|
||||
const outletLineInvertAt = (offset: number): number =>
|
||||
designAt
|
||||
? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN
|
||||
: invertAt(offset);
|
||||
if (designAt) {
|
||||
// 4축 배치(유입과 동일 풀이).
|
||||
const placed = placePipeWall({
|
||||
@@ -454,7 +444,7 @@ export function computeCulvertLayout(
|
||||
};
|
||||
outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert };
|
||||
}
|
||||
let outletWall = buildWall(
|
||||
const outletWall = buildWall(
|
||||
culvert.outlet,
|
||||
outletWallAnchor,
|
||||
outletInfo.outward,
|
||||
@@ -467,91 +457,28 @@ export function computeCulvertLayout(
|
||||
);
|
||||
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
|
||||
const pipeStart = basinPipeEnd ?? inlet;
|
||||
// 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은
|
||||
// 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로.
|
||||
// 관 끝 = 유출 벽 전면 기준선 교차점 — 벽은 관 길이와 무관하게 0.1 m 로 움직이고 길이는
|
||||
// 벽 자리에서 셈(225-1 · 옛 정수 올림 연장 · 길이 맞춤 이동 없앰).
|
||||
if (outletWall) {
|
||||
const face = outletPipeEnd(outletWall);
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW);
|
||||
if (lenW > 0.5) {
|
||||
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
|
||||
outlet.offset = pipeStart.offset + runW * scaleW;
|
||||
outlet.elevation = pipeStart.elevation + riseW * scaleW;
|
||||
lengthM = Math.ceil(lenW - 1e-6);
|
||||
if (Math.hypot(face.offset - pipeStart.offset, face.elevation - pipeStart.elevation) > 0.5) {
|
||||
outlet.offset = face.offset;
|
||||
outlet.elevation = face.elevation;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질
|
||||
// 때마다 다시 유도한다(2026-08-20 사용자 ③).
|
||||
const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces);
|
||||
const deriveAxis = pipeAxis.derive;
|
||||
const endCorners = pipeAxis.corners;
|
||||
let pipeCorners = {
|
||||
const pipeCorners = {
|
||||
inlet: endCorners("inlet", pipeStart),
|
||||
outlet: endCorners("outlet", outlet),
|
||||
};
|
||||
|
||||
// ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌
|
||||
// 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23).
|
||||
const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음
|
||||
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
|
||||
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
|
||||
const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
|
||||
if (outletWall && !outletPinned) {
|
||||
const extend =
|
||||
section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`];
|
||||
fitOutletPipeLength({
|
||||
shifted: (extend ?? 0) > 1e-9,
|
||||
edge: outletInfo.edge,
|
||||
outward: outletInfo.outward,
|
||||
axis: () => pipeAxis.axis,
|
||||
thickness: outletThickness,
|
||||
anchor: outletWallAnchor,
|
||||
height: outletWallSpec.height,
|
||||
lineInvertAt: outletLineInvertAt,
|
||||
slopeAt: (offset, thickness, height) =>
|
||||
outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness),
|
||||
cutLength: () => cutLength(pipeCorners),
|
||||
rebuild: (anchor, height, target) => {
|
||||
outletWallAnchor = anchor;
|
||||
walls.pop();
|
||||
outletWall = buildWall(
|
||||
culvert.outlet,
|
||||
anchor,
|
||||
outletInfo.outward,
|
||||
null,
|
||||
outletThickness,
|
||||
{
|
||||
height,
|
||||
baseElevation: anchor.elevation,
|
||||
floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)),
|
||||
},
|
||||
outletWallSpec.material,
|
||||
outletWallSpec.form,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
if (!outletWall) return false;
|
||||
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
|
||||
const face = outletPipeEnd(outletWall);
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW) || 1;
|
||||
outlet.offset = pipeStart.offset + (runW / lenW) * target;
|
||||
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
|
||||
deriveAxis();
|
||||
pipeCorners = {
|
||||
inlet: endCorners("inlet", pipeStart),
|
||||
outlet: endCorners("outlet", outlet),
|
||||
};
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (outletWall) {
|
||||
// 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).
|
||||
// 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정).
|
||||
lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6));
|
||||
// 관 길이 = **실제 그려진 관** 0.1 m(225-1 — 표시 · 집계 반올림은 `pipeLengthWhole`).
|
||||
lengthM = Math.max(0.1, Math.round(cutLength(pipeCorners) * 10) / 10);
|
||||
slopePct =
|
||||
Math.abs(outlet.offset - pipeStart.offset) > 1e-9
|
||||
? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) *
|
||||
|
||||
@@ -50,6 +50,31 @@ export function outlineBox(points: OffsetPoint[]): OffsetPoint[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 정보 글(세월교 · 관 자리) — 노폭 · 노견 라벨(`.b06-chart__carriageway-label`) 바로 위 줄(225-1).
|
||||
* 같은 그림에 그 라벨이 없으면(B07 덧그림) `fallback` 자리 · fallback 도 없으면 안 그림.
|
||||
*/
|
||||
export function appendPipeInfoText(
|
||||
layer: SVGElement,
|
||||
text: string,
|
||||
fallback: [number, number] | null,
|
||||
): void {
|
||||
const svg: Element = layer.ownerSVGElement ?? layer; // 맨 위 svg 는 ownerSVGElement = null
|
||||
const road = svg.querySelector?.(".b06-chart__carriageway-label");
|
||||
if (!road && !fallback) return;
|
||||
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
const stacked = svg.querySelectorAll?.(".b06-chart__pipe-info").length ?? 0;
|
||||
const [x, y] = road
|
||||
? [Number(road.getAttribute("x")), Number(road.getAttribute("y")) - 13 * (stacked + 1)]
|
||||
: fallback!;
|
||||
label.setAttribute("x", String(x));
|
||||
label.setAttribute("y", String(y));
|
||||
label.setAttribute("text-anchor", "middle");
|
||||
label.setAttribute("class", "b06-chart__culvert-label b06-chart__pipe-info");
|
||||
label.textContent = text;
|
||||
(road?.parentNode ?? layer).appendChild(label);
|
||||
}
|
||||
|
||||
/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */
|
||||
export interface CulvertDesignTrim {
|
||||
minOffset: number;
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
appendPipeInfoText,
|
||||
outlineBox,
|
||||
type OffsetPoint,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
export {
|
||||
@@ -196,14 +200,8 @@ export function appendFordOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
const [labelX, labelY] = toXY(layout.label.at);
|
||||
label.setAttribute("x", String(labelX));
|
||||
label.setAttribute("y", String(labelY));
|
||||
label.setAttribute("text-anchor", "middle");
|
||||
label.setAttribute("class", "b06-chart__culvert-label");
|
||||
label.textContent = layout.label.text;
|
||||
layer.append(label);
|
||||
// 세월교 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1) · 그 라벨이 없으면 옛 자리.
|
||||
appendPipeInfoText(layer, layout.label.text, toXY(layout.label.at));
|
||||
|
||||
return (key: FordPick | null): void => {
|
||||
for (const [role, shapes] of walls) {
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface StructurePanelDeps {
|
||||
bodyGuard?: () => boolean;
|
||||
/** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */
|
||||
canNudge: (key: RevetKey) => boolean;
|
||||
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
|
||||
/** 좌우·상하 걸음이 0.1m인가(관보호공 · 독립 · 추가 기슭막이). 세월교 흙막이는 false = 1m. */
|
||||
fineMove: (key: RevetKey) => boolean;
|
||||
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
|
||||
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
|
||||
@@ -102,13 +102,13 @@ export interface StructurePanelHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정).
|
||||
* 세월교 흙막이 좌우·상하 한 걸음(m) — 안이 관이라 **1m**(80).
|
||||
* 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어).
|
||||
*/
|
||||
const STEP_M = 1.0;
|
||||
/**
|
||||
* 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자).
|
||||
* 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다.
|
||||
* 관보호공(배관 기준벽) · 독립 · 추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**
|
||||
* (관 길이와 무관 · 225-1).
|
||||
*/
|
||||
const OWN_STEP_M = 0.1;
|
||||
const HEIGHT_STEP_M = 0.1;
|
||||
|
||||
@@ -92,9 +92,13 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
return {
|
||||
scrollKey: ctx.scrollKey,
|
||||
adjustFor: ctx.adjustOf,
|
||||
// 배관 기준벽 d 빈 칸은 지금 자리로 묶음 — x 만 넣으면 옛 기본 d(경사길이 4m · 79b)로 튐(225-1).
|
||||
nudge: (key, screenDeltaM) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key),
|
||||
...(key === "inlet" || key === "outlet"
|
||||
? { d: ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? null }
|
||||
: {}),
|
||||
}),
|
||||
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
|
||||
// 기준 절대값이라 시작값이 필요하다).
|
||||
@@ -148,12 +152,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()),
|
||||
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
|
||||
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
|
||||
// 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽·
|
||||
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
|
||||
// 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2).
|
||||
// 걸음 분류: 관보호공(배관 기준벽)도 관 길이와 무관하게 0.1m(225-1) · 세월교 흙막이만
|
||||
// 안이 관이라 1m(80) · 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2).
|
||||
fineMove: (key) =>
|
||||
(key !== "inlet" && key !== "outlet") ||
|
||||
(ctx.hiddenPipe() && !ctx.fordGuard?.()) ||
|
||||
!(ctx.hiddenPipe() && ctx.fordGuard?.()) ||
|
||||
(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false),
|
||||
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
|
||||
extraState: () => ctx.extraState(),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지)
|
||||
· 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이
|
||||
돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3)
|
||||
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로)
|
||||
관 공 › Φ{관경}m/m › 관매설 m B06 관 길이 사사오입 정수(225-1)
|
||||
› 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만
|
||||
관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2)
|
||||
› 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`)
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫.
|
||||
자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 ·
|
||||
자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 별도계상만 금액에 닿음) — 사용자 자재 = 줄 덧칸 ·
|
||||
로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림) ·
|
||||
빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1).
|
||||
별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` ·
|
||||
짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음.
|
||||
% 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 ·
|
||||
단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수.
|
||||
"""
|
||||
@@ -224,6 +226,35 @@ def mark_of(one: Any, key: str) -> str:
|
||||
return marks(one).get(key) or MARK_DEFAULT[key]
|
||||
|
||||
|
||||
def apart(one: Any) -> bool:
|
||||
"""별도계상(222-1) — 세 칸 「별도계상」(빈 칸 = 기본) 자재는 일위대가 셈에서 빠짐(본 프로그램에서
|
||||
따로 계상 · 자재목록은 상세 보기라 따로 안 더함)."""
|
||||
return mark_of(one, "별도계상") == "별도계상"
|
||||
|
||||
|
||||
def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]:
|
||||
"""로직 줄 답(`run_line`)에서 별도계상 로직 자재 빼기(222-1) — 그 재료 줄 금액을 기준에서 빼고
|
||||
`줄`(일위대가 상세)에서 지움 · `재료`(자재목록 · 세 칸 고침)는 그대로.
|
||||
ponytail: 「재료비의 %」 덧줄은 엔진 값 그대로(뺀 재료 몫 안 고침) — 그런 로직이 별도계상을 쓰면 엔진에 넘김."""
|
||||
shown = shown if isinstance(shown, dict) else {}
|
||||
lines = got.get("줄") or []
|
||||
out = {
|
||||
n
|
||||
for m in got.get("재료") or []
|
||||
if (n := m["차례"]) < len(lines) and apart(shown.get(str(n)))
|
||||
}
|
||||
if not out or not got.get("기준"):
|
||||
return got
|
||||
base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()}
|
||||
for n in out:
|
||||
for k, v in (lines[n].get("비목") or {}).items():
|
||||
cut = _number(v) or Decimal(0)
|
||||
base[k] = base.get(k, Decimal(0)) - cut
|
||||
base["계"] -= cut
|
||||
kept = [x for i, x in enumerate(lines) if i not in out]
|
||||
return {**got, "기준": _json(base), "줄": kept}
|
||||
|
||||
|
||||
def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None:
|
||||
"""로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 ·
|
||||
빈 것 = None."""
|
||||
@@ -386,6 +417,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||||
hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||||
aside = {one.get("코드") for one in doc.get("자재") or [] if apart(one)} # 222-1 합에서 뺌
|
||||
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
|
||||
lines, mats, bases, reflects = [], [], [], []
|
||||
for line in result.get("줄") or []:
|
||||
@@ -399,9 +431,11 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
|
||||
price = _number(one.get("기준단가"))
|
||||
cost = price * qty if price is not None and qty is not None else None
|
||||
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
|
||||
if one.get("코드") in aside:
|
||||
continue
|
||||
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
|
||||
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
|
||||
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
|
||||
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
|
||||
|
||||
|
||||
@@ -468,7 +502,7 @@ def calc(
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
lines.append({**line, **got})
|
||||
lines.append({**line, **drop_apart(got, one.get("자재표시"))})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
@@ -16,6 +17,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
|
||||
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
|
||||
_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직
|
||||
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])") # 품셈 절 번호(「12-1-3」)
|
||||
|
||||
|
||||
def _num(value: Any) -> Any:
|
||||
@@ -63,6 +65,26 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def sources(logic: dict) -> Any:
|
||||
"""원문 출처(222-1) — 로직 `출처` + 호표 · 입력 · 중간 · 덧줄 `출처` 가운데 같은 책(번호 앞 글이
|
||||
같음)의 절 번호를 덧붙임(「산림품셈 12-1-2 · 12-1-3」 · 원문 패널이 절마다 찾음 · M01
|
||||
`make.source`) · 다른 책 절은 안 붙임 · 번호 없는 출처는 그대로."""
|
||||
main = str(logic.get("출처") or "")
|
||||
head = _NUMBER.search(main)
|
||||
if not head:
|
||||
return logic.get("출처")
|
||||
book, seen = main[: head.start()], set(_NUMBER.findall(main))
|
||||
extra = []
|
||||
for part in ("호표", "입력", "중간", "덧줄"):
|
||||
for row in logic.get(part) or []:
|
||||
src = str(row.get("출처") or "") if isinstance(row, dict) else ""
|
||||
hit = _NUMBER.search(src)
|
||||
if hit and src[: hit.start()] == book and hit.group() not in seen:
|
||||
seen.add(hit.group())
|
||||
extra.append(hit.group())
|
||||
return " · ".join([main, *extra])
|
||||
|
||||
|
||||
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
|
||||
whole = m01.loaded()[1]
|
||||
@@ -84,7 +106,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
|
||||
"로직": logic.get("키"),
|
||||
"이름": logic.get("이름"),
|
||||
"결과단위": logic.get("결과단위"),
|
||||
"출처": logic.get("출처"),
|
||||
"출처": sources(logic),
|
||||
"켜쌓기": logic.get("키") in layers,
|
||||
"입력": specs,
|
||||
}
|
||||
|
||||
@@ -423,6 +423,7 @@ class Expand:
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
@@ -463,7 +464,7 @@ class Expand:
|
||||
rows.append({k: v * qty for k, v in sub["기준"].items()})
|
||||
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
|
||||
qty = labor._number((mat.get("수량") or {}).get("손"))
|
||||
if qty is None:
|
||||
if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
|
||||
continue
|
||||
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
|
||||
price = labor._number(got["기준단가"])
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||||
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
@@ -79,7 +80,7 @@ export interface LaborCalc {
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
|
||||
}
|
||||
|
||||
function num(raw: unknown): number | null {
|
||||
@@ -130,6 +131,10 @@ export function settle(
|
||||
}
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
|
||||
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
|
||||
// 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`)
|
||||
const aside = new Set(
|
||||
(doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드),
|
||||
);
|
||||
const base = zero();
|
||||
const done = zero();
|
||||
const add = (sum: Money, m: Money | null): void => {
|
||||
@@ -146,8 +151,8 @@ export function settle(
|
||||
const [qty, source] = pick(mhands.get(one.코드), one.알약, have);
|
||||
const price = num(one.기준단가);
|
||||
const cost = price !== null && qty !== null ? price * qty : null;
|
||||
if (price !== null) add(base, { ...zero(), 재료비: price, 계: price });
|
||||
if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost });
|
||||
if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price });
|
||||
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
|
||||
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
|
||||
});
|
||||
return { 줄, 자재, 기준합: base, 반영합: done };
|
||||
|
||||
@@ -383,14 +383,16 @@ export function mountLaborDetail(
|
||||
else delete cur.담은로직[i].승격;
|
||||
opt.onChanged(dirty());
|
||||
}
|
||||
// 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음
|
||||
// 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준)
|
||||
function markMat(i: number, key: MatMarkKey, v: string): void {
|
||||
setMark(cur.자재[i], key, v);
|
||||
opt.onChanged(dirty());
|
||||
if (key === "별도계상") changed(true);
|
||||
else opt.onChanged(dirty());
|
||||
}
|
||||
function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void {
|
||||
setLogicMark(cur.담은로직[i], at, key, v);
|
||||
opt.onChanged(dirty());
|
||||
if (key === "별도계상") changed();
|
||||
else opt.onChanged(dirty());
|
||||
}
|
||||
function removeMat(i: number): void {
|
||||
cur.자재.splice(i, 1);
|
||||
|
||||
@@ -50,7 +50,9 @@ _REF = re.compile(r"\[([^\]$@][^\]@]*)\]")
|
||||
|
||||
|
||||
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
|
||||
"""B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음."""
|
||||
"""B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.
|
||||
|
||||
관 연장 = 사사오입 정수(0.5 올림 · 225-1 · ⚠ TS 짝 `pipeLengthWhole` · 거울 시험 test_225_1)."""
|
||||
found: dict[float, float] = {}
|
||||
for row in designs or []:
|
||||
design = row.get("design") if isinstance(row, dict) else None
|
||||
@@ -58,7 +60,7 @@ def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float,
|
||||
continue
|
||||
length = _number(design.get("pipe_length_m"))
|
||||
if length and length > 0:
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = length
|
||||
found[round(float(row.get("chainage_m") or 0.0), 3)] = int(length + 0.5)
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`.
|
||||
* 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료).
|
||||
*/
|
||||
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 noop = new Proxy({}, { get: () => () => "" });
|
||||
|
||||
/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.ownerSVGElement = null;
|
||||
this.textContent = "";
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attrs[k] ?? null;
|
||||
}
|
||||
appendChild(c) {
|
||||
c.parentNode = this;
|
||||
c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement;
|
||||
this.children.push(c);
|
||||
return c;
|
||||
}
|
||||
append(...cs) {
|
||||
cs.forEach((c) => this.appendChild(c));
|
||||
}
|
||||
addEventListener() {}
|
||||
all() {
|
||||
return this.children.flatMap((c) => [c, ...c.all()]);
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1)));
|
||||
}
|
||||
querySelector(sel) {
|
||||
return this.querySelectorAll(sel)[0] ?? null;
|
||||
}
|
||||
}
|
||||
globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const resolve = (base) =>
|
||||
fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`;
|
||||
const req = (r) => {
|
||||
if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop;
|
||||
if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), r)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`));
|
||||
const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom");
|
||||
const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve");
|
||||
const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const");
|
||||
const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types");
|
||||
const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure");
|
||||
|
||||
// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback
|
||||
const svg = new El("svg");
|
||||
const road = new El("text");
|
||||
road.setAttribute("class", "b06-chart__carriageway-label");
|
||||
road.setAttribute("x", 200);
|
||||
road.setAttribute("y", 180);
|
||||
svg.appendChild(road);
|
||||
const plot = new El("g");
|
||||
svg.appendChild(plot);
|
||||
appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null);
|
||||
appendPipeInfoText(plot, "세월교 L8m", [10, 10]);
|
||||
const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({
|
||||
x: Number(e.getAttribute("x")),
|
||||
y: Number(e.getAttribute("y")),
|
||||
text: e.textContent,
|
||||
inSvg: e.parentNode === svg,
|
||||
}));
|
||||
const bare = new El("svg");
|
||||
appendPipeInfoText(bare, "관", null);
|
||||
appendPipeInfoText(bare, "세월교", [10, 20]);
|
||||
const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({
|
||||
x: Number(e.getAttribute("x")),
|
||||
y: Number(e.getAttribute("y")),
|
||||
text: e.textContent,
|
||||
}));
|
||||
|
||||
// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜
|
||||
const fine = (key, hiddenPipe, fordGuard) =>
|
||||
structurePanelDeps({
|
||||
hiddenPipe: () => hiddenPipe,
|
||||
fordGuard: () => fordGuard,
|
||||
isLinked: false,
|
||||
}).fineMove(key);
|
||||
const steps = {
|
||||
pipeOutlet: fine("outlet", false, false),
|
||||
pipeInlet: fine("inlet", false, false),
|
||||
fordGuard: fine("outlet", true, true),
|
||||
hiddenOwn: fine("outlet", true, false),
|
||||
};
|
||||
const sections = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
|
||||
).cross_sections;
|
||||
const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게)
|
||||
const patches = [];
|
||||
const nudgeDeps = structurePanelDeps({
|
||||
adjustOf: () => ({ x: 0, d: null, h: null, m: null }),
|
||||
appliedD: () => new Map([["outlet", 0.4]]),
|
||||
outwardOf: () => 1,
|
||||
adjustChainage: () => 0,
|
||||
revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) },
|
||||
});
|
||||
nudgeDeps.nudge("outlet", 0.1);
|
||||
nudgeDeps.nudge("extra0", 0.1);
|
||||
const base = computeCulvertLayout(section, section.samples);
|
||||
const d0 = base.revetShift.outlet.d;
|
||||
const moved = [0, 0.1, 0.2, 0.3].map((x) => {
|
||||
const layout = computeCulvertLayout(section, section.samples, {
|
||||
outlet: { x, d: x ? d0 : null, h: null, m: null },
|
||||
});
|
||||
return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) };
|
||||
});
|
||||
|
||||
// ③ · ④ 표시 · 반올림 거울
|
||||
const values = JSON.parse(process.argv[2] ?? "[]");
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
infos,
|
||||
bareInfos,
|
||||
steps,
|
||||
patches,
|
||||
moved,
|
||||
text: values.map((v) => pipeLengthText(v)),
|
||||
whole: values.map((v) => pipeLengthWhole(v)),
|
||||
}),
|
||||
);
|
||||
@@ -31,9 +31,9 @@ def out() -> dict:
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
|
||||
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
|
||||
"""배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수."""
|
||||
case = out["wall"][side]
|
||||
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
|
||||
assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4}
|
||||
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
|
||||
assert "▼ 2.0m" in case["snap"]["text"]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m).
|
||||
|
||||
설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처
|
||||
배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6).
|
||||
배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict:
|
||||
|
||||
|
||||
def test_안_넓힌_측점은_종전_그대로(out: dict) -> None:
|
||||
"""225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8)."""
|
||||
base = out["base"]
|
||||
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6)
|
||||
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8)
|
||||
assert base["cut"] == pytest.approx(5.772)
|
||||
|
||||
|
||||
@@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte
|
||||
assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend
|
||||
assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로
|
||||
assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금
|
||||
assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림
|
||||
assert float(case["lengthM"]).is_integer()
|
||||
assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음)
|
||||
|
||||
|
||||
def test_높이가_실제로_바뀜(out: dict) -> None:
|
||||
"""대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼)."""
|
||||
assert out["shifted2"]["pureHeight"] == pytest.approx(1.8)
|
||||
"""225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로."""
|
||||
assert out["shifted2"]["pureHeight"] == pytest.approx(2.0)
|
||||
|
||||
@@ -79,7 +79,8 @@ def _world_combo(world): # noqa: F811
|
||||
{"로직": STONE, "수량": {"손": 1}},
|
||||
{"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}},
|
||||
]
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}]
|
||||
# MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐)
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
|
||||
"이름"
|
||||
|
||||
@@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None:
|
||||
"""접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만."""
|
||||
assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3)
|
||||
assert out["stored"]["fill"] == 0
|
||||
assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3)
|
||||
# 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929)
|
||||
assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3)
|
||||
|
||||
|
||||
def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""M02 구조물조합 일위대가 기계비빔타설(PLAN 222-1) — 단위 까닭 · 별도계상 빼기 · 원문 여럿.
|
||||
|
||||
① 단위 — GF000156 모래 · 자갈 = 원문 kg(QF000353) ÷ 단위중량(㎏/㎥) · 칸에 t/㎥ 값(2 · 2.1)을 넣으면
|
||||
838 ÷ 2 = 419 ㎥ 처럼 1000 배 · 0 = 표 위끝(1,700 · 1,800 ㎏/㎥) → 0.49 · 0.57 ㎥ · 칸을 비우면
|
||||
자동값(master_auto 「나누는 자리 = 1」)이 1 이라 838 ㎥(자료 · 기본값 몫 — 223 보고).
|
||||
② 별도계상 — 로직 자재(`자재표시` · 빈 칸 = 기본 별도계상) = 기준에서 빠지고 `줄` 에서 지움 · `재료` 는
|
||||
남음 · 사용자 자재 = 합에서 뺌(짝 TS `settle`) · 사전조합 안 줄 · 사본 셈 같은 길.
|
||||
③ 원문 — 로직 출처 + 호표 · 입력 출처 가운데 같은 책 절 번호(「산림품셈 12-1-2 · 12-1-3」).
|
||||
"""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Make as make
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as inputs
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from resources.tester.test_m02_labor_calc_mirror import _same, _ts
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
|
||||
MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈
|
||||
GIVEN = {"재료배합": "(A)", "골재최대치수": 40}
|
||||
MAT = "MT000008"
|
||||
DIRECT = {"별도계상": "직접계산"}
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _rows(got):
|
||||
return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]}
|
||||
|
||||
|
||||
# ── ① 단위 ──────────────────────────────────────────────────────────
|
||||
def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝():
|
||||
wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1})
|
||||
rows = _rows(wrong)
|
||||
assert rows["시멘트"]["단위"] == "kg" and rows["시멘트"]["수량"] == pytest.approx(335)
|
||||
assert rows["모래(건조상태)"]["수량"] == pytest.approx(838 / 2) # 419 ㎥
|
||||
assert rows["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 2.1) # 491.4 ㎥
|
||||
zero = {**GIVEN, "모래단위중량": 0, "자갈단위중량": 0}
|
||||
right = _rows(labor.run_line(_whole(), MIX, zero))
|
||||
assert right["모래(건조상태)"]["수량"] == pytest.approx(838 / 1700)
|
||||
assert right["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 1800)
|
||||
|
||||
|
||||
# ── ② 별도계상 ───────────────────────────────────────────────────────
|
||||
def _calc(shown=None, mats=()):
|
||||
line = {"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}}
|
||||
if shown:
|
||||
line["자재표시"] = shown
|
||||
doc = labor.clean({"열": "rv", "담은로직": [line], "자재": list(mats)})
|
||||
return labor.calc(doc, {})
|
||||
|
||||
|
||||
def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음():
|
||||
raw = labor.run_line(_whole(), MIX, GIVEN)
|
||||
cut = sum(Decimal(str(m["금액"])) for m in raw["재료"])
|
||||
one = _calc()["줄"][0]
|
||||
names = {x["이름"] for x in one["줄"]}
|
||||
assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"}
|
||||
assert {"콘크리트공", "보통인부"} <= names
|
||||
assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음
|
||||
want = Decimal(str(raw["기준"]["재료비"])) - cut
|
||||
assert one["기준"]["재료비"] == pytest.approx(float(want))
|
||||
assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut))
|
||||
assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"]))
|
||||
|
||||
|
||||
def test_직접계산은_그대로_섞이면_그_자재만_빠짐():
|
||||
raw = labor.run_line(_whole(), MIX, GIVEN)
|
||||
full = _calc({str(n): DIRECT for n in (3, 4, 5)})["줄"][0]
|
||||
assert full["기준"]["계"] == pytest.approx(float(raw["기준"]["계"]))
|
||||
assert len(full["줄"]) == len(raw["줄"])
|
||||
part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈
|
||||
names = {x["이름"] for x in part["줄"]}
|
||||
assert "시멘트" in names and "모래(건조상태)" not in names
|
||||
cement = next(m for m in raw["재료"] if m["이름"] == "시멘트")
|
||||
others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"]))
|
||||
assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others))
|
||||
|
||||
|
||||
def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임():
|
||||
aside = _calc(None, [{"코드": MAT, "수량": {"손": 2}}])
|
||||
direct = _calc(None, [{"코드": MAT, "수량": {"손": 2}, **DIRECT}])
|
||||
price = direct["자재"][0]["기준단가"]
|
||||
assert aside["자재"][0]["반영"] == pytest.approx(price * 2) # 목록엔 금액 보임
|
||||
assert direct["반영합"]["계"] - aside["반영합"]["계"] == pytest.approx(price * 2)
|
||||
assert direct["기준합"]["재료비"] - aside["기준합"]["재료비"] == pytest.approx(price)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mark", [None, "직접계산", "별도계상", "옛값"])
|
||||
def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark):
|
||||
one = {"코드": MAT, "수량": {"손": 3}, **({"별도계상": mark} if mark else {})}
|
||||
doc = labor.clean({"열": "rv", "담은로직": [{"로직": MIX, "입력값": GIVEN}], "자재": [one]})
|
||||
doc["자재"][0].update({"별도계상": mark} if mark else {}) # 옛 값도 그대로 넘겨 봄
|
||||
answer = labor.calc(doc, {})
|
||||
values = {"기계비빔타설|㎥": 2}
|
||||
_same(labor.settle(answer, doc, values), _ts(answer, doc, values))
|
||||
|
||||
|
||||
def test_사전조합_안_줄도_별도계상_뺌():
|
||||
raw = labor.run_line(_whole(), MIX, GIVEN)
|
||||
cut = sum(Decimal(str(m["금액"])) for m in raw["재료"])
|
||||
doc = {
|
||||
"이름": "비빔 조합",
|
||||
"단위": "㎥",
|
||||
"담은로직": [{"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}}],
|
||||
"자재": [
|
||||
{"코드": MAT, "수량": {"손": 2}}, # 기본 별도계상 = 안 셈 · 안 줄에도 없음
|
||||
],
|
||||
}
|
||||
ref = {"층": "system", "주인": None, "키": "SC999999"}
|
||||
got = refs.Expand(_whole(), lambda r: doc)(ref)
|
||||
assert [x.get("자재") for x in got["안"]] == [None]
|
||||
pill = got["안"][0]
|
||||
times = Decimal(1) # 기계비빔타설 알약 배 1
|
||||
assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut)
|
||||
assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut)
|
||||
|
||||
|
||||
def test_화면_별도계상_바꾸면_다시_셈():
|
||||
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
|
||||
assert 'if (key === "별도계상") changed(true);' in detail # 사용자 자재 = 짝 셈
|
||||
assert 'if (key === "별도계상") changed();' in detail # 로직 자재 = 서버 기준
|
||||
|
||||
|
||||
# ── ③ 원문 여럿 ──────────────────────────────────────────────────────
|
||||
def test_원문_출처는_같은_책_절_모두():
|
||||
src = inputs.sources(_whole().logic(MIX))
|
||||
assert src == "산림품셈 12-1-2 · 12-1-3"
|
||||
assert inputs.logic_inputs([MIX])["로직"][0]["출처"] == src
|
||||
assert [f["number"] for f in make.source(src)["found"]] == ["12-1-2", "12-1-3"]
|
||||
|
||||
|
||||
def test_원문_출처_번호_없거나_더할_것_없으면_그대로():
|
||||
assert inputs.sources({"출처": "자체"}) == "자체"
|
||||
assert inputs.sources({"출처": None}) is None
|
||||
one = {
|
||||
"출처": "산림품셈 3-1",
|
||||
"호표": [{"출처": "건설품셈 공통 1-2-4 3"}, {"출처": "산림품셈 3-1"}],
|
||||
}
|
||||
assert inputs.sources(one) == "산림품셈 3-1" # 다른 책 · 같은 절은 안 붙임
|
||||
@@ -0,0 +1,117 @@
|
||||
"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수.
|
||||
|
||||
화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`).
|
||||
짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔
|
||||
파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림.
|
||||
"""
|
||||
|
||||
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))
|
||||
|
||||
from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402
|
||||
|
||||
HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs"
|
||||
VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER), json.dumps(VALUES)],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_관_정보_글은_노폭_라벨_바로_위(out: dict) -> None:
|
||||
"""노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x."""
|
||||
pipe, ford = out["infos"]
|
||||
assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True)
|
||||
assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)"
|
||||
assert (ford["x"], ford["y"]) == (200, 154)
|
||||
|
||||
|
||||
def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None:
|
||||
"""B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리."""
|
||||
assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}]
|
||||
|
||||
|
||||
def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None:
|
||||
assert out["steps"] == {
|
||||
"pipeOutlet": True,
|
||||
"pipeInlet": True,
|
||||
"fordGuard": False,
|
||||
"hiddenOwn": True,
|
||||
}
|
||||
|
||||
|
||||
def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None:
|
||||
"""d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음."""
|
||||
assert out["patches"] == [
|
||||
{"key": "outlet", "x": 0.1, "d": 0.4},
|
||||
{"key": "extra0", "x": 0.1},
|
||||
]
|
||||
|
||||
|
||||
def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None:
|
||||
"""유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음."""
|
||||
lengths = [case["lengthM"] for case in out["moved"]]
|
||||
assert lengths == [7.9, 8.0, 8.1, 8.2]
|
||||
for case in out["moved"]:
|
||||
assert case["lengthM"] == round(case["cut"], 1)
|
||||
|
||||
|
||||
def test_표시는_정수_괄호_소수_첫째(out: dict) -> None:
|
||||
assert out["text"][VALUES.index(7.6)] == "8 m (7.6)"
|
||||
assert out["text"][VALUES.index(7.4)] == "7 m (7.4)"
|
||||
assert out["text"][VALUES.index(7.5)] == "8 m (7.5)"
|
||||
|
||||
|
||||
def test_B08_집계_값은_사사오입_정수() -> None:
|
||||
designs = [
|
||||
{"chainage_m": 10, "design": {"pipe_length_m": 7.6}},
|
||||
{"chainage_m": 20, "design": {"pipe_length_m": 7.5}},
|
||||
{"chainage_m": 30, "design": {"pipe_length_m": 7.4}},
|
||||
]
|
||||
assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7}
|
||||
|
||||
|
||||
def test_반올림_짝_거울(out: dict) -> None:
|
||||
"""TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림)."""
|
||||
designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)]
|
||||
py = pipe_lengths_from_designs(designs)
|
||||
assert [py[float(i)] for i in range(len(VALUES))] == out["whole"]
|
||||
assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10]
|
||||
ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts
|
||||
src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8")
|
||||
assert "int(length + 0.5)" in src and "pipeLengthWhole" in src
|
||||
|
||||
|
||||
def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None:
|
||||
card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card
|
||||
)
|
||||
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel
|
||||
@@ -111,11 +111,11 @@ def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None:
|
||||
assert pipe is not None
|
||||
# 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2)
|
||||
assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6)
|
||||
# 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07)
|
||||
# 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07)
|
||||
assert pipe["d"]["outlet"] < 1.0
|
||||
inlet = next(w for w in pipe["walls"] if w["role"] == "inlet")
|
||||
assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05)
|
||||
assert float(pipe["lengthM"]).is_integer()
|
||||
assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m
|
||||
|
||||
|
||||
def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None:
|
||||
|
||||
@@ -184,7 +184,10 @@ def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world):
|
||||
assert _stuck(got) == [] and got["기준합"]["계"] > 0
|
||||
|
||||
|
||||
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world):
|
||||
def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world, monkeypatch):
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
|
||||
|
||||
monkeypatch.setattr(labor_doc, "apart", lambda one: False) # M01 조합 셈과 대조(222-1 안 뺌)
|
||||
root, master_dir = world["root"], world["master"]
|
||||
_save(root, [_pipe()])
|
||||
values = _fill_values(root)
|
||||
|
||||
@@ -87,7 +87,7 @@ def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]:
|
||||
{"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}},
|
||||
{"로직": LOGICS[2]},
|
||||
],
|
||||
"자재": [{"코드": MATERIAL}],
|
||||
"자재": [{"코드": MATERIAL, "별도계상": "직접계산"}], # 222-1 합에 셈
|
||||
}
|
||||
)
|
||||
_write(system / "labor" / f"{COLUMN}.json", doc)
|
||||
|
||||
Reference in New Issue
Block a user