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

This commit is contained in:
2026-10-09 19:20:13 +09:00
29 changed files with 847 additions and 52 deletions
@@ -48,6 +48,7 @@ import {
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute";
import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -588,8 +589,8 @@ export function buildCorridorStructures(
wall.role,
);
}
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 유출 벽 길이(135-3).
chuteBands(layout.outletFill.segments, section.culvert?.chute).forEach((band) =>
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) =>
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
);
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
@@ -19,14 +19,16 @@ interface FillSegment {
points: OffsetPoint[];
}
/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록. */
/** 유출 성토 조각들 → 띠 단면 폴리곤들. 상자를 안 골랐거나 두께가 없으면 빈 목록.
* `keep` = 띠가 서는 조각 — 기본 성토만(박스 · 세월교 본체 끝) · 관 · 흙막이는 `isChuteSegment`(152-3). */
export function chuteBands(
segments: ReadonlyArray<FillSegment>,
chute: ChuteSpec | null | undefined,
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
): OffsetPoint[][] {
if (!chute) return [];
return segments.flatMap((segment) => {
if (segment.kind === "cut") return [];
if (!keep(segment)) return [];
const band = chuteBandOutline(segment.points, chute.thickness_m);
return band ? [band.outline] : [];
});
+25 -10
View File
@@ -47,9 +47,17 @@ const EXTRA_ROWS = [
".b06-structure-panel__struct--span",
].join(", ");
/** 도수로 · 돌붙임 상자 — 배관 · 박스 · 세월교 chute_add · 물넘이 chute(152-4). */
const CHUTE_BOX =
'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]';
/** 연동을 푼 옆 카드의 「이 측점 높이」 줄 — 어느 칸이든 받음(152-2). */
const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height";
/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공(24-4a). */
const SIDE_ROWS = [
OWN_HEIGHT_ROW,
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--options",
@@ -59,15 +67,16 @@ const SIDE_ROWS = [
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
* 유출 단 수는 폼 「추가 흙막이(단)」 칸(54-27) · 읽기 · 옵션 줄은 안 받음. */
const PIPE_INLET_ROWS = [
OWN_HEIGHT_ROW,
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--basin-size",
].join(", ");
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은
* 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */
function rowsFor(slot: HTMLElement): string | null {
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW;
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
}
@@ -155,14 +164,20 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
const side = panelRoot.dataset.side;
// 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음
// 관 = 배관 명세 폼의 「관」 상자만 강조(118-1)
if (side === "body" || side === "pipe") {
const body = (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet)
?.closest(".b05-structure__spec")
?.querySelector<HTMLElement>(
side === "pipe"
? 'fieldset[data-spec-container="pipe"]'
: 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]',
);
// 도수로 띠 = 보이는 명세의 「도수로/돌붙임」 상자(배관 · 박스 · 세월교 chute_add · 물넘이 chute · 152-4)
if (side === "body" || side === "pipe" || side === "chute") {
const body =
side === "chute"
? [...document.querySelectorAll<HTMLElement>(CHUTE_BOX)].find(
(box) => !box.closest("[hidden]"),
)
: (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet)
?.closest(".b05-structure__spec")
?.querySelector<HTMLElement>(
side === "pipe"
? 'fieldset[data-spec-container="pipe"]'
: 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]',
);
if (!body || body.closest("[hidden]")) return;
setGroupLabel(body, "");
body.classList.add("is-active");
+1 -1
View File
@@ -162,7 +162,7 @@ export function appendBoxOverlay(
layer,
layout.sides
.filter((side) => side.wingRole === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")),
box.chute,
x,
toDisplayY,
+10 -5
View File
@@ -22,7 +22,11 @@ import type {
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
import {
appendChuteBand,
appendWallHatch,
isChuteSegment,
} from "./B06_Section_UI_Cross_Wall_Hatch";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
@@ -56,8 +60,8 @@ function polygon(
// "own" = 배관과 무관한 **독립 기슭막이**(구조물 정본 D군). 조정창·조작값 저장은
// 배관 벽과 같은 체계를 그대로 쓴다(2026-08-28 사용자: 배관측점처럼 이동).
export type RevetKey = "inlet" | "outlet" | "own" | `extra${number}` | `bextra${number}`;
/** 고름 전용 키 — 벽 키 + 관(118-1 · 조정창 없음 · 강조만). 조정 함수에는 `RevetKey` 만. */
export type RevetPick = RevetKey | "pipe";
/** 고름 전용 키 — 벽 키 + 관(118-1) · 도수로 띠(152-4) — 조정창 없음 · 강조만. 조정 함수에는 `RevetKey` 만. */
export type RevetPick = RevetKey | "pipe" | "chute";
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type RevetHighlightSetter = (key: RevetPick | null) => void;
@@ -110,11 +114,12 @@ export function appendCulvertOverlay(
: `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`;
};
const drawOutletFill = (): void => {
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3)
// · 연동 카드엔 안 그림
if (culvert.chute && !linked)
appendChuteBand(
layer,
layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points),
layout.outletFill.segments.filter(isChuteSegment),
culvert.chute,
x,
toDisplayY,
@@ -680,6 +680,7 @@ export function computeCulvertLayout(
},
bodyX:
inletBody || outletBody ? { inlet: inletBody?.x ?? 0, outlet: outletBody?.x ?? 0 } : null,
pureHeights: { inlet: inletWallSpec.pureHeight, outlet: outletWallSpec.pureHeight },
designTrim: body && ownTrim ? mergeGuardTrim(ownTrim, body.trim, walls) : ownTrim,
};
}
@@ -221,6 +221,8 @@ export interface CulvertLayout {
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
/** 세월교 · 박스 흙막이(73-4) — 본체 끝 자리의 배관 4축 x(적용 x 의 0점). 본체 없으면 없음. */
bodyX?: { inlet: number; outlet: number } | null;
/** 유입 · 유출 벽의 순수 높이(m) — 높이 빈 칸인 연동 옆 카드가 물려받음(152-2). */
pureHeights?: { inlet: number; outlet: number };
}
/** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
@@ -606,7 +606,16 @@ export function ownerAppliedAdjusts(
owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner),
inletStructure?.adjustFor(owner),
);
return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput;
if (!ownerLayout) return ownerInput;
// 높이 빈 칸 = 소유 측점이 그린 순수 높이 — 옆 측점엔 본체가 없어 근입 0.5 만 서던 것(152-2).
const shift = bodyAbsolute(ownerLayout);
const heights = ownerLayout.pureHeights;
if (!heights) return shift;
return {
...shift,
inlet: { ...shift.inlet, h: shift.inlet.h ?? heights.inlet },
outlet: { ...shift.outlet, h: shift.outlet.h ?? heights.outlet },
};
}
function adjustsInput(
+1 -1
View File
@@ -136,7 +136,7 @@ export function appendFordOverlay(
layer,
layout.sides
.filter((side) => side.role === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut")),
ford.chute,
x,
toDisplayY,
@@ -182,7 +182,7 @@ export function appendFordPavementOverlay(
"b06-chart__ford-deck",
),
);
if (spec.chute) appendChuteBand(svg, [outletFillLine(design)], spec.chute, x, y);
if (spec.chute) appendChuteBand(svg, [{ points: outletFillLine(design) }], spec.chute, x, y);
return true;
}
@@ -248,6 +248,7 @@ export function buildStructurePanel(
const heightRow = heightParts.row;
// 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4).
heightRow.classList.add("b06-structure-panel__struct--height");
const heightLabel = heightRow.querySelector?.<HTMLElement>(".b06-structure-panel__label");
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
const heightMinus = makeButton(
@@ -495,11 +496,11 @@ export function buildStructurePanel(
return {
root,
show: (pick) => {
// 관 = 조정창 없음 · 좌측 「관」 상자만 강조(118-1 · 세월교 본체 꼴)
const key = pick === "pipe" ? null : pick;
// 관 · 도수로 띠 = 조정창 없음 · 좌측 「관」 · 「도수로/돌붙임」 상자만 강조(118-1 · 152-4)
const key = pick === "pipe" || pick === "chute" ? null : pick;
current = key;
root.classList.toggle("is-hidden", key === null);
root.dataset.side = pick === "pipe" ? "pipe" : "";
root.dataset.side = pick === "pipe" || pick === "chute" ? pick : "";
document.removeEventListener("keydown", onArrowKey);
drag.detach();
if (!key) return;
@@ -550,11 +551,14 @@ export function buildStructurePanel(
// 형태 · 높이는 좌측 폼 칸이 정본이다(유입구 · 유출구 · 독립 좌우 · 구조물 옵션) — 폼에
// 자기 칸이 없는 **다단 벽**만 이 줄을 「추가 기슭막이」 칸에 세운다(24-4a).
materialRow.classList.toggle("is-hidden", !isExtra);
heightRow.classList.toggle("is-hidden", !isExtra);
if (isExtra) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
materialSelect.value = deps.formFor(key);
}
// 연동을 푼 옆 카드의 흙막이 = 「이 측점 높이」 줄(폼 칸은 본 흙막이 · 152-2)
const ownHeight = deps.linkState()?.linked === false && guardKey && !isBasin;
heightRow.classList.toggle("is-hidden", !isExtra && !ownHeight);
heightRow.classList.toggle("b06-structure-panel__struct--own-height", ownHeight);
if (heightLabel)
heightLabel.textContent = ownHeight ? "이 측점 높이" : L("B06_Cross_Height_Label");
if (isExtra || ownHeight) heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
if (isExtra) materialSelect.value = deps.formFor(key);
// 단 수 행 — 벽 종류를 가리지 않고 **높이 행 바로 아래** 한 자리에 온다.
// 유출 벽은 자기 채널(extra), 유입 벽·집수정 계류측은 basin 채널(bextra)을 쓴다.
// 유출 벽과 독립 기슭막이 유입 벽에서는 **항상** 보인다(2026-08-22 사용자 ⑤ —
+14
View File
@@ -22,6 +22,7 @@ import {
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
@@ -572,6 +573,19 @@ export function createCrossSectionCard(
if (highlightRevet) setRevetActive(highlightRevet);
}
raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5)
// 도수로 · 돌붙임 띠 고름 — 관 고름 꼴(152-4)
const setChuteActive = attachChuteBandPick(
plotLayer,
revetOffset && (() => toggleRevet("chute")),
);
if (setChuteActive) {
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
baseSetActive(key);
setChuteActive(key === "chute");
};
setChuteActive(highlightRevet === "chute");
}
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
const lineDesign = section.design;
linePick = attachDesignLinePick({
+42 -4
View File
@@ -349,14 +349,23 @@ export function chuteBandOutline(
return { top, normals, outline: [...top, ...bottom.reverse()] };
}
/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작).
* 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */
export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean {
if (segment.kind !== "cut") return true;
const { points } = segment;
return points.length > 1 && points[points.length - 1].elevation <= points[0].elevation + 1e-6;
}
/**
* 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
* 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴).
* 절토 조각(`kind: "cut"` · 152-3)은 3도 절토선 따라.
*/
export function appendChuteBand(
layer: SVGElement,
lines: OffsetPoint[][],
lines: ReadonlyArray<{ points: OffsetPoint[]; kind?: string }>,
chute: { structure: string; thickness_m: number },
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
@@ -365,15 +374,16 @@ export function appendChuteBand(
if (!(thickness > 0)) return;
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
for (const raw of lines) {
const shape = chuteBandOutline(raw, thickness);
for (const line of lines) {
const shape = chuteBandOutline(line.points, thickness);
if (!shape) continue;
const { top, normals, outline } = shape;
const band = document.createElementNS(SVG_NS, "polygon");
band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" "));
band.setAttribute("class", "b06-chart__culvert-pitching");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`;
const along = line.kind === "cut" ? "3도 절토선" : "성토사면";
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · ${along} 따라 원지반까지`;
band.append(title);
clipSeq += 1;
const clipId = `b06-chute-clip-${clipSeq}`;
@@ -414,3 +424,31 @@ export function appendChuteBand(
layer.append(band, clip, group);
}
}
/**
* 도수로 · 돌붙임 띠 고름(152-4 · 118-1 관 고름 꼴) — 그린 띠(관 · 박스 · 세월교 · 물넘이)마다 투명 판정면.
* 벽 판정면 아래 · 보라 선 판정 위(판정면 클래스를 같이 씀). 띠가 없으면 null · 강조 setter 반환.
*/
export function attachChuteBandPick(
layer: SVGElement,
onPick?: () => void,
): ((active: boolean) => void) | null {
const kids = [...layer.children];
const bands = kids.filter((el) => el.classList.contains("b06-chart__culvert-pitching"));
if (!bands.length) return null;
const firstHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"));
for (const band of onPick ? bands : []) {
const hit = document.createElementNS(SVG_NS, "polygon");
hit.setAttribute("points", band.getAttribute("points") ?? "");
hit.setAttribute("class", "b06-chart__culvert-revet-hit b06-chart__chute-hit");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = "도수로 · 돌붙임 선택";
hit.append(title);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onPick?.();
});
layer.insertBefore(hit, firstHit ?? null);
}
return (active) => bands.forEach((band) => band.classList.toggle("is-active", active));
}
@@ -23,8 +23,8 @@ import {
type SelectableStructure,
} from "../A00_Common/design/design_selection";
/** 벽 고름이 받는 부재키 — 관은 강조만(118-1) · 집수정·본체는 여기서 빠진다. */
const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe)$/;
/** 벽 고름이 받는 부재키 — 관 · 도수로 띠는 강조만(118-1 · 152-4) · 집수정·본체는 여기서 빠진다. */
const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe|chute)$/;
interface PickTargets {
focusStation: (stationId: string) => void;
@@ -73,10 +73,12 @@ export function wireStructureSelection(
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
if (key) markWallSelecting();
origRevet(chainageM, key);
if (key) remember?.(ownerChainageOf(chainageM), key);
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
// 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4)
const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);
if (key) remember?.(owner, key);
if (key) panel.showAtChainage(owner);
else if (!nested) {
remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
remember?.(owner, ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
panel.releasePipe();
}
if (key) syncInletStructure();
@@ -88,14 +90,20 @@ export function wireStructureSelection(
const keyOf = (box: HTMLElement): RevetPick | null => {
const id = box.dataset.specContainer;
if (id === "inlet" || id === "outlet" || id === "pipe") return id;
if (id === "chute_add" || id === "chute") return "chute"; // 도수로 띠(152-4)
return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null;
};
const pickBox = (box: HTMLElement): void => {
const chainageM = panel.currentChainage();
const target = chainageM === null ? undefined : sectionAt(chainageM);
const key = keyOf(box);
if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key)
return;
const chute =
target?.culvert?.chute ??
target?.box?.chute ??
target?.ford?.chute ??
target?.ford_pavement?.chute;
if (!key || !target || !(key === "chute" ? chute : target.culvert)) return;
if (stationControls.revetOffset.highlightFor(target) === key) return;
stationControls.revetOffset.select(target.chainage_m, key);
refreshCard?.(target.chainage_m);
};
@@ -391,6 +391,18 @@
stroke-linejoin: round;
}
/* 도수로 · 돌붙임 띠 고름(152-4) — 관 고름과 같은 강조 · 판정면은 굵은 투명 테두리로 여유 */
.b06-chart__culvert-pitching.is-active {
fill: color-mix(in srgb, var(--color-danger) 30%, transparent);
stroke: var(--color-danger);
stroke-width: 2.2;
stroke-dasharray: none;
}
.b06-chart__culvert-revet-hit.b06-chart__chute-hit {
stroke: transparent;
stroke-width: 8;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
width: 22px;
@@ -49,7 +49,7 @@ const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const { structureHashParts } = loadTs(B05("B05_Profile_UI_Corridor_Structures_Hash"));
const { structureTopLoops } = loadTs(B05("B05_Profile_UI_Corridor_Tin_Design"));
const { chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch"));
const { chuteBandOutline, isChuteSegment } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch"));
const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom"));
const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford"));
const { outletFillLine } = loadTs(B06("B06_Section_UI_Cross_Ford_Pavement"));
@@ -181,7 +181,7 @@ function check(name, section, expectedBands, expectedLength, strip) {
const { culvertLayoutOf } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const layout = culvertLayoutOf(s);
const bands = (layout?.outletFill.segments ?? [])
.filter((g) => g.kind !== "cut")
.filter(isChuteSegment) // 묻힌 끝 3도 절토 조각도(152-3)
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
const spec = s.culvert.outlet;
@@ -0,0 +1,98 @@
/* 152-2 연동 흙막이 높이 헬퍼 — 높이 칸 빈 박스 흙막이(73-4 픽스처 352.14)의 연동 옆 측점(340 · 360)이
* 소유 측점이 그린 순수 높이를 물려받는지 · 연동을 푼 카드만 자기 높이로 서는지. 결과 = 표준출력 JSON.
* 짝 = `test_152_2_link_height.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", f);
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
// 높이 칸 비움(= 자동 · 세월교 · 박스 흙막이 기본) — 원본은 3.5 를 넣은 상태
const sections = fixture.cross_sections.map((s) => {
if (!s.culvert?.body) return s;
const copy = JSON.parse(JSON.stringify(s));
copy.culvert.inlet.revet_height_m = null;
copy.culvert.outlet.revet_height_m = null;
return copy;
});
const at = (list, c) => list.find((s) => Math.abs(s.chainage_m - c) < 0.6);
const round = (v) => Math.round(v * 1000) / 1000;
const heightsOf = (list, c) => {
const culvert = computeStoredLayouts(at(list, c), list)?.culvert;
return {
pure: culvert?.pureHeights
? { inlet: round(culvert.pureHeights.inlet), outlet: round(culvert.pureHeights.outlet) }
: null,
walls: (culvert?.walls ?? []).map((w) => ({ role: w.role, height: round(w.height) })),
shift: culvert?.revetShift ?? null,
};
};
const out = { owner: heightsOf(sections, 352.14), linked: {} };
for (const c of [340, 360]) out.linked[c] = heightsOf(sections, c);
// 340 연동 풂 + 유출 흙막이만 +0.1 m(좌측 「이 측점 높이」 줄이 그 측점 값을 고친 것)
const shift = out.linked[340].shift;
const detached = sections.map((s) => {
if (Math.abs(s.chainage_m - 340) >= 0.6) return s;
const copy = JSON.parse(JSON.stringify(s));
copy.design.revet_link_detached = true;
copy.design.revet_adjust = {
...(copy.design.revet_adjust ?? {}),
inlet: { ...shift.inlet },
outlet: { ...shift.outlet, h: round(shift.outlet.h + 0.1) },
};
return copy;
});
out.detached = {
340: heightsOf(detached, 340),
360: heightsOf(detached, 360),
owner: heightsOf(detached, 352.14),
};
process.stdout.write(JSON.stringify(out));
+112
View File
@@ -0,0 +1,112 @@
/* 152-3 도수로 띠 절토 조각 헬퍼 — 묻힌 유출 끝 3도 절토선에도 띠 · 오르막 복합경사 절토엔 띠 없음.
* 화면 TS(`_Cross_Wall_Hatch` 거름 · 단면 · B05 3D `chuteBands`)를 그 자리에서 돌림 · 짝 = `test_152_3_chute_cut.py`.
* 입력 = 139-4 와 같은 픽스처(박스 352.14 · 배관 85.052 원지반 고친 사본).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
const { isChuteSegment, chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch"));
const { chuteBands } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Chute.ts"),
);
const fixture = (name) =>
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections;
const clone = (v) => JSON.parse(JSON.stringify(v));
const r3 = (v) => Math.round(v * 1000) / 1000;
const CHUTE = { structure: "돌붙임", thickness_m: 0.3 };
/** 유출 조각마다 — 종류 · 오르막 · 띠 섬 · 띠 윗면이 그 선 · 아랫면이 선 아래(두께). */
function study(section, list) {
const culvert = computeStoredLayouts(section, list).culvert;
const segments = culvert.outletFill.segments;
const kept = segments.filter(isChuteSegment);
return {
segments: segments.map((g) => {
const [a, b] = [g.points[0], g.points[g.points.length - 1]];
const band = isChuteSegment(g) ? chuteBandOutline(g.points, CHUTE.thickness_m) : null;
const top = band?.top ?? [];
const bottom = band ? band.outline.slice(top.length) : [];
return {
kind: g.kind ?? "fill",
rises: b.elevation > a.elevation + 1e-6,
band: !!band,
topIsLine: !!band && top.length === g.points.length,
below: band
? r3(
Math.min(...top.map((p, i) => p.elevation - bottom[bottom.length - 1 - i].elevation)),
)
: null,
};
}),
// 3D 띠(관 · 흙막이 = isChuteSegment) · 기본 거름(박스 · 세월교 본체 끝 = 성토만)
bands3d: chuteBands(segments, CHUTE, isChuteSegment).length,
bands3dFillOnly: chuteBands(segments, CHUTE).length,
kept: kept.length,
};
}
const out = {};
const bodies = fixture("b06_73_4_body_guard_sections.json");
out.box = study(
bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6),
bodies,
);
const pipes = fixture("b07_cross_overlay_sections.json");
const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe);
const base = computeStoredLayouts(pipe, pipes).culvert;
const tail = base.outletFill.segments[base.outletFill.segments.length - 1];
const start = tail.points[0];
const outward = Math.sign(tail.points[1].offset - start.offset) || 1;
function reshaped(rise, slope) {
const s = clone(pipe);
for (const p of s.samples) {
const t = ((p.offset_m ?? 0) - start.offset) * outward;
if (t >= 0 && typeof p.elevation_m === "number")
p.elevation_m = start.elevation + rise + slope * t;
}
return study(
s,
pipes.map((x) => (x === pipe ? s : x)),
);
}
out.pipeBase = study(pipe, pipes);
out.pipeDown = reshaped(0.3, -0.15); // 3도 선이 원지반을 만남
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 복합경사 절토
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,278 @@
/* PLAN 152-4 단위검증 헬퍼 — B06 도수로 · 돌붙임 띠 고름 · 강조(118-1 관 고름 꼴) · 조정창 숨김 · 3D chute 키.
* 화면 TS 를 그 자리에서 트랜스파일해 흉내 DOM 에서 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점(도수로 상자를 붙인 사본). 짝 = `test_152_4_chute_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
let STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
// ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ─────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.attrs = {};
this.dataset = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
this.style = { setProperty: () => {} };
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
insertBefore(kid, ref) {
const at = ref ? this.children.indexOf(ref) : -1;
at < 0 ? this.children.push(kid) : this.children.splice(at, 0, kid);
return kid;
}
appendChild(kid) {
this.children.push(kid);
return kid;
}
replaceChildren(...kids) {
this.children = kids;
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this.className = value;
}
getAttribute(name) {
return name === "class" ? this.className : (this.attrs[name] ?? null);
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
removeEventListener() {}
click() {
let stopped = false;
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
return stopped;
}
querySelectorAll(selector) {
const found = [];
const walk = (node) =>
node.children.forEach((kid) => {
if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid);
walk(kid);
});
walk(this);
return found;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] ?? null;
}
getClientRects() {
return [];
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
createComment: () => new FakeNode("#comment"),
addEventListener: () => {},
removeEventListener: () => {},
};
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
globalThis.requestAnimationFrame = (fn) => fn();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
const { attachChuteBandPick } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch"));
const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const plain = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
const pipeSection = JSON.parse(JSON.stringify(plain));
pipeSection.culvert.chute = { structure: "돌붙임", thickness_m: 0.3 };
const listWith = sections.map((s) => (s === plain ? pipeSection : s));
const out = {};
const cls = (n) => n.className;
// ① 오버레이 + 띠 고름 — 판정면 차례 · 누름 · 강조
function draw(section, list, onPick) {
const layer = new FakeNode("g");
const layout = computeStoredLayouts(section, list).culvert;
const setter = appendCulvertOverlay(
layer,
layout,
(o) => -o * 10,
(e) => -e * 10,
() => {},
false,
);
const picked = [];
const setChute = attachChuteBandPick(layer, onPick ? () => picked.push("chute") : undefined);
return { layer, setter, setChute, picked };
}
const drawn = draw(pipeSection, listWith, true);
const kids = drawn.layer.children;
const bands = kids.filter((n) => n.classList.contains("b06-chart__culvert-pitching"));
const hits = kids.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit"));
const chuteHits = hits.filter((n) => n.classList.contains("b06-chart__chute-hit"));
const stopped = chuteHits[0].click();
drawn.setChute(true);
const on = bands.map((b) => b.classList.contains("is-active"));
drawn.setChute(false);
const off = bands.map((b) => b.classList.contains("is-active"));
out.overlay = {
bands: bands.length,
chuteHits: chuteHits.length,
// 판정면 차례 — 띠 판정면이 관 · 벽 판정면보다 먼저(아래)
hitOrder: hits.map((n) => (n.classList.contains("b06-chart__chute-hit") ? "chute" : "other")),
sameShape: chuteHits.every((h, i) => h.attrs.points === bands[i].attrs.points),
picked: drawn.picked,
stopped,
on,
off,
};
// 고름 배선 없음(조작 없는 카드) — 판정면 없이 강조만 · 띠 없으면 null
const bare = draw(pipeSection, listWith, false);
out.bare = {
hits: bare.layer.children.filter((n) => n.classList.contains("b06-chart__chute-hit")).length,
setter: typeof bare.setChute,
};
out.noChute = draw(plain, sections, true).setChute === null;
// ② B05 3D 띠 고름(chute 키) → B06 고름 길로
const calls = [];
applyStructurePick(
{ at: pipeSection.chainage_m, key: "chute" },
{ cross_sections: [pipeSection] },
{
focusStation: () => {},
refreshCard: () => {},
revetSelect: (c, k) => calls.push(["revet", k]),
fordSelect: (c, k) => calls.push(["ford", k]),
boxSelect: (c, k) => calls.push(["box", k]),
},
);
out.pick = calls;
// ③ 조정창 — show("chute") = 숨김 · side "chute"
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
STUBS = {
...STUBS,
"./B06_Section_UI_Section_Common": { L: (key) => key },
"./B06_Section_UI_Cross_Panel_Base": {
...base,
bindDragHandle: () => ({ attach: () => {}, detach: () => {} }),
},
};
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
const touched = [];
const spy = (name, value) => () => {
touched.push(name);
return value;
};
const panel = buildStructurePanel({
scrollKey: "t152",
adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }),
nudge: spy("nudge"),
nudgeSlope: spy("nudgeSlope"),
nudgeHeight: spy("nudgeHeight"),
heightFor: spy("heightFor", 2),
formFor: spy("formFor", "돌쌓기(메)"),
setForm: spy("setForm"),
reset: spy("reset"),
close: () => {},
structureFor: () => "revet",
basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }),
updateBasin: () => {},
nudgeBasinLateral: () => {},
resetBasin: () => {},
canNudge: spy("canNudge", true),
fineMove: spy("fineMove", false),
dragAxes: spy("dragAxes", null),
extraState: () => ({ canAdd: false, count: 0 }),
isLinkedCard: () => false,
basinExtraState: () => ({ canAdd: false, count: 0 }),
hiddenPipe: () => false,
bodyGuard: () => false,
setExtraCount: () => {},
setBasinExtraCount: () => {},
equalizeExtras: () => {},
spanFor: spy("spanFor", null),
setSpan: () => {},
linkState: () => null,
setLinked: () => {},
followGrade: () => true,
setFollowGrade: () => {},
});
panel.show("chute");
out.panel = {
hidden: panel.root.classList.contains("is-hidden"),
side: panel.root.dataset.side,
touched,
};
process.stdout.write(JSON.stringify(out));
+1 -1
View File
@@ -49,7 +49,7 @@ const run = (line) => {
const layer = new Node("g");
appendChuteBand(
layer,
[line],
[{ points: line }],
{ structure: "돌붙임", thickness_m: 0.3 },
(o) => o * SCALE,
(e) => -e * SCALE,
+1 -1
View File
@@ -92,7 +92,7 @@ def test_관은_조정창_없음(out) -> None:
def test_좌측_관_상자_강조_스크롤() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
assert 'if (side === "body" || side === "pipe") {' in dock
assert 'if (side === "body" || side === "pipe" || side === "chute") {' in dock # 152-4 도수로
assert "'fieldset[data-spec-container=\"pipe\"]'" in dock
assert 'body.classList.add("is-active");' in dock
assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock
@@ -0,0 +1,55 @@
"""152-2 연동 흙막이 높이 — 본 흙막이가 그린 순수 높이를 옆 카드가 물려받음 · 풀면 그 측점만 따로.
화면 TS 를 그 자리에서 돌린 값(`helper_152_2_link_height.cjs`) · 픽스처 = 73-4(박스 352.14 · 높이 칸 비움).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_152_2_link_height.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def screen() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _src(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def test_연동_옆_카드_높이_본_흙막이와_같음(screen: dict) -> None:
owner = screen["owner"]["pure"]
assert owner == {"inlet": 2.0, "outlet": 2.0} # 본체 끝 높이(근입 0.5 아님)
for chainage in ("340", "360"):
assert screen["linked"][chainage]["pure"] == owner
assert screen["linked"][chainage]["walls"] == screen["owner"]["walls"]
def test_연동_풀린_카드만_따로(screen: dict) -> None:
detached = screen["detached"]
assert detached["340"]["pure"] == {"inlet": 2.0, "outlet": 2.1}
assert detached["360"]["pure"] == {"inlet": 2.0, "outlet": 2.0}
assert detached["owner"]["pure"] == {"inlet": 2.0, "outlet": 2.0}
def test_풀린_카드_이_측점_높이_줄() -> None:
panel = _src("B06_Section/B06_Section_UI_Cross_Structure_Panel.ts")
assert "deps.linkState()?.linked === false && guardKey && !isBasin" in panel
assert '"이 측점 높이"' in panel
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
assert 'const OWN_HEIGHT_ROW = ".b06-structure-panel__struct--own-height";' in dock
assert "return OWN_HEIGHT_ROW;" in dock # 세월교 · 박스 날개벽 칸도 받음
wire = _src("B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts")
assert "h: shift.inlet.h ?? heights.inlet" in wire
+65
View File
@@ -0,0 +1,65 @@
"""152-3 도수로 · 돌붙임 띠 — 묻힌 유출 끝 3도 절토선(내림 · 수평)에도 띠 · 오르막 복합경사 절토엔 없음.
화면 TS 를 그 자리에서 돌린 값(`helper_152_3_chute_cut.cjs`) · B06 관 · 흙막이 띠와 3D 띠가 같은 거름.
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_152_3_chute_cut.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _src(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def test_묻힌_흙막이_유출_3도_선에_띠(out: dict) -> None:
(cut,) = out["box"]["segments"]
assert cut["kind"] == "cut" and not cut["rises"]
assert cut["band"] and cut["topIsLine"] # 윗면 = 3도 선
assert cut["below"] == pytest.approx(0.3, abs=0.01) # 선 아래 두께
assert out["box"]["bands3d"] == 1 and out["box"]["bands3dFillOnly"] == 0
def test_배관_묻힌_끝_내림이면_띠_오르막이면_없음(out: dict) -> None:
down = out["pipeDown"]["segments"]
assert [s["kind"] for s in down] == ["fill", "cut"]
assert all(s["band"] for s in down)
up = out["pipeUp"]["segments"]
assert up[-1]["kind"] == "cut" and up[-1]["rises"] and not up[-1]["band"]
assert out["pipeDown"]["bands3d"] == 2 and out["pipeUp"]["bands3d"] == 1
def test_성토만인_측점은_그대로(out: dict) -> None:
base = out["pipeBase"]
assert base["bands3d"] == base["bands3dFillOnly"] == 2
def test_거름_한_벌_배선() -> None:
culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts")
assert "layout.outletFill.segments.filter(isChuteSegment)," in culvert
corridor = _src("B05_Profile/B05_Profile_UI_Corridor_Structures.ts")
assert (
"chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment)" in corridor
)
hatch = _src("B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts")
assert '"3도 절토선"' in hatch
# 박스 · 세월교 본체 끝은 성토만(3도 규칙 안 넓힘)
for name in ("B06_Section_UI_Cross_Box.ts", "B06_Section_UI_Cross_Ford.ts"):
assert 'side.fillSegments.filter((s) => s.kind !== "cut")' in _src(f"B06_Section/{name}")
+66
View File
@@ -0,0 +1,66 @@
"""152-4 도수로 · 돌붙임 띠 고름 · 강조 — 118-1 관 고름 꼴(판정면 · setter · 좌측 상자 · 3D 키).
화면 TS 를 흉내 DOM 에서 돌린 값(`helper_152_4_chute_pick.cjs`) + 배선 문자열.
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_152_4_chute_pick.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _src(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def test_띠마다_판정면_벽_관_아래(out: dict) -> None:
overlay = out["overlay"]
assert overlay["bands"] == overlay["chuteHits"] == 2
assert overlay["sameShape"]
n = overlay["chuteHits"]
assert overlay["hitOrder"][:n] == ["chute"] * n # 벽 · 관 판정면이 위(겹치면 이김)
assert "chute" not in overlay["hitOrder"][n:]
def test_띠_누름_고름_강조(out: dict) -> None:
overlay = out["overlay"]
assert overlay["picked"] == ["chute"] and overlay["stopped"]
assert overlay["on"] == [True, True] and overlay["off"] == [False, False]
assert out["bare"] == {"hits": 0, "setter": "function"}
assert out["noChute"]
def test_조정창_없음_3D_키(out: dict) -> None:
assert out["panel"] == {"hidden": True, "side": "chute", "touched": []}
assert out["pick"] == [["revet", "chute"]]
def test_좌측_상자_강조_배선() -> None:
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "attachChuteBandPick(" in view and 'toggleRevet("chute")' in view
assert 'setChuteActive(key === "chute");' in view
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
assert (
'fieldset[data-spec-container="chute_add"], fieldset[data-spec-container="chute"]' in dock
)
selection = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts")
assert 'if (id === "chute_add" || id === "chute") return "chute";' in selection
assert 'const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);' in selection
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__culvert-pitching.is-active {" in css
@@ -88,5 +88,8 @@ def test_자동_자리_읽기_글이_없음() -> None:
def test_배관_유출_칸은_옛_단_수_줄을_안_받음() -> None:
dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts")
# 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음)
assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock
# 54-29 — 배관 유출 칸 = 집수정 내공 줄만(단 수 · 읽기 · 옵션 줄 안 받음) · 152-2 풀린 카드 높이 줄
assert (
"const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;"
in dock
)
@@ -25,7 +25,11 @@ def test_배관_칸은_옵션_읽기_줄을_안_받음() -> None:
assert inlet
assert "struct--options" not in inlet.group(1)
assert "struct--readout" not in inlet.group(1)
assert 'const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";' in dock
# 152-2 — 풀린 연동 카드 「이 측점 높이」 줄만 더 받음
assert (
"const PIPE_OUTLET_ROWS = `${OWN_HEIGHT_ROW}, .b06-structure-panel__struct--basin-size`;"
in dock
)
def test_배관_칸_이름표는_그대로() -> None:
+5 -2
View File
@@ -168,8 +168,11 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
def test_B06_세월교_박스_칸은_받는_줄_없음():
"""107-3 — 측벽 높이 줄 지움."""
"""107-3 — 측벽 높이 줄 지움 · 152-2 연동 풀린 카드 「이 측점 높이」 줄만 받음."""
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock
assert (
"slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return OWN_HEIGHT_ROW"
in dock
)
lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8")
assert "return isNone(c, state[c.id][0])" in lists
+1 -1
View File
@@ -44,7 +44,7 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict)
def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None:
"""107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출)."""
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert "sideSlots?.wingOutlet) return null" in dock
assert "sideSlots?.wingOutlet) return OWN_HEIGHT_ROW" in dock # 152-2 풀린 카드 높이 줄만
assert "wall-height" not in dock