Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-10 16:44:26 +09:00
19 changed files with 364 additions and 213 deletions
@@ -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[],
+6 -2
View File
@@ -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;
+7 -9
View File
@@ -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(),
+1 -1
View File
@@ -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`)
+4 -2
View File
@@ -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)),
}),
);
+2 -2
View File
@@ -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"]
+6 -6
View File
@@ -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)
+2 -1
View File
@@ -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:
+117
View File
@@ -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
+2 -2
View File
@@ -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: