Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-08 16:27:52 +09:00
101 changed files with 3082 additions and 495 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ const guardContainer = (id: "inlet" | "outlet", title: string, link: string): Sp
key: `${id}_type`,
choices: guard(id === "inlet" ? "in" : "out"),
},
...bodyGuardItems(id, "본체 끝 높이(자동)"),
...bodyGuardItems(id, "본체 끝 높이(자동)", true),
],
});
+13 -3
View File
@@ -83,19 +83,29 @@ export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => (
});
/** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) ·
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */
export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] {
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함 · `wingBerm` = 세월교
* 유입 날개벽에도 같은 칸(수평 여유 · 107-3). */
export function bodyGuardItems(
side: "inlet" | "outlet",
heightHint: string,
wingBerm = false,
): SpecItem[] {
const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) =>
item.id === "height" ? { ...item, default: undefined, hint: heightHint } : item,
);
if (side === "outlet") return items;
const branch = { key: `${side}_revet_berm_m` };
const berm: SpecItem = {
id: "berm",
label: "수평공간 (m)",
kind: "number",
step: 0.1,
default: "1",
by: { item: "structure", map: { 흙막이: { key: `${side}_revet_berm_m` } } },
// 세월교 유입 날개벽 = 같은 칸 · 같은 키(끝이 묻히면 수평 여유 뒤 절토 · 면적 밖)
by: {
item: "structure",
map: wingBerm ? { 흙막이: branch, 날개벽: branch } : { 흙막이: branch },
},
};
return [...items, berm];
}
@@ -330,7 +330,7 @@ export function createFacilityOptionsForm(
if (c.id === "inlet" && item.id === "structure" && inletIsAuto()) {
const drawn = drawnNow();
return drawn
? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" }
? { value: drawn.basin ? "집수정" : "흙막이", tag: "자동" }
: { value: "", tag: "자동(지형 규칙)" };
}
// 빈 고르기 칸 = 「선택 안 함」 그대로(54-39 · 기본값 꼬리 없음)
@@ -412,7 +412,7 @@ export function createFacilityOptionsForm(
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
const extraGroup = group("추가 기슭막이");
const extraGroup = group("추가 흙막이");
const extraSlot = document.createElement("div");
extraSlot.className = "b05-structure__adjust-slot";
extraGroup.body.append(extraSlot);
@@ -25,7 +25,7 @@ export const INLET_KINDS: ReadonlyArray<{
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "흙막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
+8
View File
@@ -262,6 +262,9 @@ export interface FordWingSpec {
angle_deg: number | null;
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
slab_extend_m: number;
/** 세월교 유입 날개벽 끝 수평 여유(m) — 흙막이 수평공간과 같은 칸(`inlet_revet_berm_m` · 107-3) ·
* null = 화면 기본값(1 m). */
berm_m?: number | null;
}
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
@@ -289,6 +292,8 @@ export interface FordSet {
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
export interface BoxSet {
type: "box";
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */
chainage_m?: number;
/** 사용자 입력 내공(유로) 폭·높이(m). */
inner_width_m: number;
inner_height_m: number;
@@ -561,6 +566,9 @@ export interface CrossSectionPatch {
cut_slope_ratio_user?: number;
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
pipe_length_m?: number;
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
pipe_slope_pct?: number;
box_ground_slope_pct?: number;
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
basin_adjust?: {
innerWidthM: number;
@@ -444,6 +444,9 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
if wing_in["installed"]:
# 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
wing_in["berm_m"] = _number(values.get("inlet_revet_berm_m"), None)
defaulted = _defaulted(
values,
[
+4
View File
@@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel):
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
pipe_length_m: float | None = Field(default=None, ge=0)
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
pipe_slope_pct: float | None = None
box_ground_slope_pct: float | None = None
class SectionConfirmRequest(BaseModel):
@@ -66,6 +66,8 @@ _AREA_KEYS = (
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
"pipe_slope_pct",
"box_ground_slope_pct",
)
+36 -14
View File
@@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
@@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = {
};
/**
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
*
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
*/
function pipeOwnerChainage(
section: CrossSection,
function ownerChainage(
target: number | undefined,
sections: readonly CrossSection[],
): number | null {
const target = section.culvert?.chainage_m;
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
@@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
"pipe_slope_pct",
"box_ground_slope_pct",
] as const;
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
const groundAt = groundInterpolator(section.samples);
const inlet = box.sides.find((side) => side.wingRole === "inlet");
const outlet = box.sides.find((side) => side.wingRole === "outlet");
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
}
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
@@ -195,18 +209,26 @@ function areaRowOf(
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
const isOwner = (target: number | undefined): boolean => {
const owner = ownerChainage(target, sections);
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
};
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
const pipeOwner =
!!section.culvert &&
!section.culvert.body &&
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
if (pipe && pipe.lengthM > 0) {
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
}
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
const boxSlope =
layouts.box && isOwner(section.box?.chainage_m)
? boxGroundSlopePct(section, layouts.box)
: null;
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
@@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
const owner = ownerChainage(section.culvert.chainage_m, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
+4 -6
View File
@@ -47,14 +47,13 @@ const EXTRA_ROWS = [
".b06-structure-panel__struct--span",
].join(", ");
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공(24-4a). */
const SIDE_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--options",
".b06-structure-panel__struct--basin-size",
".b06-structure-panel__struct--wall-height",
].join(", ");
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
@@ -64,12 +63,11 @@ const PIPE_INLET_ROWS = [
".b06-structure-panel__struct--basin-size",
].join(", ");
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height";
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은
* 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */
* 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */
function rowsFor(slot: HTMLElement): string | null {
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW;
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
}
+17 -9
View File
@@ -43,9 +43,11 @@ export interface BodyGuardBase {
trim: CulvertDesignTrim;
}
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
defaults = false,
): BodyGuardBase | null {
const body = section.culvert?.body;
const adjust = section.design;
@@ -74,9 +76,10 @@ export function bodyGuardBase(
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
if (body === "ford" && section.ford) {
const stored = defaults ? null : adjust?.ford_adjust;
const layout = computeFordLayout(section, samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
});
if (!layout) return null;
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
@@ -143,16 +146,21 @@ export function bodyGuardAxes(
export type BodyAxes = { x: number; d: number; through: number; outward: number };
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
* 수량 L 은 그린 관 길이 올림 그대로). */
export function fordPipeFit(
base: BodyGuardBase | null,
section: CrossSection,
samples: SectionSample[],
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
axesOf: (role: Role) => BodyAxes | null,
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
): [BodyAxes | null, BodyAxes | null] {
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
if (!base || culvert.body !== "ford") return axes;
if (culvert.body !== "ford") return axes;
const base = bodyGuardBase(section, samples, true);
if (!base) return axes;
const roles = ["inlet", "outlet"] as const;
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null));
const tips = roles.map((role, i) => {
const end = base.ends[role];
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
@@ -163,8 +171,8 @@ export function fordPipeFit(
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
const gap = Math.ceil(length - 1e-6) - length;
const at = guarded[1] ? 1 : 0;
const fit = guarded[at];
if (!fit || gap < 1e-6) return axes;
const fit = axes[at];
if (!guarded[at] || !fit || gap < 1e-6) return axes;
axes[at] = { ...fit, x: fit.x + gap };
return axes;
}
+32 -20
View File
@@ -28,12 +28,40 @@ export type {
const SVG_NS = "http://www.w3.org/2000/svg";
/** 고르는 부재 — 좌 · 우 끝 또는 본체(구체 가운데 1/3 · 좌측 「본체」 상자 · 66-1). */
/** 고르는 부재 — 좌 · 우 끝 또는 본체(두 구체 끝 사이 · 좌측 「본체」 상자 · 66-1 · 108-3). */
export type BoxPick = BoxSideRole | "body";
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type BoxHighlightSetter = (role: BoxPick | null) => void;
/** 날개벽(에이프런) 없는 끝의 판정 띠 폭(m) — 구체 끝 바깥으로. */
const BARE_END_HIT_M = 0.3;
/**
* 고르기 판정면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝(`side.offset`) ~ 에이프런 바깥
* (`apron[1]`) · 에이프런이 없으면 구체 끝 바깥 얇은 띠. 높이 = 저판 밑 ~ 상판 위. 서로 겹치지 않는다.
*/
export function boxHitBands(layout: BoxLayout): Array<[BoxPick, OffsetPoint[]]> {
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
const band = (from: number, to: number): OffsetPoint[] => [
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
];
return [
["body", band(layout.sides[0].offset, layout.sides[1].offset)],
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
const outward = side.role === "left" ? 1 : -1;
const outer = side.apron.length
? side.apron[1].offset
: side.offset + outward * BARE_END_HIT_M;
return [side.role, band(side.offset, outer)];
}),
];
}
/**
* BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다.
* `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다).
@@ -172,27 +200,11 @@ export function appendBoxOverlay(
label.textContent = layout.label.text;
layer.append(label);
// 투명 겹면 — 구체 가운데 1/3 = 본체, 그 바깥 양쪽 = 그 끝(66-1). 어느 것을 조정할지 클릭으로 고른다.
// 투명 겹면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥(날개벽 없으면 바깥
// 얇은 띠) · 높이 저판 밑 ~ 상판 위. 어느 것을 조정할지 클릭으로 고른다.
const hits = new Map<BoxPick, SVGPolygonElement>();
if (onSelectSide) {
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
const third = (layout.sides[0].offset - layout.sides[1].offset) / 6;
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
const band = (from: number, to: number): OffsetPoint[] => [
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
];
const parts: Array<[BoxPick, OffsetPoint[]]> = [
["body", band(midOffset - third, midOffset + third)],
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
const outer = side.apron.length ? side.apron[1].offset : side.offset;
const inner = midOffset + Math.sign(side.offset - midOffset) * Math.abs(third);
return [side.role, band(inner, outer)];
}),
];
const parts: Array<[BoxPick, OffsetPoint[]]> = boxHitBands(layout);
for (const [role, points] of parts) {
const hit = polygon(
points,
+25 -9
View File
@@ -14,9 +14,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
/** 그 끝의 구체 표고 ±0.1m. */
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
/** 그 측을 자동 자리로 되돌린다. */
reset: (role: BoxSideRole) => void;
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
const RISE_LIMIT_M = 10;
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const riseLine = document.createElement("span");
const slopeLine = document.createElement("span");
const dragLine = dragReadout();
value.append(lengthLine, riseLine, slopeLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
let current: BoxSideRole | null = null;
const act = (run: (role: BoxSideRole) => void) => () => {
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
const off = { attach: (): void => undefined, detach: (): void => undefined };
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
const drag = opts?.dock
? off
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudgeLength(current, left * STEP_M);
if (down) deps.nudgeRise(current, -down * STEP_M);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const lengthSign = role === "left" ? 1 : -1;
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: null,
clamp: (left, down) => [
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, -down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
+7 -7
View File
@@ -124,14 +124,14 @@ export function appendCulvertOverlay(
segment.points,
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
(layout.fillSlope.roadWideningM > 0.01
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
: "") +
(layout.fillSlope.structureRequired
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
? " · ⚠ 흙막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
: ""),
);
}
@@ -148,7 +148,7 @@ export function appendCulvertOverlay(
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
(segment.overLimit
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
: " (법정 5m 이내)") +
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
@@ -192,7 +192,7 @@ export function appendCulvertOverlay(
"b06-chart__culvert-revet",
// 높이 표기 = 순수 높이(바닥~상단, 근입 0.5 포함). 추가 기슭막이도 같은 기준으로
// 통일했다(2026-08-23 배관 벽 → 2026-08-29 추가 벽).
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
`${roleLabel(wall.role)} 흙막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` +
@@ -221,8 +221,8 @@ export function appendCulvertOverlay(
basin.reason === "cut"
? "절토측 유입 — 집수정 기본(성토사면 없음)"
: basin.reason === "manual"
? "사용자 선택 집수정(규칙상 기슭막이 자리)"
: "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본";
? "사용자 선택 집수정(규칙상 흙막이 자리)"
: "유입측 성토사면 3m 이하 — 흙막이 대신 집수정 기본";
for (const part of basin.parts) {
const shape = polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
@@ -344,7 +344,7 @@ export function appendCulvertOverlay(
const hit = polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet-hit",
`${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
`${roleLabel(wall.role)} 흙막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
@@ -56,7 +56,7 @@ export interface OutletExtrasInput {
}
/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
function bermCut(
export function bermCut(
src: OffsetPoint,
outward: number,
berm: { lengthM: number; cut: StructureCutSpec },
@@ -172,8 +172,8 @@ export function computeCulvertLayout(
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
const bodyAxesOf = (role: "inlet" | "outlet") => {
const end = body?.ends[role];
const bodyAxesOf = (role: "inlet" | "outlet", base = body) => {
const end = base?.ends[role];
const [spec, info] =
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
if (!end) return null;
@@ -186,7 +186,7 @@ export function computeCulvertLayout(
thickness: REVET_THICKNESS_M,
});
};
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf);
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
+21 -16
View File
@@ -17,7 +17,7 @@
* ========================================================================== */
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
BasinLayout,
CulvertDesignTrim,
@@ -25,12 +25,13 @@ import type {
OutletFillSegment,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
export interface FordWallAdjust {
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */
heightM: number | null;
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
lateralM: number;
@@ -103,7 +104,8 @@ export interface FordLayout {
adjust: FordAdjust;
}
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
/** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 ·
* 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */
function minWallHeight(ford: FordSet): number {
return ford.diameter_m + ford.min_cover_m;
}
@@ -149,15 +151,6 @@ export function computeFordLayout(
const inletOnLeft = uphill === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
const bedElevation = (() => {
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
low = Math.min(low, groundAt(offset));
}
return low;
})();
const buildSide = (role: FordWallRole): FordSideLayout | null => {
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
const edge = onLeft ? edges.left : edges.right;
@@ -166,8 +159,7 @@ export function computeFordLayout(
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
const lateralM = Math.max(wanted.lateralM, 0);
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford));
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
const anchor: OffsetPoint = {
offset: edge.offset_m,
@@ -235,7 +227,20 @@ export function computeFordLayout(
);
})(),
fillSegments: extras.segments,
cutLine: built.basin.cutLine,
// 유입 날개벽 끝이 묻히면 수평 여유 뒤 절토 — 흙막이 수평공간과 같은 칸 · 기본 1 m(107-3)
cutLine:
role === "inlet" && wing.installed && built.basin.cutLine
? bermCut(
floor.points[1],
outward,
{
lengthM: Math.max(wing.berm_m ?? GUARD_CUT_BERM_M, 0),
cut: structureCutSpec(section.design),
},
groundAt,
limitOffset,
).points
: built.basin.cutLine,
adjust: {
heightM: wanted.heightM === null ? null : heightM,
lateralM,
+34 -39
View File
@@ -6,8 +6,8 @@
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
*
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽
* 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) ·
* 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
* ========================================================================== */
@@ -17,10 +17,11 @@ import {
bindArrowKeys,
bindDragHandle,
buildPanelShell,
clampAxis,
dpadButton,
dragReadout,
makeButton,
makeRow,
snapAxisDrag,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -29,12 +30,14 @@ export interface FordPanelDeps {
adjustFor: (role: FordWallRole) => FordWallAdjust;
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
heightFor: (role: FordWallRole) => number;
/** 높이 ±0.1m. */
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
nudge: (role: FordWallRole, screenDeltaM: number) => void;
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
outwardOf: (role: FordWallRole) => number;
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
/** 자동 자리로 되돌린다(세 축 모두). */
reset: (role: FordWallRole) => void;
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
@@ -51,11 +54,13 @@ export interface FordPanelHandle {
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
const STEP_M = 0.1;
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
const MOVE_LIMIT_M = 10;
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
* `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다.
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
@@ -68,33 +73,14 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
const moveLine = document.createElement("span");
const slabLine = document.createElement("span");
const dragLine = dragReadout();
value.append(heightLine, moveLine, slabLine, dragLine);
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
let current: FordWallRole | null = null;
const act = (run: (role: FordWallRole) => void) => () => {
if (current) run(current);
};
// 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a).
const heightRow = makeRow("측벽 높이");
heightRow.row.classList.add("b06-structure-panel__struct--wall-height");
const heightLabel = heightRow.row.querySelector<HTMLElement>(".b06-structure-panel__label")!;
const heightValue = document.createElement("span");
heightValue.className = "b06-structure-panel__hval";
heightRow.controls.append(
makeButton(
"−",
"측벽 높이 0.1m 낮추기",
act((role) => deps.nudgeHeight(role, -STEP_M)),
),
heightValue,
makeButton(
"+",
"측벽 높이 0.1m 높이기",
act((role) => deps.nudgeHeight(role, STEP_M)),
),
);
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
const moveRow = makeRow("이동");
moveRow.controls.classList.add("b06-structure-panel__buttons");
@@ -126,22 +112,34 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
),
);
root.append(value, opts?.dock ? heightRow.row : moveRow.row);
// 측벽 높이 줄 없음 — 세월교 바닥은 노면 − (관경 + 토피) 기본(107-3)
root.append(value);
if (!opts?.dock) root.append(moveRow.row);
const arrows = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindArrowKeys(root, () => [moveRow.controls]);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
const drag = opts?.dock
? { attach: (): void => undefined, detach: (): void => undefined }
: bindDragHandle(root, {
stepM: () => (current ? STEP_M : null),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (left) deps.nudge(current, left * STEP_M);
if (down) deps.nudgeSlope(current, down * STEP_M);
snap: (leftM, downM) => {
const role = current;
if (!role) return null;
const from = deps.adjustFor(role);
const outward = deps.outwardOf(role);
return snapAxisDrag(leftM, downM, {
stepM: STEP_M,
slopeOutward: outward,
clamp: (left, down) => [
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
],
apply: (left, down) => deps.move(role, left, down),
});
},
preview: (text) => (dragLine.textContent = text),
});
function render(): void {
@@ -153,9 +151,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`;
// 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에
heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이";
}
return {
+76 -27
View File
@@ -10,6 +10,8 @@
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
* ========================================================================== */
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
export interface DragSnap {
leftM: number;
downM: number;
text: string;
apply: () => void;
}
/** 끌기 손잡이가 조정 창에 묻는 것. */
export interface DragHandleDeps {
stepM: () => number | null;
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
snap: (leftM: number, downM: number) => DragSnap | null;
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
preview: (text: string) => void;
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
commit: (stepsLeft: number, stepsDown: number) => void;
}
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampTo = (value: number, low: number, high: number): number =>
Math.min(Math.max(value, low), high);
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
export interface AxisDragSpec {
stepM: number;
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
slopeOutward: number | null;
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
clamp: (left: number, down: number) => [number, number];
apply: (left: number, down: number) => void;
}
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
/**
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
*/
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
const { stepM, slopeOutward: outward } = spec;
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
return {
leftM: left + (outward ?? 0) * down,
downM: outward === null ? down : down / SLOPE_RUN,
text: moveText(left, down),
apply: () => spec.apply(left, down),
};
}
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
function moveText(left: number, down: number): string {
return [
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
]
.filter(Boolean)
.join(" · ");
}
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
export function clampAxis(from: number, move: number, low: number, high: number): number {
return round1(clampTo(from + move, low, high) - from);
}
/**
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
*/
export function bindDragHandle(
root: HTMLElement,
@@ -126,33 +186,22 @@ export function bindDragHandle(
event.clientY <= box.bottom + 3
);
});
const stepM = deps.stepM();
const pxPerM = Number(svg.dataset.pxPerM);
const exaggeration = Number(svg.dataset.exaggeration) || 1;
const ctm = layer.getScreenCTM();
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
event.preventDefault();
event.stopPropagation();
const [startX, startY] = [event.clientX, event.clientY];
let left = 0;
let down = 0;
let snapped: DragSnap | null = null;
const move = (next: PointerEvent): void => {
const dx = next.clientX - startX;
const dy = next.clientY - startY;
const horizontal = Math.abs(dx) >= Math.abs(dy);
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
const tx = -left * stepM * pxPerM;
const ty = down * stepM * pxPerM * exaggeration;
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
snapped = deps.snap(leftM, downM);
const tx = -(snapped?.leftM ?? 0) * pxPerM;
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
const meters = (Math.abs(left || down) * stepM).toFixed(1);
deps.preview(
left
? `${left > 0 ? "◀" : "▶"} ${meters}m`
: down
? `${down > 0 ? "▼" : "▲"} ${meters}m`
: "",
);
deps.preview(snapped?.text ?? "");
};
const up = (): void => {
window.removeEventListener("pointermove", move);
@@ -160,12 +209,12 @@ export function bindDragHandle(
window.removeEventListener("pointercancel", up);
shapes.forEach((shape) => shape.removeAttribute("transform"));
deps.preview("");
if (!left && !down) return;
if (!snapped?.text) return;
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
const swallow = (click: Event): void => click.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
deps.commit(left, down);
snapped.apply();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
@@ -25,6 +25,8 @@ import {
dragReadout,
makeButton,
makeRow,
snapAxisDrag,
type AxisDragSpec,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
canNudge: (key: RevetKey) => boolean;
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
fineMove: (key: RevetKey) => boolean;
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
extraState: () => { canAdd: boolean; count: number };
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
@@ -407,14 +411,13 @@ export function buildStructurePanel(
return { button, set };
};
const LINK_TITLE =
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
"옆 측점 흙막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
const LINK_LOCKED_TITLE =
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 흙막이 Z를 하나로 묶는다.";
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
const gradeToggle = makeToggle(
"경사",
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
"종단경사 반영(3D 전용) — 켜면 3D 흙막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
(on) => deps.setFollowGrade(on),
);
@@ -457,9 +460,8 @@ export function buildStructurePanel(
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
basinReset.classList.add("b06-structure-panel__basin-reset");
head.append(title, basinReset, closeButton);
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
head.append(title, closeButton);
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
@@ -478,22 +480,16 @@ export function buildStructurePanel(
: [basinRow.row, moveRow.row]),
);
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
let dragKind: "wall" | "basin" | null = null;
const drag = bindDragHandle(root, {
stepM: () =>
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
preview: (text) => (dragLine.textContent = text),
commit: (left, down) => {
if (!current) return;
if (dragKind === "basin") {
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
if (down) moveBasinSlope(down);
return;
}
if (left) deps.nudge(current, left * moveStepOf(current));
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
snap: (leftM, downM) => {
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
if (!current || !axes) return null;
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
return snapAxisDrag(leftM, downM, { stepM, ...axes });
},
preview: (text) => (dragLine.textContent = text),
});
return {
@@ -541,7 +537,6 @@ export function buildStructurePanel(
.querySelector(".b06-structure-panel__btn--equal")
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
for (const entry of basinSizeRows) {
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
+5 -4
View File
@@ -213,6 +213,7 @@ export function createCrossSectionCard(
box,
isCardSelected: () => isSelected,
clearArea: () => {
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
if (activeArea === null) return;
activeArea = null;
setBandActive(null);
@@ -235,10 +236,10 @@ export function createCrossSectionCard(
setChipActive(null);
}
if (activeRevet !== null) {
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
// 면적 강조를 이미 비운다.
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
if (wasSelected) onAreaSelect?.(section.station_id, null);
else onSelect(section.station_id);
}
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
}),
nudgeRise: (role, deltaM) =>
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
move: (role, screenDeltaM, riseDeltaM) => {
const from = box.adjustFor(chainageM)[role];
box.update(chainageM, role, {
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
});
},
reset: (role) => box.reset(chainageM, role),
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
@@ -13,7 +13,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford";
export interface FordPanelContext {
section: CrossSection;
ford: FordControl;
/** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */
/** 마지막 계산의 측벽 높이(m) — 읽기 줄. */
heightFor: (role: FordWallRole) => number;
/** 마지막 계산의 바닥판 길이(m). */
slabLengthM: () => number;
@@ -31,8 +31,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
return {
adjustFor: (role) => ford.adjustFor(chainage)[role],
heightFor: context.heightFor,
nudgeHeight: (role, deltaM) =>
ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }),
nudge: (role, screenDeltaM) =>
ford.update(chainage, role, {
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
@@ -41,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
ford.update(chainage, role, {
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
}),
outwardOf: (role) => outwardOf(section, role),
move: (role, screenDeltaM, slopeDeltaM) => {
const from = ford.adjustFor(chainage)[role];
ford.update(chainage, role, {
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
slopeM: from.slopeM + slopeDeltaM,
});
},
reset: (role) => ford.reset(chainage, role),
slabLengthM: context.slabLengthM,
close: context.close,
@@ -8,10 +8,13 @@
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_FORM_DEFAULT,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
fineMove: (key) =>
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
extraState: () => ctx.extraState(),
isLinkedCard: () => ctx.isLinked,
basinExtraState: () => ctx.basinExtraState(),
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
};
}
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
const DRAG_LIMIT_M = 10;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/**
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
*/
function wallDragAxes(
ctx: StructurePanelContext,
key: RevetKey,
basin: boolean,
): Omit<AxisDragSpec, "stepM"> | null {
if (!ctx.revetOffset) return null;
const outward = ctx.outwardOf(basin ? "inlet" : key);
if (basin) {
const from = ctx.inletStructure?.adjustFor(ctx.section);
if (!from) return null;
const lateralMove = (left: number): number =>
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
return {
slopeOutward: outward,
clamp: (left, down) => [
lateralMove(left) * outward,
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
],
apply: (left, down) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...from,
lateralM: round1(from.lateralM + left * outward),
slopeM: round1(from.slopeM + down),
}),
};
}
const adjust = ctx.adjustOf(key);
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
const diagonal = key === "inlet" || key === "outlet";
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
// 독립 = 성토선 각도(1.2·d − 기본 run)
const xLow = (d: number): number =>
isTierKey(key)
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
: key === "own"
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
: ctx.bodyGuard?.()
? -DRAG_LIMIT_M
: 0;
return {
slopeOutward: diagonal ? outward : null,
clamp: (left, down) => {
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
},
apply: (left, down) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: round1(adjust.x + left * outward),
d: round1(d0 + down),
}),
};
}
/**
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
@@ -109,6 +109,8 @@ export interface FordControlDeps {
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
round1: (value: number) => number;
clampMove: (value: number) => number;
/** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */
releaseWall: (chainageM: number) => void;
}
export interface FordControls {
@@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
fordSelections.set(chainageM.toFixed(2), role);
},
};
@@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): {
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
selections.set(chainageM.toFixed(2), role);
},
};
@@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
select: (chainageM, key) => {
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
const { ford, box } = bodyControls;
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
revetOffsetControl.onSelectionChange?.();
},
syncApplied: (chainageM, role, applied) => {
@@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
releaseWall: (chainageM) => {
if (revetSelection?.at !== chainageM.toFixed(2)) return;
revetSelection = null;
revetOffsetControl.onSelectionChange?.();
},
},
deps.sessionKey,
);
+33 -3
View File
@@ -19,6 +19,7 @@ import type {
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
@@ -197,6 +198,26 @@ export function createSectionView(
};
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
/**
* 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
*/
let spanMarks: ReadonlyArray<StructureInstance> = [];
let spanMarkId: string | null = null;
const inSpan = (section: CrossSection): boolean => {
const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null;
const { start_m: start, end_m: end } = mark ?? {};
if (start == null || end == null || start === end) return false;
const at = section.chainage_m;
return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01;
};
const syncSpanHighlights = (): void => {
for (const section of currentDetail?.cross_sections ?? [])
document
.getElementById(`cross-${section.station_id}`)
?.classList.toggle("b06-cross-card--span", inSpan(section));
};
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
let stationSelectListener: ((stationId: string | null) => void) | null = null;
@@ -269,8 +290,8 @@ export function createSectionView(
section: CrossSection,
forcedHeightPx?: number,
plotBase?: CrossPlotBase | null,
): HTMLElement =>
createCrossSectionCard(
): HTMLElement => {
const card = createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
@@ -296,6 +317,9 @@ export function createSectionView(
plotBase,
cutSlope,
);
card.classList.toggle("b06-cross-card--span", inSpan(section));
return card;
};
/**
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
@@ -483,7 +507,11 @@ export function createSectionView(
);
}
},
selectMark: (structureId) => profile.selectMark(structureId),
selectMark: (structureId) => {
spanMarkId = structureId;
syncSpanHighlights();
profile.selectMark(structureId);
},
setStationSelectListener(listener) {
stationSelectListener = listener;
},
@@ -491,6 +519,8 @@ export function createSectionView(
structureEdit = edit;
},
setStructureMarks(structures, types) {
spanMarks = structures;
syncSpanHighlights();
profile.setMarks(structures, types);
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
// (`__corridorBuild` 등과 같은 용도).
@@ -175,6 +175,12 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
}
/* 구간형 구조물(노면공 등)을 고르면 시작~종료 안 모든 카드(106-1) — 가까운 한 장의 선택 강조보다 옅게. */
.b06-cross-card--span:not(.b06-cross-card--selected) {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
}
/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */
.b06-cross-card > header {
display: flex;
@@ -174,8 +174,9 @@
grid-area: se;
}
.b06-structure-panel__basin-reset {
margin-left: auto;
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
display: none;
}
.b06-structure-panel__select {
@@ -297,11 +298,6 @@
display: none;
}
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
display: none;
}
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
.b06-structure-panel--dock .b06-structure-panel__struct--form,
@@ -180,7 +180,10 @@ export interface PlanKind {
id: string;
name: string;
pieces: string[];
/** 양식 — plan(평면도 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). */
layout: PlanKindLayout;
}
export type PlanKindLayout = "plan" | "watershed";
export interface PlanMark {
chainage_m: number;
refuge: boolean;
@@ -202,10 +205,11 @@ export function addPlanKind(
projectId: string,
name: string,
pieces: string[],
layout: PlanKindLayout = "plan",
): Promise<PlanKindsState> {
return requestJson(`/projects/${projectId}/plan-kinds`, {
method: "POST",
body: JSON.stringify({ name, pieces }),
body: JSON.stringify({ name, pieces, layout }),
});
}
@@ -110,33 +110,31 @@ def ip_blocks(
def _place(blocks: list[dict[str, Any]], width_mm: float) -> None:
"""묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄."""
"""묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 → 오른쪽 · 위 → 아래(첫 줄 맨 위).
묶음 전체는 도곽 왼쪽 아래 기준 — 줄이 늘면 위로 쌓이고 마지막 줄이 맨 아래다.
"""
block_w = sum(_COLUMNS)
per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w))
lines = -(-len(blocks) // per_line)
for order, block in enumerate(blocks):
line, column = divmod(order, per_line)
top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H
top = _MARGIN + (lines - 1 - line) * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H
block["origin"] = [_MARGIN + column * block_w, top]
block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS
def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None:
"""표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다."""
min_x, min_y = box[0], box[1]
width = sum(_COLUMNS) / MM
area = unary_union(
[
rect(
min_x + b["origin"][0] / MM,
min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM,
min_x + b["origin"][0] / MM + width,
min_y + b["origin"][1] / MM,
)
for b in blocks
]
)
def clip_lines(
pieces: dict[str, dict[str, Any]],
rects: list[tuple[float, float, float, float]],
names: tuple[str, ...],
) -> None:
"""상자들(사업지 m · 최소 x · y · 최대 x · y) 밑 선 조각 `names` 를 상자 밖으로 잘라 낸다."""
if not rects:
return
area = unary_union([rect(*bounds) for bounds in rects])
inside = prep(area)
for piece in _CLIPPED:
for piece in names:
lines = (pieces.get(piece) or {}).get("lines")
if not lines:
continue
@@ -155,6 +153,22 @@ def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box:
pieces[piece]["lines"] = kept
def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None:
"""표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다."""
min_x, min_y = box[0], box[1]
width = sum(_COLUMNS) / MM
rects = [
(
min_x + b["origin"][0] / MM,
min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM,
min_x + b["origin"][0] / MM + width,
min_y + b["origin"][1] / MM,
)
for b in blocks
]
clip_lines(pieces, rects, _CLIPPED)
def ip_table_piece(
pieces: dict[str, dict[str, Any]],
curves: list[dict[str, Any]],
@@ -75,7 +75,7 @@ LEGACY_PIECES: dict[str, tuple[str, ...]] = {
_OUTLINE = LEGACY_PIECES["slope_outline"]
_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius")
# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
{
@@ -100,6 +100,8 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
"name": "용지도",
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
},
# 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장).
{"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"},
)
_COLORS = {
@@ -127,7 +129,7 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
"emd": (2, [8, 4]),
"sgg": (3, [14, 5, 3, 5]),
}
_PARCEL_FONT = 1.8
PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
_LABEL_FONT = 2.5
@@ -518,7 +520,7 @@ def _piece_entities(
"rect",
layer=layer,
color=color,
font=_PARCEL_FONT,
font=PARCEL_FONT,
)
)
return out
@@ -39,14 +39,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
MASS_HAUL_ID,
WATERSHED_ID,
_cross_sheet_plan,
_drawing_list,
_invalidate_drawing,
_read_drawing,
_read_json,
_store_confirmed_drawing,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
@@ -322,12 +320,6 @@ async def get_design_drawing(
async with pool.acquire() as connection:
section = await get_longitudinal_section(connection, project_id, route_id)
source_design = ((section or {}).get("data") or {}).get("mass_haul")
elif drawing_id == WATERSHED_ID:
# 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다.
context, reason = await load_drainage_context(project_id)
if context is None:
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
source_design = await asyncio.to_thread(watershed_source, context)
elif drawing_id == CROSS_STANDARD_ID:
# 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값).
pool = get_db_pool()
@@ -34,6 +34,8 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
class PlanKindRequest(BaseModel):
name: str
pieces: list[str] = Field(default_factory=list)
# 양식 — plan(평면도 · 조각 고름) · watershed(유역도). 추가에서만 쓰고 업데이트는 그대로 둔다.
layout: str = "plan"
class PlanMark(BaseModel):
@@ -79,10 +81,10 @@ async def get_plan_kinds(project_id: UUID) -> Any:
@router.post("/{project_id}/plan-kinds")
async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
"""종류 추가 — 조각을 골라 이름을 붙인다."""
"""종류 추가 — 양식 · 조각을 골라 이름을 붙인다."""
try:
root = await _project_root(project_id)
await asyncio.to_thread(add_kind, root, request.name, request.pieces)
await asyncio.to_thread(add_kind, root, request.name, request.pieces, request.layout)
return await asyncio.to_thread(_state, root)
except ValueError as exc:
return _error(400, exc)
@@ -76,6 +76,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
PLAN_KIND_ID,
WATERSHED_LAYOUT,
plan_kind_items,
)
from B07_DesignDetail.B07_DesignDetail_Schema import (
@@ -92,7 +93,6 @@ _CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed"
COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
@@ -156,7 +156,6 @@ def _drawing_list(
(COVER_ID, "cover", "표지"),
(CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL),
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
):
drawings.append(
DesignDrawingItem(
@@ -164,12 +163,6 @@ def _drawing_list(
kind=kind,
label=label,
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
# 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다.
**(
{"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"}
if drawing_id == WATERSHED_ID
else {}
),
)
)
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
@@ -405,7 +398,7 @@ def _read_drawing(
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
if saved.get("format") == DRAWING_FORMAT:
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID):
if drawing_id in (COVER_ID, MASS_HAUL_ID, CROSS_STANDARD_ID):
kind = drawing_id # id와 kind가 같은 단장 도면
elif PLAN_KIND_ID.fullmatch(drawing_id):
kind = "plan"
@@ -451,36 +444,22 @@ def _read_drawing(
if not isinstance(stored_design, dict):
raise FileNotFoundError("계획평면도 자료가 없습니다.")
label = str(stored_design.get("label") or drawing_id)
return (
"plan",
label,
build_plan_kind_drawing(
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
),
False,
None,
)
if drawing_id == WATERSHED_ID:
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
if not isinstance(stored_design, dict):
raise FileNotFoundError("배수 유역 산출물이 없습니다.")
longitudinal = _read_json(longitudinal_path)
interval = infer_station_interval(longitudinal.get("stations") or [])
return (
"watershed",
"유역도(배수 유역도)",
build_watershed_drawing(
if stored_design.get("layout") == WATERSHED_LAYOUT:
# 유역도 양식 — watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
longitudinal = _read_json(longitudinal_path)
drawing = build_watershed_drawing(
drawing_id,
stored_design.get("route_xy") or [],
stored_design.get("basins") or [],
stored_design.get("contours") or [],
stored_design.get("streams") or [],
interval,
),
False,
None,
)
infer_station_interval(longitudinal.get("stations") or []),
)
else:
drawing = build_plan_kind_drawing(
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
)
return "plan", label, drawing, False, None
if _LONG_ID.fullmatch(drawing_id):
source = _read_json(longitudinal_path)
@@ -20,7 +20,6 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed"
COVER_ID = "cover"
# ⚠ 빈-도각 목록이 여기 있었다 — 계획평면도·표준 횡단면도·용지도·표준도 일곱 줄.
@@ -19,16 +19,18 @@ from shapely.validation import make_valid
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PARCEL_FONT
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features
LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg")
# 필지 정보 상자 밑에서 잘라 낼 선 조각 — 배경이 글을 가리지 않게(109-4).
LABEL_CLIPPED = ("contour", "stream", "parcel", "emd", "sgg")
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
PARCEL_FILE = "연속지적도_bounds.geojson"
EMD_FILE = "행정구역_읍면동_bounds.geojson"
SGG_FILE = "행정구역_시군구_bounds.geojson"
PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm)
ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m)
_GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m)
_RINGS = 5 # 후보 자리 고리 수
@@ -187,6 +189,17 @@ def place_labels(
return items
def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]:
"""필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기."""
rects = []
for item in items:
width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT)
half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0
cx, cy = item["center"]
rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h))
return rects
def landuse_pieces(
project_root: Path,
crs: str,
@@ -1,12 +1,14 @@
"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장).
저장 자리(프로젝트 저장소 `B07_DesignDetail/`):
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout} — 파일 하나씩
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다.
`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를
쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각
없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도
고치고 지울 수 있다.
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
"""
@@ -24,7 +26,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
_structure_label,
plan_area_mm,
@@ -47,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
_sheet_box,
map_background,
plan_stations,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_design_root,
@@ -55,7 +58,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
_write_manifest,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
LABEL_CLIPPED,
LANDUSE_PIECES,
label_rects,
landuse_pieces,
)
from common_util.common_util_json import atomic_write_json
@@ -65,6 +70,8 @@ from config.config_system import DRAWING_SCALE_PLAN
KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$")
PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$")
_NAME_MAX = 40
WATERSHED_LAYOUT = "watershed"
_LAYOUTS = ("plan", WATERSHED_LAYOUT)
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측)
@@ -80,16 +87,24 @@ def _kinds_dir(project_root: Path) -> Path:
return _design_root(project_root) / "plan_kinds"
# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
_LANDUSE_MARK = "_landuse_added"
# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
_ADDED_ONCE = ("landuse", "watershed")
def _add_landuse_once(folder: Path) -> None:
"""93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다."""
mark = folder / _LANDUSE_MARK
if mark.exists():
def _document(kind: dict[str, Any], order: int) -> dict[str, Any]:
return {
"name": kind["name"],
"pieces": list(kind["pieces"]),
"order": order,
"layout": kind.get("layout", "plan"),
}
def _add_defaults_once(folder: Path) -> None:
"""기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다."""
pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()]
if not pending:
return
landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse")
documents = []
for path in folder.glob("*.json"):
try:
@@ -97,21 +112,23 @@ def _add_landuse_once(folder: Path) -> None:
except (OSError, ValueError):
continue
names = {str(document.get("name")) for document in documents}
if not (folder / "landuse.json").exists() and landuse["name"] not in names:
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
atomic_write_json(
folder / "landuse.json",
{"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order},
)
mark.touch()
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
for kind_id in pending:
kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id)
if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names:
atomic_write_json(folder / f"{kind_id}.json", _document(kind, order))
order += 1
(folder / f"_{kind_id}_added").touch()
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류."""
"""종류 목록 [{id, name, pieces, layout}] — 차례대로. 파일이 없으면 기본 종류."""
folder = _kinds_dir(project_root)
if not folder.is_dir():
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
_add_landuse_once(folder)
return [
{"layout": "plan", **kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS
]
_add_defaults_once(folder)
rows: list[tuple[int, str, dict[str, Any]]] = []
for path in folder.glob("*.json"):
if not KIND_ID.fullmatch(path.stem):
@@ -123,67 +140,76 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]:
stored = set()
for piece in document.get("pieces") or []:
stored.update(LEGACY_PIECES.get(piece, (piece,)))
pieces = [piece for piece in PIECE_IDS if piece in stored]
layout = document.get("layout") if document.get("layout") in _LAYOUTS else "plan"
pieces = [] if layout == WATERSHED_LAYOUT else [p for p in PIECE_IDS if p in stored]
rows.append(
(
int(document.get("order") or 0),
path.stem,
{"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces},
{
"id": path.stem,
"name": str(document.get("name") or path.stem),
"pieces": pieces,
"layout": layout,
},
)
)
return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])]
def _materialize(project_root: Path) -> list[dict[str, Any]]:
"""기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
"""기본 종류를 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
kinds = list_kinds(project_root)
folder = _kinds_dir(project_root)
if not folder.is_dir():
for order, kind in enumerate(kinds):
atomic_write_json(
folder / f"{kind['id']}.json",
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
)
(folder / _LANDUSE_MARK).touch()
atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order))
for kind_id in _ADDED_ONCE:
(folder / f"_{kind_id}_added").touch()
return kinds
def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]:
def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, list[str]]:
text = str(name or "").strip()
if not text or len(text) > _NAME_MAX:
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
if layout not in _LAYOUTS:
raise ValueError("올바르지 않은 양식입니다.")
if layout == WATERSHED_LAYOUT:
return text, [] # 유역도 양식은 그림이 정해져 있다 — 조각을 고르지 않음
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
if not chosen:
raise ValueError("얹을 정보를 하나 이상 고르세요.")
return text, chosen
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
text, chosen = _checked(name, pieces)
def add_kind(
project_root: Path, name: str, pieces: list[str], layout: str = "plan"
) -> dict[str, Any]:
text, chosen = _checked(name, pieces, layout)
kinds = _materialize(project_root)
if any(kind["name"] == text for kind in kinds):
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
kind_id = f"k{uuid.uuid4().hex[:8]}"
atomic_write_json(
_kinds_dir(project_root) / f"{kind_id}.json",
{"name": text, "pieces": chosen, "order": len(kinds)},
)
return {"id": kind_id, "name": text, "pieces": chosen}
kind = {"id": f"k{uuid.uuid4().hex[:8]}", "name": text, "pieces": chosen, "layout": layout}
atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds)))
return kind
def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]:
"""종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다."""
"""종류 이름 · 조각을 고친다(차례 · 양식은 그대로). 그 종류의 확정본은 풀어 다시 그린다."""
if not KIND_ID.fullmatch(kind_id):
raise ValueError("올바르지 않은 종류 id 입니다.")
text, chosen = _checked(name, pieces)
kinds = _materialize(project_root)
current = next((kind for kind in kinds if kind["id"] == kind_id), None)
path = _kinds_dir(project_root) / f"{kind_id}.json"
if current is None or not path.is_file():
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
text, chosen = _checked(name, pieces, current["layout"])
if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds):
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
path = _kinds_dir(project_root) / f"{kind_id}.json"
if not path.is_file():
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
order = int(_read_json(path).get("order") or 0)
atomic_write_json(path, {"name": text, "pieces": chosen, "order": order})
kind = {"id": kind_id, "name": text, "pieces": chosen, "layout": current["layout"]}
atomic_write_json(path, _document(kind, order))
manifest = _read_manifest(project_root)
released = False
for drawing_id, entry in manifest["drawings"].items():
@@ -193,7 +219,7 @@ def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str])
released = True
if released:
_write_manifest(project_root, manifest)
return {"id": kind_id, "name": text, "pieces": chosen}
return kind
def delete_kind(project_root: Path, kind_id: str) -> None:
@@ -449,6 +475,8 @@ def build_pieces(
pieces.update(
landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route])
)
if "parcel_info" in wanted:
clip_lines(pieces, label_rects(pieces["parcel_info"]["items"]), LABEL_CLIPPED)
if "ip_table" in wanted:
pieces["ip_table"] = ip_table_piece(
pieces, _load_design_curves(project_root), route, box, chunk, total
@@ -477,6 +505,9 @@ def plan_kind_source(
)
if kind is None:
raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.")
if kind["layout"] == WATERSHED_LAYOUT:
# 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장.
return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)}
number = int(match.group(2)) if match.group(2) else 1
pieces, box, chunk, total = build_pieces(
context, longitudinal, longitudinal_path, designs, number, kind["pieces"]
@@ -494,6 +525,16 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di
sheets = plan_chunks(plan_stations(longitudinal))
items = []
for kind in list_kinds(project_root):
if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음
items.append(
{
"id": f"plan_{kind['id']}",
"label": kind["name"],
"plan_kind": kind["id"],
"plan_kind_name": kind["name"],
}
)
continue
title = plan_title(kind["name"])
for chunk in sheets:
single = len(sheets) <= 1
@@ -29,7 +29,7 @@ export const DRAWING_GROUPS: readonly {
idPrefix?: string;
}[] = [
{ label: "표지", kind: "cover" },
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로.
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도 · 사용자 추가)를 한 묶음으로.
{ label: "평면도", kind: "plan" },
{ label: "종단도", kind: "longitudinal" },
// 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5).
@@ -89,7 +89,7 @@ export function buildDrawingSidePanel(
for (const group of DRAWING_GROUPS) {
if (group.kind === "plan") {
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed");
const plans = drawings.filter((item) => item.kind === "plan");
panel.append(buildPlanSection(plans, drawingButton, plan));
continue;
}
@@ -4,11 +4,11 @@
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
* 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
* 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { showToast } from "@ui/ui_template_elements";
import { createSelectField, showToast } from "@ui/ui_template_elements";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import {
addPlanKind,
@@ -17,6 +17,7 @@ import {
savePlanMarks,
updatePlanKind,
type DesignDrawingItem,
type PlanKindLayout,
type PlanKindsState,
type PlanMark,
} from "./B07_DesignDetail_Api_Fetch";
@@ -51,15 +52,31 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
}
}
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */
/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */
const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
{ value: "plan", text: "평면도" },
{ value: "watershed", text: "유역도" },
];
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
const form = el("div", "b07-plan-tools__block");
form.append(el("strong", "", "종류"));
const pick = el("select", "b07-plan-tools__kind");
pick.append(
...state.kinds.map((kind) => new Option(kind.name, kind.id)),
new Option("새 종류", ""),
);
const pick = createSelectField({
options: [
...state.kinds.map((kind) => ({ value: kind.id, text: kind.name })),
{ value: "", text: "새 종류" },
],
compact: true,
ariaLabel: "종류",
onChange: () => show(),
});
const layout = createSelectField({
options: LAYOUTS,
compact: true,
ariaLabel: "양식",
onChange: () => showPieces(),
});
const name = el("input", "b07-plan-tools__name");
name.type = "text";
name.placeholder = "종류 이름";
@@ -69,27 +86,35 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme
pieceList.append(...boxes.map((box) => box.root));
const action = el("button", "b07-plan-tools__button");
action.type = "button";
// 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다.
const showPieces = (): void => {
pieceList.hidden = layout.select.value === "watershed";
};
const show = (): void => {
const kind = state.kinds.find((item) => item.id === pick.value);
const kind = state.kinds.find((item) => item.id === pick.select.value);
name.value = kind?.name ?? "";
boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false));
// 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만.
layout.select.value = kind?.layout ?? "plan";
layout.select.disabled = Boolean(kind);
action.textContent = kind ? "업데이트" : "추가";
showPieces();
};
pick.addEventListener("change", show);
show();
action.addEventListener("click", () => {
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
const kindId = pick.value;
const kindId = pick.select.value;
const label = name.value.trim();
const chosen = layout.select.value as PlanKindLayout;
void run(
() =>
kindId
? updatePlanKind(controls.projectId, kindId, label, pieces)
: addPlanKind(controls.projectId, label, pieces),
: addPlanKind(controls.projectId, label, pieces, chosen),
controls,
);
});
form.append(pick, name, pieceList, action);
form.append(pick.root, layout.root, name, pieceList, action);
return form;
}
@@ -173,8 +198,7 @@ export function buildPlanSection(
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
// × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404).
if (controls && sheets[0].kind === "plan") {
if (controls) {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
@@ -279,7 +279,10 @@
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
.b07-plan-tools__kind,
.b07-plan-tools__pieces[hidden] {
display: none;
}
.b07-plan-tools__name {
min-width: 0;
}
+38 -6
View File
@@ -1,7 +1,8 @@
"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96).
표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값.
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음.
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사는 B06 횡단 경사(110) ·
관 종류 · n 은 안 이음.
근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본).
"""
@@ -17,6 +18,7 @@ from config.config_system_design import (
DRAINAGE_RUNOFF_COEFFICIENT,
DRAINAGE_TC_MIN_MINUTES,
)
from M02_MasterTemplete.M02_Table_Fill import _nearest
#: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준
FACTORS = (2.0, 1.5, 1.2)
@@ -66,6 +68,11 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
}
#: 장 → B06 횡단 정본 경사 키(110) — 관 = 관 바닥 두 끝 경사 · 박스 = 박스 두 끝 원지반 경사
#: (본체는 늘 수평) · 세월교 · 물넘이는 장 기본값(B05 세월교 높이 계산과 같은 값)
SLOPE_KEYS = {"pipe": "pipe_slope_pct", "box": "box_ground_slope_pct"}
def _positive(value: Any) -> float | None:
"""양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸)."""
try:
@@ -75,9 +82,29 @@ def _positive(value: Any) -> float | None:
return number if number > 0 else None
def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]:
"""B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션."""
def bed_slopes(designs: list[dict[str, Any]] | None) -> dict[str, dict[float, float]]:
"""B06 횡단 설계 `[{chainage_m, design}]` → 장별 `{측점: 경사 %}` — 0 % 이하는 안 담음(빈칸)."""
found: dict[str, dict[float, float]] = {sheet: {} for sheet in SLOPE_KEYS}
for row in designs or []:
design = row.get("design") or {}
for sheet, key in SLOPE_KEYS.items():
pct = _positive(design.get(key))
if pct:
found[sheet][round(float(row.get("chainage_m") or 0.0), 3)] = pct
return found
def _linked(
sheet: str,
options: dict[str, Any],
basin: dict[str, Any] | None,
slope_pct: float | None = None,
) -> dict[str, Any]:
"""연결값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션 ·
하상경사 S = 1 / 분모 는 B06 경사(110 · 0 % 이하면 빈칸 · 손 입력)."""
values: dict[str, Any] = {}
if slope_pct:
values["slope_denominator"] = round(100 / slope_pct, 3)
if basin is not None:
area_m2 = _positive(basin.get("area_m2"))
values["area_km2"] = area_m2 / 1e6 if area_m2 else None
@@ -94,13 +121,17 @@ def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -
def station_rows(
points: list[Any], basins: list[dict[str, Any]] | None = None
points: list[Any],
basins: list[dict[str, Any]] | None = None,
designs: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).
basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은
`carry_indices`(±0.05 m) · 안 주면 둘 다 None.
`carry_indices`(±0.05 m) · 안 주면 둘 다 None. designs(B06 횡단 설계) 경사는 관 지점과
가장 가까운 횡단 측점(±0.5 m · 관 연장과 같은 짝)에서.
"""
slopes = bed_slopes(designs)
points = sorted(points, key=lambda p: p.chainage_m)
picks = (
carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins])
@@ -114,7 +145,8 @@ def station_rows(
row["linked"] = row["b05"] = None
if basins is not None:
basin = basins[pick] if pick is not None else None
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin)
slope = _nearest(slopes.get(sheet, {}), p.chainage_m)
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin, slope)
if basin is not None:
row["b05"] = {
"design_flow_m3s": basin.get("design_flow_m3s"),
+18 -4
View File
@@ -1,12 +1,13 @@
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12).
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 · B06 연결값
+ 기본값 · 선택지 + 공사지(지역 · R24) + 저장본
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) —
화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) ·
하상경사는 B06 횡단 설계(관 경사 · 박스 원지반 경사 · 110) — 화면이 빈칸만 채움 · 관 종류 · n 은
잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
"""
from __future__ import annotations
@@ -19,6 +20,11 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
from B06_Section.B06_Section_Repository import (
get_confirmed_route_context,
get_cross_section_designs,
get_workflow_route_context,
)
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows
@@ -81,7 +87,15 @@ async def _project_root(project_id: UUID) -> Path:
@router.get("/{project_id}/quantity/hydro")
async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
root = await _project_root(project_id)
stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root))
# 설계 = B08 집계표와 같은 길(확정 노선 먼저) · 노선 전이면 경사 없이
async with get_db_pool().acquire() as connection:
context = await get_confirmed_route_context(connection, project_id)
context = context or await get_workflow_route_context(connection, project_id)
designs = (
await get_cross_section_designs(connection, int(context["route_id"])) if context else []
)
points = read_pipe_points_file(pipe_points_path_in(root))
stations = station_rows(points, read_basins(root), designs)
project = await get_project_by_id(str(project_id)) or {}
return {
"stations": stations,
+10 -6
View File
@@ -7,7 +7,8 @@
* 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12)
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」.
* B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2).
* 연결값과 같은 칸은 이름 옆 작은 원천 표 — 「B05」(풍선 「B05 분석」) · 하상경사는 「B06」
* (풍선 「B06 횡단 경사」 · 110) · Qd · T 옆에 B05 값 참고(96-2).
*
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
* ========================================================================== */
@@ -78,7 +79,7 @@ export interface HydroOptions {
site: HydroSite;
}
/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */
/** 연결(96 · 110) — 칸 값(B05 배수유역 · 하상경사는 B06 횡단) · 비교용 설계유량 · 도달시간(분) */
export interface HydroLink {
values: Partial<Record<keyof HydroInputs, number>>;
b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null;
@@ -236,7 +237,7 @@ export function buildHydroSheet(
link: HydroLink = { values: {}, b05: null },
): HydroSheet {
const site = options.site;
/** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */
/** 연결된 칸의 원천 표(「B05」 · 「B06」) — 값이 연결값과 같을 때만 보임 */
const marks = new Map<Key, HTMLElement>();
const markLinked = (key: Key): void => {
const mark = marks.get(key);
@@ -263,9 +264,12 @@ export function buildHydroSheet(
input.value = value == null ? "" : String(value);
const linked = link.values[key];
if (linked != null) {
input.title = `B05 분석 ${linked}`;
const mark = node("span", "b08-hydro__hint", "B05");
mark.title = "B05 분석";
// 원천 표 — 하상경사는 B06 횡단 경사(관 바닥 · 박스 원지반 두 끝 · 110) · 그 밖은 B05
const [source, title] =
key === "slope_denominator" ? ["B06", "B06 횡단 경사"] : ["B05", "B05 분석"];
input.title = `${title} ${linked}`;
const mark = node("span", "b08-hydro__hint", source);
mark.title = title;
mark.dataset.b08HydroLinked = key;
marks.set(key, mark);
}
@@ -43,6 +43,11 @@
overflow: auto;
}
/* 본문 칸은 스크롤 칸이 아님(바깥 층이 스크롤) — 오른쪽 패널이 아래로 삐져나와 막대가 생겼다 사라지는 떨림 방지 */
.m01-logic__main {
overflow: visible;
}
.m01-logic__calc {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
+14 -3
View File
@@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]:
return out
def seed_picks(labor: Any) -> dict[str, Any]:
"""일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1)."""
doc = labor.get("줄") if isinstance(labor, dict) else None
return {
str(line["로직"]): dict(line["고름"])
for line in (doc or {}).get("담은로직") or []
if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict)
}
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
"""원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면
LookupError."""
"""원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` ·
SS 줄이 없으면 LookupError."""
files = build_source(item)
return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}}
labor = files.get(LABOR_FILE)
return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)}
def source_files(files: dict[str, Any]) -> dict[str, Any]:
+5 -1
View File
@@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
doc = (labor or {}).get("줄") or {}
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
]
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
return files
+85 -10
View File
@@ -1,7 +1,7 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
@@ -14,11 +14,15 @@
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
같은 품목 두 번은 [저장] 때 400.
"""
from __future__ import annotations
import json
import re
from decimal import Decimal, InvalidOperation
from typing import Any
@@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
MATERIAL_HEAD = "자재|"
_MAT_COLS = ("코드", "이름", "규격", "단위")
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
def _bad(text: str) -> store.StoreError:
@@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]:
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
rate = _rate(one.get("켜쌓기율"), code, where)
picks = _picks(one.get("고름"), logic, whole, where)
lines.append(
{
"로직": code,
@@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]:
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
"수량": _qty(one.get("수량"), where),
**({"켜쌓기율": rate} if rate is not None else {}),
**({"고름": picks} if picks else {}),
}
)
mats, codes = [], set()
@@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None:
return alias or None
def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None:
"""고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None."""
if not value:
return None
if not isinstance(value, dict):
raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}")
rows = logic.get("호표") or []
out = {}
for at, key in value.items():
n = int(at) if str(at).isdigit() else -1
if not 0 <= n < len(rows) or rows[n].get("종류") != "재료":
raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님")
try:
ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None
except mf.FormulaError:
ok = False
if not ok:
raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음")
out[str(n)] = str(key)
return out
def _rate(value: Any, code: str, where: str) -> int | float | None:
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
if value is None or value == "":
@@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
"분모": pill.get("단위"),
"배": pill.get("배") or 1,
}
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
lines.append({**line, **got})
mats = [
{
"코드": one["코드"],
@@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
return settle(_json({"줄": lines, "자재": mats}), doc, have)
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
def run_line(
whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None
) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진
`choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
try:
logic = whole.logic(code)
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
with mf.mp.choosing(picks):
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"기준": None, "까닭": str(e) or type(e).__name__}
if "결과" in got:
return {"결과": got["결과"], "기준": None, "까닭": None}
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
money = {k: got[k] for k in (*COSTS, "계")}
mats = logic_mats(whole, logic, got["줄"], picks)
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
"""로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직
재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이
바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`)."""
if isinstance(logic.get("흐름"), dict):
return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임
names = {str(spec.get("이름")) for spec in _specs(logic)}
out = []
for n, item in enumerate(logic.get("호표") or []):
if item.get("종류") != "재료" or n >= len(lines):
continue
line, goods = lines[n], lines[n].get("품목")
try:
row = (whole.get(goods["키"]) if goods else None) or {}
except mf.FormulaError:
row = {}
cond = item.get("요소")
texts = cond.values() if isinstance(cond, dict) else [cond]
tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)}
out.append(
{
"차례": n,
"이름": item.get("이름") or "",
"단위": item.get("단위") or "",
**{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")},
"품목": goods,
"가격": {slot: row.get(slot) for slot in mm.SLOTS},
"고름": str(n) in (picks or {}),
"걸림": sorted(tied if tied <= names else names) if tied else [],
}
)
return out
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
@@ -421,6 +490,12 @@ def _json(value: Any) -> Any:
def server(doc: Any) -> dict[str, Any]:
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) ·
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
got = clean(doc)
return {**got, "계산값": calc(got)}
out = calc(got)
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
twice = sorted({m["코드"] for m in got["자재"]} & inside)
if twice:
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
return {**got, "계산값": out}
@@ -0,0 +1,53 @@
/* =============================================================================
* M02_Labor_LogicMats_Model.ts
* 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) ·
* 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`).
* 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼).
* ========================================================================== */
/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */
export interface LogicMatItem {
키: string;
이름?: string | null;
규격?: string | null;
단위?: string | null;
값칸?: string | null;
}
/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */
export interface LogicMat {
차례: number;
이름: string;
단위: string;
수량: number | null;
단가: number | null;
금액: number | null;
까닭?: string | null;
품목: LogicMatItem | null;
/** M01 자재품목 값 열 다섯(빈 값 null) */
가격?: Record<string, number | null>;
고름: boolean;
/** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */
걸림: string[];
}
/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */
export function releaseTied<
T extends { 입력값: Record<string, unknown>; 고름?: Record<string, string> },
>(row: T, before: Record<string, unknown>, mats: readonly LogicMat[]): T {
if (!row.고름) return row;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]);
const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k]));
for (const mat of mats)
if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)];
if (!Object.keys(row.고름).length) delete row.고름;
return row;
}
/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */
export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set<string> {
return new Set(
(lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))),
);
}
+6 -2
View File
@@ -370,7 +370,9 @@ class Expand:
line = (doc.get("담은로직") or [{}])[0]
code = str(line.get("로직") or "")
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
got = labor.run_line(
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
)
times = labor._number(doc.get("배")) or Decimal(1)
base = got.get("기준")
why = got.get("까닭") or (None if base else "돈 아닌 로직")
@@ -397,7 +399,9 @@ class Expand:
from M02_MasterTemplete import M02_Labor_Doc as labor
pill = pill or {}
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
got = labor.run_line(
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
times = labor._number(pill.get("배")) or Decimal(1)
base = got.get("기준")
return {
@@ -189,6 +189,9 @@ export interface StructureNumbers {
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
일위대가합계?: number;
합계기준?: boolean;
/** 합계 칸 색(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` · 까닭 글은 칸 툴팁 */
합계상태?: "ok" | "short" | "error";
합계까닭?: string;
}
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
@@ -53,6 +53,16 @@
font-size: var(--text-caption);
}
/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */
.m02-labrow__logic-mat .m02-labrow__money {
color: var(--color-text-secondary);
}
.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key {
color: var(--color-accent);
font-weight: 600;
}
.m02-labrow__money,
.m02-labrow__num {
text-align: right;
@@ -90,10 +100,10 @@
white-space: nowrap;
}
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */
.m02-labrow__order {
display: inline-flex;
align-items: center;
flex-direction: column;
gap: 2px;
}
@@ -116,14 +126,37 @@
box-shadow: inset 0 2px 0 var(--color-primary);
}
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */
.m02-labrow tr.is-dragging {
opacity: 0.5;
}
.m02-labrow tr.is-dragunder td {
box-shadow: inset 0 -2px 0 var(--color-primary);
}
/* 손잡이(102-1) — 차례 칸 앞 */
.m02-labrow__no {
white-space: nowrap;
}
/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */
.m02-labrow__tools {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
align-items: stretch;
gap: var(--btn-group-gap);
}
.m02-labrow__tools > .ui-btn-group {
align-items: stretch;
}
/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */
.m02-labrow__book {
display: block;
font-size: var(--text-caption);
}
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
.m02-labrow .m02-labrow__optrow td {
padding-top: 0;
@@ -396,6 +429,20 @@
grid-column: 1 / -1;
max-width: none;
}
/* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */
.m02-labrow tr.is-dragover td,
.m02-labrow tr.is-dragunder td {
box-shadow: none;
}
.m02-labrow tr.is-dragover {
box-shadow: inset 0 2px 0 var(--color-primary);
}
.m02-labrow tr.is-dragunder {
box-shadow: inset 0 -2px 0 var(--color-primary);
}
}
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
@@ -131,6 +131,8 @@ export interface LaborRow {
수량: LaborQty;
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
켜쌓기율?: number | null;
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
고름?: Record<string, string>;
}
export interface LaborMat {
코드: string;
@@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
수량: r.수량 ?? null,
...(r.참조 ? { 참조: r.참조 } : {}),
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -7,6 +7,7 @@
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
@@ -36,6 +37,8 @@ export interface CalcLine {
결과?: unknown;
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
줄?: LogicLine[];
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
재료?: LogicMat[];
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
이름?: string | null;
@@ -7,6 +7,8 @@
* [확인] = 화면 캐시만 · 저장은 [저장]
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
@@ -57,9 +59,12 @@ import {
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { pickMaterial } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
@@ -103,6 +108,8 @@ export function mountLaborDetail(
const promote = lib === "promote";
const logics = new Map<string, ItemLogic>();
let calc: LaborCalc | null = null;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
let last: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
@@ -180,29 +187,43 @@ export function mountLaborDetail(
};
dgBox.addEventListener("toggle", drawDg);
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
const drawLogic = (): void =>
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
edit && !promote
? {
open: pick,
option: openRow,
ref: openRef,
qty: openQty,
move: reorder,
remove: removeRow,
alias: setAlias,
}
: { open: pick, option: openRow, ref: openRef },
afterRowDrag(logicHost, () =>
logicHost.replaceChildren(
logicTable(
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
edit && !promote
? {
open: pick,
option: openRow,
ref: openRef,
qty: openQty,
move: reorder,
remove: removeRow,
alias: setAlias,
}
: { open: pick, option: openRow, ref: openRef },
),
),
);
const draw = (): void => {
drawLogic();
matHost.replaceChildren(
matTable(
{ mats: cur.자재, calc, fail, cells: cells() },
{
mats: cur.자재,
calc,
fail,
cells: cells(),
lead: logicMatRows(
cur.담은로직,
calc ?? last,
!calc && !fail,
edit ? { swap: swapMat, reset: resetMat } : undefined,
),
},
edit ? { open: openMat, remove: removeMat } : { open: openMat },
),
);
@@ -222,7 +243,7 @@ export function mountLaborDetail(
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
if (!alive || mine !== seq) return;
calc = got;
calc = last = got;
pills = new Map(
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
);
@@ -343,6 +364,25 @@ export function mountLaborDetail(
cur.자재.splice(i, 1);
changed();
}
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
function swapMat(i: number, at: number, key: string): void {
const own = cur.자재.find((m) => m.코드 === key);
if (own)
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
"error",
);
const row = cur.담은로직[i];
row.고름 = { ...row.고름, [String(at)]: key };
changed();
}
function resetMat(i: number, at: number): void {
const row = cur.담은로직[i];
if (!row.고름) return;
delete row.고름[String(at)];
if (!Object.keys(row.고름).length) delete row.고름;
changed();
}
function openRow(i: number): void {
const row: LaborRow = cur.담은로직[i];
@@ -359,7 +399,9 @@ export function mountLaborDetail(
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
@@ -434,6 +476,11 @@ export function mountLaborDetail(
variant: "filled",
onClick: () =>
pickMaterial((m) => {
if (insideKeys((calc ?? last)?.줄).has(m.코드))
return showToast(
tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
"error",
);
if (cur.자재.some((x) => x.코드 === m.코드))
return showToast(
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
@@ -0,0 +1,144 @@
/* =============================================================================
* M02_MasterTemplete_Labor_LogicMats.ts
* 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸
* (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞.
* · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂)
* · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음)
* · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable";
import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal";
import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
export interface LogicMatHandlers {
/** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */
swap: (row: number, at: number, key: string) => void;
/** [기본] — 그 줄 고름 풂 */
reset: (row: number, at: number) => void;
}
const num = (v: number | null): string =>
v === null
? "—"
: Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */
export function logicMatRows(
rows: LaborRow[],
calc: LaborCalc | null,
wait: boolean,
on?: LogicMatHandlers,
): HTMLElement[] {
return rows.flatMap((row, r) => {
const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row));
return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on));
});
}
function matRow(
row: LaborRow,
r: number,
mat: LogicMat,
wait: boolean,
on?: LogicMatHandlers,
): HTMLElement {
const item = mat.품목;
const name = item?.이름 ?? mat.이름;
const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!item?.값칸 && item.값칸 === slot;
const v = mat.가격?.[slot];
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
attrs: { "data-label": slot, "data-price-slot": slot },
});
};
const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum"));
money.title = tc("LogicMat_Inside");
const tools = on
? [
el("td", {
className: "m02-labrow__move",
children: [
el("div", {
className: "ui-btn-group",
children: [
createButton({
label: tc("LogicMat_Swap"),
variant: "ghost",
onClick: (e) => {
e.stopPropagation();
openPickTable({
kind: "재료",
title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`,
seed: headWord(name),
alt: [headWord(mat.이름)],
isCurrent: (x) => String(x["키"]) === item?.키,
onPick: (x) => on.swap(r, mat.차례, String(x["키"])),
});
},
}),
...(mat.고름
? [
createButton({
label: tc("LogicMat_Reset"),
variant: "ghost",
onClick: (e) => {
e.stopPropagation();
on.reset(r, mat.차례);
},
}),
]
: []),
],
}),
],
}),
]
: [];
return el("tr", {
className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`,
attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` },
children: [
el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }),
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: name, attrs: { title: name } }),
el("small", {
className: "m02-labrow__key",
text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner,
attrs: { title: owner },
}),
...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []),
],
}),
el("td", {
className: "m02-labrow__spec",
text: item?.규격 ?? "",
attrs: { title: item?.규격 ?? "" },
}),
el("td", {
className: "m02-labrow__qty",
text: wait ? "…" : num(mat.수량),
attrs: { "data-label": tc("Labor_QtyCol") },
}),
el("td", {
className: "m02-labrow__unit",
text: mat.단위,
attrs: { "data-label": tc("Labor_Unit") },
}),
...PRICE_SLOTS.map(priceCell),
money,
...tools,
],
});
}
@@ -0,0 +1,126 @@
/* =============================================================================
* M02_MasterTemplete_Labor_RowDrag.ts
* 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길).
* 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) ·
* 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` ·
* 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번).
* ========================================================================== */
/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */
export const DRAG_THRESHOLD_PX = 6;
const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]";
const HEAD = "tr[data-labor-logic]";
let active = false;
const queued = new Map<Element, () => void>();
/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */
export function afterRowDrag(host: Element, draw: () => void): void {
if (active) return void queued.set(host, draw);
queued.delete(host);
draw();
}
/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */
export const pastThreshold = (dx: number, dy: number): boolean =>
Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX;
/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */
const headOf = (tr: Element | null | undefined): Element | null => {
let t = tr ?? null;
while (t && !t.matches(HEAD)) t = t.previousElementSibling;
return t;
};
/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */
const groupOf = (head: Element): Element[] => {
const out = [head];
for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling)
out.push(t);
return out;
};
/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */
export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void {
let id: number | null = null;
let x = 0;
let y = 0;
let moved = false;
let from = -1;
let to = -1;
let marks: Element[] = [];
const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)];
const unmark = (): void => {
for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder");
marks = [];
};
const mark = (els: Element[], cls: string): void => {
for (const e of els) e.classList.add(cls);
marks.push(...els);
};
const end = (drop: boolean): void => {
const pid = id;
id = null;
if (pid === null || !moved) return;
moved = false;
if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid);
unmark();
root.style.userSelect = "";
root.style.cursor = "";
active = false;
// 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로
const swallow = (e: MouseEvent): void => {
e.stopPropagation();
e.preventDefault();
};
window.addEventListener("click", swallow, { capture: true, once: true });
requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true }));
if (drop && to >= 0 && to !== from) move(from, to);
const later = [...queued.values()];
queued.clear();
for (const draw of later) draw();
};
root.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || moved) return;
const t = e.target as Element;
if (t.closest(BLOCK)) return;
const head = headOf(t.closest("tbody > tr"));
from = head ? heads().indexOf(head) : -1;
if (from < 0) return;
id = e.pointerId;
x = e.clientX;
y = e.clientY;
to = -1;
});
root.addEventListener("pointermove", (e) => {
if (id !== e.pointerId) return;
if (!moved) {
if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님
if (!pastThreshold(e.clientX - x, e.clientY - y)) return;
moved = true;
active = true;
// 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음
root.setPointerCapture(e.pointerId);
root.style.userSelect = "none";
root.style.cursor = "grabbing";
window.getSelection()?.removeAllRanges();
}
e.preventDefault();
unmark();
const list = heads();
if (list[from]) mark(groupOf(list[from]), "is-dragging");
const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr"));
to = over ? list.indexOf(over) : -1;
if (!over || to < 0 || to === from) return;
// 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감)
if (to < from) mark([over], "is-dragover");
else mark(groupOf(over).slice(-1), "is-dragunder");
});
root.addEventListener("pointerup", () => end(true));
root.addEventListener("pointercancel", () => end(false));
root.addEventListener("lostpointercapture", () => end(false));
}
@@ -23,6 +23,7 @@ import type {
LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "@ui/ui_template_general_layout.css";
import "./M02_MasterTemplete_Labor.css";
@@ -47,6 +48,9 @@ export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
);
};
/** 품셈 책(102-3) — 출처 첫 낱말 */
const BOOK = /^(건설|산림)품셈/;
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
@@ -121,7 +125,7 @@ function qtyCell(
}
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
function moneyCell(
export function moneyCell(
base: number | null | undefined,
applied: number | null | undefined,
wait: boolean,
@@ -148,9 +152,6 @@ function moneyCell(
}
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
let dragFrom: number | null = null;
const titled = (td: HTMLElement): HTMLElement => {
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
return td;
@@ -182,7 +183,7 @@ export interface RowHandlers {
ref?: (i: number) => void;
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
qty?: (i: number) => void;
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */
move?: (from: number, to: number) => void;
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
@@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
const note = [values.text, layer].filter(Boolean).join(" · ");
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];
const small = (
label: string,
run: () => void,
@@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
run();
},
});
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`)
const arrow = (mark: string, label: string, to: number): HTMLElement => {
const b = small(mark, () => move?.(i, to));
b.classList.add("m02-labrow__arrow");
@@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
const g = el("span", {
className: "m02-labrow__grip",
text: "⠿",
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
});
g.addEventListener("click", (e) => e.stopPropagation());
g.addEventListener("dragstart", (ev) => {
dragFrom = i;
ev.dataTransfer?.setData("text/plain", String(i));
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
});
g.addEventListener("dragend", () => {
dragFrom = null;
attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) },
});
g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음
return g;
};
const sel = data.selected === id ? " is-selected" : "";
@@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
attrs: { "data-labor-logic": id },
children: [
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
el("td", {
className: "m02-labrow__no",
children: move ? [grip(), String(i + 1)] : [String(i + 1)],
}),
el("td", {
className: "m02-labrow__kind",
children: [
kindOf(row),
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
],
}),
titled(
el("td", {
className: "m02-labrow__name",
@@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
el("div", {
className: "m02-labrow__order",
children: [
grip(),
arrow("▲", tc("Up"), i - 1),
arrow("▼", tc("Down"), i + 1),
],
@@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
],
});
if (on) tr.addEventListener("click", () => on.open(i));
if (move) {
tr.addEventListener("dragover", (ev) => {
if (dragFrom === null || dragFrom === i) return;
ev.preventDefault();
tr.classList.add("is-dragover");
});
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
tr.addEventListener("drop", (ev) => {
ev.preventDefault();
tr.classList.remove("is-dragover");
const from = dragFrom;
dragFrom = null;
if (from !== null && from !== i) move(from, i);
});
}
const subs = sel ? subRows(line, wait, tools) : [];
if (!note) return [tr, ...subs];
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
@@ -509,13 +497,16 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
...COSTS.map((c) => tc(HEAD[c])),
...(tools ? [tc("Col_Move")] : []),
];
return data.rows.length
? table(heads, trs, foot)
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
const out = table(heads, trs, foot);
if (move) attachRowDrag(out, move);
return out;
}
export interface MatTableData {
mats: LaborMat[];
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
lead?: HTMLElement[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
@@ -609,7 +600,8 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
return data.mats.length
? table(heads, trs, [])
const lead = data.lead ?? [];
return data.mats.length || lead.length
? table(heads, [...lead, ...trs], [])
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
}
@@ -110,6 +110,16 @@ const TEXT = {
Labor_Spec: ["규격", "Spec"],
Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"],
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
// 로직 자재(103-1)
Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"],
LogicMat_Swap: ["바꾸기", "Change"],
LogicMat_Reset: ["기본", "Default"],
LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"],
LogicMat_Inside: [
"로직 단가 안 금액 — 합계에 다시 안 더함",
"Inside the logic price — not added again",
],
LogicMat_Picked: ["바꾼 품목", "Changed item"],
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
In_Memo: ["메모", "Memo"],
@@ -20,6 +20,7 @@ import {
type LaborDoc,
} from "./M02_MasterTemplete_Labor_Api";
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
@@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
onRow ? { open: logicOpen } : undefined,
),
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
matTable(
{ mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) },
onRow ? { open: matOpen } : undefined,
),
);
};
+23 -4
View File
@@ -237,20 +237,39 @@ def _total(sums: Any) -> float | None:
return None
def _sum_state(calc: dict[str, Any]) -> dict[str, Any]:
"""합계 칸 색 상태(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` + 까닭 글.
붉음 = 기준이 안 나온 돈 줄(까닭 있음) · 기준 단가 없는 자재 / 노랑 = 수량 없는 줄 · 자재 /
돈 아닌 로직 줄(`결과`)은 판정에서 뺌."""
bad = short = 0
for one in (*(calc.get("줄") or []), *(calc.get("자재") or [])):
if not isinstance(one, dict):
continue
if one.get("기준", one.get("기준단가")) is None:
bad += 1 if one.get("까닭") else 0
elif one.get("수량") is None:
short += 1
why = [f"{t} {n}" for t, n in (("기준 단가 없음", bad), ("수량 없는 줄", short)) if n]
return {
"합계상태": "error" if bad else "short" if short else "ok",
"합계까닭": " · ".join(why) or "모두 반영",
}
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙) · `합계상태`(101-1)."""
if not doc.get("담은로직"):
return {}
calc = doc.get("계산값") or {}
if total := _total(calc.get("반영합")):
return {"일위대가합계": total, "합계기준": False}
return {"일위대가합계": total, "합계기준": False, **_sum_state(calc)}
base = _total(calc.get("기준합"))
return {} if base is None else {"일위대가합계": base, "합계기준": True}
return {} if base is None else {"일위대가합계": base, "합계기준": True, **_sum_state(calc)}
def structure_numbers() -> dict[str, dict[str, Any]]:
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준, 합계상태, 합계까닭)}}` — 집계표 줄 · 좌측 줄이 한 번에
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
out: dict[str, dict[str, Any]] = {}
for kind, key, _, _ in PARTS:
@@ -58,7 +58,6 @@ const TEXT = {
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
baseNote: "(기준)",
};
/** 집계표 줄 열쇠 → 문서 */
@@ -74,11 +73,17 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */
export function laborSumText(numbers: StructureNumbers | undefined): string {
if (typeof numbers?.일위대가합계 !== "number") return "";
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
}
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */
export function laborSumState(
numbers: StructureNumbers | undefined,
): { state: string; why: string } | undefined {
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
}
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
@@ -22,6 +22,7 @@ import {
comboMerge,
comboView,
createStructurePanel,
laborSumState,
laborSumText,
pillsFor,
STRUCTURE_TABLE,
@@ -372,6 +373,7 @@ export function createMain(
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
combo: {
...(readOnly || !extras.onHeadRename
? {}
+2
View File
@@ -370,6 +370,8 @@ function fordSet(
const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
// 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
if (wingIn.installed) wingIn.berm_m = num(values.inlet_revet_berm_m, null);
const spec: CulvertSetSpec = {
type: "ford",
pipe_kind: kind,
+12 -12
View File
@@ -160,7 +160,7 @@
},
{
"key": "inlet_revet_form",
"label": "유입 기슭막이 형태",
"label": "유입 흙막이 형태",
"input": "select",
"choices": [
"돌쌓기(찰)",
@@ -176,7 +176,7 @@
},
{
"key": "inlet_revet_length_m",
"label": "유입 기슭막이 길이",
"label": "유입 흙막이 길이",
"input": "number",
"unit": "m",
"default": 10,
@@ -184,7 +184,7 @@
},
{
"key": "inlet_revet_before_m",
"label": "유입 기슭막이 기준측점 전",
"label": "유입 흙막이 기준측점 전",
"input": "number",
"unit": "m",
"default": 5,
@@ -193,7 +193,7 @@
},
{
"key": "inlet_revet_after_m",
"label": "유입 기슭막이 기준측점 후",
"label": "유입 흙막이 기준측점 후",
"input": "number",
"unit": "m",
"default": 5,
@@ -202,7 +202,7 @@
},
{
"key": "inlet_revet_height_m",
"label": "유입 기슭막이 높이",
"label": "유입 흙막이 높이",
"input": "number",
"unit": "m",
"default": 1.5,
@@ -223,7 +223,7 @@
},
{
"key": "outlet_revet_form",
"label": "유출 기슭막이 형태",
"label": "유출 흙막이 형태",
"input": "select",
"choices": [
"돌쌓기(찰)",
@@ -240,7 +240,7 @@
},
{
"key": "outlet_revet_length_m",
"label": "유출 기슭막이 길이",
"label": "유출 흙막이 길이",
"input": "number",
"unit": "m",
"default": 10,
@@ -248,7 +248,7 @@
},
{
"key": "outlet_revet_before_m",
"label": "유출 기슭막이 기준측점 전",
"label": "유출 흙막이 기준측점 전",
"input": "number",
"unit": "m",
"default": 5,
@@ -257,7 +257,7 @@
},
{
"key": "outlet_revet_after_m",
"label": "유출 기슭막이 기준측점 후",
"label": "유출 흙막이 기준측점 후",
"input": "number",
"unit": "m",
"default": 5,
@@ -266,7 +266,7 @@
},
{
"key": "outlet_revet_height_m",
"label": "유출 기슭막이 높이",
"label": "유출 흙막이 높이",
"input": "number",
"unit": "m",
"default": 1.5,
@@ -277,7 +277,7 @@
},
{
"key": "revet_foundation",
"label": "기슭막이 기초",
"label": "흙막이 기초",
"input": "select",
"choices": [
"기초유",
@@ -317,7 +317,7 @@
},
{
"key": "revet_foundation",
"label": "기슭막이 기초",
"label": "흙막이 기초",
"input": "select",
"choices": [
"기초유",
+172
View File
@@ -0,0 +1,172 @@
/* 107 세월교 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_107_ford_free.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", f);
const out = {};
// 107-1 — 세월교 · 박스를 고르면 같은 측점 벽 고름을 풂(푸는 것은 안 부름)
const { createBodyControls } = loadTs(B06("B06_Section_UI_Page_Ford_Controls.ts"));
const released = [];
const bodies = createBodyControls(
{
sectionAt: () => undefined,
patchCachedDesign: () => undefined,
refreshCard: () => undefined,
queuePipeOptions: () => undefined,
round1: (v) => Math.round(v * 10) / 10,
clampMove: (v) => v,
releaseWall: (chainageM) => released.push(chainageM),
},
() => null,
);
bodies.ford.control.select(173.09, "inlet");
bodies.ford.control.select(173.09, null);
bodies.box.control.select(352.14, "left");
out.released = released;
// 107-2 — 원지반을 바꾼 사본: 세월교 바닥 = 노면 − (관경 + 토피) · 박스 본체 · 끝 · 날개 · 흙막이 자리 그대로
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const at = (c) => sections.find((x) => Math.abs(x.chainage_m - c) < 0.6);
const clone = (v) => JSON.parse(JSON.stringify(v));
const r3 = (v) => Math.round(v * 1000) / 1000;
const pts = (list) => (list ?? []).map((p) => [r3(p.offset), r3(p.elevation)]);
/** 원지반 사본 — [from, to] 안 샘플을 dz 만큼 옮김. */
const shiftGround = (x, dz, from = -Infinity, to = Infinity) => {
for (const p of x.samples)
if (p.offset_m >= from && p.offset_m <= to && p.elevation_m != null) p.elevation_m += dz;
};
const GROUNDS = {
base: () => undefined,
bedDown2: (x) => shiftGround(x, -2, -2, 2),
allUp05: (x) => shiftGround(x, 0.5),
allDown1: (x) => shiftGround(x, -1),
};
const walls = (culvert) =>
(culvert?.walls ?? []).filter((w) => w.role !== "extra").map((w) => pts(w.points));
function fordShape(base, edit, ground) {
const x = clone(base);
edit(x);
ground(x);
const { ford, culvert } = computeStoredLayouts(
x,
sections.map((y) => (y === base ? x : y)),
);
return {
sides: ford.sides.map((side) => ({
role: side.role,
parts: side.parts.map((part) => pts(part.points)),
slabTop: r3(side.slabTopElevation),
// 노면(벽 상단) − 바닥판 상면 = 관경 + 최소 토피
depth: r3(side.top.elevation - side.slabTopElevation),
})),
pipeCover: r3(ford.ford.diameter_m + ford.ford.min_cover_m),
slab: pts(ford.slabBridge),
pipe: pts(ford.pipe.outer),
walls: walls(culvert),
};
}
function boxShape(base, edit, ground) {
const x = clone(base);
edit(x);
ground(x);
const { box, culvert } = computeStoredLayouts(
x,
sections.map((y) => (y === base ? x : y)),
);
return {
top: pts(box.topSlab),
bottom: pts(box.bottomSlab),
barrel: pts(box.barrel),
sides: box.sides.map((side) => ({
role: side.role,
offset: r3(side.offset),
top: r3(side.topElevation),
bottom: r3(side.bottomElevation),
apron: pts(side.apron),
})),
walls: walls(culvert),
};
}
const FORD = at(173.087);
const BOX = at(352.14);
const keep = () => undefined;
const inletWing = (x) => {
x.culvert.inlet = { role: "inlet", structure: "" };
};
// 날개벽 박스 — 흙막이 없이 양쪽 날개벽(에이프런 1.414 m)
const noGuard = (x) => {
delete x.culvert;
for (const wing of [x.box.wing_in, x.box.wing_out])
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
};
const run = (shape, base, edit) =>
Object.fromEntries(Object.entries(GROUNDS).map(([k, g]) => [k, shape(base, edit, g)]));
out.ford = { guards: run(fordShape, FORD, keep), inletWing: run(fordShape, FORD, inletWing) };
out.box = { guards: run(boxShape, BOX, keep), bare: run(boxShape, BOX, noGuard) };
// 107-3 — 유입 날개벽 끝이 묻히면 수평 여유(흙막이 수평공간과 같은 칸 · 기본 1 m) 뒤 절토
function wingCut(berm) {
const x = clone(FORD);
delete x.culvert;
for (const wing of [x.ford.wing_in, x.ford.wing_out])
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
x.ford.wing_in.berm_m = berm;
shiftGround(x, 3); // 바닥판 끝을 원지반 안으로
const { ford } = computeStoredLayouts(
x,
sections.map((y) => (y === FORD ? x : y)),
);
return Object.fromEntries(ford.sides.map((side) => [side.role, pts(side.cutLine)]));
}
out.wingCut = { auto: wingCut(null), two: wingCut(2), zero: wingCut(0) };
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,302 @@
/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
* 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다.
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
*/
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 = {};
// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ─────────────────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
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),
};
}
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);
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
setAttribute(name, value) {
this[name] = value;
}
addEventListener() {}
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;
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
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);
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 localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
return loadTs(path.join(path.dirname(full), `${request}.ts`));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */
const drags = [];
STUBS = {
"./B06_Section_UI_Section_Common": { L: (key) => key },
"@ui/ui_template_elements": { showToast: () => {} },
"./B06_Section_UI_Cross_Panel_Base": {
...base,
bindDragHandle: (root, deps) => {
drags.push(deps);
return { attach: () => {}, detach: () => {} };
},
},
};
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel"));
const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel"));
const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure"));
const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve"));
const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box"));
const r3 = (v) => Math.round(v * 1000) / 1000;
const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null);
// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ─────────────────────────
function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) {
const updates = [];
const ctx = {
section: { chainage_m: 100 },
adjustChainage: () => 100,
isLinked: false,
revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) },
inletStructure: {
valueFor: () => structure,
adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 },
updateAdjust: (at, next) => updates.push({ basin: next }),
},
adjustOf: () => adjust,
outwardOf: () => outward,
heightOfWall: () => 2,
drawnWallKeys: () => [key],
formOfWall: () => "돌쌓기(메)",
appliedD: () => new Map([[key, appliedD]]),
inletIsBasin: () => structure !== "revet",
hiddenPipe: () => false,
bodyGuard: () => !!guard,
fordGuard: () => false,
inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }),
extraState: () => ({ canAdd: true, count: 1 }),
basinExtraState: () => ({ canAdd: false, count: 0 }),
activeRevet: () => key,
setActiveRevet: () => {},
toggleRevet: () => {},
scrollKey: "t",
};
const panel = buildStructurePanel(structurePanelDeps(ctx));
panel.show(key);
const drag = drags[drags.length - 1];
return {
panel,
snap: (leftM, downM) => {
const s = drag.snap(leftM, downM);
if (s?.text) s.apply();
return { snap: snapOut(s), update: updates.pop() ?? null };
},
};
}
/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */
const wallAt = (outward, adjust) =>
placePipeWall({
autoOffset: 5 * outward,
outward,
height: 2,
baseElevation0: 98,
edgeElevation: 100,
invertCap: 99,
adjust: { x: adjust.x, d: adjust.d, h: null, m: null },
defaultD: 0,
});
const shiftOf = (outward, from, to) => {
const a = wallAt(outward, from);
const b = wallAt(outward, to);
return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) };
};
const out = { wall: {}, box: {}, ford: {} };
for (const outward of [1, -1]) {
const from = { x: 0, d: 2, h: null, m: null };
const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from });
// 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음
const got = snap(3.3 * outward, 1.7);
out.wall[`diag${outward}`] = {
...got,
result: got.update ? shiftOf(outward, from, got.update) : null,
overlayHead: panel.root.children[0].children.map((c) => c.className),
};
}
// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0)
out.wall.limit = wallCase({
key: "outlet",
outward: 1,
adjust: { x: 0.5, d: 0.5, h: null, m: null },
}).snap(-5, -3);
// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝
out.wall.guard = wallCase({
key: "outlet",
outward: 1,
adjust: { x: 0, d: 0, h: null, m: null },
guard: true,
}).snap(-14, 0);
// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감
out.wall.extra = wallCase({
key: "extra0",
outward: 1,
adjust: { x: 0, d: 0, h: null, m: null },
}).snap(0.04, 1.0);
// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0
out.wall.basin = wallCase({
key: "inlet",
structure: "L",
outward: -1,
adjust: { x: 0, d: null, h: null, m: null },
basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 },
}).snap(-0.86, 0.25);
out.wall.basinNone = wallCase({
key: "inlet",
structure: "L",
outward: -1,
adjust: { x: 0, d: null, h: null, m: null },
}).snap(0.02, -0.01);
// ── BOX 창 — 길이(화면 좌 +) · 표고 ───────────────────────────────────────────
function boxCase(role, adjust, leftM, downM) {
const moves = [];
const panel = buildBoxPanel({
adjustFor: () => adjust,
nudgeLength: () => {},
nudgeRise: () => {},
move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }),
reset: () => {},
wingRoleFor: () => "inlet",
bodyLengthM: () => 5,
slopeRatio: () => null,
close: () => {},
});
panel.show(role);
const s = drags[drags.length - 1].snap(leftM, downM);
if (s?.text) s.apply();
return {
snap: snapOut(s),
move: moves[0] ?? null,
value: panel.root.children[1].children.map((c) => c.className || "line"),
};
}
out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
function fordCase(outward, adjust, leftM, downM) {
const moves = [];
const panel = buildFordPanel({
adjustFor: () => adjust,
heightFor: () => 1.5,
nudge: () => {},
nudgeSlope: () => {},
outwardOf: () => outward,
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
reset: () => {},
slabLengthM: () => 3.2,
close: () => {},
});
panel.show("inlet");
const s = drags[drags.length - 1].snap(leftM, downM);
if (s?.text) s.apply();
return {
snap: snapOut(s),
move: moves[0] ?? null,
value: panel.root.children[1].children.map((c) => c.className || "line"),
};
}
out.ford = {
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
};
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
const layout = {
sides: [
{ role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] },
{ role: "right", offset: -4, apron: [] },
],
topSlab: [{ elevation: 99 }, { elevation: 99 }],
bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }],
};
out.hit = Object.fromEntries(
boxHitBands(layout).map(([role, points]) => [
role,
{
offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b),
elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b),
},
]),
);
process.stdout.write(JSON.stringify(out));
+22
View File
@@ -107,6 +107,28 @@ const out = {
}),
};
// 107-1 — 유입 날개벽(측벽) 좌우 · 상하를 옮겨도 유출 흙막이 자리 그대로(관 1 m 맞춤 = 기본 자리 기준)
const outletWallAt = (inlet) => {
const section = clone(FORD);
section.culvert.inlet = { role: "inlet", structure: "" };
section.design.ford_adjust = { inlet: { heightM: null, ...inlet } };
const { ford, culvert } = computeStoredLayouts(
section,
sections.map((s) => (s === FORD ? section : s)),
);
const wall = culvert.walls.find((w) => w.role === "outlet");
const [right, left] = ford.pipe.outer.slice(2);
return {
back: r3(wall.bottomBack.offset),
label: ford.label.text,
lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)),
};
};
out.inletMoves = [0, 0.1, 0.2, 0.3, 0.5, 1.0].map((lateralM) =>
outletWallAt({ lateralM, slopeM: 0 }),
);
out.inletSlopes = [0.1, 0.5].map((slopeM) => outletWallAt({ lateralM: 0, slopeM }));
// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로
const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6);
function boxFit(edit) {
+1 -1
View File
@@ -68,7 +68,7 @@ const layoutsOf = (s, base) =>
sections.map((x) => (x === base ? s : x)),
);
// ① 91-2 세월교 바닥 — 자동은 하상을 따라감 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
// ① 91-2 · 107-2 세월교 바닥 — 기본 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
const FORD = at(173.087);
function fordFloor(edit) {
const s = clone(FORD);
@@ -186,7 +186,6 @@ for (const [label, key, kind] of [
const fordDeps = {
adjustFor: () => ({ heightM: null, lateralM: 0, slopeM: 0 }),
heightFor: () => 1.5,
nudgeHeight: () => {},
nudge: () => {},
nudgeSlope: () => {},
reset: () => {},
@@ -0,0 +1,15 @@
/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.releaseTied(input.row, input.before, input.mats)));
@@ -0,0 +1,210 @@
/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 —
* 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"M02_MasterTemplete",
"M02_MasterTemplete_Labor_RowDrag.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
/* ---- 가짜 DOM ---- */
const one = (node, sel) => {
const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim());
if (!m) throw new Error(`셀렉터 모름: ${sel}`);
return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs);
};
const matches = (node, sel) =>
sel.split(",").some((part) => {
const [up, me] = part.includes(">") ? part.split(">") : [null, part];
return one(node, me) && (!up || (node.parent && one(node.parent, up)));
});
class Node {
constructor(tag, attrs = {}, parent = null) {
this.tag = tag;
this.attrs = attrs;
this.parent = parent;
this.kids = [];
this.on = {};
this.style = {};
const set = new Set();
this.classList = {
add: (...c) => c.forEach((x) => set.add(x)),
remove: (...c) => c.forEach((x) => set.delete(x)),
contains: (c) => set.has(c),
list: () => [...set],
};
if (parent) parent.kids.push(this);
}
matches(sel) {
return matches(this, sel);
}
closest(sel) {
for (let n = this; n; n = n.parent) if (n.matches(sel)) return n;
return null;
}
get previousElementSibling() {
const k = this.parent.kids;
return k[k.indexOf(this) - 1] ?? null;
}
get nextElementSibling() {
const k = this.parent.kids;
return k[k.indexOf(this) + 1] ?? null;
}
querySelectorAll(sel) {
const out = [];
const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c)));
walk(this);
return out;
}
addEventListener(type, fn) {
(this.on[type] ??= []).push(fn);
}
}
/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */
const root = new Node("div");
const tbody = new Node("tbody", {}, new Node("table", {}, root));
const tr = (attrs) => new Node("tr", attrs, tbody);
const rows = [
tr({ "data-labor-logic": "A" }),
tr({ "data-labor-logic": "B" }),
tr({ "data-labor-opt": "B" }),
tr({ "data-labor-sub": "" }),
tr({ "data-labor-sub": "" }),
tr({ "data-labor-logic": "C" }),
tr({ "data-labor-logic": "D" }),
];
const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]);
const td = rows.map((_, r) => cell(r));
const btn = new Node("button", {}, cell(1));
const input = new Node("input", { "data-labor-alias": "" }, cell(1));
const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1)));
const grip = new Node("span", { "data-labor-grip": "0" }, td[0]);
const Y = (r) => r * 20 + 10;
let capture = null;
root.hasPointerCapture = (id) => capture === id;
root.setPointerCapture = (id) => (capture = id);
root.releasePointerCapture = () => (capture = null);
const winClick = [];
let frames = [];
global.window = {
addEventListener: (t, fn) => winClick.push(fn),
removeEventListener: (t, fn) => {
const i = winClick.indexOf(fn);
if (i >= 0) winClick.splice(i, 1);
},
getSelection: () => ({ removeAllRanges() {} }),
};
global.requestAnimationFrame = (fn) => frames.push(fn);
global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null };
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const m = box.exports;
/* ---- 부르기 ---- */
const moves = [];
m.attachRowDrag(root, (from, to) => moves.push([from, to]));
const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e }));
const down = (target, y) =>
fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target });
const mv = (y, dx = 0) =>
fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y });
/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */
const click = () => {
let stopped = false;
[...winClick].forEach((fn) =>
fn({ stopPropagation: () => (stopped = true), preventDefault() {} }),
);
winClick.length = 0; // once
return !stopped;
};
const frame = () => {
const f = frames;
frames = [];
f.forEach((fn) => fn());
};
const classes = () => rows.map((r) => r.classList.list().sort().join(" "));
const out = {};
out.threshold = [
[5, 0],
[6, 0],
[0, -6],
[5, 5],
[-7, 2],
].map(([dx, dy]) => m.pastThreshold(dx, dy));
// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기)
down(td[0], Y(0));
mv(Y(0) + 5);
fire("pointerup", {});
out.small = { moves: moves.length, clickPasses: click(), capture };
// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴
let drawn = 0;
const host = {};
down(td[0], Y(0));
mv(Y(0) + 7);
m.afterRowDrag(host, () => drawn++);
m.afterRowDrag(host, () => drawn++); // 마지막 것만
mv(Y(5));
out.during = { drawn, capture, classes: classes(), select: root.style.userSelect };
fire("pointerup", {});
out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() };
frame();
out.afterFrame = { listeners: winClick.length };
// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림
down(td[4], Y(4));
mv(Y(0));
out.fromSub = { classes: classes() };
fire("pointerup", {});
out.fromSub.moves = moves.splice(0);
click();
frame();
// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선
down(td[0], Y(0));
mv(Y(2));
out.downMark = classes();
fire("pointerup", {});
out.downMark.push(JSON.stringify(moves.splice(0)));
click();
frame();
// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함
for (const [name, t] of [
["button", btn],
["input", input],
["qty", qty],
]) {
down(t, Y(1));
mv(Y(6));
fire("pointerup", {});
out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() };
}
// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로)
down(grip, Y(0));
mv(Y(0) + 8);
fire("pointerup", {});
out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() };
frame();
// 7) 끄는 중이 아니면 바로 그림
let now = 0;
m.afterRowDrag(host, () => now++);
out.idleDraw = now;
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""PLAN 106 — 구간형 구조물 범위 강조 · B05/B06 화면 글 「흙막이」.
• 106-1 구조물을 고르면 시작~종료 안 횡단 카드 전부에 `--span` 클래스 · 카드를 다시 만들어도 유지
• 106-2 로케일 · 툴팁 · 정의 label 에 「기슭막이」 없음(독립=D군 · 사방 근거 글 빼고) · 저장 값 · choices 그대로
"""
import json
import os
import re
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW)
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
def test_범위_판정은_시작_종료가_다른_구간형만():
assert "start == null || end == null || start === end" in VIEW
def test_범위_강조_CSS는_선택_강조를_덮지_않는다():
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css")
assert ".b06-cross-card--span:not(.b06-cross-card--selected)" in css
def test_로케일에_기슭막이는_독립_한_곳뿐():
locale = _read("ui_template", "ui_template_locale_b2.ts")
korean = [
line
for line in locale.splitlines()
if "기슭막이" in line and not line.strip().startswith(("//", "/*"))
]
assert len(korean) == 1 and "B06_Cross_Revet_Own" in korean[0]
def test_정의_label은_흙막이_choices_값은_그대로():
definition = json.loads(_read("resources", "master_template", "구조물정의.json"))
labels, choices = [], []
def walk(node):
if isinstance(node, dict):
if isinstance(node.get("label"), str):
labels.append(node["label"])
if node.get("key") in ("inlet_type", "outlet_type"):
choices.append(node["choices"])
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(definition)
assert not [label for label in labels if "기슭막이" in label and label != "기슭막이"]
assert ["기슭막이", "집수정"] in choices and ["기슭막이"] in choices
+92
View File
@@ -0,0 +1,92 @@
"""107 세월교 — 유입 · 유출 따로 · 원지반 제약 삭제 · 유입 날개벽 수평 여유.
화면 TS 를 그 자리에서 돌린 값(`helper_107_ford_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HELPER = Path(__file__).parent / "helper_107_ford_free.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_세월교_박스를_고르면_같은_측점_벽_고름을_풂(out: dict) -> None:
assert out["released"] == [173.09, 352.14]
def test_벽을_고르면_세월교_박스_고름을_풂() -> None:
station = _read("B06_Section/B06_Section_UI_Page_Station_Controls.ts")
assert (
"if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);"
in station
)
assert (
"if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);"
in station
)
view = _read("B06_Section/B06_Section_UI_Cross_View.ts")
# 카드 안 — 벽을 고르면 본체 창을 닫고 · 본체를 고르면 벽 창을 닫음
assert "bodies.clearSelection(); // 세월교 · 박스 고름을 풂" in view
assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view
@pytest.mark.parametrize("variant", ["guards", "inletWing"])
def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: str) -> None:
"""107-2 — 바닥 = 노면 − (관경 + 최소 토피) · 하상 −2 m · 전체 +0.5 / −1 m 사본도 같은 모양."""
runs = out["ford"][variant]
for name, shape in runs.items():
assert shape == runs["base"], name
base = runs["base"]
assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"])
@pytest.mark.parametrize("variant", ["guards", "bare"])
def test_박스_원지반을_바꿔도_본체_끝_날개_흙막이_그대로(out: dict, variant: str) -> None:
runs = out["box"][variant]
for name, shape in runs.items():
assert shape == runs["base"], name
if variant == "bare":
assert all(side["apron"] for side in runs["base"]["sides"])
else:
assert len(runs["base"]["walls"]) == 2
def test_유입_날개벽_끝이_묻히면_수평_여유_뒤_절토(out: dict) -> None:
"""107-3 — 흙막이 수평공간과 같은 칸(빈 값 = 1 m) · 유출 날개벽은 수평 구간 없음."""
cuts = out["wingCut"]
for case, berm in (("auto", 1.0), ("two", 2.0)):
(x0, z0), (x1, z1) = cuts[case]["inlet"][:2]
assert z1 == pytest.approx(z0) and abs(x1 - x0) == pytest.approx(berm), case
(x0, z0), (x1, z1) = cuts["zero"]["inlet"][:2]
assert z1 > z0 + 0.1 # 0 = 바로 절토
assert cuts["auto"]["outlet"][1][1] > cuts["auto"]["outlet"][0][1] + 0.1
def test_날개벽_수평_여유_칸은_흙막이_수평공간과_같은_키() -> None:
ford = _read("A00_Common/design/design_structure_ford.ts")
assert '...bodyGuardItems(id, "본체 끝 높이(자동)", true),' in ford
guard = _read("A00_Common/design/design_structure_guard.ts")
assert "map: wingBerm ? { 흙막이: branch, 날개벽: branch } : { 흙막이: branch }," in guard
+124
View File
@@ -0,0 +1,124 @@
"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) ·
`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` ·
`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
case = out["wall"][side]
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
assert "▼ 2.0m" in case["snap"]["text"]
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None:
"""미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래)."""
case = out["wall"][side]
assert case["snap"]["leftM"] == case["result"]["leftM"]
assert case["snap"]["downM"] == case["result"]["downM"]
def test_한계면_가장_가까운_허용_자리(out) -> None:
"""사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음."""
case = out["wall"]["limit"]
assert case["update"] == {"role": "outlet", "x": 0, "d": 0}
assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"}
def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None:
assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0}
assert out["wall"]["guard"]["snap"]["leftM"] == -10
def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None:
"""다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리."""
case = out["wall"]["extra"]
assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1}
assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1
def test_집수정은_사면_축_0_1_걸음(out) -> None:
case = out["wall"]["basin"]
assert case["update"]["basin"]["lateralM"] == 0.9
assert case["update"]["basin"]["slopeM"] == 0.3
assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"}
def test_걸음_반도_안_끌면_반영_없음(out) -> None:
assert out["wall"]["basinNone"] == {
"snap": {"leftM": 0, "downM": 0, "text": ""},
"update": None,
}
def test_BOX_끌기는_길이_표고_함께(out) -> None:
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
assert out["box"]["left"]["snap"]["leftM"] == 0.5
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
def test_BOX_한계_길이_0_표고_10(out) -> None:
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
case = out["ford"]["diag"]
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
"""좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리)."""
case = out["ford"]["inward"]
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0}
assert case["snap"]["leftM"] == 0.2
def test_조정창은_제목과_단추만(out) -> None:
"""머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나."""
assert out["wall"]["diag1"]["overlayHead"] == [
"b06-structure-panel__title",
"b06-structure-panel__btn b06-structure-panel__close",
]
assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"]
assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"]
def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None:
"""본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠."""
hit = out["hit"]
assert hit["body"]["offsets"] == [-4, 4]
assert hit["left"]["offsets"] == [4, 6]
assert hit["right"]["offsets"] == [-4.3, -4]
for role in ("body", "left", "right"):
assert hit[role]["elevations"] == [96.5, 99]
+131
View File
@@ -0,0 +1,131 @@
"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사.
관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) ·
관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로
실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로.
"""
from __future__ import annotations
import copy
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402
DEFAULTS,
SHEET_DEFAULTS,
station_rows,
)
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
FIXTURES = Path(__file__).parent / "fixtures"
NODE_MODULES = ROOT / "config" / "node_modules"
needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
BASINS = [
{"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9,
"relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0}
for c in (85.05, 173.09, 300.0, 400.0, 500.0)
] # fmt: skip
POINTS = [
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}),
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
PipePoint(chainage_m=300.004, facility="box_culvert",
options={"body_width_m": 2.0, "body_height_m": 1.5}),
PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}),
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}),
] # fmt: skip
DESIGNS = [
# 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안)
{"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}},
# 세월교에 경사 키가 있어도 안 이음(장 기본값)
{"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}},
{"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}},
{"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸
{"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음
]
def _rows() -> dict[float, dict]:
return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)}
def test_관_경사가_하상경사_칸으로() -> None:
assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20
def test_박스는_원지반_경사() -> None:
assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50
def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None:
rows = _rows()
assert "slope_denominator" not in rows[400.0]["linked"]
assert "slope_denominator" not in rows[500.0]["linked"]
assert DEFAULTS["slope_denominator"] is None # 손 입력 자리
def test_세월교는_장_기본값_그대로() -> None:
assert "slope_denominator" not in _rows()[173.09]["linked"]
assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0
def test_연결값으로_판정이_나옴() -> None:
row = _rows()[85.052]
out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"})
assert out["ok"] in (True, False)
assert out["slope_bed"] == pytest.approx(0.05)
def test_저장된_측점_null_이_연결값을_덮지_않음() -> None:
"""화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜."""
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
assert "v != null || !(k in linkedOf(station))" in ui
assert "...station.linked," in ui
def test_패치가_받는다() -> None:
patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5)
assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5)
def _server_rows(sections: list[dict]) -> dict[float, dict]:
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
from common_util.common_util_node_bundle import run_bundle_json
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
assert output, "서버 Node 번들 실행 실패"
return {round(r["chainage_m"], 3): r for r in output["areas"]}
@needs_node
def test_서버가_관_경사를_관_길이와_같이_냄() -> None:
sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8"))
row = _server_rows(sections["cross_sections"])[85.052]
assert row["pipe_length_m"] > 0
# 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프)
assert row["pipe_slope_pct"] >= 0
@needs_node
def test_서버가_박스_원지반_경사를_주인_측점에만() -> None:
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
sections = data["cross_sections"]
box_at = next(s for s in sections if s.get("box"))
# 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림
neighbour = next(s for s in sections if s["chainage_m"] == 340)
neighbour["box"] = copy.deepcopy(box_at["box"])
rows = _server_rows(sections)
assert isinstance(rows[352.14]["box_ground_slope_pct"], float)
assert "box_ground_slope_pct" not in rows[340]
# 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음
assert all("pipe_slope_pct" not in r for r in rows.values())
+6 -6
View File
@@ -56,6 +56,9 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
for spec in ("ford", "box"):
for side in ("inlet", "outlet"):
want = GUARD if side == "inlet" else GUARD[:-1]
if spec == "ford" and side == "inlet":
# 세월교 유입 날개벽도 같은 칸(수평 여유 · 107-3)
want = [*GUARD[:-1], ["berm", ["흙막이", "날개벽"], "1"]]
assert out[spec][side] == want, (spec, side)
assert [i[0] for i in out[spec]["soil_add"]] == [
"form",
@@ -164,12 +167,9 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
assert groups(type_id, "", None) == []
def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음():
def test_B06_세월교_박스_칸은_받는_줄_없음():
"""107-3 — 측벽 높이 줄 지움."""
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert (
"slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW"
in dock
)
assert 'WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"' in dock
assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock
lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8")
assert "return isNone(c, state[c.id][0])" in lists
+9
View File
@@ -4,6 +4,7 @@
"""
import json
import math
import subprocess
import sys
from pathlib import Path
@@ -74,3 +75,11 @@ def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None:
assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx(
fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3
)
def test_유입_측벽을_옮겨도_유출_흙막이_자리_그대로(fit: dict) -> None:
"""107-1 — 관 1 m 맞춤은 기본 자리 기준 · 사용자 이동이 반대쪽 흙막이를 안 밈 · L 은 올림."""
backs = {case["back"] for case in fit["inletMoves"] + fit["inletSlopes"]}
assert len(backs) == 1, fit["inletMoves"]
for case in fit["inletMoves"] + fit["inletSlopes"]:
assert case["label"].endswith(f"L{math.ceil(case['lengthM'] - 1e-6)}m")
+7 -5
View File
@@ -29,9 +29,9 @@ def fit() -> dict:
return json.loads(done.stdout)
def test_세월교_바닥_자동은_하상을_따라감(fit: dict) -> None:
for before, after in zip(fit["fordAuto"], fit["fordAutoBedDown"]):
assert after["slabTop"] < before["slabTop"] - 1.0
def test_세월교_바닥_기본도_원지반을_바꿔도_그대로(fit: dict) -> None:
"""107-2 — 기본 바닥 = 노면 − (관경 + 토피) · 하상을 안 따라감(옛 91-2 「자동은 하상」 뒤집음)."""
assert fit["fordAuto"] == fit["fordAutoBedDown"]
def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) -> None:
@@ -41,9 +41,11 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict)
assert side["wallBottom"] == pytest.approx(side["slabTop"] - 0.3, abs=1e-3)
def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None:
def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None:
"""107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출)."""
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock
assert "sideSlots?.wingOutlet) return null" in dock
assert "wall-height" not in dock
def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None:
@@ -68,6 +68,7 @@ def test_측점마다_연결값() -> None:
def test_잇지_않는_칸() -> None:
# 하상경사는 B06 횡단 경사가 있을 때만 이음(110 · test_110_b08_bed_slope) — 설계 없이는 빈칸
for row in station_rows(POINTS, BASINS):
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
@@ -41,7 +41,7 @@ def test_폼과_같은_값은_사본_줄로_만들지_않는다(out) -> None:
assert out["dock"]["outlet"] == ["options", "readout", "tier"]
# 집수정 내공 줄은 화면에서 숨김(54-40 · 값 · 그림 그대로)
assert out["dock"]["basin"] == ["options", "readout"]
assert out["dock"]["ford"] == ["readout", "wall-height"]
assert out["dock"]["ford"] == ["readout"] # 측벽 높이 줄 지움(107-3)
assert out["dock"]["box"] == ["readout"]
@@ -86,7 +86,6 @@ def test_사본_줄은_모두_폼_칸으로_옮겨_붙는다() -> None:
"tier",
"options",
"basin-size",
"wall-height",
"form",
"height",
"span",
@@ -207,6 +207,9 @@ def test_ford_box_guard_attached_as_culvert() -> None:
assert "culvert" not in sections[100.0]
# 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2)
assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"]
# 유입 날개벽 수평 여유 = 같은 칸(107-3) · 안 세운 날개벽엔 안 실림
wings = sections[60.0]["ford"]
assert wings["wing_in"]["berm_m"] == 2 and "berm_m" not in wings["wing_out"]
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
assert "chute" not in sections[40.0]["culvert"]
@@ -40,6 +40,9 @@ _COMPUTED_FIELDS = {
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
"pipe_length_m",
# B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리.
"pipe_slope_pct",
"box_ground_slope_pct",
}
+3 -3
View File
@@ -52,7 +52,7 @@ def test_면적을_못_내는_측점에서도_길이는_낸다():
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
source = _LAYOUTS.read_text(encoding="utf-8")
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
assert "layouts.culvert?.pipe?.lengthM" in source
assert "layouts.culvert?.pipe" in source and "pipe.lengthM" in source
def test_이름이_한_벌이다():
@@ -99,8 +99,8 @@ def test_소유_측점에만_싣는다():
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
"""
layouts = _LAYOUTS.read_text(encoding="utf-8")
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
assert "ownerChainage" in layouts, "소유 측점을 안 가리고 있다"
assert "isOwner(section.culvert.chainage_m)" in layouts, "관이 놓인 자리를 안 읽고 있다"
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
encoding="utf-8"
)
@@ -3,6 +3,7 @@
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4)
"""
import json
@@ -11,6 +12,7 @@ from pathlib import Path
from shapely.geometry import LineString, Point
from shapely.geometry import box as box_polygon
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
@@ -18,9 +20,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
layer_id,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
LABEL_CLIPPED,
PARCEL_FONT,
ROUTE_CLEAR_M,
clip_parcels,
label_rects,
place_labels,
)
@@ -161,3 +165,44 @@ def _polyline(entity: dict) -> LineString:
c["shapeData"]["endPoint"] for c in children
]
return LineString([(p["x"], p["y"]) for p in points])
def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None:
features = [
(
{
"pnu": f"P{i}{j}",
"jibun": f"{i}{j}-1",
"jimok": "임야",
"parea": 9999,
"owner_nm": "홍길동",
},
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
)
for i in range(4)
for j in range(3)
]
parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX)
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
items = place_labels(parcels, route, BOX)
rects = label_rects(items)
assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음
# 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감
across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)]
pieces = {
"contour": {"lines": [list(line) for line in across]},
"stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]},
"parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]},
"emd": {"lines": [list(line) for line in across]},
"sgg": {"lines": [list(line) for line in across]},
"centerline": {"lines": [[list(p) for p in route]]},
}
clip_lines(pieces, rects, LABEL_CLIPPED)
inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects]
for piece in LABEL_CLIPPED:
for points in pieces[piece]["lines"]:
line = LineString(points)
assert all(line.intersection(area).length < 1e-6 for area in inner), piece
# 상자 밖은 남음 · 노선은 그대로
assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400
assert pieces["centerline"]["lines"] == [[list(p) for p in route]]
+102 -6
View File
@@ -8,6 +8,8 @@
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 ·
용지도 상자 안 선 0
"""
import json
@@ -19,6 +21,7 @@ import pytest
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import (
_place,
curve_values,
dms,
ip_blocks,
@@ -38,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
add_kind,
delete_kind,
list_kinds,
plan_kind_items,
read_marks,
slope_records,
update_kind,
@@ -139,6 +143,7 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
"대피소",
"라이다",
"용지도",
"배수유역도",
]
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
assert added["pieces"] == ["centerline", "station"]
@@ -148,14 +153,28 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
add_kind(root, "빈 것", [])
delete_kind(root, "terrain") # 기본 넷도 지움
again = list_kinds(root) # 다시 읽어 같음
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
assert [k["name"] for k in again] == [
"노선배치도",
"대피소",
"라이다",
"용지도",
"배수유역도",
"내 평면도",
]
assert again[-1]["pieces"] == ["centerline", "station"]
with pytest.raises(FileNotFoundError):
delete_kind(root, "terrain")
for kind in list(again):
delete_kind(root, kind["id"])
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
assert {k["id"] for k in DEFAULT_KINDS} == {
"terrain",
"route",
"layout",
"lidar",
"landuse",
"watershed",
}
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
@@ -202,7 +221,7 @@ def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
outline = {"road_edge", "fill_outline", "cut_outline"}
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
for kind in DEFAULT_KINDS:
if kind["id"] != "landuse":
if kind["id"] not in ("landuse", "watershed"):
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
folder = _kinds_dir(root)
folder.mkdir(parents=True)
@@ -227,6 +246,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
"id": "terrain",
"name": "지형2",
"pieces": ["contour", "centerline"],
"layout": "plan",
}
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
drawings = _read_manifest(root)["drawings"]
@@ -315,9 +335,9 @@ def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
)
kinds = list_kinds(root)
assert [k["id"] for k in kinds][-1] == "landuse"
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
assert [k["id"] for k in kinds][-2:] == ["landuse", "watershed"] # 늘어난 기본 종류 둘
assert kinds[-2]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
assert len(list_kinds(root)) == 6 # 두 번 읽어도 한 번
delete_kind(root, "landuse")
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
@@ -344,3 +364,79 @@ def test_landuse_pieces_become_layers() -> None:
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
]
assert labels == ["1-1", "임야", "국"]
def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None:
"""배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장."""
root = tmp_path / "project"
watershed = next(k for k in list_kinds(root) if k["id"] == "watershed")
assert watershed == {
"id": "watershed",
"name": "배수유역도",
"pieces": [],
"layout": "watershed",
}
# 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장
stations = [
{"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20)
]
items = plan_kind_items(root, {"stations": stations})
terrain = [item for item in items if item["plan_kind"] == "terrain"]
mine = [item for item in items if item["plan_kind"] == "watershed"]
assert len(terrain) > 1
assert mine == [
{
"id": "plan_watershed",
"label": "배수유역도",
"plan_kind": "watershed",
"plan_kind_name": "배수유역도",
}
]
def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None:
"""× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로."""
root = tmp_path / "project"
delete_kind(root, "watershed")
assert "watershed" not in [k["id"] for k in list_kinds(root)]
assert "watershed" not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남
added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림
assert added["layout"] == "watershed" and added["pieces"] == []
again = next(k for k in list_kinds(root) if k["id"] == added["id"])
assert again == added
assert any(
item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []})
)
update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트
assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed"
with pytest.raises(ValueError):
add_kind(root, "이상한 양식", ["contour"], "nope")
def test_watershed_kind_added_once_to_folder_with_landuse(tmp_path: Path) -> None:
"""용지도 표시가 있는 옛 폴더 — 배수유역도만 한 번 더해짐 · 지우면 안 살아남."""
root = tmp_path / "project"
folder = _kinds_dir(root)
folder.mkdir(parents=True)
for order, kind in enumerate(DEFAULT_KINDS[:5]):
(folder / f"{kind['id']}.json").write_text(
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
)
(folder / "_landuse_added").touch()
assert [k["id"] for k in list_kinds(root)][-1] == "watershed"
delete_kind(root, "watershed")
assert [k["id"] for k in list_kinds(root)][-1] == "landuse"
def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None:
"""한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽."""
blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)]
block_w = 7.0 + 6.0 + 23.0
_place(blocks, 2 * 5.0 + 3 * block_w + 1.0)
origins = [b["origin"] for b in blocks]
tops = [o[1] for o in origins]
assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위
assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오
assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽
assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음)
assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로
+39
View File
@@ -118,6 +118,39 @@ def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) >
초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
monkeypatch.setattr(store, "FOLDER", tmp_path)
(tmp_path / "labor").mkdir()
money = {"기준": {"계": 100}, "까닭": None}
docs = {
"ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [
{"기준단가": 5, "수량": 1}
]},
"short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []},
"error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [
{"기준단가": None, "까닭": "단가 없음", "수량": None},
{"기준단가": 5, "수량": None},
]},
} # fmt: skip
for name, calc in docs.items():
doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}}
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}}
(tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8")
got = store.structure_numbers()
assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영")
assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1")
assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == (
"error",
"기준 단가 없음 2 · 수량 없는 줄 2",
)
assert "합계상태" not in got["empty"]
def test_일위대가_합계_줄_표_부품과_연결():
sheet = _text(SHEET / "ui_template_sheet.ts")
assert "laborSumLabel: opts.laborSumLabel," in sheet
@@ -131,6 +164,12 @@ def test_일위대가_합계_줄_표_부품과_연결():
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
SHEET / "ui_template_sheet.css"
)
assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
@@ -0,0 +1,162 @@
"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음.
- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록
- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) ·
고름 없으면 지금 값
- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400
- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node)
- 프로젝트 사본 씨앗 = 문서 고름
"""
import json
import os
import subprocess
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
HERE = os.path.dirname(os.path.abspath(__file__))
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시)
BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림)
BARE = "GC000994" # 재료 줄 없는 돈 로직
WIRE = "MT000194"
NET_ITEM = "MT006697"
def _whole():
return m01.loaded()[1]
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록():
got = labor.run_line(_whole(), NET, {})
assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [
(3, NET_ITEM),
(4, WIRE),
(5, "MT007705"),
]
assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"])
assert labor.run_line(_whole(), BARE, {})["재료"] == []
def test_조건에_든_입력은_걸림():
mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]}
assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9))
assert mats[9]["걸림"] == []
def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값():
plain = labor.run_line(_whole(), NET, {})
again = labor.run_line(_whole(), NET, {}, None, None)
assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀)
picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE})
row = picked["재료"][0]
assert (row["품목"]["키"], row["고름"]) == (WIRE, True)
assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"]
gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"]
assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap)
assert picked["기준"]["노무비"] == plain["기준"]["노무비"]
def test_합_알약에_로직_자재_금액_다시_안_더함():
doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}]))
got = labor.calc(doc, {})
line = got["줄"][0]
assert line["재료"] and got["자재"] == []
assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"])
assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2)
assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)]
def test_고름_정리_틀리면_400():
got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}]))
assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1]
for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]):
with pytest.raises(store.StoreError) as e:
labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}]))
assert e.value.status == 400
def test_사용자_자재가_로직_자재와_같으면_저장_400():
with pytest.raises(store.StoreError) as e:
labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}]))
assert e.value.status == 400 and WIRE in str(e.value.detail)
# 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음
with pytest.raises(store.StoreError):
labor.server(
_doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}])
)
ok = labor.server(
_doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}])
)
assert ok["자재"][0]["코드"] == WIRE
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_저장_뒤_고름_남고_계산값에_반영(system):
have = store.read("labor", "rv")
sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["담은로직"][0]["고름"] == {"3": WIRE}
assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE
def test_단가승격도_고름을_따름_없으면_지금_값():
expand = refs.Expand(_whole())
plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1})
picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1})
assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"]
assert picked["기준"]["재료비"] < plain["기준"]["재료비"]
def test_사본_씨앗은_문서_고름():
labor_file = {
"줄": {
"담은로직": [
{"로직": NET, "고름": {"3": WIRE}},
{"로직": BARE},
{"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""},
]
}
}
assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}}
assert snap.seed_picks(None) == {}
def _release(row: dict, before: dict, mats: list) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")],
input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_입력이_바뀌면_걸린_고름만_풂_화면_짝():
mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"])
picks = {"4": "MT007895", "9": WIRE}
row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks}
assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로
assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만
row = {**row, "고름": {"4": "MT007895"}}
assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움
@@ -0,0 +1,85 @@
"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글.
끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 ·
6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸.
배치(102-1) · 품셈 글(102-3) = 소스 꼴.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_문턱_6px(r):
assert r["threshold"] == [False, True, True, False, True]
def test_작게_움직이면_누름(r):
assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None}
def test_끌어_놓으면_순서만_누름_삼킴(r):
assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음
assert r["during"]["capture"] == 1 and r["during"]["select"] == "none"
assert r["during"]["classes"][0] == "is-dragging"
assert r["during"]["classes"][5] == "is-dragunder"
assert r["drop"]["moves"] == [[0, 2]]
assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번
assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로
assert set(r["drop"]["classes"]) == {""}
assert r["afterFrame"]["listeners"] == 0
assert r["idleDraw"] == 1
def test_열린_줄_하위_줄째(r):
assert r["fromSub"]["moves"] == [[1, 0]]
assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4
assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]"
@pytest.mark.parametrize("name", ["button", "input", "qty"])
def test_단추_입력칸에선_시작_안_함(r, name):
assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True}
def test_손잡이_제자리(r):
assert r["gripSelf"] == {"moves": 0, "clickPasses": False}
def test_배치_손잡이_차례칸_앞_단추칸():
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows
assert 'draggable: "true"' not in rows and "dragFrom" not in rows
assert "if (move) attachRowDrag(out, move);" in rows
css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8")
order = css[css.index(".m02-labrow__order {") :]
assert "flex-direction: column;" in order[: order.index("}")]
assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
assert "afterRowDrag(logicHost, () =>" in detail
def test_구분열_품셈_글():
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
assert "const BOOK = /^(건설|산림)품셈/;" in rows
assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows
assert 'className: "m02-labrow__book"' in rows
+2 -2
View File
@@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양():
), name
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
assert re.search(
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;(\s*/\*\*[^*]*\*/)?(\s*합계\w+\?: [^;]+;)*\s*\}",
body,
)
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
@@ -93,7 +93,7 @@ def test_집계표만_컨테이너를_붙임():
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
assert re.search(
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
r"toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*"
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
body,
)
@@ -120,7 +120,7 @@
out.splitAfter = ctl("inlet", "after").value;
await set("inlet", "after", "4");
out.splitLength = ctl("inlet", "length").value;
// 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
// 흙막이 = 유출 「추가 흙막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length;
out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length;
spec().querySelector('[data-spec-add="chute_add"]').click();
@@ -239,9 +239,9 @@
sec()
.querySelectorAll("button")
.forEach((b) => flat(b) === "리셋" && b.click());
// 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
// 다단 벽(추가 흙막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
out.tier = null;
for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
for (const card of cards().filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
if (/추가\(성토부\)/.test(card.innerHTML)) {
out.tier = card.id;
break;
@@ -306,7 +306,7 @@
}
// ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ──
await clearCard();
const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML));
const pipeCard = targets.find((c) => /유입 집수정|유출 흙막이/.test(c.innerHTML));
if (pipeCard) {
click(pipeCard.querySelector("svg"));
await w(1400);
@@ -423,12 +423,12 @@
judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state());
};
// 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음
for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
for (const c of targets.filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
await clearCard();
click(uniqueHits(document.getElementById(c.id))[0]);
await w(1400);
if (!ctlOf("outlet.form")) continue;
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t));
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 흙막이 콘크리트/.test(t));
break;
}
const fordCard = targets.find((c) => /련/.test(titles(c).join("|")));
@@ -1,10 +1,10 @@
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 흙막이」 칸 · 다단 벽 그림(형태 · 높이)
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / -
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
*/
(async () => {
@@ -71,7 +71,7 @@
),
),
extraGroupShown: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
).length,
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
walls: extraShapes(),
@@ -3,7 +3,7 @@
* 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)"
* look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림
* prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / - → [저장] → look
* 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 ·
* 다시 2 에 줄 · 상자 값 되살아남
*/
@@ -108,10 +108,10 @@
["inlet", "유입"],
["outlet", "유출"],
]) {
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
const wall = pic.walls.find((t) => t.startsWith(`${role} 흙막이`));
if (side === "inlet" && basin) continue;
const f = head0(fields[`${side}.form`]);
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
if (!wall || flat(wall) !== flat(`${role} 흙막이 ${f}`))
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
}
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
@@ -1,11 +1,11 @@
/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) ·
* 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 ·
* 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N
* 흙막이 컨테이너 수 = 유출 「추가 흙막이(단)」 N
* 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5)
* 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)"
* look → 지금 화면 배관 폼 수치(아무것도 안 바꿈)
* basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함)
* tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
* tier+ / tier- → B06 유출 벽을 골라 「추가 흙막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
* 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 ·
* 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after ·
* soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수
@@ -86,7 +86,7 @@
outletText: box("outlet")?.textContent.includes("자동 자리"),
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
extraGroupShown: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
).length,
};
};
@@ -120,7 +120,7 @@
for (const form of forms) {
await set(side, "form", form);
const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) =>
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`),
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 흙막이`),
);
const hatch = poly.nextElementSibling?.nextElementSibling;
rows.push({
+50
View File
@@ -107,6 +107,19 @@
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
}
/* 구조물 조합 일위대가 합계 칸(101-1) — 초록 모두 반영 · 노랑 일부 빠짐 · 붉음 기준 단가 안 나옴(설계값·계산 칸보다 진함) */
.ui-sheet__table td.is-sum-ok {
background: color-mix(in srgb, var(--color-success) 26%, var(--color-canvas));
}
.ui-sheet__table td.is-sum-short {
background: color-mix(in srgb, var(--color-warning) 34%, var(--color-canvas));
}
.ui-sheet__table td.is-sum-error {
background: color-mix(in srgb, var(--color-danger) 28%, var(--color-canvas));
}
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
color: var(--color-text-secondary);
}
@@ -141,6 +154,43 @@
outline-offset: -2px;
}
/* 구조물 조합 — 고른 열의 머리 칸 + 합계 칸을 한 상자로(머리 아래 · 합계 위 변 없음 = 중간선 없음) */
.ui-sheet--combo .ui-sheet__table:has(tr.ui-sheet__meta td.is-selected) th.is-head-picked {
border-bottom-width: 0;
outline: 0;
box-shadow:
inset 2px 0 0 var(--color-focus-ring),
inset -2px 0 0 var(--color-focus-ring),
inset 0 2px 0 var(--color-focus-ring);
}
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected {
outline: 0;
box-shadow:
inset 2px 0 0 var(--color-focus-ring),
inset -2px 0 0 var(--color-focus-ring),
inset 0 -2px 0 var(--color-focus-ring);
}
/* 틀고정 칸의 오른쪽 선 그림자(:87-91)와 합침 */
.ui-sheet--combo
.ui-sheet__table:has(tr.ui-sheet__meta td.is-selected)
th.is-head-picked.is-frozen {
box-shadow:
inset 2px 0 0 var(--color-focus-ring),
inset -2px 0 0 var(--color-focus-ring),
inset 0 2px 0 var(--color-focus-ring),
1px 0 0 var(--color-border);
}
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected.is-frozen {
box-shadow:
inset 2px 0 0 var(--color-focus-ring),
inset -2px 0 0 var(--color-focus-ring),
inset 0 -2px 0 var(--color-focus-ring),
1px 0 0 var(--color-border);
}
.ui-sheet__table tr.is-fixed td {
border-bottom: 2px solid var(--color-border);
}
+13 -1
View File
@@ -25,6 +25,7 @@ import {
addLevel,
deleteColumn,
isBlankPick,
KEY_SUM,
KEY_UNIT,
navRows,
PICK_ROWS,
@@ -61,6 +62,8 @@ export interface SheetOptions {
areaLabel?: (colId: string) => string;
/** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */
laborSumLabel?: (colId: string) => string;
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — `state` = ok · short · error · 없으면 색 없음. */
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
@@ -93,6 +96,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
drawingLabel: opts.drawingLabel,
areaLabel: opts.areaLabel,
laborSumLabel: opts.laborSumLabel,
laborSumState: opts.laborSumState,
pick: !!opts.onPick,
headLocked: !!opts.lockHead,
combo: !!opts.combo,
@@ -186,7 +190,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
state.result = recalcSheet(state.doc);
const { scrollLeft, scrollTop } = scroll;
scroll.replaceChildren(renderSheet(state));
if (opts.combo && state.sel) deepestHead(state.sel.c)?.classList.add("is-head-picked");
if (opts.combo && state.sel) {
deepestHead(state.sel.c)?.classList.add("is-head-picked");
markSum(state.sel.c);
}
scroll.scrollLeft = scrollLeft;
scroll.scrollTop = scrollTop;
syncToolbar();
@@ -212,6 +219,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
}
return best;
};
/** 구조물 조합 — 머리를 고르면 그 열 합계 칸도 늘 같이 고름 표시(101-2) */
const markSum = (colId: string): void => {
for (const cell of cellsAt(KEY_SUM, colId)) cell.classList.add("is-selected");
};
const select = (r: string, c: string, reveal = false): void => {
for (const cell of scroll.querySelectorAll(".is-selected, .is-head-picked"))
cell.classList.remove("is-selected", "is-head-picked");
@@ -220,6 +231,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
head?.classList.add("is-head-picked");
const cells = cellsAt(r, c);
for (const cell of cells) cell.classList.add("is-selected");
if (opts.combo) markSum(c);
if (reveal) (cells[0] ?? head)?.scrollIntoView({ block: "nearest", inline: "nearest" });
syncToolbar();
opts.onSelect?.(selectionOf(state.doc, r, c));
@@ -46,6 +46,8 @@ export interface RenderState {
areaLabel?: (colId: string) => string;
/** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */
laborSumLabel?: (colId: string) => string;
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) */
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
pick?: boolean;
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
@@ -194,6 +196,14 @@ export function renderSheet(state: RenderState): HTMLElement {
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
if (key === KEY_SUM) {
td.classList.remove("is-calc", "is-bound", "is-hand"); // 합계 칸은 열 꼴 색 없음 — 상태 색만
const sum = blank ? undefined : state.laborSumState?.(col.id);
if (sum) {
td.classList.add(`is-sum-${sum.state}`);
td.title = sum.why;
}
}
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
tr.append(td);
});

Some files were not shown because too many files have changed in this diff Show More