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

충돌 풂 — Culvert_Tiers cutBerm = sub7 (lengthM) 꼴 + 91-4 복합경사 제원 · test_79 유출 끝 = 수평공간 없이 바로 절토(89-4) · Culvert_Geom 700줄 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 12:39:28 +09:00
co-authored by Claude Opus 5.5
171 changed files with 10904 additions and 625 deletions
@@ -32,6 +32,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 구조 · 두께(cm · 새 상자 30) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
items: [
{
id: "structure",
@@ -42,6 +43,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
{ value: "돌붙임", label: "돌붙임" },
],
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
],
},
],
+17 -12
View File
@@ -83,11 +83,12 @@ export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => (
});
/** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) ·
* 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */
export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): 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 berm: SpecItem = {
id: "berm",
label: "수평공간 (m)",
@@ -109,6 +110,20 @@ export const SOIL_ADD: SpecContainer = {
items: guardItems(),
};
/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 물넘이 포장(`ford_chute`)도 같은 칸. */
export const CHUTE_ITEMS: SpecItem[] = [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" },
];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
export const CHUTE_ADD: SpecContainer = {
id: "chute_add",
@@ -117,15 +132,5 @@ export const CHUTE_ADD: SpecContainer = {
prefix: "chute_add",
link: "gchuteadd",
max: 1,
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
],
items: CHUTE_ITEMS,
};
@@ -20,6 +20,9 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu
sectionsSource = source;
}
/** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */
export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null;
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */
let extraCountSource: ((section: CrossSection) => number) | null = null;
export function setPipeExtraCountSource(source: (section: CrossSection) => number): void {
+12 -5
View File
@@ -2,9 +2,10 @@
* design_structure_road.ts
* 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`).
*
* 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력.
* 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`.
* 시작 · 종료 측점(패널 위치 줄 · 기준 측점 · 전 · 후 · 길이 입력 없음) + 필수 컨테이너 「상세 내용」 한 벌 =
* 포장사양 고르기 → 길이(m · 시작~종료 출력) → 높이(m · 두께 손 입력). 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m` · `widening_off`(확폭 끈 구간 · 87-3) ·
* 옛 `length_m` · `before_m` · `after_m` 은 저장 때 뺌(`dropped`).
* 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) ·
* 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
@@ -12,8 +13,11 @@
import type { SpecChoice, StructureSpec } from "./design_structure_spec";
/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }];
/** 포장사양 선택지 — 근거 별표2 · 별표3 · 임도기술교본 3장. */
export const PAVE_SPEC_CHOICES: SpecChoice[] = ["콘크리트", "아스콘", "쇄석"].map((v) => ({
value: v,
label: v,
}));
export const PAVEMENT_SPEC: StructureSpec = {
typeId: "pavement_concrete",
@@ -31,6 +35,7 @@ export const PAVEMENT_SPEC: StructureSpec = {
key: "pave_spec",
choices: PAVE_SPEC_CHOICES,
},
{ id: "length", label: "길이 (m)", kind: "auto" },
{ id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 },
],
},
@@ -44,6 +49,8 @@ export const PAVEMENT_SPEC: StructureSpec = {
"separation_sheet",
"wire_mesh",
],
dropped: ["length_m", "before_m", "after_m"],
byRange: true,
};
export const ETC_SPEC: StructureSpec = {
@@ -69,6 +69,10 @@ export interface StructureSpec {
containers: SpecContainer[];
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
retired: string[];
/** 저장 때 지우는 옛 키 — 화면에도 안 그림(옛 항목을 [수정]하면 빠짐). */
dropped?: string[];
/** 범위 직접 입력형 — 시작 · 종료 측점만 받고 기준 측점 칸 없음(알약 = 시점 · 길이 · 전 · 후 입력 없음). */
byRange?: boolean;
}
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
@@ -0,0 +1,114 @@
/* =============================================================================
* design_structure_widening.ts
* 노면공 › 확폭 구간(87-3) — 범위 안에서 확폭(좌 + 우) > 0 인 횡단 측점을 이어진 구간으로 묶고,
* 끈 구간을 `options.widening_off = "시작-끝,시작-끝"`(측점 m · 빈 글 = 모두 켬)으로 읽고 씀.
* 켬 = 확폭 포함 · 끔 = 규격 폭만. 노선이 바뀌어 구간 끝이 안 맞는 끈 값은 무시(= 켬).
* 횡단 형상 쪽(88장)도 이 키를 같은 글로 읽음 — 측점이 범위 ±0.02 m 안.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
/** 구조물 범위 · 구간 끝 맞춤 허용치(m). */
export const WIDENING_TOL_M = 0.02;
/** 횡단 측점에서 읽는 몫만 — 횡단 설계 확폭(좌 · 우) · 설계 없으면 저장된 곡선 확폭(바깥쪽 한 방향). */
export interface WideningStation {
chainage_m: number;
curve_widening_m?: number | null;
curve_outer_side?: "left" | "right" | null;
design?: { widening_left_m?: number; widening_right_m?: number } | null;
}
export interface WideningRun {
start_m: number;
end_m: number;
/** 구간 안 확폭(좌 + 우) 최댓값(m). */
widening_m: number;
}
/** 한 측점의 확폭(좌 + 우, m) — 없으면 0. */
export function wideningOf(station: WideningStation): number {
const sum = station.design
? (station.design.widening_left_m ?? 0) + (station.design.widening_right_m ?? 0)
: station.curve_outer_side
? (station.curve_widening_m ?? 0)
: 0;
return Number.isFinite(sum) && sum > 0 ? sum : 0;
}
/** 범위 `[start, end]` 안 확폭 > 0 측점을 이어진 구간(범위 안 측점 목록에서 이웃)으로 묶음. */
export function wideningRuns(
stations: readonly WideningStation[],
start: number,
end: number,
): WideningRun[] {
const inside = stations
.filter((s) => s.chainage_m >= start - WIDENING_TOL_M && s.chainage_m <= end + WIDENING_TOL_M)
.sort((a, b) => a.chainage_m - b.chainage_m);
const runs: WideningRun[] = [];
let open: WideningRun | null = null;
for (const station of inside) {
const width = wideningOf(station);
if (width <= 0) {
open = null;
continue;
}
if (open) {
open.end_m = station.chainage_m;
open.widening_m = Math.max(open.widening_m, width);
} else {
open = { start_m: station.chainage_m, end_m: station.chainage_m, widening_m: width };
runs.push(open);
}
}
return runs;
}
const round = (value: number): number => Number(value.toFixed(2));
/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */
export function parseOff(text: unknown): Array<[number, number]> {
if (typeof text !== "string") return [];
const pairs: Array<[number, number]> = [];
for (const part of text.split(",")) {
const [a, b] = part.split("-").map((token) => Number(token.trim()));
if (part.includes("-") && Number.isFinite(a) && Number.isFinite(b) && b >= a)
pairs.push([a, b]);
}
return pairs;
}
export const formatOff = (pairs: ReadonlyArray<readonly [number, number]>): string =>
pairs.map(([a, b]) => `${round(a)}-${round(b)}`).join(",");
/** 끈 쌍이 이 구간과 맞는가(시작 · 끝 각각 허용치 안). */
export const pairMatches = (pair: readonly [number, number], run: WideningRun): boolean =>
Math.abs(pair[0] - run.start_m) <= WIDENING_TOL_M &&
Math.abs(pair[1] - run.end_m) <= WIDENING_TOL_M;
/** 지금 구간들 중 끈 것만 남긴 글(안 맞는 옛 값은 빠짐). */
export function offForRuns(runs: readonly WideningRun[], text: unknown): string {
const pairs = parseOff(text);
return formatOff(
runs
.filter((run) => pairs.some((p) => pairMatches(p, run)))
.map((run) => [run.start_m, run.end_m] as const),
);
}
/** 구간 하나의 켬/끔을 뒤집은 새 글(맞는 구간 쌍만 남김). */
export function toggleRun(
runs: readonly WideningRun[],
text: unknown,
target: WideningRun,
): string {
const pairs = parseOff(text);
const isOff = (run: WideningRun): boolean => pairs.some((p) => pairMatches(p, run));
return formatOff(
runs
.filter((run) => (run === target ? !isOff(run) : isOff(run)))
.map((run) => [run.start_m, run.end_m] as const),
);
}
export const isRunOff = (run: WideningRun, text: unknown): boolean =>
parseOff(text).some((p) => pairMatches(p, run));
+4
View File
@@ -59,6 +59,10 @@ function bootstrap(): void {
let shown = "";
let shownHash = "";
const renderAll = (): void => {
document.documentElement.classList.toggle(
"is-bm-route",
NATIVE_MENU_ROUTE.test(currentRoute()),
); // 전역 글 선택 막음(81-1)
const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a)
shown = currentRoute();
shownHash = window.location.hash;
+9 -9
View File
@@ -110,7 +110,7 @@ export function createSpreadsheet(
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음
const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`,
});
@@ -142,7 +142,7 @@ export function createSpreadsheet(
ctx.editor = ed;
const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = bare ? null : attachMenu(ctx);
const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3)
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
@@ -171,7 +171,7 @@ export function createSpreadsheet(
}
grid.render();
select(ctx.selection);
select(ctx.selection, false, true);
loadLinks();
if (opts.conditions && !readOnly)
void import("./spreadsheet_condition").then((m) => {
@@ -221,7 +221,7 @@ export function createSpreadsheet(
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
else grid.render();
}
select(ctx.selection);
select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2)
opts.onChange?.(structuredClone(ctx.book));
}
@@ -235,7 +235,7 @@ export function createSpreadsheet(
return out;
}
function select(selection: Selection, reveal = false): void {
function select(selection: Selection, reveal = false, dataChanged = false): void {
ctx.selection = selection;
memory.set(selection.시트, selection);
grid.renderSelection();
@@ -244,7 +244,7 @@ export function createSpreadsheet(
grid.reveal(far.r, far.c);
}
ed.sync();
for (const p of others) p.refresh();
for (const p of others) p.refresh(dataChanged);
const g = selection.범위[0];
opts.onSelect?.({
시트: selection.시트,
@@ -261,7 +261,7 @@ export function createSpreadsheet(
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
select(ctx.selection, true);
select(ctx.selection, true, true);
ed.focus();
}
@@ -275,13 +275,13 @@ export function createSpreadsheet(
memory.clear();
ctx.selection = selectCell(firstSheet(next), 0, 0);
grid.render();
select(ctx.selection);
select(ctx.selection, false, true);
loadLinks();
},
recalc() {
ctx.engine.rebuild(ctx.book);
grid.render();
for (const p of others) p.refresh();
for (const p of others) p.refresh(true);
return ctx.engine.snapshot();
},
getLinkedValues() {
@@ -97,6 +97,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
back();
};
const act = mountActions(ctx, back);
ctx.dataMenu = (kind) => {
if (kind === "filter") return (toggleFilter(), back());
if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel);
if (kind === "validation")
return void openPanel(TEXT.validation, async () => createValidationPanel);
void openPanel(
TEXT.names,
async () => (await import("./spreadsheet_names")).createNameManagerPanel,
);
};
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
let closeSort: (() => void) | null = null;
+37 -9
View File
@@ -358,7 +358,9 @@ export function mountLinks(
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
}
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감
* 읽기(slot — getBoundingClientRect) 를 칸마다 먼저 다 끝내고 쓰기(append)는 나중에 — 안 그러면
* 읽기·쓰기가 번갈아 와서 매 칸마다 강제 레이아웃이 남(81-2②). */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
@@ -368,11 +370,37 @@ export function mountLinks(
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
const byCell = new Map<string, CellLink[]>();
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
// 1) 읽기 — 칸마다 자리만 먼저 모음(DOM 변경 없음)
const cellSlots: Array<{
list: CellLink[];
layer: HTMLElement;
box: ReturnType<typeof slot>["box"];
}> = [];
for (const [a1, list] of byCell) {
const p = parseA1(a1);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
cellSlots.push({ list, layer, box });
}
const rangeSlots: Array<{
mark: { 시트: string; 범위: string; 이름: string; 설명?: string };
layer: HTMLElement;
box: ReturnType<typeof slot>["box"];
end: ReturnType<typeof slot>["box"];
}> = [];
for (const mark of opts?.표시범위 ?? []) {
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
if (!g) continue;
const { layer, box } = slot(ctx, g.r0, g.c0);
const end = slot(ctx, g.r1, g.c1).box;
if (!box.w || !box.h) continue;
rangeSlots.push({ mark, layer, box, end });
}
// 2) 쓰기 — 모은 자리에 한 번에 그림
for (const { list, layer, box } of cellSlots) {
const first = list[0];
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
@@ -392,12 +420,7 @@ export function mountLinks(
dots.push(...marks);
}
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
for (const mark of opts?.표시범위 ?? []) {
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
if (!g) continue;
const { layer, box } = slot(ctx, g.r0, g.c0);
const end = slot(ctx, g.r1, g.c1).box;
if (!box.w || !box.h) continue;
for (const { mark, layer, box, end } of rangeSlots) {
const frame = el("div", {
className: "ss-link-range",
attrs: { "data-range": mark.범위 },
@@ -542,12 +565,17 @@ export function mountLinks(
ctx.grid.root.addEventListener("drop", onDropEvent);
ctx.grid.root.addEventListener("mousemove", onHover);
let dotsReady = false;
return {
root: opts ? bar : null,
apply: applyLinked,
refresh() {
// dataChanged 거짓(고름만 바뀜) 이면 점은 건너뜀 — 점은 선택에 안 매임(81-2②)
refresh(dataChanged = false) {
renderBar();
placeDots();
if (dataChanged || !dotsReady) {
placeDots();
dotsReady = true;
}
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
+39 -19
View File
@@ -12,10 +12,21 @@
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { rangeToA1 } from "./spreadsheet_address";
import { confirmMerge, dialogKeys } from "./spreadsheet_actions";
import { deleteComment, getComment, setComment } from "./spreadsheet_comments";
import {
cellInsertItems,
cellMoreItems,
colInsertItems,
colMoreItems,
copyCut,
copyOnlyItems,
pasteItems,
rowInsertItems,
rowMoreItems,
} from "./spreadsheet_menu_items";
import { st } from "./spreadsheet_text";
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean {
/** `ctx.root` 의 contextmenu 를 받음(root null) */
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
// 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3)
const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview");
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
@@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
}
function cutCopy(kind: "cut" | "copy"): void {
document.execCommand(kind);
copyCut(ctx, kind);
}
/** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는
@@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
...pasteItems(ctx),
"-",
...cellInsertItems(ctx, range),
"-",
[
st("MenuClearContent"),
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
@@ -107,21 +122,20 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
...(note
? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[])
: []),
...cellMoreItems(ctx, range),
...(ctx.cellMenuExtra?.(r, c) ?? []),
];
}
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
const at = Math.min(...rows);
const at = rows[0]; // rows 는 늘 오름차순 연속(81-2③) — 전체 고름이면 104만 개라 Math.min(...rows) 는 스택 넘침
const 수 = rows.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertRow"),
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...rowInsertItems(ctx, at, 수),
[
st("MenuDeleteRow"),
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -148,20 +162,19 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...rowMoreItems(ctx, rows),
];
}
function colMenuItems(cols: number[]): MapContextMenuItem[] {
const at = Math.min(...cols);
const at = cols[0]; // cols 도 늘 오름차순 연속 — 같은 까닭(81-2③)
const 수 = cols.length;
return [
[st("MenuCut"), () => cutCopy("cut")],
[st("MenuCopy"), () => cutCopy("copy")],
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
[
st("MenuInsertCol"),
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
],
...pasteItems(ctx),
"-",
...colInsertItems(ctx, at, 수),
[
st("MenuDeleteCol"),
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
@@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
숨김: false,
}),
],
...colMoreItems(ctx, cols),
];
}
@@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: hit.r, c: 0 },
기준: { r: hit.r, c: 0 },
});
menu.open(x, y, rowMenuItems(rows));
menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows));
return;
}
@@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
활성: { r: 0, c: hit.c },
기준: { r: 0, c: hit.c },
});
menu.open(x, y, colMenuItems(cols));
menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols));
return;
}
@@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle {
});
}
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
menu.open(x, y, cellMenuItems(active, hit.r, hit.c));
menu.open(
x,
y,
bare
? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])]
: cellMenuItems(active, hit.r, hit.c),
);
}
}
@@ -0,0 +1,441 @@
/* =============================================================================
* spreadsheet_menu_items.ts (81-3)
* 우클릭 메뉴에 덧붙이는 항목 — 구글 시트 우클릭 가운데 기능이 이미 있는 것만(조사_A 3 표 (a)).
* 붙여넣기 · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 행 · 열 크기 · 데이터에 맞추기 ·
* 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸(드롭다운 · 조건부 서식 · 범위 이름).
* 안 만듦 = 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사.
* `spreadsheet_menu.ts` 가 이 목록을 펼쳐 붙임. 몸통 창 · 명령 모듈은 누를 때 `import()`(크기 예산).
* 붙여넣기는 보안 문맥(localhost · https)에서만 클립보드를 읽어 실제로 붙임 — http 는 Ctrl+V 안내.
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { dialogKeys, type Act } from "./spreadsheet_actions";
import { parseA1 } from "./spreadsheet_address";
import { parseClipboard } from "./spreadsheet_clipboard";
import type { Editor } from "./spreadsheet_editor";
import {
buildColGeometry,
buildRowGeometry,
MAX_COL_CHARS,
pxToColChars,
pxToPt,
} from "./spreadsheet_headers";
import type { PasteSpecialMode } from "./spreadsheet_paste_special";
import { st } from "./spreadsheet_text";
import type { CellRange, Command, Sheet } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
paste: "붙여넣기",
pasteSpecial: "선택하여 붙여넣기",
pasteValues: "값만 붙여넣기",
pasteFormat: "서식만 붙여넣기",
pasteSpecialHint: "선택하여 붙여넣기는 Ctrl+Shift+V 를 쓸 것",
pasteFailed: "붙여넣을 내용이 없음",
insertLines: "행 · 열 삽입",
rowsAbove: (n: number) => `위에 ${n}행 삽입`,
rowsBelow: (n: number) => `아래에 ${n}행 삽입`,
colsLeft: (n: number) => `왼쪽에 ${n}열 삽입`,
colsRight: (n: number) => `오른쪽에 ${n}열 삽입`,
insertCells: "셀 삽입",
shiftRight: "셀 삽입 후 오른쪽으로 밀기",
shiftDown: "셀 삽입 후 아래로 밀기",
deleteLines: "행 · 열 삭제",
deleteRows: (n: number) => `${n}행 삭제`,
deleteCols: (n: number) => `${n}열 삭제`,
deleteCells: "셀 삭제",
shiftLeft: "셀 삭제 후 왼쪽으로 당기기",
shiftUp: "셀 삭제 후 위로 당기기",
rowSize: "행 크기 조절…",
colSize: "열 크기 조절…",
rowSizeLabel: "행 높이(px)",
colSizeLabel: "열 너비(px)",
autofit: "데이터에 맞추기",
sortAsc: "시트 정렬 A → Z",
sortDesc: "시트 정렬 Z → A",
filterOn: "필터 만들기",
filterOff: "필터 삭제",
freeze: "고정",
freezeRow: "첫 행 고정",
freezeCol: "첫 열 고정",
freezeAt: "현재 칸까지 고정",
unfreeze: "고정 풀기",
group: "그룹",
groupOn: "그룹으로 묶기",
groupOff: "그룹 풀기",
more: "셀 작업 더보기",
dropdown: "드롭다운…",
condfmt: "조건부 서식…",
nameRange: "범위 이름 지정…",
nameLabel: "이름",
nameEmpty: "이름을 넣을 것",
ok: "확인",
cancel: "취소",
badNumber: "1 이상 숫자를 넣을 것",
};
type Item = [string, () => void];
const SEP = "-" as const;
/** 메뉴를 닫으면 누른 단추가 지워져 초점이 문서 밖으로 감 — 격자 입력칸으로 되돌림 */
function back(ctx: SpreadsheetContext): void {
(ctx.editor as Editor).focus?.();
}
/** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */
export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void {
back(ctx);
document.execCommand(kind);
}
/** 읽기 전용 · 미리보기 시트 — 「복사」 한 줄 */
export function copyOnlyItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
return [[st("MenuCopy"), () => copyCut(ctx, "copy")]];
}
const id = (ctx: SpreadsheetContext): string => ctx.selection.시트;
/** 메뉴로 클립보드를 읽을 수 있는가 — 보안 문맥(localhost · https)만 */
export function canMenuPaste(): boolean {
return globalThis.isSecureContext === true && typeof navigator?.clipboard?.read === "function";
}
async function readClipboard(): Promise<{ html: string; text: string } | null> {
if (!canMenuPaste()) return null;
try {
let html = "";
let text = "";
for (const item of await navigator.clipboard.read()) {
if (!html && item.types.includes("text/html"))
html = await (await item.getType("text/html")).text();
if (!text && item.types.includes("text/plain"))
text = await (await item.getType("text/plain")).text();
}
return { html, text };
} catch {
return null; // 권한 거절 — 안내로
}
}
/** 메뉴 붙여넣기 — 보통은 부품의 paste 듣기로(잘라내기 옮김 · 엑셀 경고 그대로) · 골라 붙이기는 바로 */
export async function menuPaste(ctx: SpreadsheetContext, mode?: PasteSpecialMode): Promise<void> {
const clip = await readClipboard();
if (!clip) return showToast(mode ? TEXT.pasteSpecialHint : st("MenuPasteHint"), "info");
back(ctx);
if (!mode) {
const data = new DataTransfer();
data.setData("text/html", clip.html);
data.setData("text/plain", clip.text);
ctx.root.dispatchEvent(
new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }),
);
return;
}
const at = { ...ctx.selection.활성 };
const block = parseClipboard(clip.html, clip.text, at);
if (!block) return showToast(TEXT.pasteFailed, "error");
const source =
block.출처 === "aislo" && block.원점 ? { 시트: id(ctx), ...block.원점 } : undefined;
(await import("./spreadsheet_paste_special")).pasteSpecial(ctx, block, at, mode, source);
}
export function pasteItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
return [
[TEXT.paste, () => void menuPaste(ctx)],
{
label: TEXT.pasteSpecial,
children: [
[TEXT.pasteValues, () => void menuPaste(ctx, "값")],
[TEXT.pasteFormat, () => void menuPaste(ctx, "서식")],
],
},
];
}
/** 행 · 열 넣기 — 위 · 왼쪽은 `at` · 아래 · 오른쪽은 `at + 수`(고른 줄 수만큼) */
export function insertLines(
ctx: SpreadsheetContext,
rows: boolean,
at: number,
수: number,
after: boolean,
): void {
ctx.dispatch({ 종류: rows ? "행넣기" : "열넣기", 시트: id(ctx), at: after ? at + 수 : at, 수 });
}
function deleteLines(ctx: SpreadsheetContext, rows: boolean, at: number, 수: number): void {
ctx.dispatch({ 종류: rows ? "행지우기" : "열지우기", 시트: id(ctx), at, 수 });
}
export function rowInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] {
return [
[TEXT.rowsAbove(수), () => insertLines(ctx, true, at, 수, false)],
[TEXT.rowsBelow(수), () => insertLines(ctx, true, at, 수, true)],
];
}
export function colInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] {
return [
[TEXT.colsLeft(수), () => insertLines(ctx, false, at, 수, false)],
[TEXT.colsRight(수), () => insertLines(ctx, false, at, 수, true)],
];
}
const shifts = () => import("./spreadsheet_insert_dialog");
/** 칸 메뉴 — 행 · 열 · 셀 삽입/삭제 ▸ */
export function cellInsertItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] {
const rows = g.r1 - g.r0 + 1;
const cols = g.c1 - g.c0 + 1;
return [
{
label: TEXT.insertLines,
children: [...rowInsertItems(ctx, g.r0, rows), ...colInsertItems(ctx, g.c0, cols)],
},
{
label: TEXT.insertCells,
children: [
[TEXT.shiftRight, () => void shifts().then((m) => m.insertShiftRight(ctx, g))],
[TEXT.shiftDown, () => void shifts().then((m) => m.insertShiftDown(ctx, g))],
],
},
{
label: TEXT.deleteLines,
children: [
[TEXT.deleteRows(rows), () => deleteLines(ctx, true, g.r0, rows)],
[TEXT.deleteCols(cols), () => deleteLines(ctx, false, g.c0, cols)],
],
},
{
label: TEXT.deleteCells,
children: [
[TEXT.shiftLeft, () => void shifts().then((m) => m.deleteShiftLeft(ctx, g))],
[TEXT.shiftUp, () => void shifts().then((m) => m.deleteShiftUp(ctx, g))],
],
},
];
}
/** 몸통은 [데이터 ▾] 와 같은 늦은 길(`spreadsheet_actions_run.ts`) */
function run(ctx: SpreadsheetContext, act: Act): void {
void import("./spreadsheet_actions_run").then((m) => m.run(ctx, () => back(ctx), act));
}
function freezeGroupItems(ctx: SpreadsheetContext): MapContextMenuItem[] {
return [
{
label: TEXT.freeze,
children: [
[TEXT.freezeRow, () => run(ctx, "freezeRow")],
[TEXT.freezeCol, () => run(ctx, "freezeCol")],
[TEXT.freezeAt, () => run(ctx, "freezeAt")],
[TEXT.unfreeze, () => run(ctx, "unfreeze")],
],
},
{
label: TEXT.group,
children: [
[TEXT.groupOn, () => run(ctx, "group")],
[TEXT.groupOff, () => run(ctx, "ungroup")],
],
},
];
}
/** 쓴 칸을 모두 덮는 범위(없으면 null) */
export function usedRange(sheet: Sheet): CellRange | null {
let g: CellRange | null = null;
for (const key of Object.keys(sheet.칸)) {
const p = parseA1(key);
if (!p) continue;
g = g
? {
r0: Math.min(g.r0, p.r),
c0: Math.min(g.c0, p.c),
r1: Math.max(g.r1, p.r),
c1: Math.max(g.c1, p.c),
}
: { r0: p.r, c0: p.c, r1: p.r, c1: p.c };
}
return g;
}
/** 열 머리에서 — 쓴 범위를 잠깐 골라 `job`(정렬 · 필터) 뒤 고름을 되돌림. 빈 열이면 안 함 */
async function onUsed(
ctx: SpreadsheetContext,
c: number,
job: () => void | Promise<void>,
): Promise<void> {
const used = usedRange(ctx.sheet());
if (!used || c < used.c0 || c > used.c1) return;
const keep = ctx.selection;
const top = { r: used.r0, c };
ctx.select({ 시트: id(ctx), 범위: [used], 활성: top, 기준: top });
try {
await job();
} finally {
ctx.select(keep);
}
}
/** 시트 정렬 — 쓴 범위 전체를 이 열 기준으로(열 하나만 섞이지 않게) */
export function sortByColumn(ctx: SpreadsheetContext, c: number, desc: boolean): Promise<void> {
return onUsed(ctx, c, async () =>
(await import("./spreadsheet_sort_dialog")).sortSelection(ctx, desc),
);
}
function filterItem(ctx: SpreadsheetContext, c?: number): Item {
const on = !!ctx.sheet().필터;
const go = (): void => ctx.dataMenu?.("filter");
return [
on ? TEXT.filterOff : TEXT.filterOn,
() => (on || c === undefined ? go() : void onUsed(ctx, c, go)),
];
}
/** 숫자 · 글 하나 받는 작은 창 — 확인이 거르면(오류 글) 창이 남음 */
function askValue(
ctx: SpreadsheetContext,
title: string,
label: string,
value: string,
submit: (text: string) => string | null | Promise<string | null>,
): void {
const overlay = el("div", { className: "ui-confirm" });
const close = (): void => {
overlay.remove();
back(ctx);
};
const field = createInputField({ label, value });
const error = el("p", { text: "" });
error.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
const ok = async (): Promise<void> => {
const fail = await submit(field.input.value.trim());
if (fail) error.textContent = fail;
else close();
};
field.input.addEventListener("keydown", (e) => {
if (e.key === "Enter") void ok();
});
overlay.append(
el("div", {
className: "ui-confirm__panel",
children: [
el("h3", { text: title }),
field.root,
error,
el("div", {
className: "ui-confirm__actions",
children: [
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
createButton({ label: TEXT.ok, variant: "filled", onClick: () => void ok() }),
],
}),
],
}),
);
document.body.append(overlay);
dialogKeys(overlay, close);
field.input.focus();
field.input.select();
}
/** 행 높이 · 열 너비 바꾸기(px) — 고른 줄 모두 한 명령(한 번에 되돌림) · 잘못된 값이면 오류 글 */
export function resizeLines(
ctx: SpreadsheetContext,
rows: boolean,
lines: number[],
text: string,
): string | null {
const px = Number(text);
if (!Number.isFinite(px) || px < 1) return TEXT.badNumber;
ctx.dispatch(
rows
? { 종류: "행높이", 시트: id(ctx), 행: lines, 높이: Math.min(pxToPt(px), 409) }
: { 종류: "열폭", 시트: id(ctx), 열: lines, 폭: Math.min(pxToColChars(px), MAX_COL_CHARS) },
);
return null;
}
function sizeItem(ctx: SpreadsheetContext, rows: boolean, lines: number[]): Item {
return [
rows ? TEXT.rowSize : TEXT.colSize,
() => {
const geo = (rows ? buildRowGeometry : buildColGeometry)(ctx.book, ctx.sheet());
askValue(
ctx,
rows ? TEXT.rowSize : TEXT.colSize,
rows ? TEXT.rowSizeLabel : TEXT.colSizeLabel,
String(Math.round(geo.size(lines[0]))),
(text) => resizeLines(ctx, rows, lines, text),
);
},
];
}
/** 열 너비를 데이터에 맞춤 — 고른 열 모두 한 명령 */
export async function autofitColumns(ctx: SpreadsheetContext, cols: number[]): Promise<void> {
const { estimateColumnWidth } = await import("./spreadsheet_autofit");
const 명령: Command[] = cols.map((c) => ({
종류: "열폭",
시트: id(ctx),
열: [c],
폭: estimateColumnWidth(ctx.book, id(ctx), c),
}));
ctx.dispatch({ 종류: "묶음", 명령 });
}
/** 행 머리 끝 — 크기 · 고정 ▸ · 그룹 ▸ */
export function rowMoreItems(ctx: SpreadsheetContext, rows: number[]): MapContextMenuItem[] {
return [SEP, sizeItem(ctx, true, rows), SEP, ...freezeGroupItems(ctx)];
}
/** 열 머리 끝 — 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ */
export function colMoreItems(ctx: SpreadsheetContext, cols: number[]): MapContextMenuItem[] {
const c = cols[0];
return [
SEP,
sizeItem(ctx, false, cols),
[TEXT.autofit, () => void autofitColumns(ctx, cols)],
SEP,
[TEXT.sortAsc, () => void sortByColumn(ctx, c, false)],
[TEXT.sortDesc, () => void sortByColumn(ctx, c, true)],
filterItem(ctx, c),
SEP,
...freezeGroupItems(ctx),
];
}
/** 칸 메뉴 끝 — 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ */
export function cellMoreItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] {
return [
SEP,
...freezeGroupItems(ctx),
{
label: TEXT.more,
children: [
[TEXT.dropdown, () => ctx.dataMenu?.("validation")],
[TEXT.condfmt, () => ctx.dataMenu?.("condfmt")],
[
TEXT.nameRange,
() =>
askValue(ctx, TEXT.nameRange, TEXT.nameLabel, "", (name) => nameRange(ctx, name, g)),
],
filterItem(ctx),
],
},
SEP,
];
}
/** 범위 이름 — `spreadsheet_names.defineName`(오류 글 · 성공 null) */
export async function nameRange(
ctx: SpreadsheetContext,
name: string,
g: CellRange,
): Promise<string | null> {
if (!name) return TEXT.nameEmpty;
return (await import("./spreadsheet_names")).defineName(ctx, name, g);
}
@@ -10,6 +10,7 @@
import { el } from "@ui/ui_template_elements";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { isFrac, fracToDouble } from "./spreadsheet_functions_math";
import { computeExtent } from "./spreadsheet_grid_cells";
import type { CellRange, Scalar } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -31,13 +32,16 @@ export interface SelectionStats {
/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */
export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats {
const sheet = ctx.selection.시트;
const extent = computeExtent(ctx.sheet()); // 쓴 범위까지만 셈(81-2 — 열 머리 전체 고름이면 104만 칸을 다 돎)
const nums: number[] = [];
let 개수 = 0;
const seen = new Set<number>();
const visit = (range: CellRange): void => {
for (let r = range.r0; r <= range.r1; r++) {
for (let c = range.c0; c <= range.c1; c++) {
const r1 = Math.min(range.r1, extent.rows - 1);
const c1 = Math.min(range.c1, extent.cols - 1);
for (let r = range.r0; r <= r1; r++) {
for (let c = range.c0; c <= c1; c++) {
const key = r * 1_000_000 + c;
if (seen.has(key)) continue;
seen.add(key);
@@ -112,11 +112,14 @@ export interface SpreadsheetContext {
/** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) ·
* 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */
cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>;
/** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */
dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void;
}
/** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */
export interface PartHandle {
root: HTMLElement | null;
refresh(): void;
/** `dataChanged` — 문서가 실제로 바뀐 다시 그림(참) vs 고름만 바뀐 다시 그림(거짓 · 81-2 알약 점 건너뜀용) */
refresh(dataChanged?: boolean): void;
destroy(): void;
}
@@ -49,6 +49,7 @@ import {
type WallPath,
} from "./B05_Profile_UI_Corridor_Structures_Path";
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -274,7 +275,8 @@ export function buildCorridorStructures(
const solids: CorridorStructure[] = [];
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
// 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1)
const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout);
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
@@ -449,12 +451,11 @@ export function buildCorridorStructures(
if (fordLayout) {
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
const half = Math.max(fordLayout.ford.span_m, 0.5) / 2;
const invertPoints = fordLayout.pipe.outer;
const fordBore = pipeBore(
fordLayout.ford.diameter_m,
{ offset: invertPoints[3].offset, elevation: invertPoints[3].elevation },
{ offset: invertPoints[2].offset, elevation: invertPoints[2].elevation },
);
// 관 끝 = 윗 꼭짓점 자리(흙막이 사면에서 안 튀어나옴 · 89-1) · 높이 = invert
const [topLeft, topRight, invertRight, invertLeft] = fordLayout.pipe.outer;
const pipeStart = { offset: topLeft.offset, elevation: invertLeft.elevation };
const pipeEnd = { offset: topRight.offset, elevation: invertRight.elevation };
const fordBore = pipeBore(fordLayout.ford.diameter_m, pipeStart, pipeEnd);
// 관은 련수만큼 폭 안에 등간격 — 구멍도 관마다 하나씩 낸다(2026-08-25 사용자 ⑥).
const count = Math.max(fordLayout.ford.pipe_count, 1);
const pipeChainages = Array.from(
@@ -518,7 +519,6 @@ export function buildCorridorStructures(
),
);
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
const invert = fordLayout.pipe.outer;
for (const at of pipeChainages) {
const frame = frameAt(at);
solids.push({
@@ -526,8 +526,8 @@ export function buildCorridorStructures(
kind: "pipe",
key: "pipe",
pipe: {
start: [invert[3].offset, invert[3].elevation],
end: [invert[2].offset, invert[2].elevation],
start: [pipeStart.offset, pipeStart.elevation],
end: [pipeEnd.offset, pipeEnd.elevation],
diameterM: fordLayout.ford.diameter_m,
wallThicknessM: pipeWallThicknessM(
fordLayout.ford.pipe_kind,
+23 -24
View File
@@ -40,13 +40,16 @@ import {
INLET_REVET_KEYS,
type InletStructureKind,
labeled,
numberInput,
OUTLET_REVET_KEYS,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
import {
guardChoiceFields,
guardSoilCount,
outletTiersRow,
revetChoiceRow,
} from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
createSpecForm,
@@ -67,7 +70,6 @@ import {
pipeDrawn,
pipeExtraCount,
pipeLengthM,
setPipeExtraCount,
} from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
@@ -296,25 +298,11 @@ export function createFacilityOptionsForm(
return drawnMemo;
};
// 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감
const tiersInput = numberInput("1", "0");
tiersInput.max = "9";
const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0));
tiersRow.dataset.specItem = "outlet.tiers";
tiersRow
.querySelectorAll("button")
.forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
const paintTiers = (values: Record<string, string>): HTMLElement => {
const count = pipeExtraCount(chainage);
tiersInput.value = count === null ? "" : String(count);
tiersInput.disabled = count === null;
tiersRow.hidden = !!values.structure && values.structure !== "기슭막이";
return tiersRow;
};
tiersInput.addEventListener("change", () => {
const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0)));
// 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림
void setPipeExtraCount(chainage, count).then(() => pipeForm.recount());
});
const tiersRow = outletTiersRow(
() => chainage,
() => pipeForm.recount(),
(structure) => !structure || structure === "기슭막이",
);
const inletChoice = revetChoiceRow(
() => chainage,
() => pipeForm.refresh(),
@@ -327,7 +315,7 @@ export function createFacilityOptionsForm(
// 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김)
fields: (c, values) =>
c.id === "outlet"
? [paintTiers(values), outletChoice.paint(values.structure !== "기슭막이")]
? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")]
: c.id === "inlet"
? [inletChoice.paint(values.structure !== "기슭막이")]
: c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값)
@@ -407,11 +395,17 @@ export function createFacilityOptionsForm(
};
};
const fordFlowBox = overflow(() => fordForm, "ford");
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
() => fordForm.recount(),
),
count: guardSoilCount(
() => chainage,
() => fordForm.state().outlet?.[0]?.structure,
),
});
@@ -473,6 +467,11 @@ export function createFacilityOptionsForm(
fields: guardChoiceFields(
() => chainage,
() => boxForm.refresh(),
() => boxForm.recount(),
),
count: guardSoilCount(
() => chainage,
() => boxForm.state().outlet?.[0]?.structure,
),
});
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
@@ -43,7 +43,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
...revetKeys("inlet"),
...revetKeys("outlet"),
"inlet_revet_berm_m", // 흙막이 절토부 앞 수평공간(79-7)
"outlet_revet_berm_m",
"outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움
],
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 ·
// 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
@@ -61,7 +61,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
...revetKeys("inlet"),
...revetKeys("outlet"),
"inlet_revet_berm_m",
"outlet_revet_berm_m",
"outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
@@ -6,8 +6,11 @@
* ========================================================================== */
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
import { labeled, numberInput, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
import {
pipeExtraCount,
pipeRevetChoice,
setPipeExtraCount,
setPipeRevetChoice,
} from "../A00_Common/design/design_structure_pipe_calc";
@@ -54,20 +57,67 @@ export function revetChoiceRow(
return { paint };
}
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄.
* 명세 폼 `fields` 자리에 그대로 씀. */
/** 유출 「추가 흙막이(단)」(54-27 · 90-1 배관 · 세월교 · 박스 공용) — 값 = 횡단 조정 단 수(같은 저장 자리) ·
* 흙막이 상자 수가 따라감(폼 `count` 훅 = `pipeExtraCount`). `shown` = 유출 구조 값이 벽인지. */
export function outletTiersRow(
chainageOf: () => number | null,
/** 쓴 뒤 — 지형이 못 세운 단은 횡단 기하가 자름 · 세운 만큼으로 상자를 다시 셈. */
recount: () => void,
shown: (structure: string | undefined) => boolean,
): { paint: (values: Record<string, string>) => HTMLElement } {
const input = numberInput("1", "0");
input.max = "9";
const row = labeled("추가 흙막이(단)", stepper(input, 1, 0));
row.dataset.specItem = "outlet.tiers";
row.querySelectorAll("button").forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`));
input.addEventListener("change", () => {
const count = Math.max(0, Math.min(9, Math.round(Number(input.value) || 0)));
void setPipeExtraCount(chainageOf(), count).then(recount);
});
return {
paint: (values) => {
const count = pipeExtraCount(chainageOf());
input.value = count === null ? "" : String(count);
input.disabled = count === null;
row.hidden = !shown(values.structure);
return row;
},
};
}
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄 ·
* 유출 상자엔 「추가 흙막이(단)」(90-1). 명세 폼 `fields` 자리에 그대로 씀. */
export function guardChoiceFields(
chainageOf: () => number | null,
written: () => void,
recount: () => void,
): (c: { id: string }, values: Record<string, string>) => HTMLElement[] {
const sides = {
inlet: revetChoiceRow(chainageOf, written),
outlet: revetChoiceRow(chainageOf, written),
};
const tiers = outletTiersRow(chainageOf, recount, (structure) => structure === "흙막이");
return (c, values) =>
c.id === "soil_add"
? [revetChoiceRow(chainageOf, written).paint(false)]
: c.id === "inlet" || c.id === "outlet"
? [sides[c.id].paint(values.structure !== "흙막이")]
? [
...(c.id === "outlet" ? [tiers.paint(values)] : []),
sides[c.id].paint(values.structure !== "흙막이"),
]
: [];
}
/** 세월교 · 박스 흙막이 상자 수(90-1) — 유출이 흙막이면 지형이 세운 유출 단 수 · 아니면 0(옛 상자는
* 숨기고 값은 파일에 남김). 명세 폼 `count` 훅 자리. */
export function guardSoilCount(
chainageOf: () => number | null,
outletStructure: () => string | undefined,
): (c: { id: string }) => number | null | undefined {
return (c) =>
c.id !== "soil_add"
? undefined
: outletStructure() === "흙막이"
? pipeExtraCount(chainageOf())
: 0;
}
@@ -29,6 +29,7 @@ import {
type FacilityDefinition,
} from "./B05_Profile_UI_Structures_Define_Form";
import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec";
import { createWideningBox } from "./B05_Profile_UI_Structures_Widening";
export interface StructuresFormElements {
root: HTMLElement;
@@ -156,11 +157,38 @@ export function buildStructuresForm(options: {
set: groupBoxes.setOne,
});
groupBoxes.watch(facilityOptions.paintLinks);
// 노면공 시작~종료 읽기(87-1 길이 출력 · 87-3 확폭 구간 상자가 같이 씀) — 못 읽으면 null
const rangeNow = (): { start: number | null; end: number | null } => {
const step = options.getInterval();
return { start: startFields.read(step, false), end: endFields.read(step, false) };
};
const widening = createWideningBox({
range: rangeNow,
interval: options.getInterval,
onChange: options.onFacilityChange,
});
// 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음
const structureSpec = createStructureSpecForm(
optionRow,
{ get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne },
options.onFacilityChange,
// 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」)
(_c, item) => {
const { start, end } = rangeNow();
return item.id === "length" && start !== null && end !== null && end > start
? String(Number((end - start).toFixed(2)))
: "";
},
widening,
);
// 시작 · 종료를 고치면 길이 출력 · 확폭 구간을 다시 그림
[startFields, endFields].forEach((fields) =>
[fields.station, fields.remainder].forEach((input) =>
input.addEventListener("change", () => {
structureSpec.refresh();
widening.refresh();
}),
),
);
groupBoxes.watch(structureSpec.paintLinks);
const staleBox = document.createElement("fieldset");
@@ -174,6 +202,7 @@ export function buildStructuresForm(options: {
positionDivider,
optionRow,
structureSpec.root,
widening.root, // 노면공 「확폭 구간」 — 상세 내용 아래(87-3)
facilityOptions.root,
staleBox,
groupBoxes.root,
@@ -56,6 +56,7 @@ import {
DEFAULT_INTERVAL_LENGTH_M,
groupOptions,
hasComputedRange,
isRangeOnly,
typeOptions,
readBeforeOption,
readLengthOption,
@@ -238,7 +239,7 @@ export function createStructuresSection(
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
anchorFields.wrap.hidden = isInterval && (!showsAnchor(type!) || isRangeOnly(type));
anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43)
syncRangeDisplay();
}
@@ -314,7 +314,10 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
return;
}
// 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다.
anchor = ctx.anchorFields.read(step, false) ?? start;
// 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점.
anchor = STRUCTURE_SPECS[type.type_id]?.byRange
? start
: (ctx.anchorFields.read(step, false) ?? start);
if (anchor < start || anchor > end) {
ctx.anchorFields.station.classList.add("is-invalid");
ctx.anchorFields.station.focus();
@@ -20,6 +20,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
@@ -209,6 +210,10 @@ export function readBeforeOption(inputs: OptionEntry[]): number {
return length !== null && before > length ? length : before;
}
/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */
export const isRangeOnly = (type: { type_id: string } | null): boolean =>
!!type && !!STRUCTURE_SPECS[type.type_id]?.byRange;
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
* 준다(2026-08-19 사용자 지시). */
@@ -217,6 +222,7 @@ export function hasComputedRange(type: StructureType | null): boolean {
!!type &&
!type.managed_by &&
type.placement === "interval" &&
!isRangeOnly(type) &&
type.options.some((option) => option.key === "length_m" && isB05Option(option))
);
}
@@ -54,7 +54,7 @@ export async function splitPavementRange(
await showConfirmDialog(
`지정한 포장 구간(${format(start)}~${format(end)})이 물넘이포장` +
`(${format(partial.start)}~${format(partial.end)})의 끝에 걸칩니다.\n` +
"물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 기준 측점·길이·전후 값을 고쳐 주세요.",
"물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 시작·종료 측점을 고쳐 주세요.",
"확인",
);
return null;
+28 -6
View File
@@ -12,13 +12,17 @@ import type { StructureType } from "./B05_Profile_Api_Structures";
import {
createSpecForm,
type SpecForm,
type SpecFormHooks,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
const forms = new WeakMap<HTMLElement, SpecForm>();
/** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */
const widenings = new WeakMap<HTMLElement, WideningBox>();
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
@@ -29,14 +33,20 @@ export function createStructureSpecForm(
optionRow: HTMLElement,
links: SpecLinks,
onChange: () => void,
auto?: SpecFormHooks["auto"],
widening?: WideningBox,
): SpecForm {
const form = createSpecForm(links, { onChange });
const form = createSpecForm(links, { onChange, auto });
form.root.hidden = true;
forms.set(optionRow, form);
if (widening) widenings.set(optionRow, widening);
const lock = (): void => {
const locked = optionRow.classList.contains("is-locked");
form.root.inert = locked;
form.root.classList.toggle("is-locked", locked);
for (const root of [form.root, widening?.root]) {
if (!root) continue;
root.inert = locked;
root.classList.toggle("is-locked", locked);
}
};
if (typeof MutationObserver !== "undefined")
new MutationObserver(lock).observe(optionRow, {
@@ -49,7 +59,16 @@ export function createStructureSpecForm(
/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */
export function specOwnedKeys(type: StructureType | null): Set<string> {
const spec = structureSpecOf(type);
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []);
return new Set(
spec
? [
...specKeys(spec).keys,
...spec.retired,
...(spec.dropped ?? []),
...(spec.byRange ? ["widening_off"] : []),
]
: [],
);
}
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
@@ -60,6 +79,7 @@ export function paintStructureSpec(
): void {
const form = forms.get(optionRow);
const spec = structureSpecOf(type);
widenings.get(optionRow)?.paint(!!spec?.byRange, values.widening_off);
if (!form) return;
form.root.hidden = !spec;
if (!spec) return;
@@ -73,7 +93,9 @@ export function readStructureSpec(
type: StructureType | null,
): Record<string, string | number> {
const form = forms.get(optionRow);
return form && structureSpecOf(type) ? form.read() : {};
const spec = structureSpecOf(type);
if (!form || !spec) return {};
return { ...form.read(), ...(spec.byRange ? widenings.get(optionRow)?.read() : {}) };
}
/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */
@@ -85,5 +107,5 @@ export function specRenderedKeys(
if (!spec) return [];
const { keys, prefixes } = specKeys(spec);
const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p)));
return [...keys, ...own];
return [...keys, ...own, ...(spec.dropped ?? []), ...(spec.byRange ? ["widening_off"] : [])];
}
@@ -0,0 +1,102 @@
/* =============================================================================
* B05_Profile_UI_Structures_Widening.ts
* 노면공 「확폭 구간」 상자(PLAN 87-3) — 상세 내용 아래. 범위 안 확폭 > 0 측점을 이어진 구간으로 묶어
* 구간마다 단추(켬 = 확폭 포함 · 끔 = 규격 폭만 · 기본 켬). 끈 구간 = `options.widening_off`.
* 규칙(구간 묶기 · 글 읽고 쓰기)은 `design_structure_widening` · 횡단 측점 읽기는 `pipeSections`.
* ========================================================================== */
import { pipeSections } from "../A00_Common/design/design_structure_pipe_calc";
import {
isRunOff,
offForRuns,
toggleRun,
wideningRuns,
type WideningRun,
} from "../A00_Common/design/design_structure_widening";
import { stationLabel } from "../common_util/common_util_station";
import { group } from "./B05_Profile_UI_Drainage_Facility_Fields";
export interface WideningBox {
root: HTMLElement;
/** 종류 · 저장 값으로 다시 그림(범위형이 아니면 가림). */
paint: (visible: boolean, stored: string | number | undefined) => void;
/** 저장할 값 — 끈 구간이 없으면 빈 묶음. 횡단 자료가 없으면 저장 값을 그대로 지킴. */
read: () => Record<string, string>;
/** 범위 · 횡단 자료가 바뀌었을 때 다시 그림. */
refresh: () => void;
}
export function createWideningBox(hooks: {
range: () => { start: number | null; end: number | null };
interval: () => number;
onChange: () => void;
}): WideningBox {
const box = group("확폭 구간");
box.root.classList.add("b05-structure__widening");
box.root.hidden = true;
let off = "";
/** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */
const runs = (): { list: WideningRun[] | null; note: string } => {
const sections = pipeSections();
if (!sections?.length) return { list: null, note: "횡단 자료 없음" };
const { start, end } = hooks.range();
if (start === null || end === null || end <= start)
return { list: null, note: "시작 · 종료 측점을 넣으면 보임" };
const list = wideningRuns(sections, start, end);
return { list, note: list.length ? "" : "범위 안 확폭 없음" };
};
const paintBody = (): void => {
const { list, note } = runs();
if (!list?.length) {
const text = document.createElement("span");
text.className = "b05-structure__widening-note";
text.textContent = note;
box.body.replaceChildren(text);
return;
}
const step = hooks.interval();
box.body.replaceChildren(
...list.map((run) => {
const on = !isRunOff(run, off);
const button = document.createElement("button");
button.type = "button";
button.className = `b05-route__irregular-btn${on ? " is-primary" : ""}`;
button.setAttribute("aria-pressed", String(on));
button.dataset.wideningRun = `${run.start_m}-${run.end_m}`;
button.textContent = `${stationLabel(run.start_m, step)} ~ ${stationLabel(run.end_m, step)} · 확폭 ${Number(run.widening_m.toFixed(2))} m`;
button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함";
button.addEventListener("click", () => {
off = toggleRun(list, off, run);
paintBody();
hooks.onChange();
});
return button;
}),
);
};
// 횡단 자료가 늦게 오면 상자 위에 올 때 다시 그림
box.root.addEventListener("pointerenter", () => {
if (!box.root.hidden) paintBody();
});
return {
root: box.root,
paint(visible, stored) {
box.root.hidden = !visible;
off = typeof stored === "string" ? stored : "";
if (visible) paintBody();
},
read() {
const { list } = runs();
const text = list ? offForRuns(list, off) : off;
const out: Record<string, string> = text ? { widening_off: text } : {};
return out;
},
refresh() {
if (!box.root.hidden) paintBody();
},
};
}
@@ -570,3 +570,25 @@
font-size: var(--text-caption);
line-height: 1;
}
/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */
.b05-structure__widening[hidden] {
display: none;
}
.b05-structure__widening .b05-structure__group-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.b05-structure__widening .b05-structure__group-body button {
align-self: stretch;
text-align: left;
white-space: normal;
}
.b05-structure__widening-note {
color: var(--text-muted, inherit);
font-size: var(--text-caption);
}
+12 -4
View File
@@ -60,6 +60,7 @@ export type {
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
import type { PavementEntry } from "@util/common_util_structure_pavement";
export type { FordPavementSpec, RevetmentSpec };
export interface SectionContextResponse {
@@ -219,10 +220,12 @@ export interface CulvertSideSpec {
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
apron_thickness_m?: number;
}
/** 도수로 · 돌붙임(90-2) — 유출 성토사면을 따라 사면 안쪽으로 두께만큼 띠 · 안에 돌(그림만 · 면적 무관). */
export interface ChuteSpec {
structure: string;
thickness_m: number;
}
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
@@ -248,6 +251,7 @@ export interface CulvertSet {
foundation?: string | null;
/** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */
body?: "box" | "ford";
chute?: ChuteSpec;
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
@@ -279,6 +283,7 @@ export interface FordSet {
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
chute?: ChuteSpec;
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -299,6 +304,7 @@ export interface BoxSet {
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
chute?: ChuteSpec;
}
export interface CrossSection extends SectionStation {
@@ -315,6 +321,8 @@ export interface CrossSection extends SectionStation {
ford_pavement?: FordPavementSpec;
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
revetment?: RevetmentSpec;
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
pavement?: PavementEntry[];
}
export interface SectionDetailResponse {
+37 -20
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import json
import logging
import math
import re
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -63,12 +64,8 @@ _SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"})
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
MIN_PIPE_COVER_M = 0.5
# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수.
# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택.
APRON_LENGTH_FACTOR = 2.0
# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합).
# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다.
APRON_THICKNESS_M = 0.45
# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 30 cm(10-8 · 90-2).
CHUTE_THICKNESS_CM = 30.0
# 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
@@ -226,15 +223,36 @@ def _side_spec(
"face_slope": REVET_FACE_SLOPE,
}
)
# 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
def _chute(values: dict[str, Any]) -> dict[str, Any] | None:
"""도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) ·
구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`."""
head = re.compile(r"^chute_add_(\d+)_structure$")
picked = sorted(
(int(hit.group(1)), str(value).strip())
for key, value in values.items()
if (hit := head.match(key)) and str(value or "").strip()
)
if not picked:
return None
n, structure = picked[0]
cm = _number(values.get(f"chute_add_{n}_thickness"), None)
cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM
return {"structure": structure, "thickness_m": round(cm / 100.0, 3)}
def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2)."""
chute = _chute(values)
if chute:
spec["chute"] = chute
return spec
def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공)."""
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임)."""
defaults = _registry_pipe_defaults()
values = dict(options or {})
diameter_mm = _number(
@@ -242,7 +260,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
)
kind = values.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
spec = {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": diameter_m,
@@ -253,6 +271,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
return _with_chute(spec, values)
def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
@@ -286,9 +305,6 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
@@ -329,12 +345,12 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]):
guarded = str(values.get(key) or "").strip() == "흙막이"
sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""}
if guarded:
# 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값
if guarded and role == "inlet":
# 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값
sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None)
if not any(side["structure"] for side in sides.values()):
return None
return {
guard = {
"type": "pipe",
"hidden_pipe": True,
"body": body,
@@ -346,6 +362,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str
"min_cover_m": 0.0,
**sides,
}
return _with_chute(guard, values) # 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2)
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
@@ -459,7 +476,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
guard = _guard_set(values, "ford", spec["diameter_m"])
if guard:
spec["guard"] = guard
return spec
return _with_chute(spec, values)
def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
@@ -536,7 +553,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
guard = _guard_set(values, "box", box_body_height(spec))
if guard:
spec["guard"] = guard
return spec
return _with_chute(spec, values)
def box_body_height(box: dict[str, Any]) -> float:
+3
View File
@@ -77,6 +77,7 @@ from B06_Section.B06_Section_Schema import (
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_pavement import attach_pavement_structures
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
@@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
attach_wall_structures(root, cross_sections)
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -497,6 +499,7 @@ async def regenerate_sections(
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
attach_wall_structures(project_root, result["cross_sections"])
attach_pavement_structures(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
+14
View File
@@ -34,6 +34,11 @@ import {
wallSpecsFrom,
type WallStructureInput,
} from "@util/common_util_structure_walls";
import {
attachPavementSpecs,
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
@@ -65,6 +70,15 @@ async function withDraftWalls(
await withDraftPipeKinds(detail, projectId);
if (!drafts || !drafts.length) return withStructureAreas(detail);
const types = await fetchStructureTypes().catch(() => []);
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id));
if (paveTypes.size) {
for (const section of detail.cross_sections) delete section.pavement;
attachPavementSpecs(
detail.cross_sections as unknown as Array<Record<string, unknown>>,
pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes),
);
}
const names = new Map(
types
// 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
+5 -4
View File
@@ -176,10 +176,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
const group = target.closest<HTMLElement>("fieldset");
if (!group) return;
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
// 단 수 · 옵션은 부모(유출) 칸에 그대로
// 배관 · 세월교 · 박스 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10 · 90-1) — 옛 추가 기슭막이 칸
// 대신 그 상자를 켬 · 단 수 · 옵션은 부모(유출) 칸에 그대로
const soil =
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
(ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet) &&
slotUsable(ownerSlot)
? ownerSlot
.closest(".b05-structure__spec")
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
@@ -187,7 +188,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
]
: undefined;
if (soil) {
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot);
setGroupLabel(soil, "");
soil.classList.add("is-active");
if (scroll) soil.scrollIntoView({ block: "nearest" });
+31 -10
View File
@@ -210,8 +210,12 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
if (!layout || !culvert?.culvert.body) return layout;
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
const mid = layout.label.at.offset;
const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] =>
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p));
const reach = (
points: OffsetPoint[],
outward: number,
offset: (p: OffsetPoint) => number,
): OffsetPoint[] =>
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset: offset(p) } : p));
if ("box" in layout && "topSlab" in layout) {
const sides = layout.sides.map((side) =>
guarded(side.role === "left" ? 1 : -1)
@@ -226,7 +230,7 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
const to = wall?.bottomBack.offset ?? side.offset;
if ((to - side.offset) * outward < 1e-6) continue;
[topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) =>
reach(p, outward, to),
reach(p, outward, () => to),
);
}
const run =
@@ -237,19 +241,19 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
const sides = ford.sides.map((side) =>
guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side,
);
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80)
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 꼭짓점 = 그 높이의 흙막이 전면(사면 이용 · 89-1) ·
// 아래 꼭짓점(invert)은 종전 자리라 1 m 맞춤 그대로 · 바닥판 끝 = 배면(80)
let { pipe, slabBridge } = ford;
for (const side of ford.sides) {
const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward);
if (!wall) continue;
const front =
wall.points[2].offset +
wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation);
const faceAt = (p: OffsetPoint): number =>
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - p.elevation);
pipe = {
outer: reach(pipe.outer, side.outward, front),
inner: reach(pipe.inner, side.outward, front),
outer: reach(pipe.outer, side.outward, faceAt),
inner: reach(pipe.inner, side.outward, faceAt),
};
slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset);
slabBridge = reach(slabBridge, side.outward, () => wall.bottomBack.offset);
}
if (pipe === ford.pipe) return { ...ford, sides } as T;
const [right, left] = pipe.outer.slice(2);
@@ -267,3 +271,20 @@ export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` },
} as T;
}
/** 세월교 · 박스 유입 흙막이 수평공간 시작 = 본체 바닥(세월교 바닥판 · 박스 저판 상면) 높이의
* 흙막이 전면 자리(89-3). 본체가 이 측점에 없으면 null(배관 규칙). */
export function guardFloorStart(
section: CrossSection,
base: BodyGuardBase | null,
wall: WallLayout,
): OffsetPoint | null {
const end = base?.ends.inlet;
if (!end) return null;
const slab = section.culvert?.body === "box" ? (section.box?.slab_thickness_m ?? 0) : 0;
const floor = end.invert + slab;
return {
offset: wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - floor),
elevation: floor,
};
}
+12
View File
@@ -11,6 +11,7 @@
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
export {
computeBoxLayout,
@@ -127,6 +128,17 @@ export function appendBoxOverlay(
polyline.append(title);
layer.append(polyline);
}
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
if (box.chute)
appendChuteBand(
layer,
layout.sides
.filter((side) => side.wingRole === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
box.chute,
x,
toDisplayY,
);
// ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠.
for (const side of layout.sides) {
const mark = side.role === "left" ? "좌" : "우";
+11 -1
View File
@@ -22,7 +22,7 @@ import type {
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
@@ -101,6 +101,16 @@ export function appendCulvertOverlay(
layer.append(line);
};
const drawOutletFill = (): void => {
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
// (B07 은 `linked` 없이 관 숨김으로만 알림 — 관 숨긴 배관 = 연동 카드)
if (culvert.chute && !linked && (!hidePipe || culvert.body))
appendChuteBand(
layer,
layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points),
culvert.chute,
x,
toDisplayY,
);
for (const segment of layout.outletFill.segments) {
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
@@ -20,9 +20,6 @@ export const REVET_THICKNESS_M = 0.45;
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
export const PITCHING_THICKNESS_M = 0.45;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
@@ -25,6 +25,7 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
import {
bodyGuardAxes,
bodyGuardBase,
guardFloorStart,
bodyGuardHeight,
fordPipeFit,
mergeGuardTrim,
@@ -597,7 +598,7 @@ export function computeCulvertLayout(
outletBottom: outletWall?.bottomBack.elevation ?? 0,
basinStart: culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom)
: null
: basinFillStart,
basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom,
@@ -617,8 +618,7 @@ export function computeCulvertLayout(
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe);
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
// 사면선은 노견부터 우리가 그림 — 트림을 노견까지 당겨 백엔드 설계선 성토 구간을 통째로 덮음
if (wall.outward > 0) {
trimMax = slope.points[0].offset;
trimMaxElevation = null;
@@ -5,7 +5,7 @@
* 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
GUARD_CUT_BERM_M,
REVET_EMBED_DEPTH_M,
@@ -86,17 +86,14 @@ export function buildCulvertTiers(input: {
side: culvert?.hidden_pipe ? culvert.inlet.face_side : null,
});
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
const cutBerm = (spec: CulvertSideSpec | undefined) =>
const cutBerm = (lengthM: number) =>
culvert?.body
? {
lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0),
cut: structureCutSpec(section.design),
}
? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) }
: undefined;
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const outletInput = {
cutBerm: cutBerm(culvert?.outlet),
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4)
startBottomElevation: input.outletBottom,
outward: input.outletInfo.outward,
groundAt,
@@ -110,7 +107,7 @@ export function buildCulvertTiers(input: {
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinInput = {
cutBerm: cutBerm(culvert?.inlet),
cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M),
startBottomElevation: input.basinBottom,
outward: input.inletInfo.outward,
groundAt,
@@ -1,3 +1,4 @@
import type { PavementEntry } from "@util/common_util_structure_pavement";
import type { CrossDesign } from "./B06_Section_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -29,26 +30,79 @@ export function appendRockBoundaryOverlay(
}
}
type Edge = { offset_m: number; elevation_m: number };
/** 포장사양 → 무늬(88) — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌. 그 밖 = 채움만. */
const PAVE_MARKS: Record<string, { size: number; marks: string }> = {
콘크리트: { size: 6, marks: '<path d="M0,6 L6,0" class="b06-chart__pave-mark"/>' },
아스콘: {
size: 5,
marks: '<circle cx="1.5" cy="1.5" r="0.7" class="b06-chart__pave-mark--dot"/>',
},
쇄석: {
size: 9,
marks:
'<circle cx="2.5" cy="2.5" r="1.6" class="b06-chart__pave-mark"/>' +
'<ellipse cx="6.8" cy="6.5" rx="1.8" ry="1.2" class="b06-chart__pave-mark"/>',
},
};
/** 무늬 id 는 문서 안에서 유일 — 카드 · 다시 그리기마다 새 번호(물넘이 빗금과 같은 방식). */
let paveSeq = 0;
/** 확폭 끈 구간 — 규격 폭 끝(좌 = left − 확폭좌 · 우 = right + 확폭우 · 표고는 두 끝 사이 직선). */
function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) {
const { left, right } = edges;
const span = right.offset_m - left.offset_m || 1;
const at = (offset: number): Edge => ({
offset_m: offset,
elevation_m:
left.elevation_m + ((right.elevation_m - left.elevation_m) * (offset - left.offset_m)) / span,
});
return {
left: at(left.offset_m - (design.widening_left_m ?? 0)),
right: at(right.offset_m + (design.widening_right_m ?? 0)),
};
}
/**
* 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 ·
* 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만.
*/
export function appendPavementOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
pavement?: readonly PavementEntry[] | null,
): void {
const edges = design.carriageway_edges ?? design.road_edges;
if (!design.paved || !edges) return;
const thickness = design.pavement_thickness_m ?? 0.2;
const { left, right } = edges;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
[
`${x(left.offset_m)},${y(left.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m - thickness)}`,
`${x(left.offset_m)},${y(left.elevation_m - thickness)}`,
].join(" "),
);
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
if (!edges) return;
const layers = pavement?.length ? pavement : design.paved ? [null] : [];
let depth = 0;
for (const entry of layers) {
const thickness = entry?.thickness_m ?? design.pavement_thickness_m ?? 0.2;
const { left, right } = entry && !entry.widening_on ? standardEdges(edges, design) : edges;
const points = [
`${x(left.offset_m)},${y(left.elevation_m - depth)}`,
`${x(right.offset_m)},${y(right.elevation_m - depth)}`,
`${x(right.offset_m)},${y(right.elevation_m - depth - thickness)}`,
`${x(left.offset_m)},${y(left.elevation_m - depth - thickness)}`,
].join(" ");
depth += thickness;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute("points", points);
polygon.setAttribute("class", "b06-chart__pavement");
const spec = entry?.spec;
const mark = spec ? PAVE_MARKS[spec] : undefined;
if (spec && mark) {
// 무늬는 바탕 채움째 한 패턴 — 다각형 한 장(B07 도면 수확이 겹선을 안 담음)
const id = `b06-pave-hatch-${(paveSeq += 1)}`;
const defs = document.createElementNS(SVG_NS, "defs");
defs.innerHTML = `<pattern id="${id}" patternUnits="userSpaceOnUse" width="${mark.size}" height="${mark.size}"><rect width="${mark.size}" height="${mark.size}" class="b06-chart__pave-bg"/>${mark.marks}</pattern>`;
svg.append(defs);
polygon.style.fill = `url(#${id})`;
polygon.dataset.paveSpec = spec;
}
svg.append(polygon);
}
}
+36 -9
View File
@@ -11,6 +11,7 @@
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch";
export {
computeFordLayout,
@@ -127,6 +128,17 @@ export function appendFordOverlay(
layer.append(pipeOuter, pipeInner);
const bodyShapes = [slab, pipeOuter, pipeInner];
// 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
if (ford.chute)
appendChuteBand(
layer,
layout.sides
.filter((side) => side.role === "outlet")
.flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)),
ford.chute,
x,
toDisplayY,
);
// ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
for (const side of layout.sides) {
const role = side.role === "inlet" ? "유입" : "유출";
@@ -145,15 +157,30 @@ export function appendFordOverlay(
// 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다
// (배수관 기슭막이 `culvert-revet-hit`와 같은 수법).
if (onSelectWall) {
// 본체(바닥판 · 관) 판정면 — 벽 판정면보다 먼저 깔아 겹친 자리는 벽이 먹는다(66-1).
for (const points of [layout.slabBridge, layout.pipe.outer]) {
const hit = polygon(points, "b06-chart__culvert-revet-hit", "세월교 본체 선택");
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall("body");
});
layer.append(hit);
}
// 본체 판정면 = 바닥판 밑 ~ 측벽 상단 최고 사각형 하나(관 위 성토 · 벽-관 사이 포함 · 89-2) —
// 측벽 판정면 · 흙막이(뒤에 그리는 배관 오버레이)가 위에 얹혀 겹친 자리는 그쪽이 먹는다(66-1).
const offsets = [
...layout.slabBridge,
...layout.sides.flatMap((side) => side.parts.flatMap((part) => part.points)),
].map((point) => point.offset);
const top = Math.max(...layout.sides.map((side) => side.top.elevation));
const bottom = Math.min(...layout.slabBridge.map((point) => point.elevation));
const [from, to] = [Math.min(...offsets), Math.max(...offsets)];
const bodyHit = polygon(
[
{ offset: from, elevation: top },
{ offset: to, elevation: top },
{ offset: to, elevation: bottom },
{ offset: from, elevation: bottom },
],
"b06-chart__culvert-revet-hit",
"세월교 본체 선택",
);
bodyHit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectWall("body");
});
layer.append(bodyHit);
// 측벽 판정면 = ㄴ(벽 + 바닥판)을 감싸는 가상 사각형(79-6) — 얇은 벽만 잡던 것을 넓힘.
for (const side of layout.sides) {
const points = side.parts.flatMap((part) => part.points);
+2 -1
View File
@@ -429,7 +429,8 @@ export function createCrossSectionCard(
x,
toDisplayY,
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
if (!fordPaved)
appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement);
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
+97 -5
View File
@@ -44,7 +44,7 @@ export function hatchOfForm(form: string | null | undefined): WallHatch {
}
/** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */
interface BandFrame {
export interface BandFrame {
point: (u: number, v: number) => [number, number];
/** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */
leanDegrees: number;
@@ -64,7 +64,7 @@ function line(a: [number, number], b: [number, number]): SVGLineElement {
}
/** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */
function cell(
export function cell(
frame: BandFrame,
u: number,
v: number,
@@ -87,14 +87,20 @@ function cell(
}
/** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */
function courses(spanM: number, targetM: number): number {
export function courses(spanM: number, targetM: number): number {
return Math.max(2, Math.round(spanM / targetM));
}
function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void {
/** 돌 한 줄 — `thicknessM` = 띠 두께(벽 = 흙막이 띠 · 도수로/돌붙임 = 그 두께 · 90-2). */
export function drawStones(
group: SVGElement,
frame: BandFrame,
wet: boolean,
thicknessM = REVET_THICKNESS_M,
): void {
const count = courses(frame.spanM, 0.45);
const courseM = frame.spanM / count;
const widthM = REVET_THICKNESS_M * (wet ? 0.76 : 0.9);
const widthM = thicknessM * (wet ? 0.76 : 0.9);
const heightM = courseM * (wet ? 0.7 : 0.86);
for (let i = 0; i < count; i += 1) {
group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3));
@@ -308,3 +314,89 @@ export function appendWallHatch(
// 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2).
if (wall.retaining) drawDrains(group, wall, x, toDisplayY);
}
/**
* 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한
* 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관).
* 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴).
*/
export function appendChuteBand(
layer: SVGElement,
lines: OffsetPoint[][],
chute: { structure: string; thickness_m: number },
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const thickness = chute.thickness_m;
if (!(thickness > 0)) return;
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)];
for (const raw of lines) {
const top = raw.filter(
(p, i) =>
i === 0 ||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
);
if (top.length < 2) continue;
// 변마다 아래쪽(사면 안쪽) 단위 법선
const normals = top.slice(1).map((p, i) => {
const dx = p.offset - top[i].offset;
const dz = p.elevation - top[i].elevation;
const length = Math.hypot(dx, dz);
const sign = dx / length > 0 ? -1 : 1;
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
});
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
const bottom = top.map((p, i) => {
const a = normals[Math.max(i - 1, 0)];
const b = normals[Math.min(i, normals.length - 1)];
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
});
const outline = [...top, ...bottom.reverse()];
const band = document.createElementNS(SVG_NS, "polygon");
band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" "));
band.setAttribute("class", "b06-chart__culvert-pitching");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`;
band.append(title);
clipSeq += 1;
const clipId = `b06-chute-clip-${clipSeq}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
const clipShape = document.createElementNS(SVG_NS, "polygon");
clipShape.setAttribute("points", band.getAttribute("points") ?? "");
clip.append(clipShape);
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("clip-path", `url(#${clipId})`);
top.slice(1).forEach((end, i) => {
const start = top[i];
const normal = normals[i];
const [ax, ay] = px(start);
const [bx, by] = px(end);
drawStones(
group,
{
spanM: Math.hypot(end.offset - start.offset, end.elevation - start.elevation),
pixelsPerMeter,
point: (u, v) =>
px({
offset:
start.offset +
(end.offset - start.offset) * u +
normal.offset * thickness * (v + 0.5),
elevation:
start.elevation +
(end.elevation - start.elevation) * u +
normal.elevation * thickness * (v + 0.5),
}),
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
},
false,
thickness,
);
});
layer.append(band, clip, group);
}
}
@@ -142,13 +142,6 @@ export function applyPipeOptionsToCache(
});
}
}
// 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4)
const soil = body ? soilCount(patch) : null;
if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0))
// 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴)
void import("../A00_Common/design/design_structure_pipe_calc").then((calc) =>
calc.setPipeExtraCount(owner.chainage_m, soil),
);
if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole);
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
@@ -190,14 +183,6 @@ function syncBodyGuard(
return !!guard;
}
/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */
function soilCount(patch: Record<string, number | string>): number {
return Math.max(
0,
...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)),
);
}
/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에
* 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */
const soilSent = new Map<string, { h: number | null; m: string | null }>();
@@ -185,6 +185,22 @@
stroke-width: 1;
}
/* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */
.b06-chart__pave-bg {
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
}
.b06-chart__pave-mark {
fill: none;
stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent);
stroke-width: 1;
}
.b06-chart__pave-mark--dot {
fill: color-mix(in srgb, var(--color-text-body) 70%, transparent);
stroke: none;
}
/* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */
.b06-chart__revetment {
fill: color-mix(in srgb, #9b6bdc 22%, transparent);
@@ -344,7 +360,7 @@
stroke-width: 2.2;
}
/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */
/* 유출부 도수로 · 돌붙임 띠(90-2) — 성토사면 안쪽으로 두께만큼 · 안에 돌(`appendChuteBand`) */
.b06-chart__culvert-pitching {
fill: color-mix(in srgb, #3fa34d 18%, transparent);
stroke: #3fa34d;
@@ -317,6 +317,108 @@ function rectPoints(element: SVGElement): XY[] {
});
}
/** pattern 한 칸의 도형(SVG 좌표) — 선(path M/L) · 원/타원. 바탕 rect 는 그림이 아니라 뺌. */
export interface PatternTile {
width: number;
height: number;
paths: XY[][];
rings: Array<{ cx: number; cy: number; rx: number; ry: number }>;
}
/** 무늬 칸 수 상한 — 넘으면 무늬를 버리고 외곽선만(ponytail: 큰 다각형이면 칸 크기를 키울 것). */
const PATTERN_TILE_LIMIT = 20000;
/**
* pattern 채움을 다각형(도면 좌표) 안으로 펴서 자른다(88-4) — CAD 에는 무늬 채움이 없다.
* 칸은 SVG 사용자 좌표 원점 기준(`patternUnits="userSpaceOnUse"`). 선은 이어 붙여 한 줄로,
* 다각형 안에 온전히 든 원은 원 그대로 · 걸친 원/타원은 잘린 폴리선으로.
*/
export function patternMarks(
polygon: XY[],
tile: PatternTile,
): { polylines: XY[][]; circles: Array<{ center: XY; radius: number }> } {
const polylines: XY[][] = [];
const circles: Array<{ center: XY; radius: number }> = [];
const { width, height } = tile;
if (polygon.length < 3 || !(width > 0) || !(height > 0)) return { polylines, circles };
const xs = polygon.map((point) => point[0]);
const ys = polygon.map((point) => -point[1]);
const [i0, i1] = [Math.floor(Math.min(...xs) / width), Math.floor(Math.max(...xs) / width)];
const [j0, j1] = [Math.floor(Math.min(...ys) / height), Math.floor(Math.max(...ys) / height)];
if ((i1 - i0 + 1) * (j1 - j0 + 1) > PATTERN_TILE_LIMIT) return { polylines, circles };
const lines: XY[][] = [];
const arcs: XY[][] = [];
for (let i = i0; i <= i1; i += 1) {
for (let j = j0; j <= j1; j += 1) {
const [dx, dy] = [i * width, j * height];
for (const path of tile.paths) {
lines.push(
...clipPointsToPolygon(
path.map(([x, y]) => flip(x + dx, y + dy)),
polygon,
),
);
}
for (const { cx, cy, rx, ry } of tile.rings) {
const center = flip(cx + dx, cy + dy);
const ring: XY[] = circlePoints([0, 0], 1).map(([x, y]) => [
center[0] + x * rx,
center[1] + y * ry,
]);
if (rx === ry && ring.every((point) => pointInPolygon(point, polygon))) {
circles.push({ center, radius: rx });
} else {
arcs.push(...clipPointsToPolygon(ring, polygon));
}
}
}
}
polylines.push(...chain(lines).map((points) => simplify(points)), ...chain(arcs));
return { polylines, circles };
}
/** 다각형 채움이 가리키는 pattern 을 칸 도형으로 읽는다(채움 = fill 속성 또는 style). 없으면 null. */
function patternTileOf(element: SVGElement, root: SVGElement): PatternTile | null {
const fill = element.getAttribute("fill") ?? String(element.style?.fill ?? "");
const id = /url\(["']?#([^"')]+)/.exec(fill)?.[1];
if (!id) return null;
const pattern = Array.from(root.querySelectorAll<SVGElement>("pattern")).find(
(node) => node.getAttribute("id") === id,
);
if (!pattern) return null;
const tile: PatternTile = {
width: attr(pattern, "width"),
height: attr(pattern, "height"),
paths: [],
rings: [],
};
for (const child of Array.from(pattern.children) as SVGElement[]) {
const tag = child.tagName.toLowerCase();
if (tag === "path") {
// M/L 절대 좌표만(무늬 빗금 꼴) — M 마다 새 줄
for (const run of (child.getAttribute("d") ?? "").split(/(?=M)/i)) {
const numbers = run.match(/-?[\d.]+/g)?.map(Number) ?? [];
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push([numbers[index], numbers[index + 1]]);
}
if (points.length > 1) tile.paths.push(points);
}
} else if (tag === "circle") {
const r = attr(child, "r");
tile.rings.push({ cx: attr(child, "cx"), cy: attr(child, "cy"), rx: r, ry: r });
} else if (tag === "ellipse") {
tile.rings.push({
cx: attr(child, "cx"),
cy: attr(child, "cy"),
rx: attr(child, "rx"),
ry: attr(child, "ry"),
});
}
}
return tile;
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
@@ -337,6 +439,21 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
// 무늬 채움(노면공 · 물넘이포장) — 무늬 선 · 원을 다각형 안으로 펴서 같은 층 · 색으로(88-4)
const tile = tag === "polygon" && !clip ? patternTileOf(element, root) : null;
if (tile) {
const marks = patternMarks(points, tile);
for (const run of marks.polylines) {
const poly = polyEntity(style, clipX(run, fx0, fx1));
if (poly) entities.push(poly);
}
for (const { center, radius } of marks.circles) {
if (!inside(center[0])) continue;
entities.push(
baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius }),
);
}
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
@@ -415,7 +532,7 @@ function drawDesign(
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement);
appendCrossDesignOverlay(
svg,
design,
@@ -195,6 +195,9 @@ def _add_entity(
start_angle = shape.get("startAngle")
end_angle = shape.get("endAngle")
if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)):
# DXF 호는 늘 반시계 — 시계방향 호는 시작 · 끝을 바꿔야 같은 작은 호.
if shape.get("counterClockwise") is False:
start_angle, end_angle = end_angle, start_angle
space.add_arc(
center,
radius,
@@ -29,6 +29,8 @@ from uuid import NAMESPACE_URL, uuid5
import ezdxf
from ezdxf import colors as ezdxf_colors
from ezdxf import path as ezdxf_path
from ezdxf.enums import TextEntityAlignment
from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode
logger = logging.getLogger(__name__)
@@ -38,6 +40,8 @@ _DEFAULT_COLOR = "#f5f7fa"
# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다.
_FLATTEN_MM = 0.2
_MAX_ENTITIES = 20000
# 블록 속 블록을 풀 깊이 — 자기를 품는 깨진 블록에서 끝없이 돌지 않게.
_MAX_BLOCK_DEPTH = 16
# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`.
@@ -117,16 +121,26 @@ def _plain(label: str) -> str:
return decode_dxf_unicode(label) if has_dxf_unicode(label) else label
def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]:
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
def _text(
index: int,
entity: Any,
label: str,
insert: Any,
height: float,
direction: Any = None,
align: str = "left",
) -> dict[str, Any]:
if direction is None:
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
direction = (cos(radians(rotation)), sin(radians(rotation)))
return {
**_base(index, entity, "Text"),
"shapeData": {
"label": _plain(label),
"basePoint": _point(insert[0], insert[1]),
"options": {
"textDirection": _point(cos(radians(rotation)), sin(radians(rotation))),
"textAlign": "left",
"textDirection": _point(direction[0], direction[1]),
"textAlign": align,
"textColor": _color(entity),
"fontSize": float(height) or 3.0,
"fontFamily": "sans-serif",
@@ -143,16 +157,50 @@ def _flatten(entity: Any) -> list[Any] | None:
return None
def _polyline_points(entity: Any) -> tuple[list[Any], bool]:
"""폴리선 → WCS 점열. bulge(호 마디)는 펴고 OCS(압출 −Z 거울 블록)는 WCS 로.
`make_path` 가 닫힌 폴리선의 닫는 마디까지 넣으므로 닫힘 표시는 False.
면 · 메시 POLYLINE 처럼 경로로 못 바꾸면 꼭짓점을 그대로 잇는다.
"""
try:
return list(ezdxf_path.make_path(entity).flattening(_FLATTEN_MM)), False
except Exception:
if entity.dxftype() == "LWPOLYLINE":
return list(entity.get_points("xy")), bool(entity.closed)
return [vertex.dxf.location for vertex in entity.vertices], bool(entity.is_closed)
# DXF 가로 정렬 → CAD 글 정렬. 맞춤(ALIGNED · FIT)은 첫 점에서 왼쪽.
_TEXT_ALIGN = {
TextEntityAlignment.CENTER: "center",
TextEntityAlignment.MIDDLE: "center",
TextEntityAlignment.TOP_CENTER: "center",
TextEntityAlignment.MIDDLE_CENTER: "center",
TextEntityAlignment.BOTTOM_CENTER: "center",
TextEntityAlignment.RIGHT: "right",
TextEntityAlignment.TOP_RIGHT: "right",
TextEntityAlignment.MIDDLE_RIGHT: "right",
TextEntityAlignment.BOTTOM_RIGHT: "right",
}
def _text_placement(entity: Any) -> tuple[Any, Any, str]:
"""TEXT 의 (WCS 기준점, WCS 방향, 가로 정렬). 정렬점 · OCS(압출 −Z)를 따른다."""
align, first, _second = entity.get_placement()
ocs = entity.ocs()
rotation = radians(float(entity.dxf.rotation or 0.0))
direction = ocs.to_wcs((cos(rotation), sin(rotation), 0.0))
return ocs.to_wcs(first), direction, _TEXT_ALIGN.get(align, "left")
def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
kind = entity.dxftype()
if kind == "LINE":
return [_line(index, entity, entity.dxf.start, entity.dxf.end)]
if kind == "LWPOLYLINE":
shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed))
return [shape] if shape else []
if kind == "POLYLINE":
points = [vertex.dxf.location for vertex in entity.vertices]
shape = _polyline(index, entity, points, bool(entity.is_closed))
if kind in ("LWPOLYLINE", "POLYLINE"):
points, closed = _polyline_points(entity)
shape = _polyline(index, entity, points, closed)
return [shape] if shape else []
if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"):
points = _flatten(entity)
@@ -172,24 +220,32 @@ def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
label = str(entity.dxf.text or "").strip()
if not label:
return []
return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))]
insert, direction, align = _text_placement(entity)
height = float(entity.dxf.height or 3.0)
return [_text(index, entity, label, insert, height, direction, align)]
if kind == "MTEXT":
label = str(entity.plain_text() or "").strip()
if not label:
return []
height = float(entity.dxf.char_height or 3.0)
return [_text(index, entity, label, entity.dxf.insert, height)]
# 붙임점 1~9 = 위 · 가운데 · 아래 줄의 왼 · 가운데 · 오른.
align = ("left", "center", "right")[(int(entity.dxf.attachment_point or 1) - 1) % 3]
return [_text(index, entity, label, entity.dxf.insert, height, align=align)]
return []
def _expand(entity: Any) -> list[Any]:
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다."""
def _expand(entity: Any, depth: int = 0) -> list[Any]:
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 — 블록 속 블록까지. 못 풀면 버린다."""
if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"):
if depth >= _MAX_BLOCK_DEPTH:
logger.info("B07 도각 불러오기 — 블록이 %d 겹 넘게 겹쳐 건너뜀", _MAX_BLOCK_DEPTH)
return []
try:
return list(entity.virtual_entities())
inner = list(entity.virtual_entities())
except Exception:
logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype())
return []
return [item for child in inner for item in _expand(child, depth + 1)]
return [entity]
+40 -11
View File
@@ -52,6 +52,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
@@ -109,7 +110,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
onChanged: (dirty) => {
if (!frameEditor.isEditing()) cadDirty = dirty;
if (frameEditor.isEditing()) return;
cadDirty = dirty;
if (dirty) tabs.markDirty();
},
onNavigate: (direction) => navigateDrawing(direction),
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
@@ -248,7 +251,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
const mayDiscardEdits = (): boolean => {
if (!cadDirty) return true;
if (!cadDirty && !tabs.anyDirty()) return true;
return window.confirm(
"저장하지 않은 편집이 있습니다.\n" +
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
@@ -259,7 +262,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
let loadInFlight = false;
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
/** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */
const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => {
if (!projectId || loadInFlight) return;
loadInFlight = true;
highlightActive(drawing.id);
@@ -271,10 +275,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing = drawing;
currentIndex = index;
currentConfirmed = response.confirmed;
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만
applyConfirmButtonState();
updateInfoPanel(drawing, response);
cad.load(response.drawing, buildMeta(drawing, response, index));
cad.load(stash ?? response.drawing, buildMeta(drawing, response, index));
if (response.drawing.stale_layout) {
showToast(
"옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.",
@@ -290,17 +294,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
};
/**
* 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다.
* 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음.
*/
const tabs = createCadTabs<CadDrawing>({
features: { close: true },
capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }),
canLeave: () => {
if (!frameEditor.isEditing()) return !loadInFlight;
showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning");
return false;
},
show: async (tab: CadTab<CadDrawing>) => {
const index = drawings.findIndex((item) => item.id === tab.id);
if (index >= 0)
await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined);
},
});
const openTab = (drawing: DesignDrawingItem): Promise<boolean> =>
tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label });
const selectDrawing = (drawing: DesignDrawingItem) => {
if (drawing === currentDrawing || !mayDiscardEdits()) return;
void loadDrawing(drawing, drawings.indexOf(drawing));
if (drawing === currentDrawing) return;
void openTab(drawing);
};
const navigateDrawing = (direction: "prev" | "next") => {
if (currentIndex < 0) return;
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
if (target < 0 || target >= drawings.length) return;
if (!mayDiscardEdits()) return;
void loadDrawing(drawings[target], target);
void openTab(drawings[target]);
};
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
@@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentConfirmed = true;
currentDrawing.confirmed = true;
cadDirty = false; // 저장했다
const savedTab = tabs.active();
if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨
applyConfirmButtonState();
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
@@ -462,12 +488,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
drawingPanel.append(infoPanelHost, confirmActions);
const cadStack = document.createElement("div");
cadStack.className = "b07-cad-stack";
cadStack.append(tabs.element, cad.element);
const layout = createWorkflowLayout({
title: L("B07_Design_Title"),
steps: workflowSteps(),
activeStep: 4,
leftPanel: drawingPanel,
mainContent: cad.element,
mainContent: cadStack,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
@@ -492,7 +521,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const reopen = drawings.findIndex((item) => item.id === reopenDrawingId);
reopenDrawingId = undefined;
const first = Math.max(reopen, 0);
void loadDrawing(drawings[first], first).then(() => {
void openTab(drawings[first]).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
void prefetchAllDrawings();
});
@@ -328,3 +328,16 @@
.b07-plan-tools__remove {
padding: 0 var(--spacing-8);
}
/* 목록에서 연 도면 탭 줄 + CAD(82-4) */
.b07-cad-stack {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.b07-cad-stack > .cad-host {
flex: 1 1 0;
height: auto;
}
@@ -4,6 +4,7 @@ import { clearRecovery, restoreRecovery } from '../helpers/autosave';
import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json';
import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage';
import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge';
import { runSendSelectionTable } from '../integration/aislo-table-export';
import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png';
import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg';
import { isHostSave, redo, undo } from '../state';
@@ -87,6 +88,14 @@ export const FILE_COMMANDS: CadCommand[] = [
return 'PNG 내보내기';
},
},
{
id: 'TOAREA',
label: '면적산출로 보내기',
glyph: '▦',
hint: '고른 범위(표 · 선 표 · 글)를 면적산출 새 시트로 한 번 옮긴다',
needsSelection: true,
run: runSendSelectionTable,
},
{
id: 'UNDO',
label: '실행 취소',
@@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
'EXPORTPNG',
'COPYCLIP',
'COPYBASE',
'TOAREA', // 고른 범위 → 면적산출 표(M02 83장)
]);
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
@@ -1,7 +1,10 @@
import { type Arc, Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import { EPSILON } from '../App.consts.ts';
import type { DrawController } from '../drawControllers/DrawController.ts';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
import { ArcEntity } from './ArcEntity.ts';
import { LineEntity } from './LineEntity.ts';
describe('ArcEntity.distanceTo', () => {
/**
@@ -57,3 +60,62 @@ describe('ArcEntity.distanceTo', () => {
expect(distanceInfo[0]).toBeLessThan(1);
});
});
describe('ArcEntity 대칭 · 그리기 · 복제(84-3)', () => {
const middle = (entity: ArcEntity) => {
const arc = entity.getShape() as Arc;
return arc.pointAtLength(arc.length / 2) as Point;
};
const mirrorOver = (axisEnd: Point) => {
const arc = new ArcEntity('layer1', new Point(2, 1), 1, 0, Math.PI / 2, true);
const before = arc.getShape() as Arc;
const axis = new LineEntity('layer1', new Point(0, 0), axisEnd);
const expected = [before.start, middle(arc), before.end].map((point) =>
mirrorPointOverAxis(point, axis)
);
arc.mirror(axis);
const after = arc.getShape() as Arc;
return { expected, actual: [after.start, middle(arc), after.end], length: after.length };
};
for (const [name, axisEnd] of [
['세로축', new Point(0, 1)],
['가로축', new Point(1, 0)],
['45° 축', new Point(1, 1)],
['30° 축', new Point(Math.cos(Math.PI / 6), Math.sin(Math.PI / 6))],
] as const) {
it(`${name} 대칭 — 끝점 · 가운데 점이 대칭 자리 · 작은 호 그대로`, () => {
const { expected, actual, length } = mirrorOver(axisEnd);
for (let index = 0; index < 3; index++) {
expect(actual[index].x).toBeCloseTo(expected[index].x, 9);
expect(actual[index].y).toBeCloseTo(expected[index].y, 9);
}
expect(length).toBeCloseTo(Math.PI / 2, 9);
});
}
it('시작각이 음수 · 끝각 0 인 호 — 끝각을 2π 로 바꾸지 않음(온 원으로 안 그림)', () => {
const arc = new ArcEntity('layer1', new Point(0, 0), 1, -Math.PI / 2, 0, true);
const calls: number[][] = [];
const controller = {
setLineStyles: () => {},
drawArc: (_c: Point, _r: number, start: number, end: number) => calls.push([start, end]),
} as unknown as DrawController;
arc.draw(controller);
expect(calls[0][1] - calls[0][0]).toBeCloseTo(Math.PI / 2, 9);
});
it('복제 — 색 · 층 · 선 꼴 그대로', () => {
const arc = new ArcEntity('구조물', new Point(0, 0), 1, 0, 1, false);
arc.lineColor = '#ff0000';
arc.lineWidth = 3;
arc.lineDash = [2, 1];
const copy = arc.clone();
expect([copy.layerId, copy.lineColor, copy.lineWidth, copy.lineDash]).toEqual([
'구조물',
'#ff0000',
3,
[2, 1],
]);
});
});
@@ -60,8 +60,9 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
drawController.drawArc(
this.arc.center,
this.arc.r.valueOf(),
this.arc?.startAngle || 0,
this.arc?.endAngle || 2 * Math.PI,
this.arc.startAngle,
// 끝각 = 시작각 ± 쓸어간 각 — 끝각 0 을 2π 로 바꾸면 시작각이 음수인 호(대칭 뒤)가 온 원으로 그려짐.
this.arc.startAngle + (this.arc.counterClockwise ? this.arc.sweep : -this.arc.sweep),
this.arc.counterClockwise
);
}
@@ -87,12 +88,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
public mirror(mirrorAxis: LineEntity) {
const mirroredCenter = mirrorPointOverAxis(this.arc.center, mirrorAxis);
mirrorAxis.getAngle();
// 축 각 θ 에 대한 대칭 — 각 α → 2θ − α · 도는 방향 반대.
const doubleAxisAngle = 2 * mirrorAxis.getAngle();
this.arc = new Arc(
mirroredCenter,
this.arc.r.valueOf(),
-this.arc.startAngle,
-this.arc.endAngle,
doubleAxisAngle - this.arc.startAngle,
doubleAxisAngle - this.arc.endAngle,
!this.arc.counterClockwise
);
}
@@ -100,14 +102,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity {
public clone(): Entity {
if (this.arc) {
const { center, r, startAngle, endAngle, counterClockwise } = this.arc;
return new ArcEntity(
getActiveLayerId(),
center,
r.valueOf(),
startAngle,
endAngle,
counterClockwise
);
// 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨.
const copy = new ArcEntity(this.layerId, center, r.valueOf(), startAngle, endAngle, counterClockwise);
copy.lineColor = this.lineColor;
copy.lineWidth = this.lineWidth;
copy.lineDash = this.lineDash;
copy.opacity = this.opacity;
return copy;
}
return this;
}
@@ -76,7 +76,13 @@ export class LineEntity implements Entity, StartAndEndpointEntity {
}
public clone(): LineEntity {
return new LineEntity(getActiveLayerId(), this.segment.clone());
// 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨.
const copy = new LineEntity(this.layerId, this.segment.clone());
copy.lineColor = this.lineColor;
copy.lineWidth = this.lineWidth;
copy.lineDash = this.lineDash;
copy.opacity = this.opacity;
return copy;
}
public intersectsWithBox(box: Box): boolean {
@@ -0,0 +1,381 @@
/**
* 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다.
* ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이)
* ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 ·
* 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글
* ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백
* 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다.
* 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`.
* 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types).
*/
import { cellOwners, type TableCells } from './table-geometry.ts';
export type DrawingCellValue = string | number;
export interface DrawingTableMerge {
row: number;
col: number;
rowSpan: number;
colSpan: number;
}
export interface DrawingTable {
/** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */
kind: 'table' | 'grid' | 'text';
rows: DrawingCellValue[][];
merges: DrawingTableMerge[];
/** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */
colWidths: number[];
rowHeights: number[];
}
/** 엔티티 JSON 중 읽는 칸만 */
export interface DrawingEntityJson {
type: string;
// biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림
shapeData?: any;
children?: DrawingEntityJson[] | null;
}
interface Seg {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface Label {
text: string;
x: number;
y: number;
size: number;
}
interface Interval {
at: number;
from: number;
to: number;
}
const NUMBER = /^[+\-−]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-−]?\.\d+$/;
/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */
export function cellValue(text: string): DrawingCellValue {
const trimmed = text.trim();
if (!NUMBER.test(trimmed)) return trimmed;
const value = Number(trimmed.replace(/,/g, '').replace('−', '-'));
return Number.isFinite(value) ? value : trimmed;
}
const span = (value: number | undefined): number => Math.max(1, Math.round(value ?? 1));
/** ① 표 엔티티 → 표 자료 */
function fromTableEntity(data: {
columnWidths: number[];
rowHeights: number[];
cells: TableCells;
}): DrawingTable {
const rowCount = data.rowHeights.length;
const colCount = data.columnWidths.length;
const rows: DrawingCellValue[][] = [];
const merges: DrawingTableMerge[] = [];
for (let row = 0; row < rowCount; row += 1) {
const line: DrawingCellValue[] = [];
for (let col = 0; col < colCount; col += 1) {
const cell = data.cells[row]?.[col];
line.push(cell ? cellValue(cell.text ?? '') : '');
if (!cell) continue;
const rowSpan = Math.min(span(cell.rowSpan), rowCount - row);
const colSpan = Math.min(span(cell.colSpan), colCount - col);
if (rowSpan > 1 || colSpan > 1) merges.push({ row, col, rowSpan, colSpan });
}
rows.push(line);
}
return {
kind: 'table',
rows,
merges,
colWidths: [...data.columnWidths],
rowHeights: [...data.rowHeights],
};
}
/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */
function collect(entities: DrawingEntityJson[], segs: Seg[], labels: Label[]): void {
for (const entity of entities) {
const data = entity.shapeData;
if (entity.type === 'Line' && data?.startPoint && data?.endPoint) {
segs.push({
x1: data.startPoint.x,
y1: data.startPoint.y,
x2: data.endPoint.x,
y2: data.endPoint.y,
});
} else if (entity.type === 'Rectangle' && Array.isArray(data?.points)) {
const points = data.points as { x: number; y: number }[];
for (let index = 0; index < points.length; index += 1) {
const a = points[index];
const b = points[(index + 1) % points.length];
segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
}
} else if (entity.type === 'PolyLine' && entity.children) {
collect(entity.children, segs, labels);
} else if (entity.type === 'Text' && data?.basePoint && String(data.label ?? '').trim()) {
labels.push(labelOf(data));
}
}
}
/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */
function labelOf(data: {
label: string;
basePoint: { x: number; y: number };
options?: { fontSize?: number; boxWidth?: number; boxHeight?: number };
}): Label {
const size = data.options?.fontSize ?? 2.2;
const boxed = Boolean(data.options?.boxWidth && data.options?.boxHeight);
return {
text: data.label,
x: data.basePoint.x,
y: boxed ? data.basePoint.y : data.basePoint.y + size * 0.35,
size,
};
}
/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */
function cluster(values: number[], tol: number): number[] {
const sorted = [...values].sort((a, b) => a - b);
const groups: number[][] = [];
for (const value of sorted) {
const last = groups[groups.length - 1];
if (last && value - last[last.length - 1] <= tol) last.push(value);
else groups.push([value]);
}
return groups.map((group) => group.reduce((sum, value) => sum + value, 0) / group.length);
}
const nearest = (edges: number[], value: number, tol: number): number => {
let best = -1;
let bestGap = tol;
for (let index = 0; index < edges.length; index += 1) {
const gap = Math.abs(edges[index] - value);
if (gap <= bestGap) {
best = index;
bestGap = gap;
}
}
return best;
};
/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */
function covers(intervals: Interval[], from: number, to: number, tol: number): boolean {
const parts = intervals
.map((part) => [Math.max(part.from, from), Math.min(part.to, to)] as const)
.filter(([a, b]) => b > a)
.sort((p, q) => p[0] - q[0]);
let covered = 0;
let reach = from;
for (const [a, b] of parts) {
const start = Math.max(a, reach);
if (b > start) covered += b - start;
reach = Math.max(reach, b);
}
return covered >= (to - from) * 0.9 - tol;
}
/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */
function fromGrid(segs: Seg[], labels: Label[]): DrawingTable | null {
const box = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity };
for (const s of segs) {
box.x0 = Math.min(box.x0, s.x1, s.x2);
box.x1 = Math.max(box.x1, s.x1, s.x2);
box.y0 = Math.min(box.y0, s.y1, s.y2);
box.y1 = Math.max(box.y1, s.y1, s.y2);
}
const extent = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-9);
const tol = extent * 0.003;
const horizontal: Seg[] = [];
const vertical: Seg[] = [];
for (const s of segs) {
const dx = Math.abs(s.x2 - s.x1);
const dy = Math.abs(s.y2 - s.y1);
if (Math.max(dx, dy) <= tol) continue; // 점 같은 선
if (dy <= dx * 0.01) horizontal.push(s);
else if (dx <= dy * 0.01) vertical.push(s);
}
let colEdges = cluster(
vertical.map((s) => (s.x1 + s.x2) / 2),
tol
);
let rowEdges = cluster(
horizontal.map((s) => (s.y1 + s.y2) / 2),
tol
).reverse(); // 위 → 아래
const lineOf = (list: Seg[], edges: number[], vertical: boolean): Interval[][] => {
const byEdge: Interval[][] = edges.map(() => []);
for (const s of list) {
const at = vertical ? (s.x1 + s.x2) / 2 : (s.y1 + s.y2) / 2;
const index = nearest(edges, at, tol);
if (index < 0) continue;
const [a, b] = vertical ? [s.y1, s.y2] : [s.x1, s.x2];
byEdge[index].push({ at, from: Math.min(a, b), to: Math.max(a, b) });
}
return byEdge;
};
// 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음
for (let pass = 0; pass < 2; pass += 1) {
if (colEdges.length < 2 || rowEdges.length < 2) return null;
const vLines = lineOf(vertical, colEdges, true);
const hLines = lineOf(horizontal, rowEdges, false);
const keepCol = colEdges.map((_, i) =>
rowEdges.slice(1).some((y, j) => covers(vLines[i], y + tol, rowEdges[j] - tol, tol))
);
const keepRow = rowEdges.map((_, j) =>
colEdges.slice(1).some((x, i) => covers(hLines[j], colEdges[i] + tol, x - tol, tol))
);
colEdges = colEdges.filter((_, i) => keepCol[i]);
rowEdges = rowEdges.filter((_, j) => keepRow[j]);
}
if (colEdges.length < 2 || rowEdges.length < 2) return null;
const colCount = colEdges.length - 1;
const rowCount = rowEdges.length - 1;
const vLines = lineOf(vertical, colEdges, true);
const hLines = lineOf(horizontal, rowEdges, false);
// 안쪽 선 빠진 곳 = 같은 묶음(union-find)
const parent = Array.from({ length: rowCount * colCount }, (_, i) => i);
const find = (i: number): number => {
while (parent[i] !== i) {
parent[i] = parent[parent[i]];
i = parent[i];
}
return i;
};
const join = (a: number, b: number): void => {
parent[find(a)] = find(b);
};
for (let r = 0; r < rowCount; r += 1) {
for (let c = 1; c < colCount; c += 1) {
if (!covers(vLines[c], rowEdges[r + 1] + tol, rowEdges[r] - tol, tol))
join(r * colCount + c - 1, r * colCount + c);
}
}
for (let r = 1; r < rowCount; r += 1) {
for (let c = 0; c < colCount; c += 1) {
if (!covers(hLines[r], colEdges[c] + tol, colEdges[c + 1] - tol, tol))
join((r - 1) * colCount + c, r * colCount + c);
}
}
// 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로
const groups = new Map<number, number[]>();
for (let i = 0; i < rowCount * colCount; i += 1) {
const root = find(i);
const members = groups.get(root);
if (members) members.push(i);
else groups.set(root, [i]);
}
const cells: TableCells = Array.from({ length: rowCount }, () =>
Array.from({ length: colCount }, () => ({ text: '' }))
);
for (const members of groups.values()) {
if (members.length < 2) continue;
const rs = members.map((i) => Math.floor(i / colCount));
const cs = members.map((i) => i % colCount);
const [r0, r1, c0, c1] = [Math.min(...rs), Math.max(...rs), Math.min(...cs), Math.max(...cs)];
if ((r1 - r0 + 1) * (c1 - c0 + 1) !== members.length) continue;
cells[r0][c0] = { text: '', rowSpan: r1 - r0 + 1, colSpan: c1 - c0 + 1 };
for (let r = r0; r <= r1; r += 1)
for (let c = c0; c <= c1; c += 1) if (r !== r0 || c !== c0) cells[r][c] = null;
}
// 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음
const owners = cellOwners(cells, rowCount, colCount);
const texts = new Map<string, Label[]>();
let outside = 0;
for (const label of labels) {
const c = colEdges.findIndex(
(x, i) => i < colCount && label.x >= x && label.x <= colEdges[i + 1]
);
const r = rowEdges.findIndex(
(y, j) => j < rowCount && label.y <= y && label.y >= rowEdges[j + 1]
);
if (c < 0 || r < 0) {
outside += 1;
continue;
}
const key = owners[r][c] ?? `${r}:${c}`;
const inCell = texts.get(key);
if (inCell) inCell.push(label);
else texts.set(key, [label]);
}
if (!labels.length || outside * 2 > labels.length) return null;
const rows: DrawingCellValue[][] = cells.map((line) => line.map(() => ''));
const merges: DrawingTableMerge[] = [];
cells.forEach((line, r) =>
line.forEach((cell, c) => {
if (!cell) return;
if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1)
merges.push({ row: r, col: c, rowSpan: cell.rowSpan ?? 1, colSpan: cell.colSpan ?? 1 });
const inCell = texts.get(`${r}:${c}`) ?? [];
const sorted = sortReading(inCell);
if (sorted.length) rows[r][c] = cellValue(sorted.map((label) => label.text.trim()).join(' '));
})
);
return {
kind: 'grid',
rows,
merges,
colWidths: colEdges.slice(1).map((x, i) => x - colEdges[i]),
rowHeights: rowEdges.slice(1).map((y, j) => rowEdges[j] - y),
};
}
/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */
function textLines(labels: Label[]): Label[][] {
const sorted = [...labels].sort((a, b) => b.y - a.y);
const lines: { y: number; size: number; items: Label[] }[] = [];
for (const label of sorted) {
const last = lines[lines.length - 1];
if (last && last.y - label.y <= Math.max(last.size, label.size) * 0.5) last.items.push(label);
else lines.push({ y: label.y, size: label.size, items: [label] });
}
return lines.map((line) => line.items.sort((a, b) => a.x - b.x));
}
const sortReading = (labels: Label[]): Label[] => textLines(labels).flat();
/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */
function fromText(labels: Label[]): DrawingTable {
const lines = textLines(labels);
const width = Math.max(...lines.map((line) => line.length));
return {
kind: 'text',
rows: lines.map((line) => {
const values: DrawingCellValue[] = line.map((label) => cellValue(label.text));
while (values.length < width) values.push('');
return values;
}),
merges: [],
colWidths: [],
rowHeights: [],
};
}
/**
* 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리.
* 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null.
*/
export function tableFromDrawing(entities: DrawingEntityJson[]): DrawingTable | null {
const tables = entities.filter(
(entity) => entity.type === 'Table' && entity.shapeData?.columnWidths?.length
);
if (tables.length) {
const size = (entity: DrawingEntityJson) =>
entity.shapeData.columnWidths.length * entity.shapeData.rowHeights.length;
const biggest = tables.reduce((best, entity) => (size(entity) > size(best) ? entity : best));
return fromTableEntity(biggest.shapeData);
}
const segs: Seg[] = [];
const labels: Label[] = [];
collect(entities, segs, labels);
if (!labels.length) return null;
return (segs.length ? fromGrid(segs, labels) : null) ?? fromText(labels);
}
@@ -0,0 +1,83 @@
/**
* CAD → 부모 화면 표 보내기(M02 83장 · 도면 → 면적산출 한 번 옮기기 · 연동 아님). 짝 = `M02_MasterTemplete_AreaDrawing.ts`.
* export-table-request 부모 [면적산출로 보내기] 단추 → 고른 범위를 표 자료로
* export-table{table} 표 자료(`helpers/table-from-drawing.ts` 꼴) → 부모가 새 시트로
* CAD 명령 TOAREA(「면적산출로 보내기」)도 같은 길. 보기 전용 도면에서도 됨(도면을 안 바꿈).
* 고른 것이 없거나 옮길 글이 없으면 부모에 안 보내고 안내만.
*/
import { toast } from 'react-toastify';
import type { DrawingEntityJson } from '../helpers/table-from-drawing.ts';
import { type DrawingTable, tableFromDrawing } from '../helpers/table-from-drawing.ts';
import { getSelectedEntities } from '../state.ts';
export const AISLO_EXPORT_TABLE_REQUEST_MESSAGE = 'aislo:b08:export-table-request';
export const AISLO_EXPORT_TABLE_MESSAGE = 'aislo:b08:export-table';
const TEXT = {
empty: '면적산출로 보낼 범위를 먼저 고르세요(누름 · 끌기).',
noText: '고른 범위에 글이 없어 표로 옮길 것이 없습니다.',
standalone: '면적산출 화면에서 연 도면에서만 보낼 수 있습니다.',
};
type Notify = (type: string, payload: Record<string, unknown>) => void;
function notifyParent(type: string, payload: Record<string, unknown>): void {
if (window.parent === window) return;
window.parent.postMessage({ type, ...payload }, window.location.origin);
}
/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */
export async function selectionTable(): Promise<DrawingTable | 'empty' | 'no-text'> {
const selected = getSelectedEntities();
if (!selected.length) return 'empty';
const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter(
(entity): entity is NonNullable<typeof entity> => Boolean(entity)
);
return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text';
}
/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */
export async function sendSelectionTable(notify: Notify = notifyParent): Promise<boolean> {
const result = await selectionTable();
if (result === 'empty') toast.info(TEXT.empty);
else if (result === 'no-text') toast.info(TEXT.noText);
else {
notify(AISLO_EXPORT_TABLE_MESSAGE, { table: result });
return true;
}
return false;
}
/** CAD 명령 TOAREA — 부모 없이 띄운 CAD(단독)면 안내만 */
export function runSendSelectionTable(): string {
if (window.parent === window) {
toast.info(TEXT.standalone);
return '부모 화면 없음';
}
void sendSelectionTable();
return '면적산출로 보내기';
}
/** 부모 요청 메시지면 처리하고 true */
export async function handleTableExportMessage(
data: unknown,
notify: Notify = notifyParent
): Promise<boolean> {
const message = data as { type?: string } | null;
if (
!message ||
typeof message !== 'object' ||
message.type !== AISLO_EXPORT_TABLE_REQUEST_MESSAGE
)
return false;
await sendSelectionTable(notify);
return true;
}
/** 부모 [면적산출로 보내기] 요청을 받음 — 같은 출처 · 부모 창만 */
export function registerAisloTableExport(): void {
window.addEventListener('message', (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
void handleTableExportMessage(event.data);
});
}
+2
View File
@@ -18,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts';
import { registerAisloTableExport } from './integration/aislo-table-export.ts';
import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts';
import {
getActiveToolActor,
@@ -155,6 +156,7 @@ function initApplication() {
setLayers(layers);
setActiveLayerId(layers[0].id);
registerAisloDrawingBridge();
registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장)
registerDxfClipPaste();
registerCadDebugHook();
registerAutoSave();
@@ -16,6 +16,7 @@ import {
} from '../state';
import { Tool } from '../tools';
import { CopyAction } from './copy-tool.ts';
import { cloneKeepingLook } from './move-tool.helpers.ts';
import { selectToolStateMachine } from './select-tool.ts';
import type {
AbsolutePointInputEvent,
@@ -337,7 +338,7 @@ export const arrayToolStateMachine = createMachine(
const y = distanceVector.y * i;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = entity.clone();
const clone = cloneKeepingLook(entity);
clone.move(x, y);
return clone;
})
@@ -346,7 +347,7 @@ export const arrayToolStateMachine = createMachine(
const angleRad = context.angleStep * i * TO_RADIANS;
resultEntities.push(
...getSelectedEntities().map((entity) => {
const clone = entity.clone();
const clone = cloneKeepingLook(entity);
clone.rotate(context.pivotPoint as Point, angleRad);
return clone;
})
@@ -17,7 +17,7 @@ import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { LineEntity } from '../entities/LineEntity';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
import { moveEntities } from './move-tool.helpers';
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
export interface CopyContext extends ToolContext {
startPoint: Point | null;
@@ -208,7 +208,7 @@ export const copyToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))),
copiedEntities: selectedEntities,
};
}),
@@ -221,7 +221,7 @@ export const copyToolStateMachine = createMachine(
// Copy the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
@@ -246,15 +246,15 @@ export const copyToolStateMachine = createMachine(
// Copy the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity));
moveEntities(
copiedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y
);
// Switch the copied entities back from the ghost helper entities to the real entities
addEntities([...context.originalSelectedEntities, ...copiedEntities], true);
// 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임).
addEntities(copiedEntities, true);
},
[CopyAction.DESELECT_ENTITIES]: assign(() => {
setGhostHelperEntities([]);
@@ -1,4 +1,5 @@
import type { Entity } from '../entities/Entity';
import { copyStyle } from './modify/modify.helpers';
/**
* Move entities by the difference between the start and end points
@@ -11,3 +12,17 @@ export function moveEntities(entities: Entity[], deltaX: number, deltaY: number)
entity.move(deltaX, deltaY);
}
}
/**
* 복제해도 색 · 층 · 선 꼴 · 투명도를 그대로 둔다(`clone()` 은 활성 층 · 흰색으로 새로 만듦).
* `inPlace` = 옮기기 · 크기 · 회전처럼 같은 도형을 갈아 끼울 때 — id · 묶음까지 그대로.
*/
export function cloneKeepingLook(entity: Entity, inPlace = false): Entity {
const copy = copyStyle(entity, entity.clone());
copy.opacity = entity.opacity;
if (inPlace) {
copy.id = entity.id;
copy.groupId = entity.groupId;
}
return copy;
}
@@ -16,7 +16,7 @@ import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { moveEntities } from './move-tool.helpers';
import { cloneKeepingLook, moveEntities } from './move-tool.helpers';
import { LineEntity } from '../entities/LineEntity';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
@@ -211,7 +211,7 @@ export const moveToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
movedEntities: selectedEntities,
};
}),
@@ -224,7 +224,7 @@ export const moveToolStateMachine = createMachine(
// Move the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true));
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
@@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { rotateEntities } from './rotate-tool.helpers';
import { cloneKeepingLook } from './move-tool.helpers';
export interface RotateContext extends ToolContext {
rotationOrigin: Point | null;
@@ -231,7 +232,7 @@ export const rotateToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
};
}),
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
@@ -245,7 +246,7 @@ export const rotateToolStateMachine = createMachine(
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
const rotatedEntities = compact(
context.originalSelectedEntities.map((entity) => entity.clone())
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
);
rotateEntities(
rotatedEntities,
@@ -264,7 +265,7 @@ export const rotateToolStateMachine = createMachine(
// Rotate the entities one final time
const rotatedEntities = compact(
context.originalSelectedEntities.map((entity) => entity.clone())
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
);
rotateEntities(
rotatedEntities,
@@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { scaleEntities } from './scale-tool.helpers';
import { cloneKeepingLook } from './move-tool.helpers';
export interface ScaleContext extends ToolContext {
baseVectorStartPoint: Point | null;
@@ -234,7 +235,7 @@ export const scaleToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))),
};
}),
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
@@ -248,7 +249,7 @@ export const scaleToolStateMachine = createMachine(
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
const scaledEntities = compact(
context.originalSelectedEntities.map((entity) => entity.clone())
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
);
scaleEntities(
scaledEntities,
@@ -267,7 +268,7 @@ export const scaleToolStateMachine = createMachine(
// Scale the entities one final time
const scaledEntities = compact(
context.originalSelectedEntities.map((entity) => entity.clone())
context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true))
);
scaleEntities(
scaledEntities,
@@ -1,7 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Source.ts
* 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 ·
* 길은 부르는 쪽이 줌(85-4 · M02 = `/m02/logic-lib/source` + 제목 행 [원문 다운로드] pdf 통째)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -16,6 +17,7 @@ const TEXT = {
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
Download: ["원문 다운로드", "Download source"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
@@ -70,15 +72,38 @@ export interface SourceView {
show(src: string): void;
}
export function createSourceView(): SourceView {
/** 출처 글 → 원문 pdf 책(85-4) — 모르면 null */
const bookOf = (src: string): string | null =>
src.startsWith("건설품셈") ? "건설" : src.startsWith("산림품셈") ? "산림" : null;
export interface SourceViewOptions {
/** 원문 절 길(기본 M01 `/m01/logic/source` — 시스템 관리자) */
path?: string;
/** 원문 pdf 길(`?book=`) — 주면 제목 행 오른쪽 [원문 다운로드] */
pdf?: string;
}
export function createSourceView(opts: SourceViewOptions = {}): SourceView {
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const download = opts.pdf
? (el("a", {
className: "ui-btn ui-btn--ghost ui-btn--sm",
text: ts("Download"),
attrs: { download: "", "data-source-pdf": "" },
}) as HTMLAnchorElement)
: null;
let shown: string | null = null;
const show = (src: string): void => {
if (shown === src) return;
shown = src;
title.textContent = src || ts("NoSource");
if (download) {
const book = bookOf(src);
download.hidden = !book;
download.href = book ? `${API_BASE_URL}${opts.pdf}?${new URLSearchParams({ book })}` : "";
}
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
@@ -86,9 +111,12 @@ export function createSourceView(): SourceView {
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
void fetch(
`${API_BASE_URL}${opts.path ?? "/m01/logic/source"}?${new URLSearchParams({ src })}`,
{
credentials: "include",
},
)
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
@@ -109,11 +137,19 @@ export function createSourceView(): SourceView {
...f.blocks.map(blockNode),
]),
);
requestAnimationFrame(() =>
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
);
// 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림)
requestAnimationFrame(() => {
const hit = body.querySelector<HTMLElement>(".m01lab-src__hit");
const box = hit?.closest<HTMLElement>(".ui-side-panels__body");
if (!hit || !box) return hit?.scrollIntoView({ block: "nearest" });
const off = hit.getBoundingClientRect().top - box.getBoundingClientRect().top;
box.scrollTop += off - (box.clientHeight - hit.offsetHeight) / 2;
});
});
};
return { root: el("div", { className: "m01lab-src", children: [title, body] }), show };
const head = download
? el("div", { className: "m02-dfp__head", children: [title, download] })
: title;
return { root: el("div", { className: "m01lab-src", children: [head, body] }), show };
}
+4 -2
View File
@@ -5,6 +5,7 @@
`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 ·
칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용).
도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽.
여러 장(82-3) — 연결표 줄 `장` = 표가 놓인 장id(1장 = 빈 값) · 맞춤은 모든 장(안 열린 장도 서버가).
"""
from __future__ import annotations
@@ -16,6 +17,7 @@ from typing import Any
from common_util import common_util_spreadsheet as sheet_node
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Template_Layers import drawing_sheets
logger = logging.getLogger(__name__)
LINKS = "연결표"
@@ -40,7 +42,7 @@ def _style_link(entity: Any) -> Any:
def refresh_drawing(column: str) -> list[str]:
"""지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
"""지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록."""
try:
drawing, area = store._path("structure", column), store._path("area", column)
except store.StoreError:
@@ -61,7 +63,7 @@ def refresh_drawing(column: str) -> list[str]:
except sheet_node.RecalcError as e:
logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e)
return []
entities = (doc.get("도면") or {}).get("entities") or []
entities = [e for _, sheet in drawing_sheets(doc) for e in sheet.get("entities") or []]
done: list[str] = []
for link, got in zip(links, results):
if not got.get("ok"):
+12 -13
View File
@@ -1,7 +1,7 @@
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」).
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
"""
@@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [c for c in doc.get("열") or [] if tree.is_item(c)]
def _unique(path: list[str], taken: list[list[str]]) -> list[str]:
n, out = 2, path
while out in taken:
out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1
return out
def paste(
source: Path,
target: Path,
@@ -67,6 +60,7 @@ def paste(
taken_folders = tree.folders_of(dst)
plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로)
new_folders: list[list[str]] = []
renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름
def busy(path: list[str]) -> bool:
paths = [*taken, *(p for _, p in plan)]
@@ -78,16 +72,18 @@ def paste(
found = next((p for p, k in src_items if k == key), None)
if found is None:
raise store.StoreError(404, f"없는 항목 「{key}」")
plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)])))
used = [*taken, *(p for _, p in plan)]
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
renamed += [path[-1]] if path[-1] != found[-1] else []
plan.append((key, path))
continue
folder = list(one.get("경로") or [])
if not folder or (
folder not in src_folders and not any(_under(p, folder) for p, _ in src_items)
):
raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」")
top, n = [*dest, folder[-1]], 2
while busy(top):
top, n = [*dest, f"{folder[-1]} ({n})"], n + 1
top = tree.unique_path([*dest, folder[-1]], busy)
renamed += [top[-1]] if top[-1] != folder[-1] else []
new_folders.append(top)
new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)]
plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k]
@@ -102,4 +98,7 @@ def paste(
doc = got["문서"]
doc["폴더"] = [*(doc.get("폴더") or []), *new_folders]
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
return {"op": "paste", "경로": dest, "열": made}
note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made}
if renamed:
note["바뀐이름"] = renamed
return note
+2 -5
View File
@@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]:
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로
(나무는 같은 경로 두 항목을 안 둠)."""
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
out, n = list(full), 2
while out in taken:
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
return out
return tree.unique_path(list(full), lambda p: p in taken)
def create_item(
+8 -1
View File
@@ -280,8 +280,15 @@ def calc(
snapshot = load_prices(prices)
gaps = missing_inputs(snapshot, labor, values)
blocked = {gap["로직"] for gap in gaps}
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0))
lines, stuck = [], []
rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered`
one.get("로직"): one.get("켜쌓기율")
for one in (labor.get("줄") or {}).get("담은로직") or []
if isinstance(one, dict)
}
for at, key in enumerate(_logic_keys(labor), start=1):
head: dict[str, Any] = {"차례": at, "로직": key}
if key in blocked:
@@ -292,7 +299,7 @@ def calc(
given = {name: one.get("값") for name, one in (values.get(key) or {}).items()}
try:
with mf.mp.choosing((picks or {}).get(key)):
got = mf.run(snapshot, key, given)
got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key))
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error:
lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__})
stuck.append(key)
+46 -5
View File
@@ -1,6 +1,6 @@
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
@@ -8,6 +8,8 @@
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율
(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수).
참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) ·
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
@@ -22,6 +24,7 @@ from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -105,6 +108,7 @@ def clean(doc: Any) -> dict[str, Any]:
if not isinstance(given, dict):
raise _bad(f"{where} · 입력값이 묶음 아님")
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
rate = _rate(one.get("켜쌓기율"), code, where)
lines.append(
{
"로직": code,
@@ -112,6 +116,7 @@ def clean(doc: Any) -> dict[str, Any]:
"메모": str(memo) if memo else None,
"입력값": {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 {}),
}
)
mats, codes = [], set()
@@ -157,6 +162,42 @@ def _alias(alias: Any, where: str) -> str | None:
return alias or None
def _rate(value: Any, code: str, where: str) -> int | float | None:
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
if value is None or value == "":
return None
rate = _number(value)
if rate is None or not Decimal(0) < rate <= Decimal(200):
raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %")
if rate != rate.quantize(Decimal("0.1")):
raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지")
if rate == 100 or code not in labor_inputs.layered():
return None
return int(rate) if rate == rate.to_integral_value() else float(rate)
def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]:
"""엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로."""
if rate in (None, 100) or "결과" in got:
return got
times = Decimal(str(rate)) / 100
def mul(value: Any) -> Any:
return value * times if isinstance(value, Decimal) else value
lines = [
{
**x,
"수량": mul(x.get("수량")),
"금액": mul(x.get("금액")),
"비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()},
}
for x in got.get("줄") or []
]
sums = {k: mul(got[k]) for k in COSTS}
return {**got, "줄": lines, **sums, "계": sum(sums.values())}
def from_combo(key: str) -> dict[str, Any]:
"""옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 —
담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키).
@@ -331,7 +372,7 @@ 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("입력값"))})
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
mats = [
{
"코드": one["코드"],
@@ -344,13 +385,13 @@ 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) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` ·
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
돈 아닌 로직은 `{결과, 기준: 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 = mf.run(whole, code, {**auto, **m01._dec(given or {})})
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:
+29 -1
View File
@@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
_SPEC = ("이름", "설명", "단위", "고르기", "범위")
_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직
def _num(value: Any) -> Any:
@@ -40,9 +41,32 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]:
return found, missing
def layered() -> set[str]:
"""켜쌓기율(85-2) 대상 로직 키 — 표 `주` 에 「골쌓기 기준」 · 그 표 `용도.로직키`(자동 판정)."""
out: set[str] = set()
for data in m01.loaded()[0].values():
for row in data.get("표") or []:
if any(_LAYERED in str(note) for note in row.get("주") or []):
out.update((row.get("용도") or {}).get("로직키") or [])
return out
def _labels(whole: Any, spec: dict) -> dict[str, str]:
"""고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌."""
table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ"
rows = whole.numbers.get(table, {})
out = {}
for value in spec.get("고르기") or []:
row = rows.get(str(value)) if isinstance(value, str) else None
if row:
out[value] = " ".join(str(row[k]) for k in ("이름", "규격") if row.get(k))
return out
def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`."""
"""로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`."""
whole = m01.loaded()[1]
layers = layered()
out = []
for logic in logics:
auto = m01.mau.values(whole, logic)
@@ -51,6 +75,9 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
one = {k: spec[k] for k in _SPEC if k in spec}
one.setdefault("설명", None)
one.setdefault("단위", None)
labels = _labels(whole, spec)
if labels:
one["고르기이름"] = labels
specs.append({**one, "자동값": auto.get(spec["이름"])})
out.append(
{
@@ -58,6 +85,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]:
"이름": logic.get("이름"),
"결과단위": logic.get("결과단위"),
"출처": logic.get("출처"),
"켜쌓기": logic.get("키") in layers,
"입력": specs,
}
)
+5 -3
View File
@@ -152,7 +152,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
이름=name or pill.get("이름") or line["로직"],
단위=unit or pill.get("단위") or None,
배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장
담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}],
담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}],
**origin(line["로직"]),
)
else:
@@ -200,6 +200,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
if "로직" in one:
head["입력값"] = one["입력값"]
head.update({k: one[k] for k in ("켜쌓기율",) if k in one})
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
at_ = here()
me = {"층": at_[0], "주인": at_[1], "키": key}
@@ -368,7 +369,8 @@ class Expand:
from M02_MasterTemplete import M02_Labor_Doc as labor
line = (doc.get("담은로직") or [{}])[0]
got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값"))
code = str(line.get("로직") or "")
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
times = labor._number(doc.get("배")) or Decimal(1)
base = got.get("기준")
why = got.get("까닭") or (None if base else "돈 아닌 로직")
@@ -395,7 +397,7 @@ class Expand:
from M02_MasterTemplete import M02_Labor_Doc as labor
pill = pill or {}
got = labor.run_line(self.whole, one["로직"], one.get("입력값"))
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
times = labor._number(pill.get("배")) or Decimal(1)
base = got.get("기준")
return {
+14 -3
View File
@@ -13,6 +13,7 @@ import {
choicesOf,
inputKey,
isOutside,
labelOf,
rangeOf,
storedOf,
type ValueDoc,
@@ -65,7 +66,12 @@ export function createValueControl(
if (control instanceof HTMLSelectElement) {
control.append(
...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []),
...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })),
...choices.map((c) =>
el("option", {
attrs: { value: c.값, ...(c.이름 !== c.값 ? { title: c.값 } : {}) },
text: c.이름,
}),
),
);
}
const empty = value === null || value === "";
@@ -194,10 +200,15 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand
};
if (opts.mode === "view") {
const text = isEmpty(stored.값) ? auto : stored.값;
const label = text === null ? "—" : labelOf(input, text);
const shown = el("span", {
className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`,
text: text === null ? "—" : String(text),
attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {},
text: label,
attrs: isEmpty(stored.값)
? { title: VALUE_TEXT.auto }
: label !== String(text)
? { title: String(text) }
: {},
});
paintState();
return el("div", { className: "m02-inputval__cell", children: [shown, state] });
+12 -3
View File
@@ -14,9 +14,14 @@ const text = (value: unknown): string =>
? JSON.stringify(value)
: String(value);
/** 값 → 보이는 글(85-1) — 원문번호면 「이름 규격」 · 그 밖은 값 그대로 */
export const labelOf = (input: ItemInput, value: unknown): string =>
input.고르기이름?.[text(value)] ?? text(value);
/** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */
export function valueHint(input: ItemInput): string {
if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / ");
if (Array.isArray(input.고르기) && input.고르기.length)
return input.고르기.map((one) => labelOf(input, one)).join(" / ");
const range = input.범위;
if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`;
if (range && typeof range === "object") {
@@ -60,7 +65,11 @@ export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw:
const object =
typeof one === "object" && one !== null ? (one as Record<string, unknown>) : null;
const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number;
return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw };
return {
값: String(raw),
이름: object ? String(object.이름 ?? raw) : labelOf(input, one),
raw,
};
});
}
@@ -112,7 +121,7 @@ export function valueLine(
for (const input of inputs) {
const value = values?.[input.이름];
if (value !== undefined && value !== "") {
parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`);
parts.push(`${input.이름} ${labelOf(input, value)}${input.단위 ? input.단위 : ""}`);
} else if (autoText(input) === "") empty += 1;
}
return { text: parts.join(" · "), empty };
@@ -150,9 +150,13 @@ export interface StructureDoc {
열: string;
도번: string;
도면: { entities: unknown[] } & Record<string, unknown>;
/** 여러 장(82-2) — `도면` = 1장 · 더한 장 차례대로 · 장 도번 = 도번 + `-n`(둘째 = -2) */
더한장?: StructureSheet[];
/** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */
연결표?: {
연결: string;
/** 표가 놓인 장id(82-3) — 1장 = 빈 값(옛 기록은 칸 없음 = 1장) */
장?: string;
시트: string;
범위: string;
글자크기: number;
@@ -160,6 +164,13 @@ export interface StructureDoc {
}[];
}
/** 구조물 도면 더한 장 하나(82-2) */
export interface StructureSheet {
장id: string;
이름: string;
도면: { entities: unknown[] } & Record<string, unknown>;
}
/** 시스템 층 구조물 도면 목록 */
export const listStructures = async (): Promise<TemplateInfo[]> =>
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
@@ -175,6 +186,9 @@ export interface StructureNumbers {
면적번: string;
/** 일위대가 `SU-######`(39장) */
일위대가번: string;
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
일위대가합계?: number;
합계기준?: boolean;
}
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
@@ -319,6 +333,8 @@ export interface ItemInput {
설명?: string;
단위?: string;
고르기?: unknown[];
/** 고르기 원문번호 → 보이는 글(85-1 · 기계 「이름 규격」 · 직종 이름) */
고르기이름?: Record<string, string>;
범위?: unknown;
자동값?: unknown;
}
@@ -329,6 +345,8 @@ export interface ItemLogic {
결과단위?: string;
/** 품셈 출처 글(47-4 · 원문 패널) */
출처?: string | null;
/** 켜쌓기율 대상(85-2 · 표 주문 「골쌓기 기준」) */
켜쌓기?: boolean;
입력: ItemInput[];
}
@@ -354,6 +372,8 @@ export interface TreeNote {
/** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */
열?: string | string[];
지운항목?: string[];
/** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */
바뀐이름?: string[];
}
export interface TreeView {
판: string;
@@ -1,19 +1,51 @@
/* =============================================================================
* M02_MasterTemplete_AreaDrawing.ts
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동).
* 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`).
* 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기 · 고르기(고치기 없음).
* 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음.
* 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료
* (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽).
* CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createButton, el } from "@ui/ui_template_elements";
import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard";
import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing";
import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
const TEXT = {
loading: "도면 불러오는 중…",
none: "이 열은 구조물 도면이 없음",
send: "면적산출로 보내기",
sendTip:
"도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 면적산출 새 시트로 한 번 옮김 — 연동 아님 · 누름 · 끌기로 고름",
};
export const EXPORT_TABLE_REQUEST = "aislo:b08:export-table-request";
export const EXPORT_TABLE = "aislo:b08:export-table";
/** 고르기만 — 제목줄 · 리본 · 패널 · 상태줄 숨김(카드 고치기 갈래와 같은 숨김 · 명령 안내 줄은 남김) */
const HIDE =
".cad-titlebar, .cad-ribbon, .cad-inspector, .cad-statusbar, .cad-quick-access" +
" { display: none !important; }";
function hideChrome(frame: HTMLIFrameElement): void {
const apply = (): void => {
const inner = frame.contentDocument;
if (!inner?.head || inner.getElementById("m02-area-hide")) return;
const style = inner.createElement("style");
style.id = "m02-area-hide";
style.textContent = HIDE;
inner.head.append(style);
};
frame.addEventListener("load", apply);
apply();
}
export interface AreaDrawingOptions {
/** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */
onTable?: (table: DrawingTable) => void;
}
export interface AreaDrawing {
root: HTMLElement;
/** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */
@@ -21,13 +53,37 @@ export interface AreaDrawing {
destroy: () => void;
}
export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
export function createAreaDrawing(
layer: Layer,
column: string,
opts: AreaDrawingOptions = {},
): AreaDrawing {
const root = el("div", { className: "m02-apanel__drawing" });
const stage = el("div", { className: "m02-apanel__drawstage" });
let view: { destroy: () => void } | null = null;
let frame: HTMLIFrameElement | null = null;
let started = false;
let dead = false;
const note = (text: string): void =>
root.replaceChildren(el("p", { className: "m02-apanel__note", text }));
stage.replaceChildren(el("p", { className: "m02-apanel__note", text }));
const send = createButton({ label: TEXT.send, size: "sm", variant: "ghost" });
send.title = TEXT.sendTip;
send.dataset.areaFromDrawing = "";
send.disabled = true; // 도면이 서면 켬
send.addEventListener("click", () =>
frame?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin),
);
if (opts.onTable) root.append(el("div", { className: "m02-apanel__drawbar", children: [send] }));
root.append(stage);
const onMessage = (event: MessageEvent): void => {
if (!frame || event.source !== frame.contentWindow) return;
if (event.origin !== window.location.origin) return;
const data = event.data as { type?: string; table?: DrawingTable } | null;
if (data?.type === EXPORT_TABLE && data.table && opts.onTable) opts.onTable(data.table);
};
window.addEventListener("message", onMessage);
const show = (): void => {
if (started) return;
@@ -38,8 +94,21 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
if (dead) return;
const doc = got.문서 as StructureDoc | null;
if (!doc?.도면) return note(TEXT.none);
root.replaceChildren();
view = mountDrawingView(root, doc, { layer, projectId: null }, column);
stage.replaceChildren();
// 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀
view = mountDrawingTemplate(stage, doc.도면 as unknown as DrawingTemplateDoc, {
readOnly: true,
name: `preview:${column}`,
layer,
panelsCollapsed: true,
originMarker: true,
noArea: true,
frame: true,
noImport: true,
});
frame = stage.querySelector("iframe");
if (frame) hideChrome(frame);
send.disabled = !frame;
})
.catch(() => !dead && note(TEXT.none));
};
@@ -49,6 +118,7 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing {
show,
destroy: () => {
dead = true;
window.removeEventListener("message", onMessage);
view?.destroy();
root.remove();
},
@@ -0,0 +1,111 @@
/* =============================================================================
* M02_MasterTemplete_AreaFromDrawing.ts
* 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를
* 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영.
* 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm).
* 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
const TEXT = {
done: (name: string) =>
`도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`,
loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인",
noHandle: "스프레드시트가 시트 더하기를 아직 못 함 — 옮기지 않음",
noSheet: "면적산출 시트가 열려 있지 않음",
};
/** CAD 가 보낸 표 자료(`DrawingTable`) */
export interface DrawingTable {
kind: "table" | "grid" | "text";
rows: (string | number)[][];
merges: { row: number; col: number; rowSpan: number; colSpan: number }[];
colWidths: number[];
rowHeights: number[];
}
export const SHEET_PREFIX = "도면에서";
/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */
const CHAR_PER_MM = 0.54;
const PT_PER_MM = 72 / 25.4;
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
const round1 = (v: number): number => Math.round(v * 10) / 10;
/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */
function colName(c: number): string {
let s = "";
for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
}
const a1 = (r: number, c: number): string => `${colName(c)}${r + 1}`;
/** 「도면에서 n」 — 안 쓴 가장 작은 n */
export function nextSheetName(book: Pick<Workbook, "시트">): string {
const used = new Set(book.시트.map((s) => s.이름));
let n = 1;
while (used.has(`${SHEET_PREFIX} ${n}`)) n += 1;
return `${SHEET_PREFIX} ${n}`;
}
/** 안 쓴 시트 id(`s1` …) */
function nextSheetId(book: Pick<Workbook, "시트">): string {
const used = new Set(book.시트.map((s) => s.id));
let n = book.시트.length + 1;
while (used.has(`s${n}`)) n += 1;
return `s${n}`;
}
/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */
export function tableToSheet(table: DrawingTable, book: Pick<Workbook, "시트">): Sheet {
const 칸: Record<string, Cell> = {};
table.rows.forEach((line, r) =>
line.forEach((value, c) => {
if (value === "" || value === null || value === undefined) return;
칸[a1(r, c)] = { 값: value };
}),
);
const 병합 = table.merges
.filter((m) => m.rowSpan > 1 || m.colSpan > 1)
.map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`);
const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 };
if (병합.length) sheet.병합 = 병합;
if (table.colWidths.length) {
sheet.열 = {};
table.colWidths.forEach((w, c) => {
if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) };
});
}
if (table.rowHeights.length) {
sheet.행 = {};
table.rowHeights.forEach((h, r) => {
if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) };
});
}
return sheet;
}
/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */
export interface AddSheetHandle {
getDoc(): Workbook;
addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown;
}
/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */
export function importDrawingTable(
handle: AddSheetHandle | null,
table: DrawingTable,
): string | null {
if (!handle?.addSheet) {
showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error");
return null;
}
const sheet = tableToSheet(table, handle.getDoc());
handle.addSheet(sheet, { activate: true });
showToast(TEXT.done(sheet.이름), "success");
if (table.kind === "text") showToast(TEXT.loose, "info");
return sheet.이름;
}
@@ -62,3 +62,27 @@
height: min(60vh, 520px);
min-height: 240px;
}
/* 도면 패널 머리 [면적산출로 보내기](83-3) — 아래 도면 칸이 남은 높이를 다 씀 */
.m02-apanel__drawing:has(> .m02-apanel__drawstage) {
flex-direction: column;
}
.m02-apanel__drawbar {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.m02-apanel__drawstage {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.m02-apanel__drawstage .cad-host {
min-height: 0;
}
@@ -2,7 +2,7 @@
* M02_MasterTemplete_AreaPanel.ts
* 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유)
* · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`)
* · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림)
* · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3)
* · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기)
* · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`)
* ========================================================================== */
@@ -12,6 +12,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels";
import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing";
import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing";
import { openAreaLink } from "./M02_MasterTemplete_AreaLink";
import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View";
import "./M02_MasterTemplete_AreaPanel.css";
@@ -95,10 +96,14 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel {
});
};
const view = createLaborView(openLink);
const drawing = createAreaDrawing(opts.layer, opts.column);
// 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음
const drawing = createAreaDrawing(opts.layer, opts.column, {
onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table),
});
const panels = createSidePanels({
prefKey: "m02-area-panels",
defaultWidth: DEFAULT_WIDTH,
follow: false, // 85-5 — 면적 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
panels: [
{ id: "labor", title: TEXT.labor, body: view.root },
{ id: "drawing", title: TEXT.drawing, body: drawing.root },
@@ -8,6 +8,8 @@
* 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1)
* ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`)
* ④ 표 여러 개 — id 마다 한 줄
* ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id
* (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -108,6 +110,12 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise
return data as AreaTable;
}
/** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */
const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [
["", doc.도면?.entities ?? []],
...(doc.더한장 ?? []).map((s): [string, unknown[]] => [s.장id, s.도면?.entities ?? []]),
];
const isTable = (e: unknown): e is TableEntityJson =>
!!e && typeof e === "object" && (e as { type?: unknown }).type === "Table";
const linkOf = (e: TableEntityJson): string | null => {
@@ -241,7 +249,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
/** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */
const nextId = (doc: StructureDoc, taken: Set<string>): string => {
for (const row of doc.연결표 ?? []) taken.add(row.연결);
for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? "");
for (const [, entities] of sheetsOf(doc))
for (const e of entities) if (isTable(e)) taken.add(linkOf(e) ?? "");
let n = 1;
while (taken.has(`T${n}`)) n += 1;
return `T${n}`;
@@ -323,12 +332,14 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing {
waiting = false;
const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row]));
const rows: TableLink[] = [];
for (const e of doc.도면?.entities ?? []) {
if (!isTable(e)) continue;
const id = linkOf(e);
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
rows.push({ ...row, 값: latest.get(id) ?? row.값 });
for (const [sheet, entities] of sheetsOf(doc)) {
for (const e of entities) {
if (!isTable(e)) continue;
const id = linkOf(e);
const row = id ? (old.get(id) ?? fresh.get(id)) : undefined;
if (!id || !row || rows.some((r) => r.연결 === id)) continue;
rows.push({ ...row, 장: sheet, 값: latest.get(id) ?? row.값 });
}
}
// 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로)
return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc;
@@ -86,3 +86,27 @@
max-width: calc(100vw - 16px);
box-shadow: 0 4px 16px rgb(0 0 0 / 25%);
}
/* 카드 미리보기 「장 n」(82-2) — 1장만 보이는 여러 장 도면 */
.m02-drawing:has(> .m02-drawing__sheets) {
position: relative;
}
.m02-drawing__sheets {
position: absolute;
z-index: 3;
top: 6px;
right: 8px;
padding: 1px 8px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: 11px;
pointer-events: none;
}
/* 탭 줄(82-1) — 그림 칸에 붙임 */
.m02-drawing > .cad-tabs {
margin-bottom: calc(-1 * var(--spacing-8));
}
+197 -18
View File
@@ -13,6 +13,7 @@
import "./M02_MasterTemplete_Drawing.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
@@ -51,13 +52,19 @@ export interface DrawingTemplateOptions {
frame?: boolean;
/** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */
noImport?: boolean;
/** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */
sheet?: string;
/** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */
frameNumber?: string;
/** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */
tabs?: HTMLElement;
}
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
function recoveryScopeOf(options: DrawingTemplateOptions): string {
const name = options.name ?? "drawing";
function recoveryScopeOf(options: DrawingTemplateOptions, sheet = ""): string {
const name = `${options.name ?? "drawing"}${sheet ? `#${sheet}` : ""}`;
if (!options.layer) return `m02:${name}`;
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
return `m02:${options.layer}${project}:${name}`;
@@ -84,6 +91,8 @@ export interface DrawingTemplateHandle {
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
placeTable: (entity: Record<string, unknown>, link: string) => void;
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
/** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */
loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void;
destroy: () => void;
}
@@ -98,18 +107,22 @@ interface StructureFrame {
layer: Record<string, unknown> | null;
}
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
let framePromise: Promise<StructureFrame | null> | null = null;
const framePromises = new Map<string, Promise<StructureFrame | null>>();
/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */
function fetchFrame(): Promise<StructureFrame | null> {
framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromise = null; // 다음에 다시 받음
return frame?.layer ? frame : null;
});
return framePromise;
/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */
function fetchFrame(number = ""): Promise<StructureFrame | null> {
const query = number ? `?number=${encodeURIComponent(number)}` : "";
const got =
framePromises.get(number) ??
fetch(`${API_BASE_URL}/m02/structure-frame${query}`, { credentials: "include" })
.then((response) => (response.ok ? (response.json() as Promise<StructureFrame>) : null))
.catch(() => null)
.then((frame) => {
if (!frame?.layer) framePromises.delete(number); // 다음에 다시 받음
return frame?.layer ? frame : null;
});
framePromises.set(number, got);
return got;
}
/** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */
@@ -147,8 +160,9 @@ export function mountDrawingTemplate(
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const noArea = options.noArea === true;
const recoveryScope = recoveryScopeOf(options);
migrateOldRecovery(options, recoveryScope);
let recoveryScope = recoveryScopeOf(options, options.sheet);
let frameNumber = options.frameNumber ?? "";
migrateOldRecovery(options, recoveryScopeOf(options));
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
@@ -238,7 +252,7 @@ export function mountDrawingTemplate(
const load = async (drawing: DrawingTemplateDoc): Promise<void> => {
const mine = ++loadSeq;
cad.beginLoading();
const frame = options.frame ? await fetchFrame() : null;
const frame = options.frame ? await fetchFrame(frameNumber) : null;
if (mine !== loadSeq) return; // 그새 다른 도면을 실음
const own = withoutFrame(drawing);
const shown = frame?.layer
@@ -319,11 +333,12 @@ export function mountDrawingTemplate(
// 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게.
toolbar.append(...(noArea ? [] : [areaButton]), actions);
const tabs = options.tabs ? [options.tabs] : [];
if (options.headerSlot) {
toolbar.classList.add("m02-drawing__toolbar--slot");
options.headerSlot.append(toolbar);
root.append(cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), cad.element);
root.append(...tabs, cad.element);
} else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element);
container.append(root);
void load(doc);
@@ -331,6 +346,12 @@ export function mountDrawingTemplate(
getDoc,
placeTable: cad.placeTable,
updateTable: cad.updateTable,
loadSheet: (drawing, sheet, number) => {
recoveryScope = recoveryScopeOf(options, sheet);
frameNumber = number ?? "";
base = drawing;
void load(drawing);
},
destroy: () => {
areaModal?.close();
cad.destroy();
@@ -339,3 +360,161 @@ export function mountDrawingTemplate(
},
};
}
/* --- 구조물 도면 여러 장(82-1 · 82-2) — 한 파일 `도면`(1장) + `더한장[]` · 탭 줄 = 공용 `cad_host_tabs` --- */
/** 구조물 도면 더한 장 — `M02_MasterTemplete_Api_Fetch` `StructureSheet` 와 같은 꼴 */
interface SheetDoc {
장id: string;
이름: string;
도면: DrawingTemplateDoc;
}
/** 구조물 도면 문서에서 쓰는 칸 */
interface SheetsDoc {
도번?: string;
도면: DrawingTemplateDoc;
더한장?: SheetDoc[];
}
const FIRST_TAB = "#1"; // 1장 탭 id — 장id 는 「#」 를 안 씀(저장은 빈 값)
const FIRST_NAME = "1장";
/** 장 도번 — 1장 = 도번 · n 째 장 = 도번-n(SD-000010-2) */
export const sheetNumber = (number: string | undefined, index: number): string =>
!number ? "" : index === 0 ? number : `${number}-${index + 1}`;
/** 표 칸 갈기 — 안 실린 장(받아 둔 편집본)에서 · 크기는 칸 수가 바뀔 때만(서버 `refresh_drawing` 과 같음) */
function updateStashedTables(
drawing: DrawingTemplateDoc,
link: string,
cells: unknown[][],
): number {
let count = 0;
for (const entity of drawing.entities) {
const shape = (entity as { type?: string; shapeData?: Record<string, unknown> }).shapeData;
const style = shape?.style as Record<string, unknown> | undefined;
if (entity.type !== "Table" || !shape || style?.연결 !== link) continue;
shape.cells = cells;
count += 1;
}
return count;
}
export interface StructureSheetsHandle extends Omit<DrawingTemplateHandle, "getDoc" | "loadSheet"> {
/** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */
getDoc: () => Promise<{ 도면: DrawingTemplateDoc; 더한장?: SheetDoc[] }>;
/** 지금 탭 장id(1장 = 빈 값) */
activeSheet: () => string;
}
/** 구조물 도면 상세 — 탭 줄 + 한 CAD · 탭 바꾸면 지금 장을 받아 두고 다음 장을 실음 */
export function mountStructureSheets(
container: HTMLElement,
doc: SheetsDoc,
options: DrawingTemplateOptions = {},
): StructureSheetsHandle {
const readOnly = options.readOnly === true;
const more = doc.더한장 ?? [];
let cadDirty = false;
let listDirty = false;
const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty());
const numberOf = (id: string): string =>
sheetNumber(
doc.도번,
tabs.tabs().findIndex((tab) => tab.id === id),
);
const tabs: CadTabs<DrawingTemplateDoc> = createCadTabs<DrawingTemplateDoc>({
features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true },
capture: async () => ({ drawing: await drawing.getDoc(), dirty: cadDirty }),
show: (tab) => {
cadDirty = false;
drawing.loadSheet(
tab.drawing ?? { entities: [], layers: [BLANK_LAYER] },
tab.id === FIRST_TAB ? "" : tab.id,
numberOf(tab.id),
);
report();
},
create: (): CadTab<DrawingTemplateDoc> => {
const names = new Set(tabs.tabs().map((tab) => tab.name));
let n = tabs.tabs().length + 1;
while (names.has(`${n}장`)) n += 1;
const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return {
id,
name: `${n}장`,
drawing: { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
};
},
onChange: () => {
listDirty = true;
retitle();
report();
},
});
const retitle = (): void => {
for (const tab of tabs.tabs()) tab.title = numberOf(tab.id);
};
tabs.set(
[
{ id: FIRST_TAB, name: FIRST_NAME, drawing: doc.도면, fixed: true },
...more.map((sheet) => ({ id: sheet.장id, name: sheet.이름, drawing: sheet.도면 })),
],
FIRST_TAB,
);
retitle();
tabs.set(tabs.tabs(), FIRST_TAB); // 풍선 글을 그림
if (readOnly && !more.length) tabs.element.hidden = true; // 볼 장이 하나 — 줄 없음
const drawing = mountDrawingTemplate(container, doc.도면, {
...options,
sheet: "",
frameNumber: doc.도번,
tabs: tabs.element,
onChanged: (dirty) => {
cadDirty = dirty;
if (dirty && !readOnly) tabs.markDirty();
report();
},
});
return {
getDoc: async () => {
const all = await tabs.collect();
const first = all.find((tab) => tab.id === FIRST_TAB);
const rest = all
.filter((tab) => tab.id !== FIRST_TAB)
.map((tab) => ({
장id: tab.id,
이름: tab.name,
도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] },
}));
listDirty = false;
tabs.markClean(undefined, true);
return {
도면: first?.drawing ?? doc.도면,
...(rest.length || doc.더한장 ? { 더한장: rest } : {}),
};
},
activeSheet: () => {
const id = tabs.active()?.id ?? FIRST_TAB;
return id === FIRST_TAB ? "" : id;
},
placeTable: drawing.placeTable,
// 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3)
updateTable: async (link, cells) => {
const now = tabs.active();
let count = 0;
for (const tab of tabs.tabs()) {
if (tab === now || !tab.drawing) continue;
const changed = updateStashedTables(tab.drawing, link, cells);
if (changed) tab.dirty = true;
count += changed;
}
if (count) report();
return count + (await drawing.updateTable(link, cells).catch(() => 0));
},
destroy: drawing.destroy,
};
}
@@ -82,11 +82,21 @@ function mountStructure(
originMarker: true, // 좌표 0,0 기준점 십자
noArea: true, // 작도 영역 칸 없음
frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장)
frameNumber: doc.도번, // 도각 `{{도면번호}}` = 1장 도번(82-2)
noImport: true, // 파일 불러오기는 도면 상세에서만(47-2)
...hooks,
});
const frame = host.querySelector("iframe");
if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW);
// 여러 장(82-2) — 미리보기는 1장 · 장 수만 알림(다른 장은 도면 상세 탭에서)
const sheets = 1 + (doc.더한장?.length ?? 0);
if (sheets > 1) {
const badge = document.createElement("span");
badge.className = "m02-drawing__sheets";
badge.textContent = `장 ${sheets}`;
badge.title = `1장을 보는 중 · 모두 ${sheets}장 — 다른 장은 도면 상세에서`;
host.querySelector(".m02-drawing")?.append(badge);
}
return handle;
}
@@ -12,6 +12,7 @@
* ========================================================================== */
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type {
@@ -23,7 +24,6 @@ import {
readTemplate,
saveTemplate,
type StructureDoc,
type StructureNumbers,
} from "./M02_MasterTemplete_Api_Fetch";
import {
cardShell,
@@ -47,7 +47,7 @@ export type EditKind = ItemPart;
/** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */
export type CardKind = EditKind | "labor";
export type SheetKind = Exclude<EditKind, "structure">;
export const PART_FIELD: Record<CardKind, keyof StructureNumbers> = {
export const PART_FIELD: Record<CardKind, "도번" | "면적번" | "일위대가번"> = {
structure: "도번",
labor: "일위대가번",
area: "면적번",
@@ -68,7 +68,7 @@ const TEXT = {
noFieldNumber: "{value} 없음",
noSheet: "스프레드시트를 못 띄움 — {value}",
readFailed: "{value} 파일을 못 읽음 — {why}",
discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?",
discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
pills: "알약",
pillsTip: "아래 알약 줄 보임 · 숨김",
take: "가져오기",
@@ -99,6 +99,8 @@ export interface ItemCardsOptions {
extraCards?: HTMLElement[];
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
onDirty?: (dirty: boolean) => void;
/** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */
onSaved?: () => void;
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
sheetEdit?: SheetEditOptions;
/** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */
@@ -179,6 +181,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? "";
const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());
const saveAll = async (): Promise<void> => {
for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error();
};
const reload = (): void => void load(column ? { id: column, label } : null, true);
/** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */
@@ -191,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
const no = el("span", { className: "m02-struct__no" });
const name = el("span", { className: "m02-struct__title" });
const shell = cardShell({ title, body, sub: [no, name] });
const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty()));
const saver = saveControl(
shell,
async () => {
await save();
opts.onSaved?.();
},
() => opts.onDirty?.(anyDirty()),
);
const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1)
const edit = createButton({
label: TEXT.edit,
@@ -461,7 +473,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
async function load(col: { id: string; label: string } | null, again = false): Promise<void> {
// 카드 고침을 저장 안 했으면 버릴지 물음(한 번) — 취소면 보던 열 그대로
if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(TEXT.discard)))
if (
!again &&
anyDirty() &&
col?.id !== column &&
(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === "stay"
)
return;
const mine = ++seq;
drop();
@@ -530,9 +547,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
},
column: () => column,
dirty: anyDirty,
saveAll: async () => {
for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error();
},
saveAll,
discard: () => {
drop();
opts.onDirty?.(false);
@@ -90,7 +90,33 @@
white-space: nowrap;
}
/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
.m02-labrow__order {
display: inline-flex;
align-items: center;
gap: 2px;
}
.m02-labrow__arrow.ui-btn {
min-width: 0;
height: 20px;
padding: 0 5px;
font-size: 10px;
line-height: 1;
}
.m02-labrow__grip {
padding: 0 4px;
color: var(--color-text-secondary);
cursor: grab;
user-select: none;
}
.m02-labrow__row.is-dragover td {
box-shadow: inset 0 2px 0 var(--color-primary);
}
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
.m02-labrow__tools {
display: inline-flex;
flex-direction: column;
@@ -129,6 +129,8 @@ export interface LaborRow {
메모: string | null;
입력값: Record<string, string | number>;
수량: LaborQty;
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
켜쌓기율?: number | null;
}
export interface LaborMat {
코드: string;
@@ -164,6 +166,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
입력값: r.입력값 ?? {},
수량: r.수량 ?? null,
...(r.참조 ? { 참조: r.참조 } : {}),
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
})),
자재: (d.자재 ?? []).map((m) => ({
코드: m.코드,
@@ -131,12 +131,17 @@ export function mountLaborDetail(
});
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
const source = createSourceView();
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
const source = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = opt.card
? null
: createSidePanels({
prefKey: "m02-labor-side",
follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
onChange: () => showSource(),
});
@@ -186,7 +191,7 @@ export function mountLaborDetail(
option: openRow,
ref: openRef,
qty: openQty,
...(opt.card ? {} : { move }),
move: reorder,
remove: removeRow,
alias: setAlias,
}
@@ -310,10 +315,10 @@ export function mountLaborDetail(
}
};
function move(i: number, d: number): void {
const j = i + d;
if (j < 0 || j >= cur.담은로직.length) return;
[cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]];
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */
function reorder(from: number, to: number): void {
if (from === to || to < 0 || to >= cur.담은로직.length) return;
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
changed();
}
/** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) ·
@@ -347,13 +352,18 @@ export function mountLaborDetail(
logic,
values: row.입력값,
memo: row.메모,
rate: row.켜쌓기율 ?? null,
readOnly: !edit,
done: (next) => {
const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
const same = JSON.stringify(values) === JSON.stringify(row.입력값);
const same =
JSON.stringify(values) === JSON.stringify(row.입력값) &&
next.켜쌓기율 === (row.켜쌓기율 ?? null);
row.입력값 = values;
row.메모 = next.메모;
changed(same); // 입력값이 그대로면(메모만) 서버 안 부름
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름
},
});
}
@@ -98,7 +98,7 @@ export interface LaborDiagramSource {
로직: string;
이름: string;
결과단위?: string;
입력: { 이름: string; 단위?: string; 자동값?: unknown }[];
입력: { 이름: string; 단위?: string; 자동값?: unknown; 고르기이름?: Record<string, string> }[];
}[];
/** calc 답 · 아직이면 null */
calc: {
@@ -137,10 +137,11 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData {
inputs: (def?.입력 ?? []).map((x) => {
const mine = r.입력값[x.이름];
const has = mine !== undefined && mine !== "";
const raw = has ? String(mine) : autoOf(x.자동값);
return {
name: x.이름,
unit: x.단위 ?? "",
value: has ? String(mine) : autoOf(x.자동값),
value: x.고르기이름?.[raw] ?? raw, // 85-1 원문번호 → 「이름 규격」
...(has || !autoOf(x.자동값) ? {} : { auto: true }),
};
}),
@@ -39,6 +39,17 @@ function footer(
export interface LaborInputResult {
입력값: LaborValues;
메모: string | null;
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 빈 칸 · 100 = null) */
켜쌓기율: number | null;
}
/** 켜쌓기율 칸 글 → 값 — 빈 글 · 100 = null · 0 초과 200 이하 · 소수 한 자리 밖 = NaN */
export function layerRate(raw: string): number | null {
if (raw.trim() === "") return null;
const v = Number(raw);
const ok =
Number.isFinite(v) && v > 0 && v <= 200 && Math.abs(v * 10 - Math.round(v * 10)) < 1e-9;
return !ok ? NaN : v === 100 ? null : v;
}
export interface LaborInputOptions {
@@ -48,6 +59,8 @@ export interface LaborInputOptions {
logic: ItemLogic | null;
values: LaborValues;
memo: string | null;
/** 켜쌓기율(85-2) — 로직이 대상일 때만 칸 */
rate?: number | null;
readOnly: boolean;
done: (next: LaborInputResult) => void;
/** 머리 한 줄 알림(51-14 · 서고 원본 고침) */
@@ -100,9 +113,27 @@ export function openLaborInput(opt: LaborInputOptions): void {
}) as HTMLInputElement;
memo.value = opt.memo ?? "";
memo.readOnly = opt.readOnly;
const rate = opt.logic?.켜쌓기
? (el("input", {
className: "ui-input m02-inputval__control",
attrs: {
type: "number",
min: "0.1",
max: "200",
step: "0.1",
placeholder: "100",
"data-labor-layer": "",
},
}) as HTMLInputElement)
: null;
if (rate) {
rate.value = opt.rate == null ? "" : String(opt.rate);
rate.readOnly = opt.readOnly;
}
const ok = (): void => {
if (bad.size) return showToast(tc("In_Bad"), "error");
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null });
const layer = rate ? layerRate(rate.value) : null;
if (bad.size || Number.isNaN(layer)) return showToast(tc("In_Bad"), "error");
opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 켜쌓기율: layer });
close();
};
body.append(
@@ -129,6 +160,21 @@ export function openLaborInput(opt: LaborInputOptions): void {
...(rows.length
? rows
: [el("p", { className: "m02-labin__muted", text: tc("In_None") })]),
...(rate
? [
el("div", {
className: "m02-inputval__row",
children: [
el("span", {
className: "m02-inputval__name",
text: tc("In_Layer"),
attrs: { title: tc("In_LayerHint") },
}),
rate,
],
}),
]
: []),
el("div", {
className: "m02-inputval__row",
children: [
@@ -56,12 +56,15 @@ export async function openRefEdit(opt: RefEditOptions): Promise<void> {
logic,
values: row.입력값,
memo: row.메모,
rate: row.켜쌓기율 ?? null,
readOnly: opt.readOnly,
notice,
okLabel: tc("Save"),
done: (next) => {
row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값;
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
void save(doc)
.catch((error) => fail(error, "Lib_SaveFailed"))
.finally(opt.closed);
@@ -148,6 +148,9 @@ 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;
@@ -179,7 +182,8 @@ export interface RowHandlers {
ref?: (i: number) => void;
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
qty?: (i: number) => void;
move?: (i: number, d: number) => void;
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
move?: (from: number, to: number) => void;
remove?: (i: number) => void;
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
alias?: (i: number, value: string | null) => void;
@@ -307,7 +311,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
// 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정)
const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? "");
const values = valueLine(logic?.입력 ?? [], row.입력값);
const note = values.text;
const layer =
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 small = (
label: string,
@@ -322,6 +328,32 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
run();
},
});
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
const arrow = (mark: string, label: string, to: number): HTMLElement => {
const b = small(mark, () => move?.(i, to));
b.classList.add("m02-labrow__arrow");
b.title = label;
b.setAttribute("aria-label", label);
if (to < 0 || to >= data.rows.length) (b as HTMLButtonElement).disabled = true;
return b;
};
const grip = (): 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;
});
return g;
};
const sel = data.selected === id ? " is-selected" : "";
const tr = el("tr", {
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
@@ -371,10 +403,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
children: [
...(move
? [
group([
small(tc("Up"), () => move(i, -1)),
small(tc("Down"), () => move(i, 1)),
]),
el("div", {
className: "m02-labrow__order",
children: [
grip(),
arrow("▲", tc("Up"), i - 1),
arrow("▼", tc("Down"), i + 1),
],
}),
]
: []),
group([
@@ -401,6 +437,21 @@ 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) · 행 높이 안 커짐
@@ -33,6 +33,7 @@ const TEXT = {
Col_Move: ["순서", "Move"],
Up: ["위로", "Up"],
Down: ["아래로", "Down"],
Labor_Drag: ["끌어 순서 바꿈", "Drag to reorder"],
Remove: ["삭제", "Delete"],
Labor_Option: ["옵션", "Options"],
Labor_Edit: ["수정", "Edit"],
@@ -112,6 +113,12 @@ const TEXT = {
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
In_Memo: ["메모", "Memo"],
In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"],
In_LayerHint: [
"0 초과 200 이하 · 소수 한 자리 · 빈 칸 = 100",
"0 < rate ≤ 200 · 1 decimal · empty = 100",
],
Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"],
In_QtyCell: ["면적산출 칸", "Area sheet cell"],
In_QtyEmpty: ["빈 칸", "empty cell"],
In_QtyHand: ["손 입력", "Manual"],
@@ -7,14 +7,19 @@
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from common_util.common_util_auth import verify_session
from common_util.common_util_http_cache import cached_file_response
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
@@ -100,3 +105,27 @@ def get_logic_auto(key: str) -> dict:
def get_logic_inputs(keys: str = "") -> dict:
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
return _read(labor_inputs.logic_inputs, keys.split(","))
#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
SOURCE_PDF = {
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
"산림": make.BODY / "산림_표준품셈" / "첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
}
@router.get("/logic-lib/source", dependencies=[Depends(verify_session)])
def get_logic_source(src: str) -> dict:
"""원문 패널 — 출처 글로 찾은 품셈 본문 절(M01 `/logic/source` 와 같은 몸 · 로그인만)."""
return _read(make.source, src)
@router.get("/logic-lib/source-pdf", dependencies=[Depends(verify_session)])
def get_source_pdf(request: Request, book: str) -> Response:
"""원문 pdf 통째 내려받기 — `book` = 건설 | 산림(그 밖 400)."""
path = SOURCE_PDF.get(book)
if path is None:
raise HTTPException(status_code=400, detail="책은 건설 · 산림 중 하나")
if not path.is_file():
raise HTTPException(status_code=404, detail="원문 pdf 없음")
return cached_file_response(request, path, "application/pdf", filename=path.name)
@@ -104,15 +104,19 @@ def _on_frame_layer(entity: dict) -> dict:
}
def structure_frame() -> dict:
"""구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층."""
def structure_frame(number: str | None = None) -> dict:
"""구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층 ·
`number` = 장 도번(82-2 · 도각 `{{도면번호}}`)."""
fields = {"도면번호": number} if number else None
placed = frame_entities("m02-structure", usable_bbox(), fit=False)
box = entities_bbox(placed)
if not box:
return {"entities": [], "layer": None}
ox, oy = box[0], box[1]
x0, y0, x1, y1 = usable_bbox()
entities = frame_entities("m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False)
entities = frame_entities(
"m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False, fields=fields
)
return {
"entities": [_on_frame_layer(entity) for entity in entities],
"layer": {
@@ -125,9 +129,9 @@ def structure_frame() -> dict:
@router.get("/structure-frame")
async def get_structure_frame() -> JSONResponse:
"""구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장)."""
return JSONResponse({"status": "success", **structure_frame()})
async def get_structure_frame(number: str | None = None) -> JSONResponse:
"""구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장) · `number` = 장 도번(82-2)."""
return JSONResponse({"status": "success", **structure_frame(number)})
@router.get("/drawing-fields")
@@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀)
if body.get("op") not in ("move", "delete") or not targets:
raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함")
doc, note = got["문서"], {"op": body["op"], "지운항목": []}
doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []}
for one in paste_store.outer(targets):
step = {**body, "경로": one.get("경로"), "키": one.get("키")}
doc, done = tree.apply(doc, step, ids)
note["지운항목"] += done.get("지운항목", [])
note["바뀐이름"] += done.get("바뀐이름", [])
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
@@ -267,7 +268,7 @@ def post_paste(
session: Session = Depends(verify_session),
):
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 ·
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림."""
target = _root(session, layer, owner, write=True)
source = _root(session, body.from_layer, body.from_owner)
who = access.grant(session, body.from_layer, body.from_owner).owner
+29 -3
View File
@@ -226,13 +226,39 @@ def _codes() -> dict[str, str]:
return codes
def structure_numbers() -> dict[str, dict[str, str]]:
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
out: dict[str, dict[str, str]] = {}
def _total(sums: Any) -> float | None:
"""`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None."""
total = sums.get("계") if isinstance(sums, dict) else None
if total is None or isinstance(total, bool):
return None
try:
return float(total)
except (TypeError, ValueError):
return None
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
if not doc.get("담은로직"):
return {}
calc = doc.get("계산값") or {}
if total := _total(calc.get("반영합")):
return {"일위대가합계": total, "합계기준": False}
base = _total(calc.get("기준합"))
return {} if base is None else {"일위대가합계": base, "합계기준": True}
def structure_numbers() -> dict[str, dict[str, Any]]:
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
out: dict[str, dict[str, Any]] = {}
for kind, key, _, _ in PARTS:
for name, doc in _docs(kind):
row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS})
row[key] = str(doc.get(key) or "")
if kind == "labor":
row.update(_labor_sum(doc))
return out
@@ -14,7 +14,8 @@
* 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식).
* ========================================================================== */
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { showToast } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import {
clearPart,
@@ -57,6 +58,7 @@ const TEXT = {
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
pillsFailed: "일위대가 알약을 못 받음 — {value}",
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
baseNote: "(기준)",
};
/** 집계표 줄 열쇠 → 문서 */
@@ -72,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
export type ColumnNumbers = Record<string, StructureNumbers>;
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
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;
}
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
export interface StructureColumn {
id: string;
@@ -182,6 +191,8 @@ export interface StructurePanelOptions {
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
* (페이지의 `open`) · label 은 화면 제목에 씀 */
onEdit: (column: string, label: string, kind: CardKind) => void;
/** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */
onNumbers?: () => void;
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
@@ -220,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
editable: () => opts.isAdmin,
onEdit: (kind) => void openEdit(kind),
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
// 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3)
onSaved: () =>
void fetchStructureNumbers().then(
(got) => {
numbers = got;
opts.onNumbers?.();
},
() => undefined,
),
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
warnOnFail: (id) => !!numbers[id],
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
@@ -252,9 +272,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
if ((col?.id ?? null) !== column && cards.dirty()) {
// 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림
const from = column;
void showConfirmDialog(TEXT.discardCards).then((ok) => {
if (!ok) return void (from && opts.keepColumn?.(from));
cards.discard(); // 한 번 물었음 — 카드가 다시 묻지 않게
void leaveOrStay(TEXT.discardCards, {
save: cards.saveAll,
stillDirty: cards.dirty,
discard: cards.discard, // 한 번 물었음 — 카드가 다시 묻지 않게
}).then((got) => {
if (got === "stay") return void (from && opts.keepColumn?.(from));
void load(col);
});
return;
@@ -272,7 +295,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
const toNo = to && numbers[to.id]?.[PART_FIELD[kind]];
if (!to || !toNo) return void showToast(TEXT.saveFirst, "error");
// 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음
if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) return;
if (
cards.dirty() &&
(await leaveOrStay(TEXT.discardCards, { save: cards.saveAll, stillDirty: cards.dirty })) ===
"stay"
)
return;
const target = `${toNo} ${columnHead(to)}`;
const here = { layer: getActiveLayer(), owner: getActiveOwner() };
const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner;
+21 -5
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import copy
import re
from collections.abc import Callable
from typing import Any
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
@@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]:
"""겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀."""
out, n = path, 1
while busy(out):
out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1
return out
def _set_path(col: dict[str, Any], path: list[str]) -> None:
col["경로"] = path
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
@@ -272,21 +281,28 @@ def apply(
to = _path(body.get("새경로"), "새경로")
if key:
col = _find(out, key, ids)
moved = [*to, path_of(col)[-1]]
_taken_item(out, moved, [col])
want = [*to, path_of(col)[-1]]
taken = _item_paths(out, [col])
moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」
out["열"] = [c for c in out["열"] if c is not col]
_set_path(col, moved)
_place(out, col)
note["경로"] = moved
if moved != want:
note["바뀐이름"] = [moved[-1]]
else:
_folder(out, where, "옮길")
if _under(to, where):
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
moved = [*to, where[-1]]
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
folders_now, paths_now = folders_of(out), _item_paths(out)
moved = unique_path(
[*to, where[-1]],
lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)),
)
_move_folder(out, where, moved, replace=True)
note["경로"] = moved
if moved[-1] != where[-1]:
note["바뀐이름"] = [moved[-1]]
else: # delete
if key:
gone = [_find(out, key, ids)]
@@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
}
picked = new Set();
paint();
showToast(L("M02_TreeDone"), "success");
const renamed = note?.바뀐이름 ?? [];
showToast(
renamed.length
? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「"))
: L("M02_TreeDone"),
"success",
);
await opt.onChanged(col);
} catch (error) {
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_MasterTemplete_TreePanel_Dialogs.ts
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음.
* 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기.
* 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel").
* ========================================================================== */
@@ -183,36 +183,3 @@ export function askFolder(
});
});
}
export type Settle = "save" | "discard" | "cancel";
/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */
export function askSettle(): Promise<Settle> {
return new Promise((resolve) => {
let done = false;
const finish = (value: Settle): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: L("M02_TreeUnsaved"),
closeLabel: L("M02_TreeCancel"),
dialogClass: "m02-modal m02-treedlg",
beforeClose: () => (finish("cancel"), true),
mount: (body, close) => {
const choose = (value: Settle) => () => {
finish(value);
close();
};
const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" });
const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" });
const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" });
save.addEventListener("click", choose("save"));
discard.addEventListener("click", choose("discard"));
cancel.addEventListener("click", choose("cancel"));
body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] }));
},
});
});
}
@@ -6,6 +6,7 @@
import { setLeaveGuard } from "../A00_Common/router";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
@@ -21,12 +22,12 @@ import {
comboMerge,
comboView,
createStructurePanel,
laborSumText,
pillsFor,
STRUCTURE_TABLE,
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
@@ -202,19 +203,26 @@ export function createMain(
event.returnValue = "";
};
window.addEventListener("beforeunload", guard);
const confirmLeave = async (): Promise<boolean> => {
if (!busy()) return true; // 카드 고침(도면 · 엑셀)도 같은 물음
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) {
// 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로
host
.querySelector<HTMLElement>(".ss-input, .ui-sheet, iframe")
?.focus({ preventScroll: true });
return false;
}
dirty = false;
toDrawing.drop();
return true;
/**
* 저장 안 한 고침 물음(떠나기 · 나무 고치기 한 몸) — 단추 셋 [취소] [버리고 넘어감] [저장 후 넘어감].
* "clean" = 고침 없음 · "saved" · "discarded" · false = 머묾(취소 · 저장 실패 · 판 다름 · 카드 저장 실패).
* 열린 양식이 없는 화면(옵션 정의 등)은 저장할 것이 없어 2단추 그대로.
*/
const askUnsaved = async (message: string): Promise<false | "clean" | "saved" | "discarded"> => {
if (!busy()) return "clean"; // 카드 고침(도면 · 엑셀)도 같은 물음
const drop_ = (): void => {
dirty = false;
toDrawing.drop();
};
let got: "saved" | "discarded" | "stay";
if (sel) got = await leaveOrStay(message, { save: doSave, stillDirty: busy, discard: drop_ });
else got = (await showConfirmDialog(message)) ? (drop_(), "discarded") : "stay";
if (got !== "stay") return got;
// 머묾 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로
host.querySelector<HTMLElement>(".ss-input, .ui-sheet, iframe")?.focus({ preventScroll: true });
return false;
};
const confirmLeave = async (): Promise<boolean> => !!(await askUnsaved(L("M02_DiscardConfirm")));
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
const showNotice = (nodes: (HTMLElement | string)[]): void => {
@@ -255,6 +263,7 @@ export function createMain(
isAdmin,
confirmLeave,
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3)
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
void open({ layer, projectId: null, kind, name: column, label });
@@ -287,7 +296,8 @@ export function createMain(
doc as Parameters<typeof createSpreadsheet>[1],
{
readOnly,
tabs: false, // 시트 하나 · 탭 줄 없음
// 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄
tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1,
onChange: () => {
dirty = !readOnly;
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
@@ -350,6 +360,7 @@ export function createMain(
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
combo: {
...(readOnly || !extras.onHeadRename
? {}
@@ -416,14 +427,13 @@ export function createMain(
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
return mountSheet(doc, at.kind, at, readOnly);
}
// 구조물 도면 = 여러 장 탭(82-1 · `도면` 1장 + `더한장`) — 도번은 안 건드림
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
DRAWING_MODS,
"mountDrawingTemplate",
at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate",
);
if (mountDrawing) {
// 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번은 안 건드림
const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc;
editor = mountDrawing(host, cad, {
editor = mountDrawing(host, doc, {
onSave: () => void doSave(),
readOnly,
name: at.name,
@@ -504,13 +514,13 @@ export function createMain(
const at = sel;
try {
const edited = editor ? await editor.getDoc() : loaded;
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로.
// 구조물 도면 저장은 `도면` · `더한장` 칸만 갈아 끼움 — 도번은 읽은 그대로.
// 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임.
const doc =
at.kind === "structure" && loaded
? toDrawing.withLinks({
...(loaded as StructureDoc),
도면: edited as StructureDoc["도면"],
...(edited as Pick<StructureDoc, "도면" | "더한장">),
})
: at.kind === "area" && loaded
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
@@ -573,15 +583,9 @@ export function createMain(
}
async function settle(): Promise<false | "clean" | "changed"> {
if (!busy()) return "clean";
const choice = await askSettle();
if (choice === "cancel") return false;
if (choice === "save") {
await doSave();
return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침
}
dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음
if (sel) await open(sel);
const got = await askUnsaved(L("M02_TreeUnsaved"));
if (got === false || got === "clean") return got;
if (got === "discarded" && sel) await open(sel); // 버림 — 화면을 서버 그대로 다시 읽음
return "changed";
}
@@ -152,6 +152,30 @@
min-width: 0;
}
/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */
.m02-main:has(> .m02-main__host > .ui-side-panels-row) {
box-sizing: border-box;
height: calc(100vh - var(--spacing-64));
min-height: 0;
}
.m02-main__host:has(> .ui-side-panels-row) {
display: flex;
flex: 1 1 0;
min-height: 0;
}
.m02-main__host > .ui-side-panels-row {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.m02-main__host > .ui-side-panels-row > :first-child {
min-height: 0;
overflow: auto;
}
.m02-main__empty {
margin: 0;
padding: var(--spacing-24);

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