feat(b06,b07): 표 위 CAD 칸 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄 (166-1)

- 구조물마다 조합 줄 뒤에 좌측 패널 옵션 줄 — 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) · 구조 갈래에 안 맞는 옛 값 뺌 · 필수 상자 빈 칸 = 폼 기본값
- B06 [상세] 모달 = 화면 TS 직접 · B07 = 같은 TS 를 Node 번들(B07_DesignDetail_Note_Options_Node · build:b07-note)로 받아 파이썬 짝이 붙임 · 실패는 조합 줄만
- 번들 낡음 판정에 A00_Common/design 더함
- 시험 test_166_1(진짜 명세 · B07 = 화면 같은 줄) · test_260 · test_265 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 17:55:45 +09:00
co-authored by Claude Opus 5.5
parent c61f9a9e9a
commit 2399c533c6
12 changed files with 395 additions and 29 deletions
+2
View File
@@ -71,6 +71,8 @@ config/formula_node/
config/spreadsheet_node/ config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물. # 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/ config/b07_extent_node/
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
config/b07_note_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
@@ -8,9 +8,12 @@
* 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`)
* · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀)
* · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값) * · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값)
* · `structureOptionLines`(166-1) = 같은 컨테이너의 **보이는 칸 전부** 「제목: 이름 값 · …」 — B06 [상세] CAD 칸
* 미리 넣음 · B07 짝(서버 Node `B07_DesignDetail_Note_Options_Node`)이 같이 씀
* ========================================================================== */ * ========================================================================== */
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box";
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
@@ -18,6 +21,8 @@ import {
branchOf, branchOf,
choiceLabel, choiceLabel,
choicesOf, choicesOf,
containerTitle,
itemLabel,
readState, readState,
type Instance, type Instance,
type SpecContainer, type SpecContainer,
@@ -46,6 +51,14 @@ export function balloonLines(
return values.length ? [first, values.join(SEP)] : [first]; return values.length ? [first, values.join(SEP)] : [first];
} }
/** 명세 이름 바꿈 표 — 배관은 정의 파일의 옛 이름 「흙막이 추가」보다 명세 이름 「흙막이」가 앞섬(54-1). */
function specNames(structure: StructureInstance, type: StructureType | undefined) {
const names = type?.spec_names ?? {};
if (structure.type_id !== "pipe") return names;
const { soil_add: _old, ...pipeNames } = names;
return pipeNames;
}
/** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */ /** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */
function specLines( function specLines(
spec: StructureSpec, spec: StructureSpec,
@@ -53,12 +66,7 @@ function specLines(
type: StructureType | undefined, type: StructureType | undefined,
count?: ContainerCount, count?: ContainerCount,
): string[] { ): string[] {
let names = type?.spec_names ?? {}; const names = specNames(structure, type);
if (structure.type_id === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
names = pipeNames;
}
const state = formState(spec, structure.type_id, structure.options ?? {}); const state = formState(spec, structure.type_id, structure.options ?? {});
return spec.containers.flatMap((c) => return spec.containers.flatMap((c) =>
instancesOf(c, state[c.id] ?? [], count).map((values) => { instancesOf(c, state[c.id] ?? [], count).map((values) => {
@@ -129,3 +137,76 @@ function groupLines(
return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : ""; return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : "";
}); });
} }
/** 「이름 (단위)」 칸 이름 + 값 → 「이름 값단위」(예 높이 (m) + 1.3 = 높이 1.3m) · 단위 없는 이름은 그대로 띄움. */
function nameValue(label: string, shown: string): string {
const hit = /^(.*?)\s*\(([^()]+)\)$/.exec(label);
return hit && shown !== AUTO_MARK ? `${hit[1]} ${shown}${hit[2]}` : `${label} ${shown}`;
}
/**
* CAD 칸 미리 넣음 옵션 줄(166-1) — 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸 전부 · 빈 칸 뺌.
* 명세 종류 = 컨테이너(선택 컨테이너는 저장된 인스턴스마다 · 뒤에 번호)마다 「제목: 이름 값 · 이름 값」 ·
* 그 밖 종류 = 정의 그룹마다(그룹 형상 칸 포함) + 그룹 밖 칸은 종류 이름 줄 · 값 하나 없는 상자는 줄 없음.
*/
export function structureOptionLines(
structure: Pick<StructureInstance, "type_id" | "options">,
type: StructureType | undefined,
): string[] {
const options = structure.options ?? {};
const line = (title: string, parts: string[]): string[] => {
const kept = parts.filter(Boolean);
return kept.length ? [`${title}: ${kept.join(SEP)}`] : [];
};
const spec = STRUCTURE_SPECS[structure.type_id];
if (spec) {
const names = specNames(structure as StructureInstance, type);
const state = formState(spec, structure.type_id, options);
return spec.containers.flatMap((c) =>
(state[c.id] ?? []).flatMap((values, index) => {
const group = type?.groups?.find((g) => g.id === c.link)?.name;
const title = containerTitle(c, names, group) + (c.required ? "" : ` ${index + 1}`);
const parts = c.items
.filter((item) => item.kind !== "auto" && (!item.by || branchOf(item, values)))
.map((item) => {
// 필수 상자 빈 칸 = 폼이 채워 보이는 기본값(177-2 · 예 동물이동형 유) · 선택 상자는 안내뿐이라 뺌
const empty = !values[item.id]?.trim() && c.required && item.default;
const filled = empty ? { ...values, [item.id]: item.default ?? "" } : values;
const shown = specValue(c, item, filled, names);
return shown ? nameValue(itemLabel(c, item, names), shown) : "";
});
return line(title, parts);
}),
);
}
const byKey = new Map((type?.options ?? []).map((option) => [option.key, option]));
const valueOf = (key: string, unit?: string | null): string => {
const raw = options[key];
if (raw === undefined || raw === null || raw === "") return "";
return raw === "auto" ? AUTO_MARK : `${raw}${unit ?? ""}`;
};
const optionParts = (keys: string[]): string[] =>
keys.map((key) => {
const option = byKey.get(key);
if (!option || option.visible === false || option.enabled === false) return "";
const shown = valueOf(key, option.unit);
return shown ? `${option.label} ${shown}` : "";
});
const groups = type?.groups ?? [];
const grouped = new Set(groups.flatMap((group) => group.options));
return [
...groups.flatMap((group) =>
line(group.name, [
...optionParts(group.options),
...shapeFields(group.shape).map((field) => {
const shown = valueOf(shapeKey(group.id, field.suffix));
return shown ? nameValue(field.label, shown) : "";
}),
]),
),
...line(
type?.name ?? structure.type_id,
optionParts((type?.options ?? []).map((o) => o.key).filter((key) => !grouped.has(key))),
),
];
}
+26 -7
View File
@@ -7,7 +7,7 @@
* (`_Engine_Cad_Table.cross_note_cad_entities`). * (`_Engine_Cad_Table.cross_note_cad_entities`).
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음 * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 = * (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
* 「이름 조합 미지정」) · 그 밖 = 빈 도면. * 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` · * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 * `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`. * 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
@@ -24,6 +24,7 @@ import {
fetchStructureTypes, fetchStructureTypes,
readPendingStructures, readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures"; } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
import { import {
readGroupItems, readGroupItems,
readSummaryItem, readSummaryItem,
@@ -144,6 +145,8 @@ export interface NoteThing {
at: number; at: number;
summary_item?: unknown; summary_item?: unknown;
group_items?: unknown; group_items?: unknown;
/** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */
optionLines?: string[];
} }
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */ /** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
@@ -156,18 +159,22 @@ export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteT
/** /**
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 + * 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 · * 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만. * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
*/ */
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] { export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
return thingsAt(things, chainageM).flatMap((thing) => { return thingsAt(things, chainageM).flatMap((thing) => {
const own = readSummaryItem(thing.summary_item); const own = readSummaryItem(thing.summary_item);
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items)); const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
if (own) slots.unshift(["", own]); if (own) slots.unshift(["", own]);
if (!slots.length) return thing.pipe ? [] : [`${thing.name} 조합 미지정`];
const names = slots const names = slots
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK))) .filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
.map(([, item]) => summaryLabel(item)); .map(([, item]) => summaryLabel(item));
return [...new Set(names)]; const combos = slots.length
? [...new Set(names)]
: thing.pipe
? []
: [`${thing.name} 조합 미지정`];
return [...combos, ...(thing.optionLines ?? [])];
}); });
} }
@@ -237,8 +244,10 @@ export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures), readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true), fetchStructureTypes(true),
]); ]);
const names = new Map(types.map((type) => [type.type_id, type.name])); const byId = new Map(types.map((type) => [type.type_id, type]));
const nameOf = (kind: string): string => names.get(kind) ?? kind; const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind;
const optionLines = (type_id: string, options?: Record<string, string | number>): string[] =>
structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id));
const piped = pipes.map((point): NoteThing => { const piped = pipes.map((point): NoteThing => {
const kind = point.facility ?? "pipe"; const kind = point.facility ?? "pipe";
const { summary_item, group_items } = point; const { summary_item, group_items } = point;
@@ -248,13 +257,23 @@ export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
at: point.chainage_m, at: point.chainage_m,
summary_item, summary_item,
group_items, group_items,
optionLines: optionLines(kind, point.options as Record<string, string | number>),
}; };
}); });
const placed = structures.flatMap((item): NoteThing[] => { const placed = structures.flatMap((item): NoteThing[] => {
const at = item.chainage_m ?? item.start_m; const at = item.chainage_m ?? item.start_m;
if (typeof at !== "number" || !Number.isFinite(at)) return []; if (typeof at !== "number" || !Number.isFinite(at)) return [];
const { summary_item, group_items } = item; const { summary_item, group_items } = item;
return [{ name: nameOf(item.type_id), pipe: false, at, summary_item, group_items }]; return [
{
name: nameOf(item.type_id),
pipe: false,
at,
summary_item,
group_items,
optionLines: optionLines(item.type_id, item.options),
},
];
}); });
return [...piped, ...placed]; return [...piped, ...placed];
} catch { } catch {
@@ -7,20 +7,31 @@ B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 (빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
(모달이 처음 열 때 옮기는 차례와 같음). (모달이 처음 열 때 옮기는 차례와 같음).
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된 그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」). 구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1).
옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node
진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 ·
실패는 비치명(조합 줄만).
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. 좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
""" """
import json import json
import logging
import math import math
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
from common_util.common_util_node_bundle import run_bundle_json
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
_OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js"
_OPTIONS_NPM = "build:b07-note"
# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 # TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭
PIPE_MATCH_M = 0.51 PIPE_MATCH_M = 0.51
@@ -35,7 +46,8 @@ _GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
#: (관 누가거리, 세트, 옵션) — 원형 마크 재료 #: (관 누가거리, 세트, 옵션) — 원형 마크 재료
Pipe = tuple[float, dict[str, Any], dict[str, Any]] Pipe = tuple[float, dict[str, Any], dict[str, Any]]
#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing` #: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items, type_id, options, option_lines}`
#: — TS `NoteThing`(option_lines = `optionLines`)
Thing = dict[str, Any] Thing = dict[str, Any]
@@ -98,22 +110,23 @@ def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]: def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
"""구조물 조합 줄 — TS `structureNoteLines` 짝. """구조물 조합 줄 — TS `structureNoteLines` 짝.
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌. 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 ·
이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1).
""" """
lines: list[str] = [] lines: list[str] = []
for thing in things_at(things, chainage_m): for thing in things_at(things, chainage_m):
own = _summary_item(thing.get("summary_item")) own = _summary_item(thing.get("summary_item"))
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items")) slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
if not slots:
if not thing["pipe"]:
lines.append(f"{thing['name']} 조합 미지정")
continue
names = [ names = [
item["name"] or item["key"] item["name"] or item["key"]
for gid, item in slots for gid, item in slots
if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK)) if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK))
] ]
lines += list(dict.fromkeys(names)) if slots:
lines += list(dict.fromkeys(names))
elif not thing["pipe"]:
lines.append(f"{thing['name']} 조합 미지정")
lines += [str(line) for line in thing.get("option_lines") or []]
return lines return lines
@@ -207,6 +220,8 @@ def _things(points: list[Any], structures: list[Any], names: dict[str, str]) ->
"at": float(point.chainage_m), "at": float(point.chainage_m),
"summary_item": point.summary_item, "summary_item": point.summary_item,
"group_items": point.group_items, "group_items": point.group_items,
"type_id": point.facility,
"options": dict(point.options or {}),
} }
for point in points for point in points
] ]
@@ -221,11 +236,46 @@ def _things(points: list[Any], structures: list[Any], names: dict[str, str]) ->
"at": float(at), "at": float(at),
"summary_item": item.summary_item, "summary_item": item.summary_item,
"group_items": item.group_items, "group_items": item.group_items,
"type_id": item.type_id,
"options": dict(item.options or {}),
} }
) )
return things return things
def with_option_lines(things: list[Thing]) -> list[Thing]:
"""구조물마다 좌측 패널 옵션 줄(166-1) — 화면 TS 를 Node 로 한 번에 · 실패면 그대로."""
if not things:
return things
try:
# 지연 import — M02 정의 저장소가 B07 엔진 import 차례에 끼지 않게
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
payload = {
"things": [
{"type_id": t["type_id"], "options": t.get("options") or {}} for t in things
],
"types": with_visible().model_dump(mode="json")["types"],
"definition": load_definition(),
}
output = run_bundle_json(_OPTIONS_BUNDLE, _OPTIONS_NPM, payload)
except Exception:
logger.warning("B07 CAD 칸 옵션 줄 계산 실패 — 조합 줄만 넣음", exc_info=True)
return things
rows = output.get("lines") if isinstance(output, dict) else None
if not isinstance(rows, list) or len(rows) != len(things):
return things
return [
{
**thing,
"option_lines": [line for line in row if isinstance(line, str)]
if isinstance(row, list)
else [],
}
for thing, row in zip(things, rows)
]
def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
"""정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치).""" """정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치)."""
try: try:
@@ -242,7 +292,7 @@ def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
pipes.append((float(point.chainage_m), spec, options)) pipes.append((float(point.chainage_m), spec, options))
_revision, structures = load_structures(str(project_root)) _revision, structures = load_structures(str(project_root))
names = {kind.type_id: kind.name for kind in load_structure_types()} names = {kind.type_id: kind.name for kind in load_structure_types()}
return pipes, _things(points, structures, names) return pipes, with_option_lines(_things(points, structures, names))
def with_structure_note( def with_structure_note(
@@ -0,0 +1,45 @@
/* =============================================================================
* B07_DesignDetail_Note_Options_Node.ts
* B07 횡단도 표 위 CAD 칸 미리 넣음의 **좌측 패널 옵션 줄**을 서버가 내는 Node 진입점(166-1 ·
* CLAUDE.md 5장 「계산 자리」 ②).
*
* 화면(B06 [상세] 모달 `loadNoteThings`)과 같은 함수(`structureOptionLines` · 정의 덧칸 `withDefinition`)를
* 그대로 돌린다 — 파이썬 짝(`B07_DesignDetail_Engine_Cad_CulvertNote`)은 받은 줄을 구조물마다 조합 줄 뒤에 붙인다.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { things: [{ type_id, options }], types: GET /projects/structure-types 의 types,
* definition: 구조물 정의(GET /m02/structure-definition 과 같은 꼴) }
* 출력 { lines: string[][] } — things 차례대로
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
import {
withDefinition,
type StructureDefinition,
} from "../B05_Profile/B05_Profile_UI_Structures_Define";
interface Input {
things?: Array<{ type_id: string; options?: Record<string, string | number> }>;
types?: StructureType[];
definition?: StructureDefinition | null;
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as Input;
const types = new Map(
withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]),
);
const lines = (input.things ?? []).map((thing) =>
structureOptionLines(
{ type_id: thing.type_id, options: thing.options ?? {} },
types.get(thing.type_id),
),
);
writeFileSync(outputPath, JSON.stringify({ lines }));
+3 -2
View File
@@ -23,8 +23,9 @@ ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util")
# 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. # 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드.
SOURCE_DATA_DIRS = ("resources/data_masonry",) SOURCE_DATA_DIRS = ("resources/data_masonry",)
# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다. # 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다 ·
SOURCE_GLOBS = ("B07_DesignDetail/*.ts",) # 구조물 명세(A00 design)는 B07 CAD 칸 옵션 줄 번들이 끌어 씀(166-1).
SOURCE_GLOBS = ("B07_DesignDetail/*.ts", "A00_Common/design/*.ts")
# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다.
BUILD_TIMEOUT_S = 300 BUILD_TIMEOUT_S = 300
RUN_TIMEOUT_S = 600 RUN_TIMEOUT_S = 600
+2 -1
View File
@@ -5,12 +5,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-cad", "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:b07-cad",
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
"build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node",
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node",
"build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node",
"build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node",
"build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node",
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
@@ -0,0 +1,45 @@
/* 166-1 CAD 칸 미리 넣음 옵션 줄 — 진짜 `structureOptionLines`(명세 · 정의 덧칸)를 Node 로 돌림.
* 표준입력 { things: [{type_id, options}], types, definition } → 표준출력 { lines: string[][] }
* (B07 번들 `B07_DesignDetail_Note_Options_Node` 와 같은 입력 · 같은 함수 — 짝 = test_166_1_note_options.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();
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 (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { structureOptionLines } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"),
);
const { withDefinition } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Define.ts"),
);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const types = new Map(
withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]),
);
const lines = (input.things ?? []).map((thing) =>
structureOptionLines(
{ type_id: thing.type_id, options: thing.options ?? {} },
types.get(thing.type_id),
),
);
process.stdout.write(JSON.stringify({ lines }));
+2
View File
@@ -22,6 +22,8 @@ const STUBS = {
fetchStructureTypes: async () => DATA.types, fetchStructureTypes: async () => DATA.types,
readPendingStructures: () => null, readPendingStructures: () => null,
}, },
// 좌측 패널 옵션 줄(166-1)은 test_166_1 이 진짜 명세로 봄 — 여기는 조합 줄 · 마크만
"../B05_Profile/B05_Profile_UI_Structures_Balloon": { structureOptionLines: () => [] },
}; };
function loadTs(file) { function loadTs(file) {
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""166-1 횡단 [상세] CAD 칸 · B07 표 위 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄.
사용자 말: 「일단은 좌측패널 옵션과 구조물조합이름을 다 넣어주면 내가 추가로 선정하겠음」.
옵션 줄 = 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) —
관 보호공 구조 갈래에 맞지 않는 옛 값(집수정인데 흙막이 칸 값)은 안 씀 ·
필수 상자 빈 칸 = 폼이 채워 보이는 기본값.
B07 은 같은 TS 를 Node 번들(`B07_DesignDetail_Note_Options_Node`)로 돌려 받음.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Structures_Schema import load_definition # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
structure_note_lines,
with_option_lines,
)
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
PIPE = {
"pipe_diameter_mm": 800,
"pipe_kind": "파형강관",
"inlet_type": "집수정",
"inlet_structure": "L",
"inlet_basin_height_m": 1.3,
# 구조를 집수정으로 바꾸기 전 흙막이 값 — 폼이 안 보이는 옛 값
"inlet_revet_form": "돌쌓기(찰)",
"inlet_revet_length_m": 10,
"outlet_type": "기슭막이",
"outlet_revet_form": "돌쌓기(찰)",
"outlet_revet_length_m": 10,
"outlet_revet_before_m": 5,
"outlet_revet_after_m": 5,
"outlet_revet_height_m": 1.3,
}
WALL = {"wall_form": "돌쌓기(찰)", "length_m": 12, "height_m": 2.5, "before_m": 6, "after_m": 6}
THINGS = [
{"type_id": "pipe", "options": PIPE},
{"type_id": "retaining_wall", "options": WALL},
{"type_id": "hazard_sign", "options": {}},
]
@pytest.fixture(scope="module")
def ts_lines() -> list[list[str]]:
payload = {
"things": THINGS,
"types": with_visible().model_dump(mode="json")["types"],
"definition": load_definition(),
}
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_166_1_note_options.cjs")],
input=json.dumps(payload, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)["lines"]
def test_배관_옵션_줄은_폼_컨테이너_그대로(ts_lines):
pipe = ts_lines[0]
assert pipe[0] == "관: 관경 800"
# 집수정 갈래 칸만 · 빈 동물이동형 = 폼 기본 「유」 · 흙막이 옛 값은 안 씀
assert pipe[1] == "관 보호공(유입): 구조 집수정 · 형태 L형 · 높이 1.3m · 동물이동형 유"
assert "10m" not in pipe[1]
assert pipe[2].startswith("관 보호공(유출): 구조 흙막이 · 형태 돌 쌓기(찰) · 길이 10m")
assert "기준측점 전 5m · 기준측점 후 5m" in pipe[2]
def test_정의_그룹_종류는_그룹마다_한_줄_옵션_없으면_줄_없음(ts_lines):
assert ts_lines[1] == [
"옹벽: 형태 돌쌓기(찰) · 길이 12m · 높이 2.5m · 기준측점 전 6m · 기준측점 후 6m"
]
assert ts_lines[2] == []
def test_B07_은_같은_TS_를_Node_번들로_받아_같은_줄(ts_lines):
things = [{**thing, "name": thing["type_id"], "pipe": False, "at": 0.0} for thing in THINGS]
got = with_option_lines(things)
assert [thing["option_lines"] for thing in got] == ts_lines
def test_조합_줄_뒤에_옵션_줄_미지정도_옵션_줄():
thing = {
"name": "옹벽",
"pipe": False,
"at": 40.0,
"summary_item": None,
"group_items": None,
"option_lines": ["옹벽: 높이 2.5m"],
}
assert structure_note_lines([thing], 40.0) == ["옹벽 조합 미지정", "옹벽: 높이 2.5m"]
def test_화면은_구조물마다_옵션_줄을_붙임():
cad = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail_Cad.ts").read_text("utf-8")
assert "structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id))" in cad
assert "...(thing.optionLines ?? [])" in cad
+3 -2
View File
@@ -448,8 +448,9 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_
seeded = with_structure_note(design, 1433.0, sources, "NO.71+13") seeded = with_structure_note(design, 1433.0, sources, "NO.71+13")
assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜
labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]]
# 관 조합 미지정 = 원형 마크 · 측점만(배관은 글 줄 없음) # 관 조합 미지정 = 원형 마크 · 측점(배관은 조합 글 줄 없음) · 이어서 좌측 패널 옵션 줄(166-1)
assert labels[2:] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] assert labels[2:6] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
assert labels[6].startswith("관: 관경 1200") and all(": " in line for line in labels[6:])
cleared = {**design, "structure_note_cad": {"entities": []}} cleared = {**design, "structure_note_cad": {"entities": []}}
assert with_structure_note(cleared, 1433.0, sources, "x") is cleared assert with_structure_note(cleared, 1433.0, sources, "x") is cleared
old = {**design, "structure_note": "옛 글"} old = {**design, "structure_note": "옛 글"}
+4 -1
View File
@@ -209,7 +209,10 @@ def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path):
assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)] assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)]
seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2") seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2")
labels = _labels(seeded["structure_note_cad"]["entities"]) labels = _labels(seeded["structure_note_cad"]["entities"])
assert labels == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"], _C["name"]] # 구조물마다 조합 줄 → 좌측 패널 옵션 줄(166-1) · 옵션 없는 옹벽은 조합 줄만
assert labels[:5] == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"]]
assert labels[5].startswith("관: 관경 800") and labels[-1] == _C["name"]
assert all(": " in line for line in labels[5:-1])
# ── 265-3 관 길이 정수 ───────────────────────────────────────────────── # ── 265-3 관 길이 정수 ─────────────────────────────────────────────────