Merge remote-tracking branch 'origin/CODEX' into CODEX
This commit is contained in:
@@ -133,6 +133,8 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||
},
|
||||
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
|
||||
"pipe-ids": { bucket: "draft", scope: "project" },
|
||||
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
||||
selection: { bucket: "draft", scope: "project" },
|
||||
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
||||
import type { DesignView } from "./design_dock";
|
||||
import type { DesignPageShared } from "./design_shared";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import "./design_page.css";
|
||||
|
||||
export type { DesignPageShared } from "./design_shared";
|
||||
@@ -105,6 +106,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
page.className = "design-page";
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||
|
||||
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
|
||||
const left = document.createElement("div");
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pick_match.ts
|
||||
* 고름 강조 한 규칙(323-5) — 그린 것(카드 형상 · 3D 부재 · 알약 · 측점선)마다 신원표 `{구조물 id · 부분 · 자리}`
|
||||
* 를 달고, 고름 한 칸(`structure-pick`)과 이 함수 하나로 켜짐을 가름.
|
||||
*
|
||||
* ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로)
|
||||
* ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값)
|
||||
* ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두
|
||||
* DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
|
||||
* 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」).
|
||||
* ========================================================================== */
|
||||
|
||||
import { isNear, type DesignSelection } from "./design_selection";
|
||||
|
||||
export interface PickTag {
|
||||
structureId?: string | null;
|
||||
part?: string | null;
|
||||
chainageM?: number | null;
|
||||
}
|
||||
|
||||
export type PickSelection =
|
||||
(Partial<DesignSelection> & { partKey?: string | null }) | null | undefined;
|
||||
|
||||
/** 이 그림이 고름에 켜지나 — 위 ① ② ③. */
|
||||
export function pickLights(selection: PickSelection, tag: PickTag): boolean {
|
||||
if (!selection) return false;
|
||||
let here: boolean;
|
||||
if (selection.structureId && tag.structureId) here = selection.structureId === tag.structureId;
|
||||
else if (selection.chainageM == null || tag.chainageM == null) return false;
|
||||
else here = isNear(tag.chainageM, selection.chainageM);
|
||||
if (!here) return false;
|
||||
return !selection.partKey || tag.part === selection.partKey;
|
||||
}
|
||||
|
||||
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
|
||||
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
|
||||
if (structureId) el.setAttribute("data-structure-id", structureId);
|
||||
if (part) el.setAttribute("data-part", part);
|
||||
}
|
||||
|
||||
/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) ·
|
||||
* 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */
|
||||
export function paintPicked(root: ParentNode, selection: PickSelection): void {
|
||||
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||
const id = shape.getAttribute("data-structure-id") ?? "";
|
||||
if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;
|
||||
if (shape.getAttribute("data-pave-type") !== null) continue;
|
||||
shape.classList.toggle(
|
||||
"is-active",
|
||||
!!selection?.structureId && pickLights(selection, { structureId: id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
|
||||
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
|
||||
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
|
||||
* 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다.
|
||||
* 그릴 때마다 지금 관 목록에서 다시 찾고 관 id 는 세션 id(323-5)라 관이 옮겨져도 끊기지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DesignSelection,
|
||||
} from "./design_selection";
|
||||
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
|
||||
import { pipeIdAt } from "./design_pipe_ids";
|
||||
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
|
||||
@@ -68,9 +69,7 @@ export function profilePickOf(
|
||||
);
|
||||
if (own) return { stationId: irregularStationId(own.id) };
|
||||
const target = graphTargetAt(null, at, source.pipes(), source.sections());
|
||||
return "pipeChainageM" in target
|
||||
? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` }
|
||||
: target;
|
||||
return "pipeChainageM" in target ? { structureId: pipeIdAt(target.pipeChainageM) } : target;
|
||||
}
|
||||
|
||||
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
|
||||
@@ -91,7 +90,9 @@ export function paintMapPick(
|
||||
export interface ViewerPickTarget {
|
||||
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
|
||||
structurePick: {
|
||||
select: (pick: { chainageM: number; kind: string; key?: string } | null) => void;
|
||||
select: (
|
||||
pick: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +101,10 @@ export interface ViewerPickTarget {
|
||||
export function viewerPickOf(
|
||||
selection: (DesignSelection & { partKey?: string }) | null,
|
||||
source: ProfilePickSource,
|
||||
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } {
|
||||
): {
|
||||
stationId: string | null;
|
||||
member: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null;
|
||||
} {
|
||||
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
|
||||
const at = selection.chainageM;
|
||||
const pick = profilePickOf(selection, source);
|
||||
@@ -108,7 +112,10 @@ export function viewerPickOf(
|
||||
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
|
||||
: undefined;
|
||||
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
|
||||
return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } };
|
||||
return {
|
||||
stationId,
|
||||
member: { chainageM: at, kind: "", key: selection.partKey, structureId: selection.structureId },
|
||||
};
|
||||
}
|
||||
|
||||
export function paintViewerPick(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pipe_ids.ts
|
||||
* 관 세션 id(323-5) — 관을 처음 볼 때 자리마다 붙이는 id · 옮겨도 그대로 · 저장 안 함(파일 모양 그대로).
|
||||
*
|
||||
* id = `pipe-s` + 차례(`pipe-s3`) — 머리 `pipe-` 로 관임을 가름(옛 자리 이름 `pipe-{누가거리}` 와 같은 머리) ·
|
||||
* 누가거리는 id 에서 읽지 않음(`pipeChainageOf`). 장부 = 세션 `pipe-ids`(고름 `structure-pick` 과 같은 수명 ·
|
||||
* 초안 비우기에 함께 비움). 자리 → id = 찾는 거리(`PIPE_MATCH_M`) 안 가장 가까운 것 · 없으면 새 차례 ·
|
||||
* 옮김 = `movePipeId`(고름 따라가기 `followMovedPipe` 가 부름).
|
||||
* 프로젝트 = 보기가 열 때 정함(`usePipeIds`) — 그리는 함수(카드 · 알약)가 프로젝트를 몰라도 같은 id.
|
||||
* 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection";
|
||||
|
||||
/** 관 찾는 거리 — 정의는 `design_selection` 한 곳(배수 패널이 관 옮김과 함께 가져감). */
|
||||
export { PIPE_MATCH_M } from "./design_selection";
|
||||
|
||||
interface Book {
|
||||
next: number;
|
||||
ids: Array<{ id: string; at: number }>;
|
||||
}
|
||||
|
||||
let active: string | null = null;
|
||||
|
||||
/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */
|
||||
export function usePipeIds(projectId: string | null): void {
|
||||
active = projectId;
|
||||
}
|
||||
|
||||
const bookOf = (projectId: string): Book =>
|
||||
readState<Book>("pipe-ids", projectId) ?? { next: 1, ids: [] };
|
||||
|
||||
/** 그 자리 관의 id — 장부에 없으면 새 차례로 적음. */
|
||||
export function pipeIdAt(chainageM: number, projectId: string | null = active): string {
|
||||
if (!projectId) return `pipe-${chainageM.toFixed(2)}`;
|
||||
const book = bookOf(projectId);
|
||||
const hit = nearestWithin(book.ids, chainageM, (entry) => entry.at);
|
||||
if (hit) return hit.id;
|
||||
const id = `pipe-s${book.next}`;
|
||||
writeState(
|
||||
"pipe-ids",
|
||||
{ next: book.next + 1, ids: [...book.ids, { id, at: chainageM }] },
|
||||
projectId,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
|
||||
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
|
||||
if (!id.startsWith("pipe-")) return null;
|
||||
const hit = projectId ? bookOf(projectId).ids.find((entry) => entry.id === id) : undefined;
|
||||
if (hit) return hit.at;
|
||||
const legacy = Number(id.slice("pipe-".length));
|
||||
return Number.isFinite(legacy) ? legacy : null;
|
||||
}
|
||||
|
||||
/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */
|
||||
export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string {
|
||||
if (!projectId) return `pipe-${toM.toFixed(2)}`;
|
||||
const book = bookOf(projectId);
|
||||
const moving = nearestWithin(book.ids, fromM, (entry) => entry.at);
|
||||
if (!moving) return pipeIdAt(toM, projectId);
|
||||
const stale = nearestWithin(
|
||||
book.ids.filter((entry) => entry !== moving),
|
||||
toM,
|
||||
(entry) => entry.at,
|
||||
);
|
||||
const ids = book.ids
|
||||
.filter((entry) => entry !== stale)
|
||||
.map((entry) => (entry === moving ? { ...entry, at: toM } : entry));
|
||||
writeState("pipe-ids", { next: book.next, ids }, projectId);
|
||||
return moving.id;
|
||||
}
|
||||
|
||||
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
|
||||
export function withPipeIds<T extends { chainage_m: number }>(
|
||||
pipes: ReadonlyArray<T>,
|
||||
projectId: string | null = active,
|
||||
): Array<T & { id: string }> {
|
||||
return pipes.map((pipe) => ({ ...pipe, id: pipeIdAt(pipe.chainage_m, projectId) }));
|
||||
}
|
||||
|
||||
/** 관을 옮김 — 관 세션 id 가 새 자리로(323-5 · id 그대로) · 그 관을 골라 둔 고름이면 자리만 새로(부재키
|
||||
* 그대로 · 옛 자리 이름 id 면 세션 id 로). 다른 것을 골랐으면 그대로. */
|
||||
export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void {
|
||||
const id = movePipeId(fromM, toM, projectId);
|
||||
const picked = readSelection(projectId);
|
||||
if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return;
|
||||
const sid = picked.structureId;
|
||||
if (sid !== null && !sid.startsWith("pipe-")) return;
|
||||
selectDesign(
|
||||
projectId,
|
||||
{ chainageM: toM, structureId: sid === null ? null : id },
|
||||
{ partKey: picked.partKey },
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,13 @@ export interface DesignSaveContext {
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
}
|
||||
|
||||
const saving = new Set<string>();
|
||||
|
||||
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
|
||||
export function savingDesign(projectId: string | null): boolean {
|
||||
return projectId !== null && saving.has(projectId);
|
||||
}
|
||||
|
||||
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
||||
function reportOnly(label: string): (error: unknown) => void {
|
||||
return (error) => {
|
||||
@@ -130,6 +137,7 @@ export async function saveDesign(
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
showLoadingOverlay();
|
||||
saving.add(ctx.projectId);
|
||||
try {
|
||||
await ctx.prepare?.();
|
||||
for (const step of DESIGN_SAVE_ORDER) {
|
||||
@@ -150,12 +158,14 @@ export async function saveDesign(
|
||||
await (label ? run().catch(reportOnly(label)) : run());
|
||||
}
|
||||
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
|
||||
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
saving.delete(ctx.projectId);
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
linkOf,
|
||||
readState,
|
||||
solveSplit,
|
||||
specContainerHidden,
|
||||
specItemHidden,
|
||||
TEXT_MAX,
|
||||
writeOptions,
|
||||
type SpecContainer,
|
||||
@@ -82,6 +84,8 @@ export interface SpecForm {
|
||||
groupNames?: Record<string, string>,
|
||||
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
|
||||
defaults?: Record<string, string | number>,
|
||||
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
|
||||
hidden?: ReadonlySet<string>,
|
||||
) => void;
|
||||
read: () => Record<string, string | number>;
|
||||
state: () => SpecState;
|
||||
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
let names: SpecNames = {};
|
||||
let groupNames: Record<string, string> = {};
|
||||
let defaults: Record<string, string | number> = {};
|
||||
let hidden: ReadonlySet<string> = new Set();
|
||||
const recent = new Map<Instance, SplitRole[]>();
|
||||
const slots = new Map<string, HTMLElement>();
|
||||
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
|
||||
@@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
}
|
||||
const fields = c.items
|
||||
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
|
||||
.filter((item) => !specItemHidden(item, hidden))
|
||||
.map((item) => {
|
||||
const input = control(c, item, view, tags.get(item.id));
|
||||
const wrap = labeled(itemLabel(c, item, names), input);
|
||||
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
const fitted: SpecContainer[] = [];
|
||||
for (const c of spec?.containers ?? []) {
|
||||
const list = (state[c.id] ??= c.required ? [{}] : []);
|
||||
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
|
||||
if (fit(c, list)) fitted.push(c);
|
||||
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
|
||||
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
|
||||
return {
|
||||
root,
|
||||
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) {
|
||||
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
|
||||
spec = next;
|
||||
defaults = nextDefaults;
|
||||
hidden = nextHidden ?? new Set();
|
||||
names = nextNames;
|
||||
groupNames = nextGroups;
|
||||
state = readState(next, options);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box";
|
||||
import { FORD_SPEC } from "./design_structure_ford";
|
||||
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
|
||||
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
|
||||
import { baseType } from "../../common_util/common_util_structure_catalog";
|
||||
|
||||
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
||||
const BASIN = "집수정";
|
||||
@@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
|
||||
pavement_concrete: PAVEMENT_SPEC,
|
||||
etc: ETC_SPEC,
|
||||
};
|
||||
|
||||
/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */
|
||||
export const specFor = (typeId: string): StructureSpec | undefined =>
|
||||
STRUCTURE_SPECS[baseType(typeId)];
|
||||
|
||||
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
|
||||
const text = (value: unknown): string =>
|
||||
value === undefined || value === null ? "" : String(value);
|
||||
|
||||
/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */
|
||||
export const specItemKeys = (item: SpecItem): string[] =>
|
||||
[item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter(
|
||||
(key): key is string => !!key,
|
||||
);
|
||||
|
||||
/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */
|
||||
export const specItemHidden = (item: SpecItem, hidden: ReadonlySet<string>): boolean => {
|
||||
const keys = specItemKeys(item);
|
||||
return keys.length > 0 && keys.every((key) => hidden.has(key));
|
||||
};
|
||||
|
||||
/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */
|
||||
export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet<string>): boolean =>
|
||||
c.items.some((item) => specItemKeys(item).length > 0) &&
|
||||
c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden));
|
||||
|
||||
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
|
||||
export function readState(
|
||||
spec: StructureSpec,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_structure_status.ts
|
||||
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
|
||||
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
|
||||
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
|
||||
*
|
||||
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||
|
||||
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
|
||||
export function structureStatus<P, S>(
|
||||
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
|
||||
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
|
||||
): { pipes: P[]; structures: S[] } {
|
||||
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||
*/
|
||||
export function plannedIrregular(
|
||||
planned: ReadonlyArray<PlannedStation>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
const interval = intervalM > 0 ? intervalM : 20;
|
||||
return planned.map((station) => {
|
||||
const owner = station.owners[0];
|
||||
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||
const number = Math.floor(station.chainage_m / interval);
|
||||
return {
|
||||
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||
station: number,
|
||||
remainder: station.chainage_m - number * interval,
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.title,
|
||||
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||
* 돌려주는 함수로 끊음.
|
||||
*/
|
||||
export function watchDrafts(projectId: string | null, onChange: () => void): () => void {
|
||||
const shop = useDesignStore(projectId, null);
|
||||
type Pipe = NonNullable<DesignStoreValues["pipes"]>[number];
|
||||
const layout = (pipes: ReadonlyArray<Pipe> | null): string =>
|
||||
JSON.stringify(
|
||||
(pipes ?? []).map((p) => [
|
||||
p.chainage_m,
|
||||
p.facility ?? "pipe",
|
||||
p.start_m ?? null,
|
||||
p.end_m ?? null,
|
||||
p.summary_item ?? null,
|
||||
p.group_items ?? null,
|
||||
]),
|
||||
);
|
||||
let seen = layout(shop.get("pipes"));
|
||||
const offs = [
|
||||
shop.subscribe("structures", onChange),
|
||||
shop.subscribe("pipes", (pipes) => {
|
||||
const key = layout(pipes);
|
||||
if (key === seen) return;
|
||||
seen = key;
|
||||
onChange();
|
||||
}),
|
||||
];
|
||||
return () => offs.forEach((off) => off());
|
||||
}
|
||||
@@ -17,10 +17,8 @@ import { leaveForDashboard } from "../b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
|
||||
import type {
|
||||
StructureInstance,
|
||||
StructureType,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { pipeIdAt } from "./design_pipe_ids";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
getSections,
|
||||
@@ -39,14 +37,14 @@ import {
|
||||
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { paintMapPick } from "./design_pick_profile";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { watchDrafts } from "./design_structure_status";
|
||||
import { savingDesign } from "./design_save";
|
||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
|
||||
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
|
||||
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
replayDraftPipeOptions,
|
||||
type PipeOptionsContext,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
|
||||
import {
|
||||
@@ -134,8 +132,7 @@ export async function mountCrossView(
|
||||
);
|
||||
|
||||
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
|
||||
let structureMarksSink:
|
||||
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
||||
let structureMarksSink: Parameters<typeof createB06StructuresPanel>[0]["onMarks"] | null = null;
|
||||
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
||||
let pipeReloadTimer = 0;
|
||||
let wallAdjustTimer = 0;
|
||||
@@ -143,12 +140,11 @@ export async function mountCrossView(
|
||||
projectId,
|
||||
shared,
|
||||
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
|
||||
onMarks: (structures, types) => {
|
||||
structureMarksSink?.(structures, types);
|
||||
onMarks: (structures, types, pipes) => {
|
||||
structureMarksSink?.(structures, types, pipes);
|
||||
syncBermSpans(structures);
|
||||
},
|
||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
|
||||
onStructuresChanged: () => void refreshDetailForStructures(),
|
||||
onPipesWritten: () => {
|
||||
window.clearTimeout(pipeReloadTimer);
|
||||
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
|
||||
@@ -223,6 +219,7 @@ export async function mountCrossView(
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
|
||||
try {
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
@@ -337,7 +334,7 @@ export async function mountCrossView(
|
||||
shared,
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
structureMarksSink = sectionView.setStructureMarks;
|
||||
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
|
||||
|
||||
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
|
||||
@@ -403,7 +400,7 @@ export async function mountCrossView(
|
||||
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : pipeIdAt(pipeAt, projectId));
|
||||
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
@@ -540,6 +537,7 @@ export async function mountCrossView(
|
||||
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
|
||||
// 실려 나가던 자리(2026-09-09 실측).
|
||||
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
|
||||
redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()),
|
||||
patchSources: () => ({
|
||||
rockOffsets: rockStore.offsets,
|
||||
cutSlopeRatios: cutSlopeStore.ratios,
|
||||
@@ -567,6 +565,11 @@ export async function mountCrossView(
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
|
||||
});
|
||||
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
|
||||
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
|
||||
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
|
||||
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
|
||||
host.onDispose(() => {
|
||||
window.clearTimeout(pipeReloadTimer);
|
||||
window.clearTimeout(wallAdjustTimer);
|
||||
@@ -666,8 +669,6 @@ export async function mountCrossView(
|
||||
bindPipeOptions(shared, (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
);
|
||||
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
|
||||
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
|
||||
renderSectionDetail();
|
||||
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
||||
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
type DesignPageShared,
|
||||
} from "./design_shared";
|
||||
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
|
||||
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
||||
import {
|
||||
@@ -57,7 +56,11 @@ import {
|
||||
type SectionDetailResponse,
|
||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
migrateLegacyStations,
|
||||
onStaleStructureTypes,
|
||||
structureAnchorM,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import {
|
||||
loadCorridorIfFresh,
|
||||
@@ -77,6 +80,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
|
||||
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
||||
import { pipeIdAt } from "./design_pipe_ids";
|
||||
import {
|
||||
DEFAULT_ROAD_WIDTHS,
|
||||
fetchRoadWidths,
|
||||
@@ -131,10 +135,8 @@ export async function mountProfileView(
|
||||
const structurePick = !!readSelection(activeProjectId)?.structureId;
|
||||
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
|
||||
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
||||
if ("pipeChainageM" in target) {
|
||||
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
if ("pipeChainageM" in target)
|
||||
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
|
||||
profilePanel.setSelectedStation(target.stationId);
|
||||
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
|
||||
};
|
||||
@@ -548,6 +550,9 @@ export async function mountProfileView(
|
||||
onReset: () => uphillOverrides.clear(),
|
||||
});
|
||||
|
||||
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||
const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load());
|
||||
host.onDispose(onStaleStructureTypes(reloadIfShown));
|
||||
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
||||
bindProfileViewLife(host, {
|
||||
projectId: activeProjectId,
|
||||
|
||||
@@ -471,17 +471,10 @@ export async function fetchRoadInflow(
|
||||
export type PipeSource = "stream" | "spacing" | "user";
|
||||
|
||||
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
|
||||
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */
|
||||
* 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
|
||||
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
|
||||
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
|
||||
|
||||
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
|
||||
["pipe", "배수관"],
|
||||
["box_culvert", "BOX암거"],
|
||||
["ford_pavement", "물넘이포장"],
|
||||
["ford_bridge", "세월교"],
|
||||
["revetment", "기슭막이"],
|
||||
];
|
||||
|
||||
export interface DetailPipePoint {
|
||||
chainage_m: number;
|
||||
lonlat: [number, number];
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { useCatalogDefinition } from "../common_util/common_util_structure_catalog";
|
||||
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import {
|
||||
type DefinedRule,
|
||||
@@ -77,7 +79,6 @@ export interface StructureType {
|
||||
placement: StructurePlacement;
|
||||
options: StructureOptionField[];
|
||||
style: { color?: string; abbr?: string };
|
||||
drawing_views: string[];
|
||||
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
|
||||
managed_by: string | null;
|
||||
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
|
||||
@@ -140,6 +141,8 @@ export interface StructureListResponse {
|
||||
project_id: string;
|
||||
revision: number;
|
||||
structures: StructureInstance[];
|
||||
/** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */
|
||||
definition_version?: number | null;
|
||||
}
|
||||
|
||||
export interface StructureSaveResponse {
|
||||
@@ -192,8 +195,66 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
|
||||
/** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */
|
||||
let typesCache: Promise<StructureType[]> | null = null;
|
||||
let typesVersion: number | null = null;
|
||||
|
||||
/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */
|
||||
export async function dropStaleStructureTypes(): Promise<boolean> {
|
||||
if (!typesCache || typesVersion === null) return false;
|
||||
const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", {
|
||||
method: "GET",
|
||||
})
|
||||
.then((payload) => payload.판)
|
||||
.catch(() => null);
|
||||
if (now === null || now === typesVersion) return false;
|
||||
typesCache = null;
|
||||
typesVersion = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const noticed = new Set<string>();
|
||||
const staleSaved = new Set<string>(); // 정본이 옛 정의 판으로 적힌 프로젝트(323-9)
|
||||
|
||||
/** 정본이 옛 정의 판으로 적혔나 — 그러면 고침이 없어도 [저장]이 정본을 다시 써 지금 판 도장(323-9). */
|
||||
export function definitionStale(projectId: string): boolean {
|
||||
return staleSaved.has(projectId);
|
||||
}
|
||||
|
||||
/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */
|
||||
export function staleDefinitionNotice(
|
||||
projectId: string,
|
||||
saved: number | null | undefined,
|
||||
): string | null {
|
||||
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) {
|
||||
staleSaved.delete(projectId);
|
||||
return null;
|
||||
}
|
||||
staleSaved.add(projectId);
|
||||
const key = `${projectId}:${saved}:${typesVersion}`;
|
||||
if (noticed.has(key)) return null;
|
||||
noticed.add(key);
|
||||
return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`;
|
||||
}
|
||||
|
||||
const staleListeners = new Set<() => void>();
|
||||
|
||||
function checkStaleOnShow(): void {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
void dropStaleStructureTypes().then((stale) => {
|
||||
if (stale) staleListeners.forEach((reload) => reload());
|
||||
});
|
||||
}
|
||||
|
||||
/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */
|
||||
export function onStaleStructureTypes(reload: () => void): () => void {
|
||||
if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow);
|
||||
staleListeners.add(reload);
|
||||
return () => {
|
||||
staleListeners.delete(reload);
|
||||
if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 타입 목록.
|
||||
@@ -212,7 +273,12 @@ export function fetchStructureTypes(includeDisabled = false): Promise<StructureT
|
||||
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
||||
method: "GET",
|
||||
})
|
||||
.then(async (payload) => withDefinition(payload.types, await definition))
|
||||
.then(async (payload) => {
|
||||
const defined = await definition;
|
||||
typesVersion = defined?.판 ?? null;
|
||||
useCatalogDefinition({ types: payload.types, ...defined }); // 구조물 표(322-1 · 반영 · 부품)
|
||||
return withDefinition(payload.types, defined);
|
||||
})
|
||||
.catch((error) => {
|
||||
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
||||
throw error;
|
||||
@@ -239,6 +305,7 @@ export async function saveStructures(
|
||||
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
|
||||
);
|
||||
noteSnapshotFailed(saved.snapshot_failed);
|
||||
staleSaved.delete(projectId); // 서버가 지금 판으로 적음
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -267,7 +334,7 @@ export function pipesToStructureMarks(
|
||||
}>,
|
||||
): StructureInstance[] {
|
||||
return pipes.map((pipe) => ({
|
||||
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5)
|
||||
type_id: pipe.facility,
|
||||
placement: "point",
|
||||
chainage_m: pipe.chainage_m,
|
||||
@@ -304,21 +371,22 @@ export function defaultOptions(type: StructureType): Record<string, string | num
|
||||
|
||||
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
|
||||
export function readPendingStructures(projectId: string): StructureInstance[] | null {
|
||||
return readState<StructureInstance[]>("structures", projectId);
|
||||
return useDesignStore(projectId, null).get("structures");
|
||||
}
|
||||
|
||||
/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */
|
||||
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
|
||||
writeState("structures", next, projectId);
|
||||
useDesignStore(projectId, null).set("structures", next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
|
||||
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
|
||||
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
|
||||
*/
|
||||
export async function flushPendingStructures(projectId: string): Promise<void> {
|
||||
const pending = readPendingStructures(projectId);
|
||||
if (!pending) return;
|
||||
if (!pending && !definitionStale(projectId)) return;
|
||||
const stored = await fetchStructures(projectId);
|
||||
await saveStructures(projectId, stored.revision, pending);
|
||||
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
|
||||
writePendingStructures(projectId, null);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,9 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
is_station_planting_type,
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_FACILITY_REVET,
|
||||
PipePoint,
|
||||
load_pipe_points_file,
|
||||
parse_pipe_points,
|
||||
@@ -43,14 +40,14 @@ from common_util.common_util_route_geometry import (
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
)
|
||||
from common_util.common_util_structure_catalog import card_title, catalog
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
DRAINAGE_PIPE_POINTS_FILENAME,
|
||||
FOREST_ROAD_PROFILE_CRITERIA,
|
||||
PIPE_DEFAULT_DIAMETER_MM,
|
||||
PIPE_DEFAULT_TYPE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -207,14 +204,10 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
||||
|
||||
|
||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름."""
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
return f"{kind} D{diameter}"
|
||||
definition = types.get(pipe.facility)
|
||||
return definition.name if definition is not None else None
|
||||
"""관 지점 측점 라벨 = 표 카드 이름(322-1 · 배관 = 「관종 D관경」 · 빈칸 = 정의 기본값)."""
|
||||
if pipe.facility not in types:
|
||||
return None
|
||||
return card_title(pipe.facility, pipe.options)
|
||||
|
||||
|
||||
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
||||
@@ -236,7 +229,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return
|
||||
types = structure_type_map()
|
||||
pipe_names = {definition.name for definition in types.values() if definition.managed_by}
|
||||
pipe_names = {row["name"] for row in catalog().values() if row["owner"] == "pipe_points"}
|
||||
for section in cross_sections:
|
||||
label = section.get("structure")
|
||||
if not isinstance(label, str) or not (
|
||||
@@ -266,50 +259,20 @@ def resolve_extra_stations(
|
||||
|
||||
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
|
||||
|
||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
||||
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
|
||||
거울 시험 = `resources/tester/test_272_1_station_list.py`.
|
||||
측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도
|
||||
(anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이
|
||||
먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다.
|
||||
거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`.
|
||||
"""
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
for pipe in pipes:
|
||||
label = pipe_station_label(pipe, types)
|
||||
if label is None:
|
||||
continue
|
||||
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
|
||||
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
|
||||
# 기준 한 곳(관이 노선을 가로지르는 점형).
|
||||
if pipe.facility == PIPE_FACILITY_REVET:
|
||||
marks = (pipe.start_m, pipe.chainage_m, pipe.end_m)
|
||||
else:
|
||||
marks = (pipe.chainage_m,)
|
||||
for mark in marks:
|
||||
if mark is not None:
|
||||
derived.append((float(mark), label))
|
||||
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
|
||||
# structures.json에 저장되지 않아 중복되지 않는다.
|
||||
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
|
||||
# · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장
|
||||
# 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가
|
||||
# 격자로 스냅해 파일이 겹치지 않는다.
|
||||
try:
|
||||
for structure in load_structures(str(project_root))[1]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or definition.managed_by:
|
||||
continue
|
||||
if not is_station_planting_type(definition):
|
||||
continue
|
||||
if definition.placement == "interval":
|
||||
marks = (structure.start_m, structure.chainage_m, structure.end_m)
|
||||
else:
|
||||
marks = (structure.anchor_m(),)
|
||||
for mark in marks:
|
||||
if mark is not None:
|
||||
derived.append((float(mark), definition.name))
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception:
|
||||
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
|
||||
return tuple(sorted(derived))
|
||||
structures = []
|
||||
return tuple(
|
||||
(float(station["chainage_m"]), str(station["title"]))
|
||||
for station in plan_stations(pipes, structures)
|
||||
)
|
||||
|
||||
|
||||
def _append_design_profiles(
|
||||
|
||||
@@ -16,15 +16,16 @@
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureInstance,
|
||||
station_planting_labels,
|
||||
structure_type_map,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
|
||||
from common_util.common_util_structure_station import SAME_STATION_M
|
||||
|
||||
PIPE_STRUCTURE_NAME = "배관"
|
||||
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`).
|
||||
@@ -54,28 +55,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE)
|
||||
_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
|
||||
|
||||
|
||||
def _managed_elsewhere_labels() -> set[str]:
|
||||
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 →
|
||||
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
|
||||
def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool:
|
||||
"""정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령.
|
||||
|
||||
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서
|
||||
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관).
|
||||
자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기
|
||||
(지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄.
|
||||
지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로.
|
||||
"""
|
||||
owned = {
|
||||
item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER
|
||||
}
|
||||
return station_planting_labels() | owned
|
||||
|
||||
|
||||
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
|
||||
"""다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가."""
|
||||
label = str(entry.get("structure", "")).strip()
|
||||
at = float(entry.get("chainage_m", 0.0))
|
||||
return (
|
||||
entry.get("origin") == "pipe"
|
||||
or entry.get("structureType") == PIPE_STRUCTURE_NAME
|
||||
or label == PIPE_STRUCTURE_NAME
|
||||
or label in _managed_elsewhere_labels()
|
||||
or label in {item.name for item in structure_type_map().values()}
|
||||
or bool(_PIPE_LABEL.match(label))
|
||||
or any(abs(at - p) < SAME_STATION_M for p in planted)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,7 +102,9 @@ def _escape_width_of(entry: dict[str, Any]) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPlan:
|
||||
def migrate_irregular_stations(
|
||||
entries: Iterable[dict[str, Any]], planted: Sequence[float] = ()
|
||||
) -> MigrationPlan:
|
||||
"""기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과).
|
||||
|
||||
타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 —
|
||||
@@ -120,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl
|
||||
seen: set[tuple[str, float]] = set()
|
||||
|
||||
for entry in entries:
|
||||
if _is_managed_elsewhere(entry):
|
||||
if _is_managed_elsewhere(entry, planted):
|
||||
continue
|
||||
|
||||
chainage = float(entry.get("chainage_m", 0.0))
|
||||
|
||||
@@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
|
||||
return revision, structures
|
||||
|
||||
|
||||
def load_saved_definition_version(project_root: str) -> int | None:
|
||||
"""정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None."""
|
||||
path = structures_file_path(project_root)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
value = json.load(handle).get("definition_version")
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
|
||||
|
||||
def load_migrated_legacy(project_root: str) -> set[str]:
|
||||
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stati
|
||||
from B05_Profile.B05_Profile_Structures_Repository import (
|
||||
StructureRevisionConflict,
|
||||
load_migrated_legacy,
|
||||
load_saved_definition_version,
|
||||
load_structures,
|
||||
requires_downstream_invalidation,
|
||||
save_structures,
|
||||
@@ -42,6 +43,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
read_pipe_points_file,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_station import plan_stations
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible
|
||||
|
||||
@@ -159,7 +161,10 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
revision, structures = load_structures(root)
|
||||
return StructureListResponse(
|
||||
project_id=str(project_id), revision=revision, structures=structures
|
||||
project_id=str(project_id),
|
||||
revision=revision,
|
||||
structures=structures,
|
||||
definition_version=load_saved_definition_version(root),
|
||||
)
|
||||
except LookupError:
|
||||
# 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다.
|
||||
@@ -189,7 +194,14 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
revision, existing = load_structures(root)
|
||||
migrated_before = load_migrated_legacy(root)
|
||||
plan = migrate_irregular_stations(payload.stations)
|
||||
# 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음)
|
||||
planted = [
|
||||
float(station["chainage_m"])
|
||||
for station in plan_stations(
|
||||
read_pipe_points_file(pipe_points_path_in(Path(root))), existing
|
||||
)
|
||||
]
|
||||
plan = migrate_irregular_stations(payload.stations, planted)
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
|
||||
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
|
||||
|
||||
@@ -88,8 +88,6 @@ class StructureType(BaseModel):
|
||||
placement: Placement
|
||||
options: list[StructureOptionField] = Field(default_factory=list)
|
||||
style: dict[str, str] = Field(default_factory=dict)
|
||||
# B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다.
|
||||
drawing_views: list[str] = Field(default_factory=list)
|
||||
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
|
||||
managed_by: str | None = None
|
||||
# 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다
|
||||
@@ -116,10 +114,19 @@ def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
#: 정의에 남은 옛 칸 — 아무도 안 읽어 걷음(323-7 · 마스터는 M02 [저장] 때 빠짐 · 읽을 때 버림).
|
||||
RETIRED_TYPE_KEYS = ("drawing_views",)
|
||||
|
||||
|
||||
def without_retired(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""종류 한 줄에서 옛 칸을 뺀 사본."""
|
||||
return {k: v for k, v in item.items() if k not in RETIRED_TYPE_KEYS}
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]:
|
||||
payload = _read_definition(path, mtime, size)
|
||||
return tuple(StructureType.model_validate(item) for item in payload["types"])
|
||||
return tuple(StructureType.model_validate(without_retired(item)) for item in payload["types"])
|
||||
|
||||
|
||||
def load_definition() -> dict[str, Any]:
|
||||
@@ -141,25 +148,19 @@ def structure_type_map() -> dict[str, StructureType]:
|
||||
|
||||
|
||||
def is_station_planting_type(item: StructureType) -> bool:
|
||||
"""서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28).
|
||||
"""서버가 종단 정본에 **측점으로 심는** 타입인가 — 구조물 표 횡단도 부품이 있음(322-1).
|
||||
|
||||
- A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다.
|
||||
- D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면
|
||||
그만큼 여러 장이 나온다(사용자 확정).
|
||||
- C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료.
|
||||
안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다.
|
||||
|
||||
측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단
|
||||
(`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다.
|
||||
anchor(기준 한 장 · 횡단배수 · 관 지점 시설) · span3(시작 · 기준 · 끝 · 벽 · 소단 · 기슭막이
|
||||
— 길이가 길면 그만큼 여러 장). 옛 군 글자 규칙(A군 · C · D군 구간형)은 표 처음값으로 옮김.
|
||||
"""
|
||||
if item.group == "A":
|
||||
return True
|
||||
return item.group in ("C", "D") and item.placement == "interval"
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
|
||||
row = catalog_row(item.type_id)
|
||||
return row is not None and row["station"] != "none"
|
||||
|
||||
|
||||
def station_planting_labels() -> set[str]:
|
||||
"""서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다."""
|
||||
return {item.name for item in load_structure_types() if is_station_planting_type(item)}
|
||||
#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름)
|
||||
HISTORY_DIRNAME = "구조물정의_이력"
|
||||
|
||||
|
||||
def registry_schema_version() -> int:
|
||||
@@ -290,3 +291,5 @@ class StructureListResponse(BaseModel):
|
||||
project_id: str
|
||||
revision: int
|
||||
structures: list[StructureInstance] = Field(default_factory=list)
|
||||
# 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림
|
||||
definition_version: int | None = None
|
||||
|
||||
@@ -382,7 +382,12 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
(build.structures ?? []).forEach((structure, index) => {
|
||||
// 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다
|
||||
// (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다.
|
||||
const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key };
|
||||
const tag = {
|
||||
chainageM: structure.chainage_m,
|
||||
kind: structure.kind,
|
||||
key: structure.key,
|
||||
structureId: structure.structure_id, // 323-5 — 벽은 id 로 고름
|
||||
};
|
||||
if (structure.kind === "pipe") {
|
||||
const mesh = structurePipeMesh(structure, origin);
|
||||
if (mesh) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Reve
|
||||
import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard";
|
||||
import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute";
|
||||
import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { builds, culvertBody, ownsBody } from "./B05_Profile_UI_Corridor_Structures_Pick";
|
||||
|
||||
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
|
||||
export interface StructureFrame {
|
||||
@@ -85,6 +86,8 @@ export interface CorridorStructure {
|
||||
* 신원표다(2026-09-04). 세월교·BOX암거 부재는 아직 비워 둔다.
|
||||
*/
|
||||
key?: string;
|
||||
/** 구조물 id(323-5) — C군 벽(id 로 고름 · 덮는 측점 어디서 골라도 · 한 번 스윕). 관 부재는 자리로 고름. */
|
||||
structure_id?: string;
|
||||
/** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */
|
||||
polygon?: Array<[number, number]>;
|
||||
/**
|
||||
@@ -276,17 +279,31 @@ export function buildCorridorStructures(
|
||||
): CorridorStructure[] {
|
||||
const solids: CorridorStructure[] = [];
|
||||
for (const section of crossSections) {
|
||||
const layout = culvertLayoutOf(section);
|
||||
// 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 시설 카드 둘이면 한 번(323-9)
|
||||
const own = (key: "culvert" | "ford" | "box"): boolean => ownsBody(section, crossSections, key);
|
||||
const layout = own("culvert") && culvertBody(section) ? culvertLayoutOf(section) : null;
|
||||
// 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1)
|
||||
const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout);
|
||||
const fordLayout =
|
||||
own("ford") && builds("ford_bridge", "ford")
|
||||
? withoutGuardedFill(fordLayoutOf(section), layout)
|
||||
: null;
|
||||
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
|
||||
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
|
||||
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
|
||||
const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections))
|
||||
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
|
||||
// C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것)
|
||||
const wallSweep = wallSweepAt(section, crossSections);
|
||||
const wallId = section.revetment?.structure_id;
|
||||
const revetLayout =
|
||||
wallSweep &&
|
||||
ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) &&
|
||||
builds(section.revetment?.type_id, "wall")
|
||||
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
|
||||
: null;
|
||||
const paveChute = builds("ford_pavement", "chute")
|
||||
? fordPavementChute(section, crossSections)
|
||||
: null;
|
||||
const paveChute = fordPavementChute(section, crossSections);
|
||||
if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue;
|
||||
const box = own("box") && builds("box_culvert", "box") ? section.box : undefined;
|
||||
if (!layout && !fordLayout && !box && !revetLayout && !paveChute) continue;
|
||||
const chainage = section.chainage_m;
|
||||
const stationFrame: StructureFrame = {
|
||||
cx: section.center_x,
|
||||
@@ -355,18 +372,12 @@ export function buildCorridorStructures(
|
||||
});
|
||||
};
|
||||
|
||||
if (revetLayout) {
|
||||
if (revetLayout && wallSweep) {
|
||||
// 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다.
|
||||
for (const tier of revetLayout.tiers) {
|
||||
pushSwept(
|
||||
"revet",
|
||||
tier.polygon,
|
||||
revetLayout.span.beforeM,
|
||||
revetLayout.span.afterM,
|
||||
null,
|
||||
"own",
|
||||
);
|
||||
}
|
||||
const from = solids.length;
|
||||
for (const tier of revetLayout.tiers)
|
||||
pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own");
|
||||
for (const solid of solids.slice(from)) solid.structure_id = wallId ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,7 +458,7 @@ export function buildCorridorStructures(
|
||||
}
|
||||
};
|
||||
|
||||
if (section.box) {
|
||||
if (box) {
|
||||
solids.push(...boxSolids(section, stationFrame, boxAdjustOf(section)));
|
||||
}
|
||||
paveChute?.bands.forEach((band) =>
|
||||
|
||||
@@ -1,141 +1,79 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures_Hash.ts
|
||||
* 저장 코리도 만료 판정용 **구조물 해시 요약**.
|
||||
* 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4).
|
||||
*
|
||||
* `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 —
|
||||
* 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것.
|
||||
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을
|
||||
* 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음.
|
||||
* 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
ChuteSpec,
|
||||
CrossSection,
|
||||
CulvertSideSpec,
|
||||
} from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys";
|
||||
|
||||
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
|
||||
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
|
||||
if (!chute) return [];
|
||||
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
|
||||
const base = ["ch", chute.structure, chute.thickness_m];
|
||||
return chute.length_m ? [...base, chute.length_m] : base;
|
||||
}
|
||||
/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */
|
||||
const ADJUST_KEYS = [
|
||||
"revet_adjust",
|
||||
"box_adjust",
|
||||
"ford_adjust",
|
||||
"basin_adjust",
|
||||
"extra_wall_counts",
|
||||
"extra_spans",
|
||||
"revet_follow_grade",
|
||||
"revet_link_detached",
|
||||
"inlet_structure",
|
||||
] as const;
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
|
||||
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
|
||||
const extras: Array<string | number> = [];
|
||||
const fordPave = section.ford_pavement;
|
||||
if (fordPave) {
|
||||
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
|
||||
extras.push(...chuteParts(fordPave.chute));
|
||||
}
|
||||
const ownRevet = section.revetment;
|
||||
if (ownRevet) {
|
||||
extras.push(
|
||||
"rv",
|
||||
ownRevet.start_m,
|
||||
ownRevet.end_m,
|
||||
ownRevet.anchor_m,
|
||||
ownRevet.height_m ?? "",
|
||||
ownRevet.side ?? "",
|
||||
ownRevet.form ?? "",
|
||||
ownRevet.tiers ?? "",
|
||||
ownRevet.lift_m ?? "",
|
||||
ownRevet.shift_m ?? "",
|
||||
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
|
||||
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
|
||||
);
|
||||
}
|
||||
const box = section.box;
|
||||
// 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음.
|
||||
if (box) {
|
||||
extras.push(
|
||||
"bx",
|
||||
box.inner_width_m,
|
||||
box.inner_height_m,
|
||||
box.span_m,
|
||||
box.cover_m,
|
||||
box.wing_in.slab_extend_m,
|
||||
box.wing_in.height_m ?? "",
|
||||
box.wing_out.slab_extend_m,
|
||||
box.wing_out.height_m ?? "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
...(["left", "right"] as const).map((side) => {
|
||||
const value = section.design?.box_adjust?.[side];
|
||||
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
|
||||
}),
|
||||
...chuteParts(box.chute),
|
||||
);
|
||||
}
|
||||
const ford = section.ford;
|
||||
if (ford) {
|
||||
const adjust = section.design?.ford_adjust;
|
||||
const wall = (role: "inlet" | "outlet"): string => {
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
extras.push(`dr:${adjust?.dropM ?? ""}`);
|
||||
extras.push(
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
ford.span_m,
|
||||
ford.wing_in.slab_extend_m,
|
||||
ford.wing_out.slab_extend_m,
|
||||
wall("inlet"),
|
||||
wall("outlet"),
|
||||
...chuteParts(ford.chute),
|
||||
);
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return extras;
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
spec.revet_form ?? "",
|
||||
spec.revet_height_m ?? "",
|
||||
spec.revet_length_m ?? "",
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
spec.basin_height_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */
|
||||
const stable = (value: unknown): string =>
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? Object.fromEntries(
|
||||
Object.keys(item)
|
||||
.sort()
|
||||
.map((key) => [key, (item as Record<string, unknown>)[key]]),
|
||||
)
|
||||
: item,
|
||||
);
|
||||
|
||||
/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */
|
||||
export function structureHashParts(section: CrossSection): string[] {
|
||||
const record = section as unknown as Record<string, unknown>;
|
||||
const shapes = SHAPE_KEYS.filter((key) => record[key] != null);
|
||||
if (!shapes.length) return [];
|
||||
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
|
||||
return [
|
||||
...extras,
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
side(culvert.inlet),
|
||||
side(culvert.outlet),
|
||||
section.design?.inlet_structure ?? "",
|
||||
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
revet
|
||||
? Object.keys(revet)
|
||||
.sort()
|
||||
.map((role) => {
|
||||
const value = revet[role];
|
||||
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
|
||||
})
|
||||
.join("|")
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
|
||||
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
|
||||
Object.entries(section.design?.extra_spans ?? {})
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
|
||||
.join("|"),
|
||||
section.design?.revet_link_detached === true ? "detached" : "",
|
||||
...chuteParts(culvert.chute),
|
||||
stable({
|
||||
...Object.fromEntries(shapes.map((key) => [key, record[key]])),
|
||||
adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 ·
|
||||
* 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것).
|
||||
*/
|
||||
export function wallSweepAt(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
): { beforeM: number; afterM: number } | null {
|
||||
const wall = section.revetment;
|
||||
if (!wall) return null;
|
||||
const same = (other: CrossSection): boolean =>
|
||||
!!other.revetment &&
|
||||
(wall.structure_id
|
||||
? other.revetment.structure_id === wall.structure_id
|
||||
: other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m);
|
||||
const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m);
|
||||
const owner = sections
|
||||
.filter(same)
|
||||
.reduce<CrossSection | null>(
|
||||
(best, other) => (!best || gap(other) < gap(best) ? other : best),
|
||||
null,
|
||||
);
|
||||
if (owner !== section) return null;
|
||||
return {
|
||||
beforeM: Math.max(section.chainage_m - wall.start_m, 0),
|
||||
afterM: Math.max(wall.end_m - section.chainage_m, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures_Pick.ts
|
||||
* 3D 몸체 고르기(322-1) — 그림 자료 열쇠(`culvert` · `box` · `ford` · `ford_pavement` · `revetment`)의 주인
|
||||
* 종류가 구조물 표 3D 부품으로 그 몸체를 세우는가. 지금 처음값 = 지금 결과 그대로(표가 끈 것만 빠짐).
|
||||
* 쓰는 곳 = `B05_Profile_UI_Corridor_Structures.buildCorridorStructures`.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
|
||||
/** 그림 자료의 주인 종류가 이 3D 몸체를 세우는가 — 표에 없는 종류면 세움. */
|
||||
export const builds = (typeId: string | null | undefined, body: string): boolean =>
|
||||
(catalogRow(typeId ?? "")?.model3d ?? body) === body;
|
||||
|
||||
/**
|
||||
* 이 카드가 시설(관 · 세월교 · BOX) 몸체를 세우는가 — 같은 시설(얹을 때 적은 시설 누가거리)을 그리는 카드 중
|
||||
* 시설 자리에 가장 가까운 하나만(노선 끝 EP 카드 0.01 m 차 · 323-9 · `fordPavementChute` 와 같은 규칙).
|
||||
*/
|
||||
export function ownsBody(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
key: "culvert" | "ford" | "box",
|
||||
): boolean {
|
||||
const at = section[key]?.chainage_m;
|
||||
if (typeof at !== "number") return true; // 시설 자리 없음 = 옛 자료 그대로
|
||||
const gap = (item: CrossSection): number => Math.abs(item.chainage_m - at);
|
||||
const same = sections.filter((item) => item[key]?.chainage_m === at);
|
||||
const owner = same.reduce((best, item) => (gap(item) < gap(best) ? item : best), same[0]);
|
||||
return owner === section;
|
||||
}
|
||||
|
||||
/** `culvert` 자리 주인 = 박스 · 세월교 흙막이(body) · 독립 기슭막이(관 숨김) · 배관. */
|
||||
export function culvertBody(section: CrossSection): boolean {
|
||||
const spec = section.culvert;
|
||||
if (!spec) return false;
|
||||
if (spec.body) return builds(spec.body === "box" ? "box_culvert" : "ford_bridge", spec.body);
|
||||
return spec.hidden_pipe ? builds("revetment", "wall") : builds("pipe", "pipe");
|
||||
}
|
||||
@@ -50,13 +50,14 @@ import {
|
||||
outletTiersRow,
|
||||
revetChoiceRow,
|
||||
} from "./B05_Profile_UI_Drainage_Revet_Choice";
|
||||
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
createSpecForm,
|
||||
type SpecForm,
|
||||
type SpecLinks,
|
||||
} from "../A00_Common/design/design_structure_form";
|
||||
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
BOX_SPEC,
|
||||
bodyOf,
|
||||
@@ -496,13 +497,16 @@ export function createFacilityOptionsForm(
|
||||
);
|
||||
|
||||
let current: PipeFacility | null = null;
|
||||
/** 서브폼 = 구조물 표 형상(323-7 · 종류 키 대신 · 관 지점 시설은 형상마다 손코딩 폼 하나). */
|
||||
const shapeOf = (facility: PipeFacility | null) =>
|
||||
(facility && catalogRow(facility)?.shape) || null;
|
||||
|
||||
function syncVisibility(): void {
|
||||
root.hidden = current === null;
|
||||
if (current === null) return;
|
||||
const isPipe = current === "pipe";
|
||||
const isBox = current === "box_culvert";
|
||||
const isBridge = current === "ford_bridge";
|
||||
const isPipe = shapeOf(current) === "pipe_set";
|
||||
const isBox = shapeOf(current) === "box";
|
||||
const isBridge = shapeOf(current) === "ford";
|
||||
pipeForm.root.hidden = !isPipe;
|
||||
boxForm.root.hidden = !isBox;
|
||||
fordForm.root.hidden = !isBridge;
|
||||
@@ -512,8 +516,8 @@ export function createFacilityOptionsForm(
|
||||
wingOutFields.root.hidden = true;
|
||||
(spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot);
|
||||
(spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot);
|
||||
paveForm.root.hidden = current !== "ford_pavement";
|
||||
const isRevet = current === "revetment";
|
||||
paveForm.root.hidden = shapeOf(current) !== "ford_pavement";
|
||||
const isRevet = shapeOf(current) === "hidden_revet";
|
||||
revetTopRow.hidden = !isRevet;
|
||||
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
|
||||
const side = revetSide.value || "양쪽";
|
||||
@@ -580,10 +584,10 @@ export function createFacilityOptionsForm(
|
||||
},
|
||||
setChainage(chainageM) {
|
||||
chainage = chainageM;
|
||||
if (current === "pipe") pipeForm.refresh();
|
||||
if (shapeOf(current) === "pipe_set") pipeForm.refresh();
|
||||
},
|
||||
recount() {
|
||||
if (current === "pipe") pipeForm.recount();
|
||||
if (shapeOf(current) === "pipe_set") pipeForm.recount();
|
||||
},
|
||||
onInletStructureChange(handler) {
|
||||
inletStructureHandler = handler;
|
||||
@@ -607,6 +611,7 @@ export function createFacilityOptionsForm(
|
||||
const names = definition?.spec_names ?? {};
|
||||
// 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈
|
||||
const defaults = optionDefaults(definition?.options ?? []);
|
||||
const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2)
|
||||
if (facility === "pipe") {
|
||||
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
|
||||
const { soil_add: _old, ...pipeNames } = names;
|
||||
@@ -616,11 +621,18 @@ export function createFacilityOptionsForm(
|
||||
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
|
||||
? { structure: kind }
|
||||
: null;
|
||||
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults);
|
||||
pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden);
|
||||
}
|
||||
if (facility === "box_culvert") {
|
||||
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
|
||||
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults));
|
||||
boxForm.write(
|
||||
BOX_SPEC,
|
||||
boxOptionsIn(options),
|
||||
names,
|
||||
groups,
|
||||
boxOptionsIn(defaults),
|
||||
hidden,
|
||||
);
|
||||
for (const [id, value] of Object.entries(bodyOf(options)))
|
||||
boxForm.setValue("body", id, value);
|
||||
}
|
||||
@@ -631,9 +643,10 @@ export function createFacilityOptionsForm(
|
||||
names,
|
||||
groups,
|
||||
withLegacyGuards(defaults),
|
||||
hidden,
|
||||
);
|
||||
if (facility === "ford_pavement")
|
||||
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults);
|
||||
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden);
|
||||
if (facility === "ford_bridge") fordFlowBox.fill();
|
||||
if (facility === "ford_pavement") paveFlowBox.fill();
|
||||
wingInFields.write(options);
|
||||
@@ -649,28 +662,28 @@ export function createFacilityOptionsForm(
|
||||
},
|
||||
setDesignFlow(designFlow) {
|
||||
fordFlow = designFlow ?? null;
|
||||
if (current === "ford_bridge") fordFlowBox.fill();
|
||||
if (current === "ford_pavement") paveFlowBox.fill();
|
||||
if (shapeOf(current) === "ford") fordFlowBox.fill();
|
||||
if (shapeOf(current) === "ford_pavement") paveFlowBox.fill();
|
||||
syncVisibility();
|
||||
},
|
||||
readOptions() {
|
||||
const options: Record<string, string | number> = {};
|
||||
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
|
||||
if (current === "pipe") {
|
||||
if (shapeOf(current) === "pipe_set") {
|
||||
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
|
||||
// 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13)
|
||||
if (inletIsAuto()) {
|
||||
if (inletAuto!.structure === undefined) delete options.inlet_structure;
|
||||
else options.inlet_structure = inletAuto!.structure;
|
||||
}
|
||||
} else if (current === "box_culvert") {
|
||||
} else if (shapeOf(current) === "box") {
|
||||
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
|
||||
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
|
||||
} else if (current === "ford_pavement") {
|
||||
} else if (shapeOf(current) === "ford_pavement") {
|
||||
Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3)
|
||||
} else if (current === "ford_bridge") {
|
||||
} else if (shapeOf(current) === "ford") {
|
||||
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
|
||||
} else if (current === "revetment") {
|
||||
} else if (shapeOf(current) === "hidden_revet") {
|
||||
if (revetSide.value) options.side = revetSide.value;
|
||||
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
|
||||
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
||||
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
||||
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
||||
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
@@ -584,6 +584,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
if (index < 0) return;
|
||||
const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
|
||||
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
|
||||
followMovedPipe(projectId, pipeEditor.pipes()[index].chainage_m, to); // 고름 유지(322-3)
|
||||
pipeEditor.moveTo(index, to);
|
||||
},
|
||||
selectBasinByChainage(chainageM) {
|
||||
@@ -637,6 +638,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const index = pipeEditor
|
||||
.pipes()
|
||||
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
|
||||
followMovedPipe(projectId, fromChainageM, toChainageM); // 고름 유지(322-3)
|
||||
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
|
||||
else void analyze();
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
RoutePointKind,
|
||||
} from "./B05_Profile_UI_Markers";
|
||||
import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import {
|
||||
irregularLabel,
|
||||
@@ -226,15 +225,6 @@ export function interpolateIrregularStations(
|
||||
});
|
||||
}
|
||||
|
||||
/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */
|
||||
export const FACILITY_NAMES: Record<PipeFacility, string> = {
|
||||
pipe: "배수관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_pavement: "물넘이포장",
|
||||
ford_bridge: "세월교",
|
||||
revetment: "기슭막이",
|
||||
};
|
||||
|
||||
/* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ─────────────────────
|
||||
* 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다.
|
||||
* 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */
|
||||
|
||||
@@ -11,36 +11,32 @@
|
||||
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
fetchStructures,
|
||||
dropStaleStructureTypes,
|
||||
fetchStructureTypes,
|
||||
staleDefinitionNotice,
|
||||
definitionStale,
|
||||
saveStructures,
|
||||
readPendingStructures,
|
||||
writePendingStructures,
|
||||
StructureConflictError,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
isPipeStation,
|
||||
structureLabel,
|
||||
type IrregularStation,
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
|
||||
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type StationMark,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import { listPick } from "../common_util/common_util_structure_catalog";
|
||||
import { planStations } from "../common_util/common_util_structure_station";
|
||||
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
|
||||
import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids";
|
||||
|
||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||
export interface PipeProjection {
|
||||
@@ -65,56 +61,12 @@ export interface StructuresBridgeDeps {
|
||||
}
|
||||
|
||||
export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */
|
||||
let irregularStations: IrregularStation[] = [];
|
||||
|
||||
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
|
||||
function stationAt(
|
||||
chainage: number,
|
||||
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
const station = Math.floor(chainage / interval);
|
||||
return { station, remainder: chainage - station * interval, chainage_m: chainage };
|
||||
}
|
||||
|
||||
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
|
||||
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
|
||||
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
|
||||
marks
|
||||
.filter((mark) => mark.role !== "anchor")
|
||||
.map((mark) => ({
|
||||
id: `${id}:${mark.role}`,
|
||||
...stationAt(mark.chainage_m),
|
||||
structure: label,
|
||||
}));
|
||||
|
||||
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
|
||||
return pipes.flatMap((pipe) => {
|
||||
const label =
|
||||
pipe.facility === "pipe"
|
||||
? structureLabel({
|
||||
structureType: "배관",
|
||||
pipeType: PIPE_DEFAULT_TYPE,
|
||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||
})
|
||||
: FACILITY_NAMES[pipe.facility];
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
...stationAt(pipe.chainage_m),
|
||||
structure: label,
|
||||
structureType: "배관" as const,
|
||||
origin: "pipe" as const,
|
||||
},
|
||||
...spanStations(id, label, pipeStationMarks(pipe)),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||
function clearProjectedStations(): void {
|
||||
pipeStations = [];
|
||||
planPipes = [];
|
||||
irregularStations = [];
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations([]);
|
||||
@@ -139,11 +91,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(stations);
|
||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||
deps
|
||||
.profilePanel()
|
||||
.drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m));
|
||||
// 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천)
|
||||
}
|
||||
|
||||
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
||||
@@ -159,41 +107,22 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||
let pipeStations: IrregularStation[] = [];
|
||||
/** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */
|
||||
let planPipes: PipeProjection[] = [];
|
||||
let planTypes: StructureType[] = [];
|
||||
let structureTypeMap = new Map<
|
||||
string,
|
||||
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
||||
>();
|
||||
|
||||
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
|
||||
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||
return structures.flatMap((structure) => {
|
||||
const type = structureTypeMap.get(structure.type_id);
|
||||
const marks = structureStationMarks(structure, type);
|
||||
const anchor = marks.find((mark) => mark.role === "anchor");
|
||||
if (!anchor) return [];
|
||||
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return [
|
||||
{ id, ...stationAt(anchor.chainage_m), structure: label },
|
||||
...spanStations(id, label, marks),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
|
||||
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
|
||||
* 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */
|
||||
/** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저).
|
||||
* 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */
|
||||
function syncCrossDrainStations(): void {
|
||||
const kept: IrregularStation[] = [];
|
||||
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
|
||||
const taken =
|
||||
station.origin !== "pipe" &&
|
||||
kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M);
|
||||
if (!taken) kept.push(station);
|
||||
}
|
||||
applyIrregularStations(kept);
|
||||
if (!planTypes.length) return;
|
||||
const interval = deps.panel().values().stationInterval || 20;
|
||||
applyIrregularStations(
|
||||
plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval),
|
||||
);
|
||||
}
|
||||
|
||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||
@@ -211,9 +140,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
|
||||
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
||||
function pipeMarkChainage(structureId: string | null): number | null {
|
||||
if (!structureId?.startsWith("pipe-")) return null;
|
||||
const value = Number(structureId.slice("pipe-".length));
|
||||
return Number.isFinite(value) ? value : null;
|
||||
return structureId ? pipeChainageOf(structureId) : null; // 관 세션 id 장부(323-5)
|
||||
}
|
||||
|
||||
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
|
||||
@@ -234,7 +161,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
}>,
|
||||
): StructureInstance[] {
|
||||
return pipes.map((pipe) => ({
|
||||
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
||||
structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5)
|
||||
type_id: pipe.facility,
|
||||
placement: "point",
|
||||
chainage_m: pipe.chainage_m,
|
||||
@@ -278,7 +205,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
|
||||
/** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */
|
||||
async function saveStructuresIfDirty(): Promise<void> {
|
||||
if (readPending() === null) return;
|
||||
if (readPending() === null && !definitionStale(deps.projectId)) return; // 옛 판 = 판 도장(323-9)
|
||||
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
||||
// 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는
|
||||
// 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐).
|
||||
@@ -346,12 +273,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
|
||||
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
|
||||
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
|
||||
await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0)
|
||||
const [allTypes, stored] = await Promise.all([
|
||||
fetchStructureTypes(true),
|
||||
fetchStructures(deps.projectId),
|
||||
]);
|
||||
deps.panel().structures.setTypes(allTypes, (type) => type.enabled);
|
||||
deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1)
|
||||
deps.profilePanel().setStructureTypes(allTypes);
|
||||
planTypes = allTypes;
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
allTypes.map((type) => [
|
||||
@@ -366,6 +295,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
]),
|
||||
);
|
||||
structureRevision = stored.revision;
|
||||
const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2
|
||||
if (note) showToast(note);
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
||||
const pending = readPending();
|
||||
ownStructures = pending ?? stored.structures;
|
||||
@@ -404,7 +335,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||
setPipes(pipes: PipeProjection[]): void {
|
||||
pipeStations = pipesToStations(pipes);
|
||||
planPipes = [...pipes];
|
||||
syncCrossDrainStations();
|
||||
pipeMarks = pipesToMarks(pipes);
|
||||
syncGraphStructures();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box";
|
||||
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||
import {
|
||||
@@ -43,7 +43,7 @@ export function balloonLines(
|
||||
head: { name: string; position: string },
|
||||
count?: ContainerCount,
|
||||
): string[] {
|
||||
const spec = STRUCTURE_SPECS[structure.type_id];
|
||||
const spec = specFor(structure.type_id);
|
||||
const values = (
|
||||
spec ? specLines(spec, structure, type, count) : groupLines(structure.options ?? {}, type)
|
||||
).filter(Boolean);
|
||||
@@ -158,7 +158,7 @@ export function structureOptionLines(
|
||||
const kept = parts.filter(Boolean);
|
||||
return kept.length ? [`${title}: ${kept.join(SEP)}`] : [];
|
||||
};
|
||||
const spec = STRUCTURE_SPECS[structure.type_id];
|
||||
const spec = specFor(structure.type_id);
|
||||
if (spec) {
|
||||
const names = specNames(structure as StructureInstance, type);
|
||||
const state = formState(spec, structure.type_id, options);
|
||||
|
||||
@@ -186,7 +186,10 @@ export function shapeFields(
|
||||
export interface StructureDefinition {
|
||||
판?: number;
|
||||
구조물군?: Record<string, string>;
|
||||
종류?: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
|
||||
종류?: Record<
|
||||
string,
|
||||
{ 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string>; 본보기?: string }
|
||||
>;
|
||||
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
|
||||
배치?: Record<string, Record<string, DefinedSpot>>;
|
||||
규칙?: DefinedRule[];
|
||||
@@ -216,7 +219,7 @@ export function withDefinition<
|
||||
});
|
||||
}
|
||||
|
||||
/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음.
|
||||
/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 대피로 + 상세로 읽음(고르기 목록 숨김 = 구조물 표 처음값 hidden · 322-1).
|
||||
* 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */
|
||||
export const ROAD_TYPE = "evac_road";
|
||||
export const ROAD_DETAILS: Readonly<Record<string, string>> = {
|
||||
@@ -225,9 +228,6 @@ export const ROAD_DETAILS: Readonly<Record<string, string>> = {
|
||||
turnaround: "차돌림곳",
|
||||
};
|
||||
|
||||
/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */
|
||||
export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id];
|
||||
|
||||
/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */
|
||||
export function asRoad<T extends { type_id: string; options?: Record<string, string | number> }>(
|
||||
record: T,
|
||||
|
||||
@@ -101,6 +101,11 @@ export function optionControl(
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 정의 「표시」로 숨긴 옵션 키(서버 `visible` 거짓 · 322-2) — 명세 폼이 그 칸 · 상자를 안 그림. */
|
||||
export const hiddenOptionKeys = (
|
||||
options: ReadonlyArray<{ key: string; visible?: boolean }>,
|
||||
): Set<string> => new Set(options.filter((o) => o.visible === false).map((o) => o.key));
|
||||
|
||||
/** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */
|
||||
export function optionDefaults(
|
||||
options: ReadonlyArray<{ key: string; default: string | number | null }>,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { numberInput } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
GROUP_JOIN,
|
||||
type GroupItems,
|
||||
@@ -117,7 +117,7 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
shapeInputs.clear();
|
||||
current = type;
|
||||
// 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움
|
||||
const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []);
|
||||
const groups = type && specFor(type.type_id) ? [] : (type?.groups ?? []);
|
||||
const boxes: HTMLElement[] = [];
|
||||
bodies = [];
|
||||
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
|
||||
@@ -200,9 +200,7 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
bodies.push(body);
|
||||
}
|
||||
const stale =
|
||||
type && !STRUCTURE_SPECS[type.type_id]
|
||||
? paintStale(new Set(groups.map((g) => g.id)))
|
||||
: null;
|
||||
type && !specFor(type.type_id) ? paintStale(new Set(groups.map((g) => g.id))) : null;
|
||||
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
|
||||
root.hidden = !type || (!boxes.length && !stale);
|
||||
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
summaryTooltip,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
@@ -103,7 +103,7 @@ export function linkLamps(
|
||||
): LinkLamp[] {
|
||||
const links = readGroupItems(source.group_items);
|
||||
const summary = readSummaryItem(source.summary_item);
|
||||
const spec = STRUCTURE_SPECS[source.type_id];
|
||||
const spec = specFor(source.type_id);
|
||||
if (spec?.containers.length) {
|
||||
const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
// 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음)
|
||||
|
||||
@@ -24,6 +24,7 @@ import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"
|
||||
import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
|
||||
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
|
||||
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
|
||||
@@ -174,7 +175,12 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
// 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는
|
||||
// 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다.
|
||||
// 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다.
|
||||
if (structure.placement === "interval") {
|
||||
// 띠 = 표 자리꼴 구간(322-1) · 관 지점 시설(물넘이포장 · 기슭막이)은 알약만(지금 그대로)
|
||||
const listed = catalogRow(structure.type_id);
|
||||
if (
|
||||
(listed?.placement ?? structure.placement) === "interval" &&
|
||||
listed?.owner !== "pipe_points"
|
||||
) {
|
||||
const band = document.createElement("div");
|
||||
band.className = `b05-structure__band${selected ? " is-selected" : ""}`;
|
||||
const startPx = options.x(structure.start_m ?? 0);
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from "./B05_Profile_UI_Structures_Panel_Commit";
|
||||
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
||||
import { addsAtOnce, finishAdd } from "./B05_Profile_UI_Structures_Panel_AddAt";
|
||||
import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
@@ -352,7 +352,7 @@ export function createStructuresSection(
|
||||
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
|
||||
function syncTypeOptions(keepTypeId?: string): void {
|
||||
const group = groupSelect.value;
|
||||
const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : [];
|
||||
const candidates = group ? types.filter((t) => t.group === group) : [];
|
||||
typeSelect.replaceChildren(new Option("— 선택 —", ""), ...typeOptions(candidates, canPick));
|
||||
typeSelect.value =
|
||||
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
|
||||
@@ -465,9 +465,7 @@ export function createStructuresSection(
|
||||
const loadPipeForm = (pipe: PipeFacilityItem): void => loadPipeFormInto(panelContext, pipe);
|
||||
const emit = (): void => emitFrom(panelContext);
|
||||
const commit = (live = false): Promise<void> => commitInto(panelContext, live);
|
||||
|
||||
/* 입력 배선은 700줄 제한으로 `_Structures_Panel_Events` 로 옮겼다 — 처리 함수는 여기 그대로다. */
|
||||
/* 저장·배선이 함께 보는 패널 컨텍스트 — 가변 상태의 주인은 그대로 패널이다. */
|
||||
/* 입력 배선(700줄 제한 · `_Structures_Panel_Events`)과 저장이 함께 보는 컨텍스트 — 가변 상태 주인 = 패널. */
|
||||
const panelContext: StructuresEventContext = {
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
@@ -493,6 +491,7 @@ export function createStructuresSection(
|
||||
commitPosition: () => commitPosition(),
|
||||
liveCommit: () => liveCommit(),
|
||||
loadForm,
|
||||
addAt: (at, typeId) => api.addAt(at, typeId), // 정본 다른 종류로 바꿈 = 같은 측점 새로 놓기(217)
|
||||
cancelTempPipe: () => cancelTempPipe(),
|
||||
syncTypeOptions: (keepTypeId) => syncTypeOptions(keepTypeId),
|
||||
syncPlacementFields: () => syncPlacementFields(),
|
||||
@@ -509,17 +508,14 @@ export function createStructuresSection(
|
||||
readBeforeM,
|
||||
syncFacilityForm: (options, designFlow) => syncFacilityForm(options, designFlow),
|
||||
editingId: () => editingId,
|
||||
setEditingId: (value) => {
|
||||
editingId = value;
|
||||
},
|
||||
setEditingId: (value) => void (editingId = value),
|
||||
selectedPipeChainage: () => selectedPipeChainage,
|
||||
setSelectedPipeChainage: (value) => {
|
||||
selectedPipeChainage = value;
|
||||
},
|
||||
tempPipeChainage: () => tempPipeChainage,
|
||||
setTempPipeChainage: (value) => {
|
||||
tempPipeChainage = value;
|
||||
},
|
||||
setTempPipeChainage: (value) => void (tempPipeChainage = value),
|
||||
selectPipe: (at) => api.selectPipeByChainage(at), // 종류 바꿈 못 함 = 옛 관 폼으로(323-6)
|
||||
positionConfirmed: () => positionConfirmed,
|
||||
setPositionConfirmed: (value) => {
|
||||
positionConfirmed = value;
|
||||
@@ -543,7 +539,7 @@ export function createStructuresSection(
|
||||
syncButtons();
|
||||
renderList();
|
||||
|
||||
return {
|
||||
const api: StructuresSection = {
|
||||
root,
|
||||
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
|
||||
listRoot: list,
|
||||
@@ -671,7 +667,7 @@ export function createStructuresSection(
|
||||
renderOptionFields();
|
||||
syncFacilityForm();
|
||||
if (addsAtOnce(type, optionInputs))
|
||||
return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this });
|
||||
return finishAdd({ type, anchorM: chainageM, structures, commit, pick: this });
|
||||
// 임시 배치도 즉시 진행(A군).
|
||||
if (type.managed_by) commitTempPipe(true);
|
||||
syncButtons();
|
||||
@@ -697,4 +693,5 @@ export function createStructuresSection(
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structu
|
||||
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
|
||||
/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창). */
|
||||
const FORM_ONLY = new Set(["pavement_concrete"]);
|
||||
/** 월류 높이를 유량 뒤에 받는 종류 — 추가 때 알림 한 줄. */
|
||||
const FORD_KINDS = new Set(["ford_bridge", "ford_pavement"]);
|
||||
/** 바로 추가하지 않는 동작 — 포장 · 물넘이 겹침(확인창 · 323-7 종류 키 대신 동작 이름). */
|
||||
const FORM_ONLY = "포장_물넘이_겹침";
|
||||
/** 월류 높이를 유량 뒤에 받는 동작 — 추가 때 알림 한 줄. */
|
||||
const FORD_KINDS = "월류단면";
|
||||
export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다.";
|
||||
|
||||
/** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */
|
||||
@@ -21,7 +21,7 @@ export function addsAtOnce(
|
||||
type: StructureType,
|
||||
optionInputs: ReadonlyArray<{ required: boolean; isEmpty: () => boolean }>,
|
||||
): boolean {
|
||||
if (FORM_ONLY.has(type.type_id)) return false;
|
||||
if (type.behaviors?.includes(FORM_ONLY)) return false;
|
||||
return !optionInputs.some((entry) => entry.required && entry.isEmpty());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function finishAdd(ctx: {
|
||||
// B05 는 재계산 뒤 배수유역 패널이 새 관 고름을 다시 알려 폼이 열림.
|
||||
await ctx.commit();
|
||||
ctx.pick.selectPipeByChainage(ctx.anchorM);
|
||||
if (FORD_KINDS.has(ctx.type.type_id)) showToast(FORD_WAIT_NOTICE);
|
||||
if (ctx.type.behaviors?.includes(FORD_KINDS)) showToast(FORD_WAIT_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const before = new Set(ctx.structures.map((entry) => entry.structure_id));
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
|
||||
@@ -21,7 +22,7 @@ import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec";
|
||||
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
||||
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
readGroupItems,
|
||||
@@ -116,7 +117,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
const links = readGroupItems(target.group_items);
|
||||
const summary = readSummaryItem(target.summary_item);
|
||||
// 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6)
|
||||
const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined;
|
||||
const first = type && !specFor(type.type_id) ? type.groups?.[0]?.id : undefined;
|
||||
const moved = !!(first && summary && !links[first]);
|
||||
ctx.setSummary(moved ? null : summary);
|
||||
ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links);
|
||||
@@ -249,15 +250,19 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
const type = ctx.currentType();
|
||||
if (!type) return;
|
||||
const step = ctx.interval();
|
||||
/** 막힌 칸으로 커서 · [추가](실시간 반영 아님)면 어느 칸인지 알림(323-6 · 무반응이던 것). */
|
||||
const stop = (input: HTMLElement | null | undefined, field: string): void => {
|
||||
input?.focus();
|
||||
if (!live) showToast(`「${field}」 칸이 비었거나 맞지 않아 못 놓음 — 칸을 채우세요.`, "error");
|
||||
};
|
||||
const labelOf = (key: string): string =>
|
||||
type.options.find((option) => option.key === key)?.label ?? key;
|
||||
|
||||
// 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다
|
||||
// (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다.
|
||||
if (type.managed_by) {
|
||||
const anchor = ctx.anchorFields.read(step, true);
|
||||
if (anchor === null) {
|
||||
ctx.anchorFields.station.focus();
|
||||
return;
|
||||
}
|
||||
if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점");
|
||||
// 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2).
|
||||
const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility };
|
||||
const options = readOptions(ctx);
|
||||
@@ -288,14 +293,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
// 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이
|
||||
// (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절.
|
||||
anchor = ctx.anchorFields.read(step, true);
|
||||
if (anchor === null) {
|
||||
ctx.anchorFields.station.focus();
|
||||
return;
|
||||
}
|
||||
if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점");
|
||||
const length = ctx.readLengthM();
|
||||
if (length === null) {
|
||||
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
|
||||
return;
|
||||
const input = ctx.optionInputs.find((entry) => entry.key === "length_m")?.input;
|
||||
return stop(input, labelOf("length_m"));
|
||||
}
|
||||
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
|
||||
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
|
||||
@@ -303,8 +305,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
if (anchor - before < -0.005) {
|
||||
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
|
||||
beforeInput?.classList.add("is-invalid");
|
||||
beforeInput?.focus();
|
||||
return;
|
||||
return stop(beforeInput, labelOf("before_m"));
|
||||
}
|
||||
// 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167)
|
||||
start = snapStructureStation(Math.max(anchor - before, 0));
|
||||
@@ -312,35 +313,27 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
} else if (placement === "interval") {
|
||||
start = ctx.startFields.read(step, true);
|
||||
end = ctx.endFields.read(step, true);
|
||||
if (start === null || end === null || end <= start) {
|
||||
(start === null ? ctx.startFields.station : ctx.endFields.station).focus();
|
||||
return;
|
||||
}
|
||||
if (start === null || end === null || end <= start)
|
||||
return stop(
|
||||
start === null ? ctx.startFields.station : ctx.endFields.station,
|
||||
"시작 · 끝 측점",
|
||||
);
|
||||
// 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다.
|
||||
// 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점.
|
||||
anchor = STRUCTURE_SPECS[type.type_id]?.byRange
|
||||
? start
|
||||
: (ctx.anchorFields.read(step, false) ?? start);
|
||||
anchor = specFor(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();
|
||||
return;
|
||||
return stop(ctx.anchorFields.station, "기준 측점(시작 ~ 끝 안)");
|
||||
}
|
||||
} else {
|
||||
anchor = ctx.anchorFields.read(step, true);
|
||||
if (anchor === null) {
|
||||
ctx.anchorFields.station.focus();
|
||||
return;
|
||||
}
|
||||
if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점");
|
||||
}
|
||||
// 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다.
|
||||
// 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다).
|
||||
// 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장]
|
||||
const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty());
|
||||
if (missing && !live) {
|
||||
missing.input.focus();
|
||||
return;
|
||||
}
|
||||
if (missing && !live) return stop(missing.input, labelOf(missing.key));
|
||||
|
||||
const summary = ctx.summary();
|
||||
const base = {
|
||||
@@ -362,7 +355,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
// 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다
|
||||
// (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다.
|
||||
const spans =
|
||||
type.type_id === "pavement_concrete" && start !== null && end !== null
|
||||
type.behaviors?.includes("포장_물넘이_겹침") && start !== null && end !== null
|
||||
? await splitPavementRange(start, end, ctx.pipeFacilities())
|
||||
: [{ start, end }];
|
||||
if (!spans) return;
|
||||
|
||||
@@ -8,13 +8,18 @@
|
||||
* 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit";
|
||||
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection";
|
||||
import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item";
|
||||
import { showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { swapsInPlace } from "../common_util/common_util_structure_catalog";
|
||||
import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
|
||||
/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */
|
||||
@@ -34,6 +39,10 @@ export interface StructuresEventContext extends StructuresCommitContext {
|
||||
commitPosition: () => void;
|
||||
liveCommit: () => void;
|
||||
loadForm: (target: StructureInstance | null) => void;
|
||||
/** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt` · 놓였으면 true). */
|
||||
addAt: (chainageM: number, typeId: string) => Promise<boolean> | void;
|
||||
/** 관 하나를 폼에 엶(패널 `selectPipeByChainage`). */
|
||||
selectPipe: (chainageM: number) => void;
|
||||
emit: () => void;
|
||||
optionRow: HTMLElement;
|
||||
}
|
||||
@@ -82,9 +91,14 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
|
||||
? null
|
||||
: ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M);
|
||||
const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? ""));
|
||||
// 정본이 다른 종류(관 지점 ↔ 구조물)로 · 빈칸으로 = 새로 놓기
|
||||
if (!next || !prev || !!next.managed_by !== !!prev.managed_by)
|
||||
return startNew(ctx.groupSelect.value, next?.type_id);
|
||||
if (!next || !prev) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기
|
||||
// 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔
|
||||
// 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류
|
||||
// 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림
|
||||
if (!swapsInPlace(prev.type_id, next.type_id)) {
|
||||
const at = record ? structureAnchorM(record) : pipeItem!.chainage_m;
|
||||
return void replaceAt(at, prev, next, record);
|
||||
}
|
||||
const key = record ? `s:${id}` : `p:${pipe}`;
|
||||
// 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음
|
||||
stashTypeValues(
|
||||
@@ -116,8 +130,37 @@ export function bindStructuresEvents(ctx: StructuresEventContext): {
|
||||
// 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4)
|
||||
if (pipeItem)
|
||||
forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id);
|
||||
// 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림
|
||||
if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record;
|
||||
// 반영이 막히면(측점 · 필수 칸 등) 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰 · 323-6)
|
||||
if (record && draft && ctx.structures[index] === draft) {
|
||||
ctx.structures[index] = record;
|
||||
ctx.loadForm(record);
|
||||
showToast(`${next.name}(으)로 못 바꿈 — 폼 칸(측점 · 필수 칸)을 확인하세요.`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 지우고 같은 기준 측점에 새로 — 새 종류가 기본값으로 놓인 뒤에만 옛 것을 지움. 못 놓으면(빈 칸 ·
|
||||
* [추가] 가 막음) 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 조용히 사라지던 것). */
|
||||
async function replaceAt(
|
||||
at: number,
|
||||
prev: { type_id: string; name: string },
|
||||
next: { type_id: string; name: string },
|
||||
record: StructureInstance | null,
|
||||
): Promise<void> {
|
||||
if (!(await ctx.addAt(at, next.type_id))) {
|
||||
if (record) ctx.loadForm(record);
|
||||
else ctx.selectPipe(at);
|
||||
showToast(
|
||||
`${prev.name} 그대로 — ${next.name}(으)로 못 바꿈(빈 칸 때문에 바로 못 놓음)`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (record) {
|
||||
const index = ctx.structures.findIndex((e) => e.structure_id === record.structure_id);
|
||||
if (index >= 0) ctx.structures.splice(index, 1);
|
||||
ctx.emit();
|
||||
} else ctx.callbacks.onPipeRemove(at);
|
||||
forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id);
|
||||
}
|
||||
|
||||
type TypeValues = {
|
||||
|
||||
@@ -21,14 +21,15 @@ import {
|
||||
} from "./B05_Profile_UI_Structures_Define";
|
||||
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { baseType } from "../common_util/common_util_structure_catalog";
|
||||
import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec";
|
||||
import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec";
|
||||
|
||||
/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로
|
||||
* 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
|
||||
export const hidesTypeSummary = (type: StructureType): boolean =>
|
||||
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B";
|
||||
!!specFor(type.type_id)?.containers.length || !!type.groups?.length || type.group === "B";
|
||||
|
||||
/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일 · 표 09 흙막이 · 옹벽). */
|
||||
const SOLVE_SPLIT = new Set(["guardrail", "soil_guard", "retaining_wall"]);
|
||||
@@ -150,7 +151,7 @@ export function renderOptionFields(
|
||||
"length" | "before_m" | "after_m";
|
||||
const refresh = (normalize: boolean): void => {
|
||||
input.classList.remove("is-invalid");
|
||||
if (SOLVE_SPLIT.has(type!.type_id)) {
|
||||
if (SOLVE_SPLIT.has(baseType(type!.type_id))) {
|
||||
solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게
|
||||
ctx.syncRangeDisplay();
|
||||
} else ctx.syncSplitInputs(source, normalize);
|
||||
@@ -273,7 +274,7 @@ export function paintStale(
|
||||
shapeKeys: string[] = [],
|
||||
): void {
|
||||
// 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎
|
||||
const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined;
|
||||
const spec = type ? specFor(type.type_id) : undefined;
|
||||
const own = spec ? specKeys(spec) : { keys: [], prefixes: [] };
|
||||
const known = [
|
||||
...shapeKeys,
|
||||
|
||||
@@ -20,8 +20,8 @@ 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";
|
||||
import { pickable } from "./B05_Profile_UI_Structures_Define";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
|
||||
/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다.
|
||||
* 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */
|
||||
@@ -36,22 +36,6 @@ export const GROUP_LABELS: Record<string, string> = {
|
||||
호환: "기타",
|
||||
};
|
||||
|
||||
/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07).
|
||||
* 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */
|
||||
export const HIDDEN_PICK_GROUPS: ReadonlySet<string> = new Set(["B", "D"]);
|
||||
|
||||
/** 고르기 목록에서 뺄 종류 — 사면안정은 흙막이 · 옹벽 · 소단만(54-31). 옛 값은 그대로. */
|
||||
export const HIDDEN_PICK_TYPES: ReadonlySet<string> = new Set([
|
||||
"masonry_wet",
|
||||
"masonry_dry",
|
||||
"boulder_masonry",
|
||||
]);
|
||||
|
||||
/** 새로 놓을 때 고르기 목록에서 가릴 종류인가 — 뺄 군이거나 뺄 종류. */
|
||||
export function isPickHidden(type: { group: string; type_id: string }): boolean {
|
||||
return HIDDEN_PICK_GROUPS.has(type.group) || HIDDEN_PICK_TYPES.has(type.type_id);
|
||||
}
|
||||
|
||||
/** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
|
||||
export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement {
|
||||
const option = new Option(text, value);
|
||||
@@ -60,9 +44,10 @@ export function pickOption(text: string, value: string, hidden: boolean): HTMLOp
|
||||
return option;
|
||||
}
|
||||
|
||||
/** 고르기 목록에서 가릴 종류인가 — 꺼진(pick) 종류 · 뺄 군 · 뺄 종류(드롭다운 · 메뉴 공용). */
|
||||
/** 고르기 목록에서 가릴 종류인가 — 페이지 거르기(`listPick` · 구조물 표 목록 · 322-1)가 고름. 옛 군 · 종류
|
||||
* 숨김(종단배수 · 계류사방 · 돌쌓기 셋 · 옛 대피 셋)은 표 처음값 hidden(드롭다운 · 메뉴 공용). */
|
||||
const pickHidden = (type: StructureType, pick: (type: StructureType) => boolean): boolean =>
|
||||
!pick(type) || isPickHidden(type);
|
||||
!pick(type);
|
||||
|
||||
/** 고르기 목록 한 군 — 가린 군 · 종류도 담음(옛 항목을 열 때 값으로 고름). */
|
||||
export interface PickGroup {
|
||||
@@ -73,20 +58,25 @@ export interface PickGroup {
|
||||
}
|
||||
|
||||
/** 군 → 종류 고르기 목록 한 벌 — 패널 드롭다운과 우클릭 메뉴(종단 · 배수유역도 · 3D · B06)가
|
||||
* 같은 자료를 봄(129-1). 군 이름은 정의(48) 먼저 · 옛 대피 종류는 뺌 · 꺼진(pick) 종류 · 뺄 군 ·
|
||||
* 뺄 종류는 가림 · 보일 종류가 없는 군도 가림. */
|
||||
* 같은 자료를 봄(129-1). 군 이름은 정의(48) 먼저 · 군 차례 = 정의 구조물군 차례 · 군 안 = 종류 차례 ·
|
||||
* 페이지 거르기(pick)가 안 고른 종류는 가림 · 보일 종류가 없는 군도 가림. */
|
||||
export function pickGroups(
|
||||
types: ReadonlyArray<StructureType>,
|
||||
pick: (type: StructureType) => boolean,
|
||||
): PickGroup[] {
|
||||
const groups = new Map(types.map((t) => [t.group, t.group_name] as const));
|
||||
return [...groups].map(([group, name]) => {
|
||||
const members = types
|
||||
.filter((t) => t.group === group && pickable(t))
|
||||
.map((type) => ({ type, hidden: pickHidden(type, pick) }));
|
||||
const hidden = members.every((member) => member.hidden);
|
||||
return { group, label: name ?? GROUP_LABELS[group] ?? group, hidden, types: members };
|
||||
});
|
||||
const rank = (group: string): number =>
|
||||
catalogRow(types.find((t) => t.group === group)?.type_id ?? "")?.groupOrder ??
|
||||
Number.MAX_SAFE_INTEGER;
|
||||
return [...groups]
|
||||
.sort(([a], [b]) => rank(a) - rank(b))
|
||||
.map(([group, name]) => {
|
||||
const members = types
|
||||
.filter((t) => t.group === group)
|
||||
.map((type) => ({ type, hidden: pickHidden(type, pick) }));
|
||||
const hidden = members.every((member) => member.hidden);
|
||||
return { group, label: name ?? GROUP_LABELS[group] ?? group, hidden, types: members };
|
||||
});
|
||||
}
|
||||
|
||||
/** 군 드롭다운 항목들 — `pickGroups` 그대로. */
|
||||
@@ -184,8 +174,8 @@ export interface StructuresSection {
|
||||
selection: DesignSelection,
|
||||
coverable?: (entry: StructureInstance) => boolean,
|
||||
) => ResolvedSelection;
|
||||
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
|
||||
addAt: (chainageM: number, typeId: string) => void;
|
||||
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. 바로 놓으면 놓였는지(true) · 폼만이면 없음. */
|
||||
addAt: (chainageM: number, typeId: string) => Promise<boolean> | void;
|
||||
/** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */
|
||||
moveById: (structureId: string, toChainageM: number) => boolean;
|
||||
removeById: (structureId: string) => boolean;
|
||||
@@ -245,7 +235,7 @@ export function readBeforeOption(inputs: OptionEntry[]): number {
|
||||
|
||||
/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */
|
||||
export const isRangeOnly = (type: { type_id: string } | null): boolean =>
|
||||
!!type && !!STRUCTURE_SPECS[type.type_id]?.byRange;
|
||||
!!type && !!specFor(type.type_id)?.byRange;
|
||||
|
||||
/** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이
|
||||
* 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만
|
||||
|
||||
@@ -125,11 +125,16 @@ export function snapOldStations(ctx: SnapContext): number {
|
||||
}
|
||||
plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes));
|
||||
}
|
||||
if (count || plan.skipped)
|
||||
showToast(
|
||||
(count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") +
|
||||
(plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""),
|
||||
plan.skipped ? "info" : "success",
|
||||
);
|
||||
snapNotice(count, plan.skipped);
|
||||
return count;
|
||||
}
|
||||
|
||||
/** 옛 소수 맞춤 알림 한 줄 — B05 종단 보기 · B06 구조물 목록(관)이 같은 글. */
|
||||
export function snapNotice(count: number, skipped: number): void {
|
||||
if (count || skipped)
|
||||
showToast(
|
||||
(count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") +
|
||||
(skipped ? ` 맞출 자리에 다른 관이 있는 관 ${skipped}건은 그대로 둡니다.` : ""),
|
||||
skipped ? "info" : "success",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
type SpecFormHooks,
|
||||
type SpecLinks,
|
||||
} from "../A00_Common/design/design_structure_form";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specFor } from "../A00_Common/design/design_structure_pipe";
|
||||
import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier";
|
||||
import { baseType } from "../common_util/common_util_structure_catalog";
|
||||
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
|
||||
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
|
||||
|
||||
const forms = new WeakMap<HTMLElement, SpecForm>();
|
||||
@@ -27,7 +28,7 @@ const widenings = new WeakMap<HTMLElement, WideningBox>();
|
||||
|
||||
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */
|
||||
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
|
||||
(type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) ||
|
||||
(type && !type.managed_by && (specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)])) ||
|
||||
null;
|
||||
|
||||
/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */
|
||||
@@ -86,7 +87,15 @@ export function paintStructureSpec(
|
||||
form.root.hidden = !spec;
|
||||
if (!spec) return;
|
||||
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
form.write(spec, values, type?.spec_names ?? {}, groups, optionDefaults(type?.options ?? []));
|
||||
const options = type?.options ?? [];
|
||||
form.write(
|
||||
spec,
|
||||
values,
|
||||
type?.spec_names ?? {},
|
||||
groups,
|
||||
optionDefaults(options),
|
||||
hiddenOptionKeys(options),
|
||||
);
|
||||
}
|
||||
|
||||
/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import { pickLights } from "../A00_Common/design/design_pick_match";
|
||||
import { isNear } from "../A00_Common/design/design_selection";
|
||||
|
||||
/** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */
|
||||
export interface StructurePick {
|
||||
@@ -17,6 +19,8 @@ export interface StructurePick {
|
||||
kind: string;
|
||||
/** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */
|
||||
key?: string;
|
||||
/** 구조물 id(323-5 · C군 벽) — 있으면 id 로 맞춤(고름 강조 한 규칙 `pickLights`). */
|
||||
structureId?: string | null;
|
||||
}
|
||||
|
||||
export interface StructurePickControls {
|
||||
@@ -64,18 +68,31 @@ export function bindStructurePick(options: {
|
||||
while (node) {
|
||||
const data = node.userData as Partial<StructurePick>;
|
||||
if (typeof data.chainageM === "number") {
|
||||
return { chainageM: data.chainageM, kind: String(data.kind ?? ""), key: data.key };
|
||||
return {
|
||||
chainageM: data.chainageM,
|
||||
kind: String(data.kind ?? ""),
|
||||
key: data.key,
|
||||
structureId: data.structureId,
|
||||
};
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 이 부재가 지금 고른 것인가 — 부재키가 없는 선택(측점만)은 그 측점 전부를 켠다. */
|
||||
/** 이 부재가 지금 고른 것인가 — 고름 강조 한 규칙(카드 형상과 같음 · 부재키가 없는 선택 = 그 자리 전부). */
|
||||
const asSelection = (pick: StructurePick) => ({
|
||||
chainageM: pick.chainageM,
|
||||
structureId: pick.structureId ?? null,
|
||||
partKey: pick.key,
|
||||
});
|
||||
const asTag = (pick: StructurePick) => ({
|
||||
chainageM: pick.chainageM,
|
||||
structureId: pick.structureId ?? null,
|
||||
part: pick.key,
|
||||
});
|
||||
function matches(tag: StructurePick): boolean {
|
||||
if (!selected) return false;
|
||||
if (Math.abs(tag.chainageM - selected.chainageM) >= 0.01) return false;
|
||||
return selected.key === undefined || tag.key === selected.key;
|
||||
return !!selected && pickLights(asSelection(selected), asTag(tag));
|
||||
}
|
||||
|
||||
function applyHighlight(): void {
|
||||
@@ -138,8 +155,8 @@ export function bindStructurePick(options: {
|
||||
if (!pick) return;
|
||||
const same =
|
||||
selected !== null &&
|
||||
Math.abs(selected.chainageM - pick.chainageM) < 0.01 &&
|
||||
selected.key === pick.key;
|
||||
selected.key === pick.key &&
|
||||
pickLights(asSelection(selected), asTag(pick));
|
||||
selected = same ? null : pick;
|
||||
applyHighlight();
|
||||
controls.onPick?.(selected);
|
||||
@@ -162,7 +179,7 @@ export function bindStructurePick(options: {
|
||||
selectAtChainage(chainageM) {
|
||||
// 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다 — 되돌아온 동기화가
|
||||
// 부재 하나짜리 강조를 그 측점 전체로 넓히면 안 된다(2026-09-04).
|
||||
if (chainageM !== null && selected && Math.abs(selected.chainageM - chainageM) < 0.01) return;
|
||||
if (chainageM !== null && selected && isNear(selected.chainageM, chainageM)) return;
|
||||
selected = chainageM === null ? null : { chainageM, kind: "" };
|
||||
applyHighlight();
|
||||
},
|
||||
|
||||
@@ -339,6 +339,8 @@ export interface CrossSection extends SectionStation {
|
||||
pavement?: PavementEntry[];
|
||||
/** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */
|
||||
underdrain?: UnderdrainSpec;
|
||||
/** 빈칸 경고(323-4 · 읽기 칸 · 저장 안 함) — 「종류 칸」(형상 필수 칸이 빈 관 · 구조물 · 측점 계획 자리). */
|
||||
blank?: string[];
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""형상 얹기 한 벌(323-4) — 측점(횡단 dict) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
|
||||
|
||||
구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 →
|
||||
빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀.
|
||||
TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) ·
|
||||
거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import (
|
||||
attach_culvert_specs,
|
||||
culvert_sets_from,
|
||||
pipe_points_file,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from
|
||||
from common_util.common_util_drainage_pipes import read_pipe_points_file
|
||||
from common_util.common_util_structure_catalog import catalog_row, field_name, type_ids_with
|
||||
from common_util.common_util_structure_pavement import (
|
||||
attach_pavement_specs,
|
||||
pavement_specs_from,
|
||||
)
|
||||
from common_util.common_util_structure_station import (
|
||||
SAME_STATION_M,
|
||||
blank_keys,
|
||||
plan_stations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 측점 그림 자료 열쇠 — TS `common_util_structure_shape_keys.SHAPE_KEYS` 짝.
|
||||
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
|
||||
|
||||
|
||||
def _blank_labels(type_id: str, options: dict | None, types: dict[str, Any]) -> list[str]:
|
||||
"""빈 필수 칸 → 「종류 이름 칸 이름」(TS `blankLabels` 짝)."""
|
||||
name = (catalog_row(type_id) or {}).get("name") or type_id
|
||||
ordered = [types[type_id]] if type_id in types else []
|
||||
ordered += list(types.values())
|
||||
out = []
|
||||
for key in blank_keys(type_id, options):
|
||||
label = next(
|
||||
(o.label for t in ordered for o in t.options if o.key == key and o.label), None
|
||||
)
|
||||
out.append(f"{name} {label or field_name(key) or key}")
|
||||
return out
|
||||
|
||||
|
||||
def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
|
||||
"""빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(TS `attachBlanks` 짝)."""
|
||||
types = structure_type_map()
|
||||
owners: dict[str, tuple[str, dict | None]] = {}
|
||||
for pipe in pipes:
|
||||
owner = getattr(pipe, "id", None) or f"pipe-{float(pipe.chainage_m):.2f}"
|
||||
owners[owner] = (pipe.facility or "pipe", pipe.options)
|
||||
for item in structures:
|
||||
if item.structure_id:
|
||||
owners[item.structure_id] = (item.type_id, item.options)
|
||||
for station in plan_stations(pipes, structures):
|
||||
if not station["blank"]:
|
||||
continue
|
||||
labels: list[str] = []
|
||||
for owner in station["owners"]:
|
||||
hit = owners.get(owner)
|
||||
for label in _blank_labels(hit[0], hit[1], types) if hit else []:
|
||||
if label not in labels:
|
||||
labels.append(label)
|
||||
for section in sections:
|
||||
at = section.get("chainage_m")
|
||||
if isinstance(at, (int, float)) and abs(at - station["chainage_m"]) < SAME_STATION_M:
|
||||
section["blank"] = labels
|
||||
|
||||
|
||||
def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
|
||||
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침)."""
|
||||
for section in sections:
|
||||
for key in (*SHAPE_KEYS, "blank"):
|
||||
section.pop(key, None)
|
||||
attach_culvert_specs(sections, culvert_sets_from(pipes))
|
||||
# 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음
|
||||
attach_wall_specs(sections, wall_specs_from(structures))
|
||||
attach_pavement_specs(
|
||||
sections,
|
||||
pavement_specs_from([s.model_dump() for s in structures], type_ids_with(shape="pavement")),
|
||||
)
|
||||
attach_underdrain_specs(sections, underdrains_from(structures))
|
||||
_attach_blanks(sections, pipes, structures)
|
||||
|
||||
|
||||
def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None:
|
||||
"""저장분 현황(관 지점 · 구조물 정본)으로 얹음 — 구조물 정본을 못 읽으면 관만(횡단 조회는 이어 감)."""
|
||||
pipes = read_pipe_points_file(pipe_points_file(project_root))
|
||||
try:
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
||||
logger.exception("B06 구조물 정본을 읽지 못했습니다 (관만 얹음)")
|
||||
structures = []
|
||||
attach_shapes(sections, pipes, structures)
|
||||
@@ -25,19 +25,13 @@ import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_FORD_PAVEMENT,
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_FACILITY_REVET,
|
||||
parse_pipe_points,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import parse_pipe_points
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
@@ -46,8 +40,8 @@ from config.config_system import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다.
|
||||
_CHAINAGE_TOLERANCE_M = 0.02
|
||||
# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7)
|
||||
_CHAINAGE_TOLERANCE_M = EDGE_TOLERANCE_M
|
||||
|
||||
# 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다.
|
||||
_SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"}
|
||||
@@ -147,12 +141,12 @@ def pipe_points_file(project_root: Path) -> Path:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _registry_defaults(type_id: str) -> dict[str, Any]:
|
||||
"""레지스트리 타입 옵션의 기본값 묶음.
|
||||
|
||||
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
|
||||
기본값과 항상 같은 값이 그림에 들어간다.
|
||||
기본값과 항상 같은 값이 그림에 들어간다. 캐시 없음(322-0) — 정의 읽기가 이미 파일 시각으로
|
||||
갈려 M02 저장 즉시 새 기본값(옛 lru 는 서버를 다시 켤 때까지 옛 값).
|
||||
"""
|
||||
structure_type = structure_type_map().get(type_id)
|
||||
if structure_type is None:
|
||||
@@ -611,20 +605,27 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path)
|
||||
return {}
|
||||
return culvert_sets_from(parse_pipe_points(document.get("points")))
|
||||
|
||||
|
||||
def culvert_sets_from(points: list[Any]) -> dict[float, dict[str, Any]]:
|
||||
"""관 지점 목록 → 누가거리(소수 2자리)별 세트 제원(TS `buildCulvertSets` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
sets: dict[float, dict[str, Any]] = {}
|
||||
for point in parse_pipe_points(document.get("points")):
|
||||
if point.facility == PIPE_FACILITY_PIPE:
|
||||
for point in points:
|
||||
# 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝)
|
||||
shape = (catalog_row(point.facility) or {}).get("shape")
|
||||
if shape == "pipe_set":
|
||||
if (point.options or {}).get("pipe_diameter_mm") in (None, ""):
|
||||
continue # 관경 안 고름 = 관 없음(54-39)
|
||||
spec = _culvert_set(point.options)
|
||||
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
|
||||
elif shape == "ford":
|
||||
spec = _ford_set(point.options)
|
||||
elif point.facility == PIPE_FACILITY_BOX:
|
||||
elif shape == "box":
|
||||
spec = _box_set(point.options)
|
||||
elif point.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
elif shape == "ford_pavement":
|
||||
# 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28).
|
||||
spec = _ford_pavement_set(point.options)
|
||||
elif point.facility == PIPE_FACILITY_REVET:
|
||||
elif shape == "hidden_revet":
|
||||
# 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관
|
||||
# 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다.
|
||||
spec = _revet_set(point.options)
|
||||
@@ -636,7 +637,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
|
||||
def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다."""
|
||||
sets = load_culvert_sets(project_root)
|
||||
return attach_culvert_specs(cross_sections, load_culvert_sets(project_root))
|
||||
|
||||
|
||||
def attach_culvert_specs(
|
||||
cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]]
|
||||
) -> int:
|
||||
"""세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다."""
|
||||
if not sets:
|
||||
return 0
|
||||
attached = 0
|
||||
|
||||
@@ -28,14 +28,13 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
|
||||
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
|
||||
_SOLVE_STEPS = 24
|
||||
#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다.
|
||||
_MAX_PROBE_WIDTH_M = 1000.0
|
||||
#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장).
|
||||
SPOIL_TYPE_ID = "spoil_bank"
|
||||
#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다.
|
||||
_SIDE_AUTO = "자동(성토 쪽)"
|
||||
_SIDE_WORDS = {"좌": "left", "우": "right"}
|
||||
@@ -108,7 +107,7 @@ def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
type_id = getattr(item, "type_id", None) or (
|
||||
item.get("type_id") if isinstance(item, dict) else None
|
||||
)
|
||||
if str(type_id) != SPOIL_TYPE_ID:
|
||||
if (catalog_row(str(type_id)) or {}).get("shape") != "spoil": # 표 형상(322-1)
|
||||
continue
|
||||
options = getattr(item, "options", None)
|
||||
if options is None and isinstance(item, dict):
|
||||
|
||||
@@ -24,21 +24,22 @@ from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from common_util.common_util_structure_catalog import base_type, catalog_row
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다.
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7)
|
||||
_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
|
||||
|
||||
# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태는 벽 안 무늬만 정하므로
|
||||
# 가장 가까운 것으로 잇는다.
|
||||
_FORM_BY_TYPE = {
|
||||
"masonry_wet": "돌쌓기(찰)",
|
||||
"masonry_dry": "돌쌓기(메)",
|
||||
"boulder_masonry": "돌쌓기(메)",
|
||||
"retaining_wall": "콘크리트",
|
||||
"soil_guard": "통나무·목재틀",
|
||||
}
|
||||
|
||||
def _wall_form(type_id: str) -> str | None:
|
||||
"""종류 → 횡단 기하가 아는 **형태** 이름 = 구조물 표 형상칸.form(322-1 · TS `formOf` 짝).
|
||||
|
||||
형태는 벽 안 무늬만 정하므로 가장 가까운 것으로 잇는다(폼에서 형태를 비웠을 때).
|
||||
"""
|
||||
form = ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form")
|
||||
return form if isinstance(form, str) else None
|
||||
|
||||
|
||||
# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`.
|
||||
@@ -58,7 +59,7 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"form": "콘크리트"
|
||||
if type_id == "retaining_wall"
|
||||
if base_type(type_id) == "retaining_wall" # 본보기 포함(323-7)
|
||||
else (options.get(f"tier_{n}_form") or None),
|
||||
"height_m": _num(options.get(f"tier_{n}_height")),
|
||||
}
|
||||
@@ -66,60 +67,60 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
|
||||
BERM_TYPE_ID = "berm"
|
||||
|
||||
|
||||
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록."""
|
||||
"""구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록.
|
||||
|
||||
소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9).
|
||||
"""
|
||||
try:
|
||||
types = structure_type_map()
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in load_structures(str(project_root))[1]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or definition.group != "C" or definition.placement != "interval":
|
||||
continue
|
||||
# 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
# 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
|
||||
if structure.type_id == BERM_TYPE_ID:
|
||||
continue
|
||||
start, end = structure.start_m, structure.end_m
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
extras = tier_extras(structure.type_id, options)
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"type_id": structure.type_id,
|
||||
"name": definition.name,
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(structure.anchor_m()),
|
||||
"form": options.get("form") or _FORM_BY_TYPE.get(structure.type_id),
|
||||
"height_m": options.get("height_m"),
|
||||
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
|
||||
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
|
||||
"side": options.get("side"),
|
||||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
||||
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
||||
"foundation": options.get("foundation"),
|
||||
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
||||
"face_role": options.get("face_role"),
|
||||
"face_slope_ratio": options.get("face_slope_ratio"),
|
||||
}
|
||||
)
|
||||
return found
|
||||
return wall_specs_from(load_structures(str(project_root))[1])
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
|
||||
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
||||
return []
|
||||
|
||||
|
||||
def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
"""구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
types = structure_type_map()
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
|
||||
continue
|
||||
start, end = structure.start_m, structure.end_m
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
extras = tier_extras(structure.type_id, options)
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"type_id": structure.type_id,
|
||||
"name": definition.name,
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(structure.anchor_m()),
|
||||
"form": options.get("form") or _wall_form(structure.type_id),
|
||||
"height_m": options.get("height_m"),
|
||||
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
|
||||
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
|
||||
"side": options.get("side"),
|
||||
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
|
||||
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
|
||||
"foundation": options.get("foundation"),
|
||||
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
|
||||
"tiers": len(extras) + 1 if extras else options.get("tiers"),
|
||||
"extras": extras,
|
||||
"lift_m": options.get("lift_m"),
|
||||
"shift_m": options.get("shift_m"),
|
||||
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
|
||||
"face_role": options.get("face_role"),
|
||||
"face_slope_ratio": options.get("face_slope_ratio"),
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
|
||||
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
|
||||
|
||||
@@ -143,7 +144,7 @@ def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, fl
|
||||
try:
|
||||
spans: list[tuple[float, float, dict[str, float]]] = []
|
||||
for item in load_structures(str(project_root))[1]:
|
||||
if item.type_id != BERM_TYPE_ID:
|
||||
if (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4)
|
||||
continue
|
||||
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
start = item.start_m if item.start_m is not None else anchor
|
||||
@@ -191,7 +192,14 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
|
||||
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
|
||||
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
|
||||
"""
|
||||
walls = load_wall_structures(project_root)
|
||||
attached = attach_wall_specs(cross_sections, load_wall_structures(project_root))
|
||||
if attached:
|
||||
logger.info("B06 구조물 벽 %d개 측점에 얹음", attached)
|
||||
return attached
|
||||
|
||||
|
||||
def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int:
|
||||
"""구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다."""
|
||||
if not walls:
|
||||
return 0
|
||||
attached = 0
|
||||
@@ -210,6 +218,4 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
|
||||
section["revetment"] = spec
|
||||
attached += 1
|
||||
break
|
||||
if attached:
|
||||
logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls))
|
||||
return attached
|
||||
|
||||
@@ -17,11 +17,12 @@ from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERDRAIN_TYPE_ID = "cross_underdrain"
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
|
||||
|
||||
TiltRange = tuple[float, float, float | None]
|
||||
|
||||
@@ -42,9 +43,14 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]:
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)")
|
||||
return []
|
||||
return underdrains_from(structures)
|
||||
|
||||
|
||||
def underdrains_from(structures: list[Any]) -> list[dict[str, Any]]:
|
||||
"""구조물 목록의 맹암거 제원(TS `underdrainSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
if structure.type_id != UNDERDRAIN_TYPE_ID:
|
||||
if (catalog_row(structure.type_id) or {}).get("shape") != "underdrain":
|
||||
continue
|
||||
options = structure.options or {}
|
||||
height = _number(options.get("culvert_height_m"))
|
||||
@@ -85,7 +91,13 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
|
||||
|
||||
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
|
||||
drains = load_underdrains(project_root)
|
||||
return attach_underdrain_specs(cross_sections, load_underdrains(project_root))
|
||||
|
||||
|
||||
def attach_underdrain_specs(
|
||||
cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]]
|
||||
) -> int:
|
||||
"""기준 측점에 맹암거 제원을 얹는다(TS `attachUnderdrainSpecs` 짝). 얹은 개수를 돌려준다."""
|
||||
attached = 0
|
||||
for section in cross_sections:
|
||||
chainage = _number(section.get("chainage_m"))
|
||||
|
||||
@@ -19,14 +19,13 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
|
||||
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
create_longitudinal_section,
|
||||
@@ -87,7 +86,6 @@ 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
|
||||
@@ -278,15 +276,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
isinstance(section, dict) for section in cross_sections
|
||||
):
|
||||
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
|
||||
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||
attach_culvert_sets(root, cross_sections)
|
||||
# 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고) = 형상 얹기 한 벌(323-4 · 화면 짝 attachShapes)
|
||||
attach_saved_shapes(root, cross_sections)
|
||||
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
|
||||
attach_underdrains(root, cross_sections) # 맹암거 형상(288-1)
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@@ -446,11 +438,8 @@ async def regenerate_sections(
|
||||
await connection.rollback()
|
||||
raise
|
||||
result = sections["result"]
|
||||
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
|
||||
attach_culvert_sets(project_root, result["cross_sections"])
|
||||
attach_wall_structures(project_root, result["cross_sections"])
|
||||
attach_pavement_structures(project_root, result["cross_sections"])
|
||||
attach_underdrains(project_root, result["cross_sections"])
|
||||
# 재생성 응답도 상세 조회와 같은 형상 얹기 한 벌(323-4) — 화면이 어긋나지 않게.
|
||||
attach_saved_shapes(project_root, result["cross_sections"])
|
||||
return SectionDetailResponse(
|
||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from common_util.common_util_cross_berm import (
|
||||
BermSpec,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -125,8 +126,8 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값.
|
||||
_PAVED_EDGE_TOLERANCE_M = 0.02
|
||||
# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7)
|
||||
_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
|
||||
|
||||
|
||||
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
|
||||
|
||||
@@ -138,11 +138,12 @@ def _station_chainages(project_root: Path) -> list[float]:
|
||||
|
||||
def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
"""종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음."""
|
||||
떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272) · 같은 자리 다른 종류 · 이름(322-3 ·
|
||||
관 공식 · 정의 판이 바뀐 이름 포함 — 이름이 지금 정의 판에서 옴). 0 이면 맞음."""
|
||||
import json
|
||||
|
||||
planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)}
|
||||
standing: set[float] = set()
|
||||
planted = {(round(value, 2), label) for value, label in _planted(project_root, route_data_path)}
|
||||
standing: set[tuple[float, str]] = set()
|
||||
for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -150,7 +151,8 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int:
|
||||
continue
|
||||
for station in data.get("stations") or []:
|
||||
if station.get("kind") == "irregular" or station.get("structure"):
|
||||
standing.add(round(float(station.get("chainage_m") or 0.0), 2))
|
||||
at = round(float(station.get("chainage_m") or 0.0), 2)
|
||||
standing.add((at, str(station.get("structure") or "")))
|
||||
return len(standing ^ planted)
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB
|
||||
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
|
||||
*
|
||||
* 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙
|
||||
* (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`.
|
||||
* 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) ·
|
||||
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점
|
||||
* 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -20,16 +21,14 @@ import {
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import { registryDefaults } from "@util/common_util_culvert_sets";
|
||||
import { attachShapes } from "@util/common_util_structure_attach";
|
||||
import {
|
||||
pipeStationMarks,
|
||||
plantedChainages,
|
||||
planStations,
|
||||
SAME_STATION_M,
|
||||
structureStationMarks,
|
||||
type PlannedStation,
|
||||
} from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
previewStructureSections,
|
||||
@@ -103,54 +102,64 @@ async function previewOf(
|
||||
return chainages.flatMap((at) => previews.get(key(at)) ?? []);
|
||||
}
|
||||
|
||||
/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
|
||||
function decorate(
|
||||
fresh: CrossSection[],
|
||||
pipes: Pipes,
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
): void {
|
||||
const registry = registryDefaults(types);
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
const pipeLabel = (pipe: Pipes[number]): string | null => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
if (facility !== "pipe") return byId.get(facility)?.name ?? facility;
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
};
|
||||
const labels: Array<[number, string]> = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const label = pipeLabel(pipe);
|
||||
return label
|
||||
? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label])
|
||||
: [];
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const type = byId.get(structure.type_id);
|
||||
const label = type?.name ?? structure.type_id;
|
||||
return structureStationMarks(structure, type).map((m): [number, string] => [
|
||||
m.chainage_m,
|
||||
label,
|
||||
]);
|
||||
}),
|
||||
];
|
||||
/** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 ·
|
||||
* 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */
|
||||
const bares = new WeakMap<CrossSection, CrossSection>();
|
||||
function bareOf(section: CrossSection): CrossSection {
|
||||
let copy = bares.get(section);
|
||||
if (!copy) {
|
||||
copy = { ...section, preview: true };
|
||||
const record = copy as unknown as Record<string, unknown>;
|
||||
for (const key of ["culvert", "ford", "box", "ford_pavement", "structure"]) delete record[key];
|
||||
bares.set(section, copy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */
|
||||
function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void {
|
||||
for (const section of fresh) {
|
||||
section.preview = true;
|
||||
const hit = labels.find(([at]) => near(at, section.chainage_m));
|
||||
if (hit) section.structure = hit[1];
|
||||
// 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저)
|
||||
const hit = planned.find((station) => near(station.chainage_m, section.chainage_m));
|
||||
if (hit) section.structure = hit.title;
|
||||
else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217)
|
||||
}
|
||||
const own = pipes.filter((pipe) =>
|
||||
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
|
||||
);
|
||||
// 미리 뜬 횡단은 한 벌을 기억해 다시 씀 — 얹기 전에 관 계열 제원을 비움(앞 종류 제원이 남아
|
||||
// 겹치던 것 · 서버가 얹어 온 저장분 종류도 · 열쇠 = `Section_Store` FACILITY_KEYS 와 같음 · 317-3)
|
||||
for (const section of fresh as unknown as Array<Record<string, unknown>>) {
|
||||
for (const key of ["culvert", "ford", "box", "ford_pavement"]) delete section[key];
|
||||
}
|
||||
attachCulvertSets(
|
||||
fresh as unknown as Array<Record<string, unknown>>,
|
||||
buildCulvertSets(own, registry),
|
||||
);
|
||||
}
|
||||
|
||||
/** 현황 = 종류마다 초안 ?? 저장분 + 종류 표 — 측점 계획 · 형상 얹기가 같은 것(못 받으면 null). */
|
||||
function statusOf(
|
||||
projectId: string,
|
||||
pipesDraft: Pipes | null,
|
||||
structuresDraft: StructureInstance[] | null,
|
||||
): Promise<[Pipes, StructureInstance[], StructureType[]] | null> {
|
||||
return Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
}
|
||||
|
||||
/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */
|
||||
const drafted = new WeakSet<SectionDetailResponse>();
|
||||
|
||||
/**
|
||||
* 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 ·
|
||||
* 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로.
|
||||
*/
|
||||
export async function withDraftShapes(
|
||||
detail: SectionDetailResponse,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
const pipesDraft = readPendingPipes(projectId);
|
||||
const structuresDraft = readPendingStructures(projectId);
|
||||
const drafts = !!(pipesDraft || structuresDraft);
|
||||
if (!drafts && !drafted.has(detail)) return;
|
||||
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
|
||||
if (!inputs) return;
|
||||
attachShapes(detail.cross_sections as unknown as Array<Record<string, unknown>>, ...inputs);
|
||||
if (drafts) drafted.add(detail);
|
||||
else drafted.delete(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,14 +184,11 @@ export async function withDraftStations(
|
||||
let stale: CrossSection[] = [];
|
||||
let fresh: CrossSection[] = [];
|
||||
if (pipesDraft || structuresDraft) {
|
||||
const inputs = await Promise.all([
|
||||
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
|
||||
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
|
||||
fetchStructureTypes(true),
|
||||
]).catch(() => null);
|
||||
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
|
||||
if (inputs) {
|
||||
const [pipes, structures, types] = inputs;
|
||||
const planted = plantedChainages(pipes, structures, types);
|
||||
const planned = planStations(pipes, structures, types, registryDefaults(types));
|
||||
const planted = planned.map((station) => station.chainage_m);
|
||||
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
|
||||
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
|
||||
const [oldPipes, oldStructures] = await savedOf(projectId).catch(
|
||||
@@ -206,19 +212,31 @@ export async function withDraftStations(
|
||||
const was = kindsAt(oldPipes, oldStructures, section.chainage_m);
|
||||
return was !== "" && was !== kindsAt(pipes, structures, section.chainage_m);
|
||||
};
|
||||
// 일반 측점 자리(grid · 272)는 측점이 남음 — 종류가 바뀌거나 빠지면 구조물 없는 사본에 새 종류(217)
|
||||
const isStale = (section: CrossSection): boolean =>
|
||||
section.kind === "irregular" &&
|
||||
!section.grid &&
|
||||
(planted.every((at) => !near(at, section.chainage_m)) || replaced(section));
|
||||
stale = all.filter(isStale);
|
||||
live = all.filter((section) => !isStale(section));
|
||||
(section.grid
|
||||
? replaced(section)
|
||||
: planted.every((at) => !near(at, section.chainage_m)) || replaced(section));
|
||||
// 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 · 형상(219) —
|
||||
// 구조물 측점(irregular)은 위 종류 대조가 맡음 · 옛 것은 빼 둠(초안이 없어지면 돌아옴)
|
||||
const retitled = all.filter(
|
||||
(section) =>
|
||||
section.kind !== "irregular" &&
|
||||
planned.some((station) => near(station.chainage_m, section.chainage_m)),
|
||||
);
|
||||
stale = [...all.filter(isStale), ...retitled];
|
||||
const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map(
|
||||
bareOf,
|
||||
);
|
||||
live = [...all.filter((section) => !stale.includes(section)), ...bare];
|
||||
const missing = planted.filter(
|
||||
(at, index) =>
|
||||
planted.findIndex((other) => near(other, at)) === index &&
|
||||
live.every((section) => !near(section.chainage_m, at)),
|
||||
);
|
||||
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
|
||||
if (fresh.length) decorate(fresh, pipes, structures, types);
|
||||
if (fresh.length || bare.length) decorate([...fresh, ...bare], planned);
|
||||
}
|
||||
}
|
||||
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
|
||||
|
||||
@@ -18,34 +18,13 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
type WallStructureInput,
|
||||
} from "@util/common_util_structure_walls";
|
||||
import {
|
||||
attachPavementSpecs,
|
||||
pavementSpecsFrom,
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { setGeneralStations } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "../common_util/common_util_structure_underdrain";
|
||||
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
|
||||
forgetDraftStations,
|
||||
withDraftShapes,
|
||||
withDraftStations,
|
||||
} from "./B06_Section_Section_Drafts";
|
||||
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";
|
||||
@@ -60,12 +39,11 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다.
|
||||
* 초안(관 · 구조물)을 상세에 얹는다 — **여기가 유일한 자리**다.
|
||||
*
|
||||
* 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의
|
||||
* 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다.
|
||||
* 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다
|
||||
* (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`).
|
||||
* 서버는 저장분만 얹어 준다. 초안이 있으면 그것이 화면의 정본이므로, 캐시·세션·새 요청 어느 길로
|
||||
* 들어오든 이 함수를 지나 같은 상태가 된다(2026-09-06 실측). 측점 = 측점 계획 한 벌(`withDraftStations`) ·
|
||||
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` · 서버 `attach_shapes` 짝 · 323-4).
|
||||
*/
|
||||
async function withDraftWalls(
|
||||
detail: SectionDetailResponse,
|
||||
@@ -74,115 +52,10 @@ async function withDraftWalls(
|
||||
): Promise<SectionDetailResponse> {
|
||||
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
|
||||
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
|
||||
const drafts = readPendingStructures(projectId);
|
||||
// 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312).
|
||||
// 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는
|
||||
// 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴.
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts) return withStructureAreas(detail);
|
||||
// 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`)
|
||||
attachUnderdrainSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]),
|
||||
);
|
||||
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군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
// 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9).
|
||||
.filter(
|
||||
(type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm",
|
||||
)
|
||||
.map((type) => [type.type_id, type.name] as const),
|
||||
);
|
||||
if (!names.size) return detail;
|
||||
for (const section of detail.cross_sections) {
|
||||
if (section.revetment) delete (section as { revetment?: unknown }).revetment;
|
||||
}
|
||||
attachWallSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
wallSpecsFrom(drafts as unknown as WallStructureInput[], names),
|
||||
);
|
||||
await withDraftShapes(detail, projectId);
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
|
||||
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
|
||||
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
|
||||
facility === "ford_bridge"
|
||||
? "ford"
|
||||
: facility === "box_culvert"
|
||||
? "box"
|
||||
: facility === "ford_pavement"
|
||||
? "ford_pavement"
|
||||
: "culvert";
|
||||
|
||||
/**
|
||||
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
|
||||
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
|
||||
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
|
||||
*/
|
||||
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
|
||||
const pipes = readPendingPipes(projectId);
|
||||
if (!pipes) return;
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
|
||||
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
|
||||
// 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314)
|
||||
for (const section of sections) {
|
||||
const spec = section.ford_pavement as Record<string, unknown> | undefined;
|
||||
const kept = (pipe: (typeof pipes)[number]) =>
|
||||
pipe.facility === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m);
|
||||
if (spec && !pipes.some(kept)) delete section.ford_pavement;
|
||||
}
|
||||
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
|
||||
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
|
||||
key === facilityKey(pipe.facility ?? "pipe") &&
|
||||
(key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment"));
|
||||
const stale = pipes.filter((pipe) => {
|
||||
const here = sections.flatMap((section) =>
|
||||
FACILITY_KEYS.flatMap((key) => {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
|
||||
}),
|
||||
);
|
||||
// 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314)
|
||||
if (!here.length) return pipe.facility === "ford_pavement";
|
||||
return !here.some(([key, spec]) => fits(pipe, key, spec));
|
||||
});
|
||||
if (!stale.length) return;
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const registry = registryDefaults(types);
|
||||
for (const section of sections) {
|
||||
for (const key of FACILITY_KEYS) {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 그 밖 = 종류 이름)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
});
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
if (facility === "pipe")
|
||||
section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility;
|
||||
}
|
||||
attachCulvertSets(sections, buildCulvertSets(stale, registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
|
||||
*
|
||||
@@ -230,17 +103,6 @@ export async function loadSectionDetail(
|
||||
return request;
|
||||
}
|
||||
|
||||
/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */
|
||||
export function replaceSectionDetail(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
): void {
|
||||
cache.set(keyOf(projectId, routeId), detail);
|
||||
writeState("section-detail", detail, projectId, routeId);
|
||||
setGeneralStations(detail.longitudinal?.stations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 —
|
||||
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
|
||||
|
||||
@@ -117,7 +117,8 @@ export function createCrossAxes(
|
||||
const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9));
|
||||
const screenYOf = (raw: number) =>
|
||||
ty + scale * y(options.elevationMid + (raw - options.elevationMid) * options.exaggeration);
|
||||
// 중심고 상자 자리 — 보이는 범위 안일 때만 · 그 높이 눈금 글자는 상자에 양보
|
||||
// 중심고 상자 자리 — 보이는 범위 안일 때만 · 겹친 눈금 글도 그림(상자를 맨 뒤에 붙여 위에 ·
|
||||
// 바탕이 안 비쳐 가림 · 319-1)
|
||||
const centerZ = options.centerZ;
|
||||
const centerY =
|
||||
typeof centerZ === "number" && centerZ >= rawBottom && centerZ <= rawTop
|
||||
@@ -134,7 +135,6 @@ export function createCrossAxes(
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
);
|
||||
if (centerY !== null && Math.abs(screenY - centerY) < CENTER_BOX_H) continue;
|
||||
layer.append(
|
||||
svgText(raw.toFixed(yDecimals), {
|
||||
x: plotLeft - Y_LABEL_GAP_PX,
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
import { tagShape } from "../A00_Common/design/design_pick_match";
|
||||
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";
|
||||
@@ -219,6 +221,7 @@ export function appendBoxOverlay(
|
||||
event.stopPropagation();
|
||||
onSelectSide(role);
|
||||
});
|
||||
tagShape(hit, box.chainage_m != null ? pipeIdAt(box.chainage_m) : null, role); // 신원표(323-5)
|
||||
hits.set(role, hit);
|
||||
layer.append(hit);
|
||||
}
|
||||
|
||||
@@ -67,20 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
|
||||
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
|
||||
]);
|
||||
}
|
||||
// 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함
|
||||
const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null;
|
||||
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
|
||||
// 빈칸 경고 한 꼴(323-4) — 형상 필수 칸이 빈 관 · 구조물(관경 · 벽 · 기슭막이 높이 · 맹암거 높이 · 월류
|
||||
// 높이 · 형상 얹기가 측점 계획 자리에 얹음). 세월교 · 박스 흙막이 높이 빈 칸 = 본체 끝 높이(79-7)라 안 셈
|
||||
for (const item of section.blank ?? [])
|
||||
notes.push([
|
||||
"⚠ 기슭막이 높이 없음 — 근입만 그림",
|
||||
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
|
||||
`⚠ ${item} 빈칸`,
|
||||
`${item}을 안 적어 그 그림을 안 그림(수치를 지어내지 않음 · 수량도 미확정) — 시설 칸에 적으면 그림`,
|
||||
]);
|
||||
}
|
||||
if (section.ford_pavement && !section.ford_pavement.depth_m) {
|
||||
notes.push([
|
||||
"⚠ 월류 높이 없음 — 파임 안 그림",
|
||||
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
|
||||
]);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22)
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
import { tagShape } from "../A00_Common/design/design_pick_match";
|
||||
import {
|
||||
REVET_EMBED_DEPTH_M,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
@@ -88,6 +90,8 @@ export function appendCulvertOverlay(
|
||||
linked?: boolean,
|
||||
): RevetHighlightSetter {
|
||||
const { culvert, pipe, pipeCorners } = layout;
|
||||
// 신원표(323-5) — 관 세션 id · 부분 = 조정창 부재키
|
||||
const ownerId = culvert.chainage_m != null ? pipeIdAt(culvert.chainage_m) : null;
|
||||
const diameter = culvert.diameter_m;
|
||||
const roleLabel = (role: "inlet" | "outlet" | "extra") =>
|
||||
role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)";
|
||||
@@ -219,6 +223,7 @@ export function appendCulvertOverlay(
|
||||
);
|
||||
if (onSelectRevet) revetShape.classList.add("is-selectable");
|
||||
if (linked) revetShape.classList.add("is-linked");
|
||||
tagShape(revetShape, ownerId, wallKey);
|
||||
revetShapes.set(wallKey, revetShape);
|
||||
layer.append(revetShape);
|
||||
// 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이
|
||||
@@ -242,6 +247,7 @@ export function appendCulvertOverlay(
|
||||
`유입 집수정(${shapeName}) ${part.kind === "floor" ? "바닥" : "벽"}` +
|
||||
` — ${reasonText} · 내공 1.0m(실무 돌집수정) · 부재 두께 0.2m`,
|
||||
);
|
||||
tagShape(shape, ownerId, "inlet"); // 집수정 = 유입 부재(조정창 같은 키)
|
||||
basinShapes.push(shape);
|
||||
layer.append(shape);
|
||||
}
|
||||
@@ -345,6 +351,7 @@ export function appendCulvertOverlay(
|
||||
];
|
||||
if (!pipeHitPoints.length) pipeHitPoints = points; // 외경 = 판정면
|
||||
const shape = polygon(points, cls, tip);
|
||||
tagShape(shape, ownerId, "pipe");
|
||||
pipeShapes.push(shape);
|
||||
layer.append(shape);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* ④ 계류측 성토부선·절토선. 관 위 성토 채움은 그리지 않는다(2026-08-25 사용자).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
import { tagShape } from "../A00_Common/design/design_pick_match";
|
||||
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import {
|
||||
appendPipeInfoText,
|
||||
@@ -54,6 +56,7 @@ export function appendFordOverlay(
|
||||
onSelectWall?: (key: FordPick) => void,
|
||||
): FordHighlightSetter {
|
||||
const { ford } = layout;
|
||||
const ownerId = ford.chainage_m != null ? pipeIdAt(ford.chainage_m) : null; // 신원표(323-5)
|
||||
const toXY = (point: OffsetPoint): [number, number] => [
|
||||
x(point.offset),
|
||||
toDisplayY(point.elevation),
|
||||
@@ -118,6 +121,7 @@ export function appendFordOverlay(
|
||||
onSelectWall(side.role);
|
||||
});
|
||||
}
|
||||
tagShape(shape, ownerId, side.role);
|
||||
shapes.push(shape);
|
||||
layer.append(shape);
|
||||
}
|
||||
@@ -133,6 +137,7 @@ export function appendFordOverlay(
|
||||
const pipeInner = polygon(layout.pipe.inner, "b06-chart__culvert-pipe", layout.label.text);
|
||||
layer.append(pipeOuter, pipeInner);
|
||||
const bodyShapes = [slab, pipeOuter, pipeInner];
|
||||
for (const shape of bodyShapes) tagShape(shape, ownerId, "body");
|
||||
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림)
|
||||
if (ford.chute)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface";
|
||||
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
import { tagShape } from "../A00_Common/design/design_pick_match";
|
||||
import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
|
||||
@@ -117,8 +119,7 @@ export function appendFordPavementOverlay(
|
||||
.forEach((el) => {
|
||||
el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
|
||||
// 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글
|
||||
if (typeof spec.chainage_m === "number")
|
||||
el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`);
|
||||
if (typeof spec.chainage_m === "number") tagShape(el, pipeIdAt(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5)
|
||||
});
|
||||
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2)
|
||||
const depth = spec.depth_m ?? 0;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { tagShape } from "../A00_Common/design/design_pick_match";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
@@ -89,6 +91,10 @@ export interface RevetmentLayout {
|
||||
heightM: number;
|
||||
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */
|
||||
chainageM: number;
|
||||
/** 벽 구조물 id — 그린 벽 신원표(323-5). */
|
||||
structureId: string | null;
|
||||
/** 종류 이름(옹벽 · 흙막이 · 돌쌓기 …) — 툴팁 글(323-7 · 「독립 기슭막이」로 뜨던 것). */
|
||||
name: string;
|
||||
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */
|
||||
requestedTiers: number;
|
||||
/** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */
|
||||
@@ -277,7 +283,8 @@ export function computeRevetmentLayout(
|
||||
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
|
||||
|
||||
const walls: WallLayout[] = [tier1, ...extras.walls];
|
||||
if (spec.type_id === "retaining_wall") for (const wall of walls) wall.retaining = true;
|
||||
// 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽)
|
||||
if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true;
|
||||
const jt = tier1.topJoint;
|
||||
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
|
||||
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
|
||||
@@ -313,6 +320,8 @@ export function computeRevetmentLayout(
|
||||
side,
|
||||
heightM: pureHeight,
|
||||
chainageM: Number(section.chainage_m) || 0,
|
||||
structureId: spec.structure_id ?? null,
|
||||
name: spec.name || "벽",
|
||||
requestedTiers,
|
||||
top: jt,
|
||||
walls,
|
||||
@@ -346,7 +355,7 @@ export function appendRevetmentOverlay(
|
||||
segment.points,
|
||||
x,
|
||||
y,
|
||||
`독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
`${layout.name} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
|
||||
);
|
||||
@@ -360,7 +369,7 @@ export function appendRevetmentOverlay(
|
||||
layout.walls.forEach((wall, index) => {
|
||||
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
|
||||
const tooltip =
|
||||
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
|
||||
`${layout.name} ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
|
||||
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` +
|
||||
(wall.floatGapM > 0.01
|
||||
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도`
|
||||
@@ -374,6 +383,7 @@ export function appendRevetmentOverlay(
|
||||
const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId);
|
||||
if (onSelect) shape.classList.add("is-selectable");
|
||||
if (layout.linked) shape.classList.add("is-linked");
|
||||
tagShape(shape, layout.structureId, keyId); // 신원표(323-5)
|
||||
drawn.push(shape);
|
||||
});
|
||||
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RevetHighlightSetter } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
|
||||
import { paintPicked } from "../A00_Common/design/design_pick_match";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */
|
||||
@@ -65,8 +67,11 @@ export function attachShapePick(
|
||||
layer: SVGElement,
|
||||
onPick?: (structureId: string) => void,
|
||||
): ((active: boolean) => void) | null {
|
||||
// 통째 형상(맹암거) · 물넘이 콘크리트만 — 조정창 부분(관 · 벽 · 날개벽 · 323-5 신원표)은 제 판정면이 있음
|
||||
const shapes = [...layer.querySelectorAll<SVGElement>("[data-structure-id]")].filter(
|
||||
(el) => !el.hasAttribute("data-pave-type"),
|
||||
(el) =>
|
||||
!el.hasAttribute("data-pave-type") &&
|
||||
[null, "concrete"].includes(el.getAttribute("data-part")),
|
||||
);
|
||||
if (!shapes.length) return null;
|
||||
const kids = [...layer.children];
|
||||
@@ -102,7 +107,8 @@ export function shapePickRoute(
|
||||
pickPipe: (chainageM: number) => void,
|
||||
): (structureId: string) => void {
|
||||
return (structureId) => {
|
||||
if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5)));
|
||||
const pipeAt = pipeChainageOf(structureId); // 관 세션 id 장부(323-5)
|
||||
if (pipeAt !== null) return pickPipe(pipeAt);
|
||||
from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId }));
|
||||
};
|
||||
}
|
||||
@@ -121,12 +127,7 @@ export function splitPipePick(
|
||||
};
|
||||
}
|
||||
|
||||
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴 · 맹암거). 물넘이
|
||||
* 콘크리트(`pipe-…`)는 형상을 직접 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */
|
||||
export function highlightShapes(root: ParentNode, structureId: string | null): void {
|
||||
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||
const id = shape.getAttribute("data-structure-id") ?? "";
|
||||
if (id.startsWith("pipe-")) continue;
|
||||
shape.classList.toggle("is-active", !!structureId && id === structureId);
|
||||
}
|
||||
}
|
||||
/** 카드 안 형상 강조 — 고름 강조 한 규칙(`paintPicked` · 323-5) · 물넘이 콘크리트(관 부분)는 형상을 직접
|
||||
* 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */
|
||||
export const highlightShapes = (root: ParentNode, structureId: string | null): void =>
|
||||
paintPicked(root, { structureId });
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchSectionDetail,
|
||||
regenerateSections,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { replaceSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -38,10 +34,9 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await regenerateSections(target.projectId, target.routeId, widthM);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
|
||||
const fresh = await fetchSectionDetail(target.projectId, target.routeId);
|
||||
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
|
||||
replaceSectionDetail(target.projectId, target.routeId, fresh);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도
|
||||
// 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4)
|
||||
const fresh = await loadSectionDetail(target.projectId, target.routeId, { force: true });
|
||||
deps.applyDetail(fresh);
|
||||
showToast(L("B06_Profile_Regenerate_Success"), "success");
|
||||
return true;
|
||||
|
||||
@@ -48,6 +48,7 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { catalogRow } from "../common_util/common_util_structure_catalog";
|
||||
|
||||
/**
|
||||
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
|
||||
@@ -104,8 +105,8 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
|
||||
writeState("berm", spans, projectId, routeId);
|
||||
}
|
||||
|
||||
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
|
||||
export const BERM_TYPE_ID = "berm";
|
||||
/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */
|
||||
export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm";
|
||||
|
||||
/**
|
||||
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
|
||||
@@ -126,7 +127,7 @@ export function bermSpansFromStructures(
|
||||
): BermSpan[] {
|
||||
const spans: BermSpan[] = [];
|
||||
for (const item of structures) {
|
||||
if (item.type_id !== BERM_TYPE_ID) continue;
|
||||
if (!isBermType(item.type_id)) continue;
|
||||
const anchor = item.chainage_m ?? item.start_m ?? null;
|
||||
const start = item.start_m ?? anchor;
|
||||
const end = item.end_m ?? anchor;
|
||||
@@ -380,6 +381,8 @@ export interface SectionPersistContext {
|
||||
* 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적
|
||||
* (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */
|
||||
reconcileDesigns: () => Promise<void>;
|
||||
/** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */
|
||||
redraw: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
|
||||
@@ -498,12 +501,14 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
|
||||
);
|
||||
clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4)
|
||||
},
|
||||
done: () => {
|
||||
done: async () => {
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
|
||||
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
|
||||
// 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9)
|
||||
const routeId = ctx.routeId();
|
||||
if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId);
|
||||
await ctx.redraw();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
@@ -276,22 +275,3 @@ function applySoilTiers(
|
||||
if (Object.keys(clipped).length)
|
||||
queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped));
|
||||
}
|
||||
|
||||
/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */
|
||||
const ADJUST_OWNED = /_revet_(height_m|form)$/;
|
||||
|
||||
/**
|
||||
* 관 초안 옵션을 **횡단 캐시에 다시 얹는다**(24-7a). 새로고침 뒤 캐시 상세는 저장분이라
|
||||
* 저장 전에 고친 월류 폭·관경 등이 폼·카드에서 옛 값으로 돌아간다 — 초안이 관 옵션의 정본이다.
|
||||
*/
|
||||
export function replayDraftPipeOptions(
|
||||
ctx: PipeOptionsContext,
|
||||
pipes: ReadonlyArray<DetailPipeInput> | null,
|
||||
): void {
|
||||
for (const pipe of pipes ?? []) {
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(pipe.options ?? {}).filter(([key]) => !ADJUST_OWNED.test(key)),
|
||||
);
|
||||
if (Object.keys(patch).length) applyPipeOptionsToCache(ctx, pipe.chainage_m, patch, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +280,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
shapePicked: () => revetSelection?.shape === true,
|
||||
syncApplied: (chainageM, role, applied) => {
|
||||
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
|
||||
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. 그리기는 조정값을
|
||||
// 새로 만들지 않음(323-6) — 사용자가 조작한 세션이 있을 때만 그 값을 깎음(없으면 자동 그대로 · 저장 안 함)
|
||||
const key = revetKey(chainageM, role);
|
||||
const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST };
|
||||
if (sameAdjust(stored, applied)) return;
|
||||
const stored = revetShifts.get(key);
|
||||
if (!stored || sameAdjust(stored, applied)) return;
|
||||
storeAdjust(key, { ...applied });
|
||||
},
|
||||
update: (chainageM, role, patch) => {
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
} from "../A00_Common/design/design_shared";
|
||||
import {
|
||||
fetchStructures,
|
||||
dropStaleStructureTypes,
|
||||
fetchStructureTypes,
|
||||
staleDefinitionNotice,
|
||||
readPendingStructures,
|
||||
pipesToStructureMarks,
|
||||
structureAnchorM,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { snapNotice, snapPlan } from "../B05_Profile/B05_Profile_UI_Structures_Snap";
|
||||
import {
|
||||
adjustDockRoot,
|
||||
refreshAdjustSlots,
|
||||
@@ -59,6 +62,8 @@ import {
|
||||
} from "./B06_Section_UI_Adjust_Dock";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec";
|
||||
import { listPick } from "../common_util/common_util_structure_catalog";
|
||||
import { followMovedPipe, pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
|
||||
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
|
||||
@@ -94,13 +99,15 @@ export interface B06StructuresPanelDeps {
|
||||
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */
|
||||
markStructure?: (structureId: string | null, span?: boolean) => void;
|
||||
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
|
||||
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
|
||||
/** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */
|
||||
onMarks?: (
|
||||
structures: StructureInstance[],
|
||||
types: StructureType[],
|
||||
pipes: ReadonlyArray<PipeFacilityItem>,
|
||||
) => void;
|
||||
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
|
||||
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
|
||||
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
|
||||
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
|
||||
onStructuresChanged?: () => void;
|
||||
/** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */
|
||||
onPipesWritten?: () => void;
|
||||
}
|
||||
@@ -190,7 +197,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
};
|
||||
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
|
||||
const pushMarks = (): void =>
|
||||
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
|
||||
deps.onMarks?.(
|
||||
[...structures, ...pipesToStructureMarks(pipeFacilities)],
|
||||
markTypes,
|
||||
pipeFacilities,
|
||||
);
|
||||
// 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은
|
||||
// 한 벌이 보기에 따라 그린다(횡단 보기에서만).
|
||||
const section = sharedStructures(deps.shared);
|
||||
@@ -201,7 +212,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
section.setStructures(structures);
|
||||
pushMarks();
|
||||
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
||||
deps.onStructuresChanged?.();
|
||||
// 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1)
|
||||
const picked = readSelection(deps.projectId);
|
||||
const moved = picked?.structureId
|
||||
@@ -270,7 +280,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.();
|
||||
showToast(PIPE_ADD_NOTICE, "success");
|
||||
},
|
||||
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
|
||||
@@ -291,7 +300,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
if (mine && JSON.stringify(linkFields(mine)) !== before) {
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
deps.onStructuresChanged?.();
|
||||
}
|
||||
}
|
||||
// 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙).
|
||||
@@ -319,10 +327,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
if (facility !== was) {
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`)
|
||||
}
|
||||
// 카드 · 그림 다시 = 초안 구독(323-6 · `watchDrafts` — 종류가 바뀌면 그 관 자리 그림이 새 종류)
|
||||
if (facility !== was) pushMarks();
|
||||
},
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
@@ -334,7 +340,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
);
|
||||
if (chainageM !== null) deps.focusChainage(chainageM);
|
||||
// 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2)
|
||||
deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`);
|
||||
deps.markStructure?.(chainageM === null ? null : pipeIdAt(chainageM, deps.projectId));
|
||||
},
|
||||
onListRelease: () => deps.releaseCard?.(),
|
||||
},
|
||||
@@ -369,6 +375,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
if (!deps.projectId) return;
|
||||
const projectId = deps.projectId;
|
||||
try {
|
||||
await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0)
|
||||
const [types, stored, pipeResponse] = await Promise.all([
|
||||
// B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서
|
||||
// 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이
|
||||
@@ -377,10 +384,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
fetchStructures(projectId),
|
||||
fetchDetailPipePoints(projectId),
|
||||
]);
|
||||
section.setTypes(types);
|
||||
section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1)
|
||||
markTypes = types;
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
|
||||
structures = readPendingStructures(projectId) ?? stored.structures;
|
||||
const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2
|
||||
if (note) showToast(note);
|
||||
section.setStructures(structures);
|
||||
const detail = deps.detail?.() ?? null;
|
||||
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
|
||||
@@ -411,6 +420,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
(basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M,
|
||||
)?.design_flow_m3s ?? null,
|
||||
}));
|
||||
// 옛 소수 관 자리 = 정수 m 초안(요구 167 · B05 `snapOldStations` 와 같은 셈) — B06 을 바로 열어도
|
||||
// 카드 · 측점이 B05 를 거친 것과 같음 · 정본은 [저장]에서
|
||||
if (!pendingPipes) snapOldPipes(projectId);
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
// 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로.
|
||||
pushMarks();
|
||||
@@ -484,6 +496,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
deps.onPipesWritten?.();
|
||||
}
|
||||
|
||||
/** 옛 소수 관 자리 → 정수 m(확인 창 없이) — 계획고 편집 · 고름도 새 자리로(`movePipeTo` 와 같은 따라가기). */
|
||||
function snapOldPipes(projectId: string): void {
|
||||
const plan = snapPlan([], pipeFacilities);
|
||||
plan.pipes.forEach(({ from, to, attributes }) => {
|
||||
const hit = pipeFacilities.find((pipe) => pipe.chainage_m === from);
|
||||
if (!hit) return;
|
||||
deps.shared.profile?.resetStationEdit(from, to);
|
||||
followMovedPipe(projectId, from, to);
|
||||
Object.assign(hit, { chainage_m: to, start_m: attributes.start_m, end_m: attributes.end_m });
|
||||
});
|
||||
if (plan.pipes.length) {
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
persistPipes();
|
||||
}
|
||||
snapNotice(plan.pipes.length, plan.skipped);
|
||||
}
|
||||
|
||||
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
|
||||
function movePipeTo(fromChainageM: number, toChainageM: number): void {
|
||||
if (!deps.projectId) {
|
||||
@@ -501,11 +530,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
||||
currentChainageM = to;
|
||||
followMovedPipe(deps.projectId, fromChainageM, to); // 고른 관이면 고름도 새 자리로(322-3)
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2)
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
@@ -524,7 +553,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.();
|
||||
showToast(PIPE_REMOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
@@ -611,7 +639,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
refreshSpecDefaults(hit.pipeChainageM);
|
||||
section.selectPipeByChainage(hit.pipeChainageM);
|
||||
// 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2)
|
||||
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);
|
||||
deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));
|
||||
} else {
|
||||
openingAtStation = preferredId === null;
|
||||
try {
|
||||
@@ -627,7 +655,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!pipe) return;
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
const id = pipeIdAt(pipe.chainage_m, deps.projectId);
|
||||
deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤 없음 — 옛 카드 · 측점선 풂(177-4 길 · 307)
|
||||
currentChainageM = pipe.chainage_m;
|
||||
refreshSpecDefaults(pipe.chainage_m);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
|
||||
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
|
||||
import { isBermType } from "./B06_Section_UI_Page_Persist";
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
@@ -200,7 +200,7 @@ export function wireStructureSelection(
|
||||
.forEach((box) => box.classList.remove("is-active"));
|
||||
if (chainageM === null) return;
|
||||
const berm = panel.getStructures().find((item) => {
|
||||
if (item.type_id !== BERM_TYPE_ID) return false;
|
||||
if (!isBermType(item.type_id)) return false;
|
||||
const start = item.start_m ?? item.chainage_m ?? -1;
|
||||
const end = item.end_m ?? item.chainage_m ?? -1;
|
||||
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { PIPE_TYPES } from "@config/config_frontend";
|
||||
import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { pipeTitleKinds } from "../common_util/common_util_structure_catalog";
|
||||
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
||||
import type {
|
||||
DesignProfile,
|
||||
@@ -62,9 +63,12 @@ export interface YScaleOptions {
|
||||
*
|
||||
* 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨
|
||||
* 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다.
|
||||
* 관종 = 설정 관종 + 표 카드이름 pipe 종류의 정의 관종(322-1 · 흄관 · VR관도 「배수관」).
|
||||
*/
|
||||
export function structureDisplayName(label: string): string {
|
||||
return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label;
|
||||
return [...PIPE_TYPES, ...pipeTitleKinds()].some((kind) => label.startsWith(`${kind} D`))
|
||||
? PIPE_DISPLAY_NAME
|
||||
: label;
|
||||
}
|
||||
|
||||
export function validElevation(
|
||||
|
||||
@@ -642,11 +642,11 @@ export function createSectionView(
|
||||
setStructureEdit(edit) {
|
||||
structureEdit = edit;
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
setStructureMarks(structures, types, pipes) {
|
||||
spanMarks = structures;
|
||||
spanTypes = types;
|
||||
syncSpanHighlights();
|
||||
profile.setMarks(structures, types);
|
||||
profile.setMarks(structures, types, pipes);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
// (`__corridorBuild` 등과 같은 용도).
|
||||
(window as unknown as { __b06Marks?: unknown }).__b06Marks = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import { listPick } from "../common_util/common_util_structure_catalog";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
|
||||
@@ -128,7 +129,7 @@ export function mountSectionStructureMenu(
|
||||
else input.edit.removeStructure(station.id);
|
||||
},
|
||||
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
|
||||
structureGroups: () => pickGroups(input.types, () => true), // B06 패널 거르기 = 전부(129-1)
|
||||
structureGroups: () => pickGroups(input.types, listPick("b06")), // B06 패널 거르기와 같음(129-1 · 322-1)
|
||||
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ import {
|
||||
moveMarkById,
|
||||
type SectionStructureEdit,
|
||||
} from "./B06_Section_UI_Section_View_Menu";
|
||||
import { planStations, type PlanPipe } from "../common_util/common_util_structure_station";
|
||||
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
|
||||
import { withPipeIds } from "../A00_Common/design/design_pipe_ids";
|
||||
|
||||
/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */
|
||||
const MATCH_M = PIPE_MATCH_M;
|
||||
@@ -79,6 +82,7 @@ export interface SectionProfile {
|
||||
setMarks: (
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
pipes?: ReadonlyArray<PlanPipe>,
|
||||
) => void;
|
||||
/** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */
|
||||
redraw: () => void;
|
||||
@@ -88,25 +92,24 @@ export interface SectionProfile {
|
||||
selectMark: (structureId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
|
||||
function pipeStations(
|
||||
/** 종단 측점선 = 측점 계획 한 벌(322-3 · 관 · 측점 심는 구조물 모두 · 종단 보기 · 카드와 같은 목록).
|
||||
* 관 구간(독립 기슭막이 시작 · 끝)은 받은 관 목록에서 · 없으면 알약에서 기준만. */
|
||||
function planMarkStations(
|
||||
marks: ReadonlyArray<StructureInstance>,
|
||||
pipes: ReadonlyArray<PlanPipe> | null,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
return marks.filter(isPipeMark).map((mark) => {
|
||||
const chainage = structureAnchorM(mark);
|
||||
const station = Math.floor(chainage / intervalM);
|
||||
return {
|
||||
id: String(mark.structure_id),
|
||||
station,
|
||||
remainder: chainage - station * intervalM,
|
||||
chainage_m: chainage,
|
||||
structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관",
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
} as IrregularStation;
|
||||
});
|
||||
const pipeList =
|
||||
pipes ??
|
||||
marks.filter(isPipeMark).map((mark) => ({
|
||||
chainage_m: structureAnchorM(mark),
|
||||
facility: mark.type_id,
|
||||
options: mark.options,
|
||||
}));
|
||||
const structures = marks.filter((mark) => !isPipeMark(mark));
|
||||
// 덮는 구조물 id = 관 세션 id(323-5 · 알약 id 와 같음)
|
||||
return plannedIrregular(planStations(withPipeIds(pipeList), structures, types), intervalM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +144,7 @@ export function createSectionProfile(
|
||||
let profile: DesignProfile | null = null;
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let marks: ReadonlyArray<StructureInstance> = [];
|
||||
let markPipes: ReadonlyArray<PlanPipe> | null = null;
|
||||
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
|
||||
let markedId: string | null = null;
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
@@ -180,7 +184,7 @@ export function createSectionProfile(
|
||||
if (!profile) return;
|
||||
profile.setStructureTypes([...types]);
|
||||
profile.setStructures([...marks]);
|
||||
stations = pipeStations(marks, types, intervalM);
|
||||
stations = planMarkStations(marks, markPipes, types, intervalM);
|
||||
profile.setIrregularStations(stations);
|
||||
// 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다.
|
||||
profile.setMinCoverPipes(
|
||||
@@ -302,9 +306,10 @@ export function createSectionProfile(
|
||||
}
|
||||
syncMarks();
|
||||
},
|
||||
setMarks(nextMarks, nextTypes) {
|
||||
setMarks(nextMarks, nextTypes, nextPipes) {
|
||||
marks = nextMarks;
|
||||
types = nextTypes;
|
||||
markPipes = nextPipes ?? null;
|
||||
syncMarks();
|
||||
},
|
||||
redraw() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { PlanPipe } from "../common_util/common_util_structure_station";
|
||||
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,8 @@ export interface SectionViewController {
|
||||
setStructureMarks: (
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
/** 관 지점(구간 포함) — 종단 띠 측점 계획(322-3 · 없으면 알약에서 기준만). */
|
||||
pipes?: ReadonlyArray<PlanPipe>,
|
||||
) => void;
|
||||
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
||||
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
||||
|
||||
@@ -395,7 +395,8 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 */
|
||||
/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 ·
|
||||
바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) */
|
||||
.b06-chart__center-box {
|
||||
fill: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface-raised));
|
||||
stroke: var(--color-accent);
|
||||
|
||||
@@ -26,6 +26,7 @@ from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_stru
|
||||
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
|
||||
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_structure_station import PIPE_MATCH_M # 같은 자리 폭 한 곳(323-7)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,8 +34,6 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
_OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js"
|
||||
_OPTIONS_NPM = "build:b07-note"
|
||||
|
||||
# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭
|
||||
PIPE_MATCH_M = 0.51
|
||||
_LAYER_ID = "기본"
|
||||
_FONT = 2.0
|
||||
_LINE = 3.0
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 반영 · 부품 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
|
||||
|
||||
- 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
|
||||
- 부품 = `{"갈래.부품": false}`(끈 것만)
|
||||
- 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
|
||||
부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
|
||||
그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
|
||||
- 앞 판이 없으면(옛 판 읽기) 꼴만 검사
|
||||
- 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
|
||||
화면 짝 = `M02_SO_Def_Check.ts`(같은 글)
|
||||
거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_structure_catalog import load_parts, resolve_catalog
|
||||
|
||||
BRANCHES = ("횡단도", "카드이름", "형상", "3D")
|
||||
# 화면 글(322-2 다듬기 · TS `M02_SO_Def_Reflect` 짝) — 자리꼴 · 정본 한국어 · 부품 짧은 이름
|
||||
PLACE_KO = {"point": "점", "interval": "구간", "site": "터"}
|
||||
OWNER_KO = {"pipe_points": "관 지점", "structures": "구조물"}
|
||||
|
||||
|
||||
def short_name(full: str) -> str:
|
||||
"""괄호 설명을 뗀 부품 이름(TS `shortName` 짝)."""
|
||||
return re.sub(r"\s*\(.*\)\s*$", "", full)
|
||||
|
||||
|
||||
OFF_VALUE = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"}
|
||||
LISTS = ("both", "b06", "hidden")
|
||||
_FIELDS = {"목록", "형상칸", *BRANCHES}
|
||||
|
||||
|
||||
def _shape_errors(doc: dict[str, Any], known: set[str]) -> list[str]:
|
||||
"""반영 · 부품 꼴."""
|
||||
parts = load_parts()["parts"]
|
||||
out: list[str] = []
|
||||
reflect = doc.get("반영", {})
|
||||
if not isinstance(reflect, dict):
|
||||
out.append("반영은 {종류: 묶음} 이어야 함")
|
||||
else:
|
||||
for tid, row in reflect.items():
|
||||
if tid not in known or not isinstance(row, dict):
|
||||
out.append(f"반영 「{tid}」 — 없는 종류이거나 묶음 아님")
|
||||
continue
|
||||
for field, value in row.items():
|
||||
where = f"반영 「{tid}.{field}」"
|
||||
if field not in _FIELDS:
|
||||
out.append(f"{where} — 모르는 칸")
|
||||
elif field == "목록":
|
||||
if value not in LISTS:
|
||||
out.append(f"{where} — both · b06 · hidden 중 하나")
|
||||
elif field == "형상칸":
|
||||
ok = isinstance(value, dict) and all(
|
||||
(isinstance(v, str) and bool(v.strip()))
|
||||
if k == "form"
|
||||
else (k == "weep" and isinstance(v, bool))
|
||||
for k, v in value.items()
|
||||
)
|
||||
if not ok:
|
||||
out.append(f"{where} — form 글 · weep 참거짓")
|
||||
elif not isinstance(value, str) or (
|
||||
value != OFF_VALUE[field] and value not in parts[field]
|
||||
):
|
||||
shown = value if isinstance(value, str) else _js(value)
|
||||
out.append(f"{where} — 없는 부품 「{shown}」")
|
||||
off = doc.get("부품", {})
|
||||
if not isinstance(off, dict):
|
||||
out.append("부품은 {갈래.부품: 참거짓} 이어야 함")
|
||||
else:
|
||||
for key, value in off.items():
|
||||
branch, _, part = key.partition(".")
|
||||
if part not in parts.get(branch, {}):
|
||||
out.append(f"부품 「{key}」 — 없는 부품")
|
||||
elif not isinstance(value, bool):
|
||||
out.append(f"부품 「{key}」 — 참거짓")
|
||||
return out
|
||||
|
||||
|
||||
def _js(value: Any) -> str:
|
||||
"""TS `String(value)` 와 같은 글(검사 글 짝)."""
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ",".join(_js(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return "[object Object]"
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def _hidden_in(doc: dict[str, Any] | None, tid: str) -> list[str]:
|
||||
row = ((doc or {}).get("표시") or {}).get(tid) or {}
|
||||
return [key for key, shown in row.items() if shown is False]
|
||||
|
||||
|
||||
def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
|
||||
"""표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만)."""
|
||||
parts = load_parts()["parts"]
|
||||
rows = resolve_catalog(doc)
|
||||
raw = resolve_catalog({**doc, "부품": {}}) # 부품 끄기 전 — 끈 탓 가름(323-9)
|
||||
off_users: dict[str, list[str]] = {} # 끈 형상 부품 → 횡단도를 바꿔야 하는 종류
|
||||
out: list[str] = []
|
||||
for t in doc.get("types") or []:
|
||||
row = rows.get(t["type_id"])
|
||||
if row is None:
|
||||
continue
|
||||
if row["shape"] == "none" and row["station"] != "none":
|
||||
was = raw[t["type_id"]]["shape"]
|
||||
if was != "none" and (doc.get("부품") or {}).get(f"형상.{was}") is False:
|
||||
label = f"{row['groupName']} · {row['name']}"
|
||||
off_users.setdefault(was, []).append(label)
|
||||
else:
|
||||
out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」")
|
||||
# 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7)
|
||||
kept = [k for tid in (t["type_id"], row["base"]) for k in _hidden_in(before or doc, tid)]
|
||||
hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept]
|
||||
# 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
|
||||
keys = {
|
||||
o["key"]
|
||||
for o in t.get("options") or []
|
||||
if o.get("default") is None and not o.get("empty_means")
|
||||
}
|
||||
used = (
|
||||
("횡단도", row["station"]),
|
||||
("카드이름", row["title"]),
|
||||
("형상", row["shape"]),
|
||||
("3D", row["model3d"]),
|
||||
)
|
||||
labels = {o["key"]: o.get("label") or o["key"] for o in t.get("options") or []}
|
||||
for branch, part_id in used:
|
||||
part = parts[branch].get(part_id)
|
||||
if part is None:
|
||||
continue
|
||||
name = short_name(part["이름"])
|
||||
place = PLACE_KO.get(row["placement"], row["placement"])
|
||||
owner = OWNER_KO.get(row["owner"], row["owner"])
|
||||
if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]:
|
||||
out.append(f"「{row['name']}」 {branch} 「{name}」 — {place} · {owner}에 안 맞음")
|
||||
for key in part["읽는 칸"]:
|
||||
if key in keys and key in hidden:
|
||||
out.append(
|
||||
f"「{row['name']}」 {branch} 「{name}」 이 쓰는 칸 「{labels[key]}」 — "
|
||||
"숨길 수 없음"
|
||||
)
|
||||
for part_id, names in off_users.items():
|
||||
name = short_name((parts["형상"].get(part_id) or {}).get("이름", part_id))
|
||||
out.append(
|
||||
f"부품 「{name}」 끔 — 이 부품을 쓰는 종류({', '.join(names)})의 "
|
||||
"횡단도를 「안 만듦」으로 바꿔야 저장"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _template_errors(doc: dict[str, Any]) -> list[str]:
|
||||
"""본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 ·
|
||||
정본(TS 짝)."""
|
||||
out: list[str] = []
|
||||
kinds = doc.get("종류") or {}
|
||||
now = {t["type_id"]: t for t in doc.get("types") or []}
|
||||
for tid, row in kinds.items():
|
||||
src = (row or {}).get("본보기") if isinstance(row, dict) else None
|
||||
if src is None or tid not in now:
|
||||
continue
|
||||
t, base = now[tid], now.get(src)
|
||||
name = t.get("name") or tid
|
||||
if base is None or src == tid or (kinds.get(src) or {}).get("본보기"):
|
||||
out.append(f"「{name}」 본보기 「{src}」 — 원본 종류여야 함")
|
||||
continue
|
||||
same = all(
|
||||
t.get(k) == base.get(k) for k in ("group", "placement", "managed_by")
|
||||
) and not base.get("managed_by")
|
||||
if not same:
|
||||
out.append(
|
||||
f"「{name}」 본보기 「{base.get('name') or src}」 와 군 · 자리꼴 · 정본이 같아야 함"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]:
|
||||
"""앞 판과 비교 — 만든 뒤 고정."""
|
||||
out: list[str] = []
|
||||
now = {t["type_id"]: t for t in doc.get("types") or []}
|
||||
base_of = lambda d, tid: ((d.get("종류") or {}).get(tid) or {}).get("본보기") # noqa: E731
|
||||
for old in before.get("types") or []:
|
||||
tid = old["type_id"]
|
||||
name = old.get("name") or tid
|
||||
t = now.get(tid)
|
||||
if t is None:
|
||||
out.append(f"종류 「{name}」 — 지울 수 없음")
|
||||
continue
|
||||
fixed = (
|
||||
("자리꼴", old.get("placement"), t.get("placement")),
|
||||
("정본", old.get("managed_by"), t.get("managed_by")),
|
||||
("군 글자", old.get("group"), t.get("group")),
|
||||
("본보기", base_of(before, tid), base_of(doc, tid)),
|
||||
)
|
||||
for what, a, b in fixed:
|
||||
if a != b:
|
||||
out.append(f"「{name}」 {what} — 만든 뒤 고정")
|
||||
options = {o["key"]: o for o in t.get("options") or []}
|
||||
for o in old.get("options") or []:
|
||||
n = options.get(o["key"])
|
||||
if o.get("input") != "select" or n is None:
|
||||
continue
|
||||
if sorted(o.get("choices") or []) != sorted(n.get("choices") or []):
|
||||
label = o.get("label") or o["key"]
|
||||
out.append(f"「{name}」 「{label}」 선택지 값 — 고칠 수 없음(이름만 고침)")
|
||||
return out
|
||||
|
||||
|
||||
def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
|
||||
"""구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 판(없으면 고정 검사 안 함)."""
|
||||
known = {t["type_id"] for t in doc.get("types") or [] if isinstance(t, dict)}
|
||||
shape = _shape_errors(doc, known)
|
||||
if shape:
|
||||
return shape
|
||||
templates = _template_errors(doc)
|
||||
return templates + (_row_errors(doc, before) + _fixed_errors(doc, before) if before else [])
|
||||
@@ -1,8 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def.ts
|
||||
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
|
||||
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
|
||||
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
|
||||
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 탭 셋(322-2): 「한눈 표」(종류마다 반영 한 줄 ·
|
||||
* `M02_SO_Def_Table`) · 「종류별」(왼쪽 구조물군 · 종류 목록 · 오른쪽 고른 종류 이름 · 반영 · 명세 이름 나무 ·
|
||||
* 줄마다 보임 · 미리보기) · 「부품」(`M02_SO_Def_Parts`) ·
|
||||
* 아래 검사(화면 = 서버 짝 `M02_SO_Def_Check`) · [저장](판 +1 · 목록 + enabled 함께 · 시스템 관리자만) ·
|
||||
* 지난 판 목록(누르면 그 판을 읽기만으로 봄).
|
||||
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -27,8 +29,13 @@ import {
|
||||
import { buildOptionsBlock } from "./M02_SO_Def_Options";
|
||||
import { buildRulesBlock } from "./M02_SO_Def_Rules";
|
||||
import { buildSpecBlock } from "./M02_SO_Def_Spec";
|
||||
import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { specFor } from "../../A00_Common/design/design_structure_pipe";
|
||||
import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check";
|
||||
import { buildParts } from "./M02_SO_Def_Parts";
|
||||
import { differs, setReflect, tidy } from "./M02_SO_Def_Reflect";
|
||||
import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table";
|
||||
import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { addFromTemplate, templates } from "./M02_SO_Def_Add";
|
||||
import { buildGroupBlock } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
@@ -44,15 +51,6 @@ export interface DefScreenOptions {
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
|
||||
function tidy(def: StructDef): StructDef {
|
||||
const out = clone(def);
|
||||
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
|
||||
for (const [k, v] of Object.entries(part))
|
||||
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 작은 단추 */
|
||||
function smallButton(
|
||||
label: string,
|
||||
@@ -88,6 +86,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
let current: string | null = null;
|
||||
const open = new Set<string>(); // 선택지를 펼친 옵션
|
||||
let versions: DefVersion[] | null = null;
|
||||
type Tab = "table" | "kinds" | "parts";
|
||||
let tab: Tab = "table";
|
||||
|
||||
const shown = (): StructDef => past ?? work;
|
||||
const canEdit = (): boolean => opt.isAdmin && !past;
|
||||
@@ -100,8 +100,15 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
attrs: { "data-sod-history": "" },
|
||||
});
|
||||
|
||||
/** 검사 글 — 이름 · 배치 · 규칙(`problems`) + 구조물 표(서버 짝 · 앞 판 = 지금 서버 판) */
|
||||
const allProblems = (): string[] =>
|
||||
past ? [] : [...problems(work), ...catalogProblems(work as CheckDef, base as CheckDef)];
|
||||
const tabBar = el("div", { className: "m02-sod__tabs", attrs: { "data-sod-tabs": "" } });
|
||||
const body = el("div", { className: "m02-sod__body" });
|
||||
const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] });
|
||||
|
||||
const changed = (): void => {
|
||||
dirty = true;
|
||||
dirty = differs(work, base);
|
||||
sync();
|
||||
paint();
|
||||
};
|
||||
@@ -110,6 +117,21 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
const d = shown();
|
||||
const edit = canEdit();
|
||||
const parts: HTMLElement[] = [];
|
||||
// 종류 더하기(323-7) — 본보기 고름 + 단추 · 새 종류를 골라 둠(이름 · 반영은 오른쪽에서 고침)
|
||||
if (edit) {
|
||||
const pairs = templates(d);
|
||||
let pick = pairs.find(([key]) => key === current)?.[0] ?? pairs[0]?.[0] ?? "";
|
||||
const box = pickBox(pairs, pick, (v) => (pick = v), { edit, attr: "data-sod-add-from" });
|
||||
box.title = to("AddKindNote");
|
||||
const add = smallButton(to("AddKind"), () => {
|
||||
const key = addFromTemplate(d, pick);
|
||||
if (!key) return;
|
||||
current = key;
|
||||
changed();
|
||||
});
|
||||
add.setAttribute("data-sod-add", "");
|
||||
parts.push(el("div", { className: "m02-sod__family", children: [box, add] }));
|
||||
}
|
||||
const families = Object.keys(d.구조물군);
|
||||
families.forEach((g, gi) => {
|
||||
parts.push(
|
||||
@@ -188,7 +210,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
const edit = canEdit();
|
||||
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
|
||||
// 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무
|
||||
const spec = STRUCTURE_SPECS[type.type_id];
|
||||
const spec = specFor(type.type_id);
|
||||
detail.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-sod__kindhead",
|
||||
@@ -199,10 +221,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
cls: "m02-sod__name",
|
||||
}),
|
||||
el("small", { className: "m02-opts__key", text: type.type_id }),
|
||||
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), {
|
||||
edit,
|
||||
attr: "data-sod-kind-off",
|
||||
}),
|
||||
// 안 씀 = 목록 B06 만(enabled 는 목록 짝 · 322-2) · 숨김(hidden)은 한눈 표 목록 칸에서
|
||||
checkBox(
|
||||
to("Unused"),
|
||||
type.enabled === false,
|
||||
(v) => (setReflect(d, type.type_id, "목록", v ? "b06" : "both"), changed()),
|
||||
{ edit, attr: "data-sod-kind-off" },
|
||||
),
|
||||
...(!OLD_EDITOR
|
||||
? []
|
||||
: [
|
||||
@@ -225,7 +250,10 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
],
|
||||
}),
|
||||
...(!OLD_EDITOR
|
||||
? [buildSpecBlock({ def: d, type, spec, edit, changed })]
|
||||
? [
|
||||
buildReflectBlock(d, type, edit, changed),
|
||||
buildSpecBlock({ def: d, base, type, spec, edit, changed }),
|
||||
]
|
||||
: [
|
||||
buildOptionsBlock({ def: d, type, edit, open, changed }),
|
||||
buildLayoutBlock({ def: d, type, edit, changed }),
|
||||
@@ -235,6 +263,27 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
);
|
||||
};
|
||||
|
||||
const paintTabs = (): void => {
|
||||
const pairs: Array<[Tab, string]> = [
|
||||
["table", to("TabTable")],
|
||||
["kinds", to("TabKinds")],
|
||||
["parts", to("TabParts")],
|
||||
];
|
||||
tabBar.replaceChildren(
|
||||
...pairs.map(([key, label]) => {
|
||||
const b = createButton({ label, variant: key === tab ? "filled" : "ghost", size: "sm" });
|
||||
b.setAttribute("data-sod-tab", key);
|
||||
b.addEventListener("click", () => ((tab = key), paint()));
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
const edit = canEdit();
|
||||
if (tab === "table")
|
||||
body.replaceChildren(buildTable({ def: shown(), edit, changed, issues: allProblems() }));
|
||||
else if (tab === "parts") body.replaceChildren(buildParts({ def: shown(), edit, changed }));
|
||||
else body.replaceChildren(panes);
|
||||
};
|
||||
|
||||
const paintFoot = (): void => {
|
||||
banner.replaceChildren(
|
||||
...(past
|
||||
@@ -248,7 +297,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
]),
|
||||
);
|
||||
banner.classList.toggle("is-past", !!past);
|
||||
const list = past ? [] : problems(work);
|
||||
const list = allProblems();
|
||||
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
|
||||
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
|
||||
};
|
||||
@@ -256,6 +305,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
const paint = (): void => {
|
||||
paintKinds();
|
||||
paintDetail();
|
||||
paintTabs();
|
||||
paintFoot();
|
||||
};
|
||||
|
||||
@@ -273,7 +323,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
save.setAttribute("data-sod-save", "");
|
||||
save.addEventListener("click", async () => {
|
||||
if (problems(work).length) return;
|
||||
if (allProblems().length) return;
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
base = await saveStructureDef(base.판, tidy(work));
|
||||
@@ -339,7 +389,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("DefNote") }),
|
||||
banner,
|
||||
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
|
||||
tabBar,
|
||||
body,
|
||||
issues,
|
||||
el("div", {
|
||||
className: "m02-side__row",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Add.ts
|
||||
* 종류 더하기(PLAN 323-7) — 본보기(구조물 정본 원본 종류)를 그대로 복사한 새 종류.
|
||||
*
|
||||
* 키 자동(`{본보기}_xxxxxx` · 만든 뒤 안 바뀜) · 이름 「{본보기} 2」(겹치면 3 …) · 군 · 자리꼴 · 옵션 · 표시 · 그룹 ·
|
||||
* 배치 · 규칙 · 종류 덧칸(동작 · 이름 표) · 반영 그대로 · `종류.{새}.본보기 = 원본` — 구조물 표가 본보기 처음값을
|
||||
* 이어받고(`resolveCatalog` · 일곱 갈래 = 목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴), 손코딩 동작 ·
|
||||
* 명세도 본보기 키로 고름(`baseType`). 관 지점 정본 종류는 본보기에서 뺌(관 시설 폼 · 정본 목록이 따로).
|
||||
* ========================================================================== */
|
||||
|
||||
import { clone, newKey, type StructDef } from "./M02_SO_Def_Model";
|
||||
|
||||
/** 본보기로 고를 수 있는 종류 — 구조물 정본 · 원본(본보기 아님) · [키, 이름] (정의 차례). */
|
||||
export const templates = (def: StructDef): Array<[string, string]> =>
|
||||
def.types
|
||||
.filter((t) => !t.managed_by && !def.종류[t.type_id]?.본보기)
|
||||
.map((t) => [t.type_id, t.name] as [string, string]);
|
||||
|
||||
/** 본보기를 복사해 그 뒤에 새 종류를 끼움 — 새 키(못 하면 null). */
|
||||
export function addFromTemplate(def: StructDef, sourceId: string): string | null {
|
||||
const index = def.types.findIndex((t) => t.type_id === sourceId);
|
||||
const src = def.types[index];
|
||||
if (!src || src.managed_by || def.종류[sourceId]?.본보기) return null;
|
||||
const key = newKey(
|
||||
`${sourceId}_`,
|
||||
def.types.map((t) => t.type_id),
|
||||
);
|
||||
const names = new Set(def.types.map((t) => t.name));
|
||||
let n = 2;
|
||||
while (names.has(`${src.name} ${n}`)) n += 1;
|
||||
const copy = clone(src);
|
||||
copy.type_id = key;
|
||||
copy.name = `${src.name} ${n}`;
|
||||
delete copy.drawing_views; // 안 읽는 칸(323-7)
|
||||
def.types.splice(index + 1, 0, copy);
|
||||
def.종류[key] = { ...clone(def.종류[sourceId] ?? {}), 본보기: sourceId };
|
||||
for (const table of [def.표시, def.그룹, def.배치] as Array<Record<string, unknown>>)
|
||||
if (table[sourceId] !== undefined) table[key] = clone(table[sourceId]);
|
||||
def.규칙.push(
|
||||
...def.규칙.filter((r) => r.종류 === sourceId).map((r) => ({ ...clone(r), 종류: key })),
|
||||
);
|
||||
const reflect = (def as { 반영?: Record<string, unknown> }).반영;
|
||||
if (reflect?.[sourceId] !== undefined) reflect[key] = clone(reflect[sourceId]);
|
||||
return key;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Check.ts
|
||||
* 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
|
||||
*
|
||||
* · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
|
||||
* · 부품 = `{"갈래.부품": false}`(끈 것만)
|
||||
* · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
|
||||
* 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
|
||||
* 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
|
||||
* · 앞 판이 없으면(옛 판 읽기) 꼴만 검사
|
||||
* · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
|
||||
* 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
|
||||
import {
|
||||
resolveCatalog,
|
||||
type CatalogDefinition,
|
||||
} from "../../common_util/common_util_structure_catalog";
|
||||
import { OWNER_KO, PLACE_KO, shortName } from "./M02_SO_Def_Reflect";
|
||||
|
||||
type Branch = "횡단도" | "카드이름" | "형상" | "3D";
|
||||
interface Part {
|
||||
이름: string;
|
||||
"읽는 칸": string[];
|
||||
자리꼴: string[];
|
||||
정본: string[];
|
||||
}
|
||||
const PARTS_BY = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
|
||||
|
||||
export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"];
|
||||
/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */
|
||||
export const OFF_VALUE: Record<Branch, string> = {
|
||||
횡단도: "none",
|
||||
카드이름: "name",
|
||||
형상: "none",
|
||||
"3D": "none",
|
||||
};
|
||||
export const LISTS = ["both", "b06", "hidden"] as const;
|
||||
const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]);
|
||||
|
||||
/** 검사가 읽는 정의 칸. */
|
||||
export interface CheckDef extends CatalogDefinition {
|
||||
types?: ReadonlyArray<{
|
||||
type_id: string;
|
||||
name?: string;
|
||||
group?: string;
|
||||
placement?: string;
|
||||
managed_by?: string | null;
|
||||
options?: ReadonlyArray<{
|
||||
key: string;
|
||||
input?: string;
|
||||
choices?: readonly string[];
|
||||
default?: unknown;
|
||||
empty_means?: string | null;
|
||||
label?: string;
|
||||
}>;
|
||||
}>;
|
||||
표시?: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */
|
||||
const partOf = (branch: string, id: string): Part | undefined => {
|
||||
const box = (PARTS_BY as Record<string, Record<string, Part>>)[branch];
|
||||
return box && Object.hasOwn(box, id) ? box[id] : undefined;
|
||||
};
|
||||
|
||||
/** 반영 · 부품 꼴. */
|
||||
function shapeErrors(doc: CheckDef, known: Set<string>): string[] {
|
||||
const out: string[] = [];
|
||||
const reflect: unknown = doc.반영 ?? {};
|
||||
if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함");
|
||||
else
|
||||
for (const [tid, row] of Object.entries(reflect)) {
|
||||
if (!known.has(tid) || !isObject(row)) {
|
||||
out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`);
|
||||
continue;
|
||||
}
|
||||
for (const [field, value] of Object.entries(row)) {
|
||||
const where = `반영 「${tid}.${field}」`;
|
||||
if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`);
|
||||
else if (field === "목록") {
|
||||
if (!(LISTS as readonly unknown[]).includes(value))
|
||||
out.push(`${where} — both · b06 · hidden 중 하나`);
|
||||
} else if (field === "형상칸") {
|
||||
const ok =
|
||||
isObject(value) &&
|
||||
Object.entries(value).every(([k, v]) =>
|
||||
k === "form"
|
||||
? typeof v === "string" && !!v.trim()
|
||||
: k === "weep" && typeof v === "boolean",
|
||||
);
|
||||
if (!ok) out.push(`${where} — form 글 · weep 참거짓`);
|
||||
} else {
|
||||
const branch = field as Branch;
|
||||
if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value)))
|
||||
out.push(`${where} — 없는 부품 「${String(value)}」`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const parts: unknown = doc.부품 ?? {};
|
||||
if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함");
|
||||
else
|
||||
for (const [key, value] of Object.entries(parts)) {
|
||||
const at = key.indexOf(".");
|
||||
if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1)))
|
||||
out.push(`부품 「${key}」 — 없는 부품`);
|
||||
else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const hiddenIn = (doc: CheckDef | null, tid: string): string[] =>
|
||||
Object.entries(doc?.표시?.[tid] ?? {})
|
||||
.filter(([, shown]) => shown === false)
|
||||
.map(([key]) => key);
|
||||
|
||||
/** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */
|
||||
function rowErrors(doc: CheckDef, before: CheckDef | null): string[] {
|
||||
const out: string[] = [];
|
||||
const rows = resolveCatalog(doc);
|
||||
const raw = resolveCatalog({ ...doc, 부품: {} }); // 부품 끄기 전 — 끈 탓 가름(323-9)
|
||||
const offUsers = new Map<string, string[]>(); // 끈 형상 부품 → 횡단도를 바꿔야 하는 종류
|
||||
for (const t of doc.types ?? []) {
|
||||
const row = rows.get(t.type_id);
|
||||
if (!row) continue;
|
||||
if (row.shape === "none" && row.station !== "none") {
|
||||
const was = raw.get(t.type_id)?.shape ?? "none";
|
||||
if (was !== "none" && doc.부품?.[`형상.${was}`] === false)
|
||||
offUsers.set(was, [...(offUsers.get(was) ?? []), `${row.groupName} · ${row.name}`]);
|
||||
else out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`);
|
||||
}
|
||||
// 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7)
|
||||
const kept = [t.type_id, row.base].flatMap((id) => hiddenIn(before ?? doc, id));
|
||||
const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key));
|
||||
// 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
|
||||
const keys = new Set(
|
||||
(t.options ?? [])
|
||||
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
|
||||
.map((o) => o.key),
|
||||
);
|
||||
const labelOf = (key: string): string =>
|
||||
(t.options ?? []).find((o) => o.key === key)?.label ?? key;
|
||||
const used: Array<[Branch, string]> = [
|
||||
["횡단도", row.station],
|
||||
["카드이름", row.title],
|
||||
["형상", row.shape],
|
||||
["3D", row.model3d],
|
||||
];
|
||||
for (const [branch, id] of used) {
|
||||
const part = partOf(branch, id);
|
||||
if (!part) continue;
|
||||
const name = shortName(part.이름);
|
||||
const place = PLACE_KO[row.placement] ?? row.placement;
|
||||
const owner = OWNER_KO[row.owner] ?? row.owner;
|
||||
if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner))
|
||||
out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner}에 안 맞음`);
|
||||
for (const key of part["읽는 칸"])
|
||||
if (keys.has(key) && hidden.includes(key))
|
||||
out.push(
|
||||
`「${row.name}」 ${branch} 「${name}」 이 쓰는 칸 「${labelOf(key)}」 — 숨길 수 없음`,
|
||||
);
|
||||
}
|
||||
}
|
||||
offUsers.forEach((names, id) =>
|
||||
out.push(
|
||||
`부품 「${shortName(partOf("형상", id)?.이름 ?? id)}」 끔 — 이 부품을 쓰는 종류(${names.join(", ")})의 ` +
|
||||
"횡단도를 「안 만듦」으로 바꿔야 저장",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 앞 판과 비교 — 만든 뒤 고정. */
|
||||
/** 본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · 정본(파이썬 짝). */
|
||||
function templateErrors(doc: CheckDef): string[] {
|
||||
const out: string[] = [];
|
||||
const kinds = doc.종류 ?? {};
|
||||
const now = new Map((doc.types ?? []).map((t) => [t.type_id, t]));
|
||||
for (const [tid, row] of Object.entries(kinds)) {
|
||||
const src = row?.본보기;
|
||||
const t = now.get(tid);
|
||||
if (src == null || !t) continue;
|
||||
const base = now.get(src);
|
||||
const name = t.name || tid;
|
||||
if (!base || src === tid || kinds[src]?.본보기) {
|
||||
out.push(`「${name}」 본보기 「${src}」 — 원본 종류여야 함`);
|
||||
continue;
|
||||
}
|
||||
const same =
|
||||
(["group", "placement", "managed_by"] as const).every(
|
||||
(k) => (t[k] ?? null) === (base[k] ?? null),
|
||||
) && !base.managed_by;
|
||||
if (!same)
|
||||
out.push(`「${name}」 본보기 「${base.name || src}」 와 군 · 자리꼴 · 정본이 같아야 함`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fixedErrors(doc: CheckDef, before: CheckDef): string[] {
|
||||
const out: string[] = [];
|
||||
const now = new Map((doc.types ?? []).map((t) => [t.type_id, t]));
|
||||
const baseOf = (d: CheckDef, tid: string) => d.종류?.[tid]?.본보기 ?? null;
|
||||
for (const old of before.types ?? []) {
|
||||
const t = now.get(old.type_id);
|
||||
const name = old.name ?? old.type_id;
|
||||
if (!t) {
|
||||
out.push(`종류 「${name}」 — 지울 수 없음`);
|
||||
continue;
|
||||
}
|
||||
const fixed: Array<[string, unknown, unknown]> = [
|
||||
["자리꼴", old.placement, t.placement],
|
||||
["정본", old.managed_by ?? null, t.managed_by ?? null],
|
||||
["군 글자", old.group, t.group],
|
||||
["본보기", baseOf(before, old.type_id), baseOf(doc, old.type_id)],
|
||||
];
|
||||
for (const [what, a, b] of fixed) if (a !== b) out.push(`「${name}」 ${what} — 만든 뒤 고정`);
|
||||
const options = new Map((t.options ?? []).map((o) => [o.key, o]));
|
||||
for (const o of old.options ?? []) {
|
||||
const n = options.get(o.key);
|
||||
if (o.input !== "select" || !n) continue;
|
||||
const a = [...(o.choices ?? [])].sort().join("\u0000");
|
||||
const b = [...(n.choices ?? [])].sort().join("\u0000");
|
||||
if (a !== b)
|
||||
out.push(`「${name}」 「${o.label ?? o.key}」 선택지 값 — 고칠 수 없음(이름만 고침)`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */
|
||||
export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] {
|
||||
const known = new Set((doc.types ?? []).map((t) => t.type_id));
|
||||
const shape = shapeErrors(doc, known);
|
||||
if (shape.length) return shape;
|
||||
const templates = templateErrors(doc);
|
||||
return [
|
||||
...templates,
|
||||
...(before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []),
|
||||
];
|
||||
}
|
||||
@@ -58,7 +58,11 @@ export interface StructDef {
|
||||
동작: Record<string, string>;
|
||||
types: TypeDef[];
|
||||
/** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */
|
||||
종류: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
|
||||
/** 본보기 = 복사한 종류의 원본 키(323-7 · 만든 뒤 고정 · 처음값 · 손코딩 동작을 이어받음). */
|
||||
종류: Record<
|
||||
string,
|
||||
{ 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string>; 본보기?: string }
|
||||
>;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
그룹: Record<string, GroupDef[]>;
|
||||
배치: Record<string, Record<string, Spot>>;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Parts.ts
|
||||
* 구조물 정의 「부품」 탭(PLAN 322-2) — 코드가 그리는 방법(부품 상자 `common_util_structure_parts.json`).
|
||||
* 갈래마다 부품 · 이름 · 읽는 칸 · 맞는 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 `부품` = 끈 것만).
|
||||
* 끈 부품을 쓰는 종류는 그 갈래가 없음(카드 이름은 종류 이름)으로 — 표 줄(`resolveCatalog`)이 따름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { StructDef } from "./M02_SO_Def_Model";
|
||||
import {
|
||||
OWNER_KO,
|
||||
PART_BOX,
|
||||
partsOff,
|
||||
PLACE_KO,
|
||||
setPartOn,
|
||||
shortName,
|
||||
usersOf,
|
||||
} from "./M02_SO_Def_Reflect";
|
||||
import { checkBox, head } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
type Branch = keyof typeof PART_BOX;
|
||||
|
||||
export function buildParts(opt: {
|
||||
def: StructDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}): HTMLElement {
|
||||
const { def, edit, changed } = opt;
|
||||
const off = partsOff(def);
|
||||
/** 옵션 키 → 정의 이름(단위) — 그 부품을 쓰는 종류의 칸 이름 먼저 · 없으면 아무 종류 · 그래도 없으면 키. */
|
||||
const labelOf = (key: string, users: readonly string[]): string => {
|
||||
const types = [
|
||||
...def.types.filter((t) => users.includes(t.type_id)),
|
||||
...def.types.filter((t) => !users.includes(t.type_id)),
|
||||
];
|
||||
const o = types.flatMap((t) => t.options).find((x) => x.key === key);
|
||||
return o ? (o.unit ? `${o.label} (${o.unit})` : o.label) : key;
|
||||
};
|
||||
const blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [
|
||||
...head(branch),
|
||||
el("table", {
|
||||
className: "m02-sod__table m02-sod__table--wrap",
|
||||
attrs: { "data-sod-parts": branch },
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: (["ColName", "PartRead", "PartFits", "PartUsers", "PartOn"] as const).map(
|
||||
(key) => el("th", { text: to(key) }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", {
|
||||
children: Object.entries(PART_BOX[branch]).map(([id, part]) => {
|
||||
const key = `${branch}.${id}`;
|
||||
// 끈 부품을 쓰던 종류도 보이게 — 끈 동안은 표가 none 으로 풀므로 켠 꼴로 셈
|
||||
const users = usersOf({ ...def, 부품: {} } as StructDef, branch, id);
|
||||
return el("tr", {
|
||||
attrs: { "data-sod-part": key },
|
||||
className: off[key] === false ? "is-off" : "",
|
||||
children: [
|
||||
// 영어 키는 툴팁만(322-2 다듬기) — 이름 = 짧은 이름 · 읽는 칸 = 정의 칸 이름
|
||||
el("td", { text: shortName(part.이름), attrs: { title: `${id} · ${part.이름}` } }),
|
||||
el("td", {
|
||||
text:
|
||||
part["읽는 칸"]
|
||||
.map((k) =>
|
||||
labelOf(
|
||||
k,
|
||||
users.map((row) => row.typeId),
|
||||
),
|
||||
)
|
||||
.join(" · ") || "—",
|
||||
}),
|
||||
el("td", {
|
||||
text: `${part.자리꼴.map((p) => PLACE_KO[p] ?? p).join(" · ")} / ${part.정본
|
||||
.map((o) => OWNER_KO[o] ?? o)
|
||||
.join(" · ")}`,
|
||||
}),
|
||||
el("td", { text: users.map((row) => row.name).join(" · ") || "—" }),
|
||||
el("td", {
|
||||
children: [
|
||||
checkBox(
|
||||
to("PartOn"),
|
||||
off[key] !== false,
|
||||
(on) => (setPartOn(def, key, on), changed()),
|
||||
{ edit, attr: "data-sod-part-on" },
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
return el("div", {
|
||||
className: "m02-sod__tablewrap",
|
||||
children: [el("p", { className: "m02-side__empty", text: to("PartsNote") }), ...blocks],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Reflect.ts
|
||||
* 구조물 표 편집(PLAN 322-2)의 순수 손질 — 정의 사본의 `반영` · `부품` · 목록 + enabled · 형상칸.
|
||||
*
|
||||
* · 지금 값 = 구조물 표 한 줄(`resolveCatalog` · 정의 반영 ⊕ 처음값 ⊕ 부품 끔)
|
||||
* · 반영에는 처음 값과 다른 칸만 남김(같으면 지움 = 「기본」) · [처음 값으로] = 그 종류 반영 지움
|
||||
* · 목록과 enabled 는 함께 씀 — enabled = 목록 both 일 때만 참(옛 짝 · 브레인 322-1 답 ①)
|
||||
* 화면 그리기 없음 — 표 · 종류별 · 부품 탭이 이 함수들로 사본을 고침.
|
||||
* ========================================================================== */
|
||||
|
||||
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
|
||||
import {
|
||||
resolveCatalog,
|
||||
type CatalogDefinition,
|
||||
type CatalogRow,
|
||||
} from "../../common_util/common_util_structure_catalog";
|
||||
import { clone, type StructDef } from "./M02_SO_Def_Model";
|
||||
|
||||
export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D";
|
||||
type Reflect = Partial<Record<ReflectField, string>> & { 형상칸?: Record<string, unknown> };
|
||||
interface Part {
|
||||
이름: string;
|
||||
"읽는 칸": string[];
|
||||
자리꼴: string[];
|
||||
정본: string[];
|
||||
}
|
||||
type Branch = Exclude<ReflectField, "목록">;
|
||||
|
||||
export const PART_BOX = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
|
||||
const START = (PARTS as unknown as { 처음값: Record<string, Reflect> }).처음값;
|
||||
|
||||
/** 화면 글(322-2 다듬기 · 영어 키 안 보임) — 자리꼴 · 정본 · 부품 짧은 이름(괄호 설명은 툴팁). */
|
||||
export const PLACE_KO: Record<string, string> = { point: "점", interval: "구간", site: "터" };
|
||||
export const OWNER_KO: Record<string, string> = { pipe_points: "관 지점", structures: "구조물" };
|
||||
export const shortName = (full: string): string => full.replace(/\s*\(.*\)\s*$/, "");
|
||||
|
||||
/** 칸 → 표 줄 열쇠. */
|
||||
const ROW_KEY: Record<ReflectField, keyof CatalogRow> = {
|
||||
목록: "list",
|
||||
횡단도: "station",
|
||||
카드이름: "title",
|
||||
형상: "shape",
|
||||
"3D": "model3d",
|
||||
};
|
||||
|
||||
export const reflectOf = (def: StructDef): Record<string, Reflect> =>
|
||||
((def as { 반영?: Record<string, Reflect> }).반영 ??= {});
|
||||
export const partsOff = (def: StructDef): Record<string, boolean> =>
|
||||
((def as { 부품?: Record<string, boolean> }).부품 ??= {});
|
||||
|
||||
/** 사본 전체 표 — 종류마다 한 줄. */
|
||||
export const rowsOf = (def: StructDef): Map<string, CatalogRow> =>
|
||||
resolveCatalog(def as unknown as CatalogDefinition);
|
||||
|
||||
/** 칸 지금 값. */
|
||||
export const valueOf = (row: CatalogRow, field: ReflectField): string =>
|
||||
String(row[ROW_KEY[field]]);
|
||||
|
||||
/** 반영이 없을 때(처음 값 · 목록은 enabled 짝) 그 칸 값. */
|
||||
export function defaultOf(def: StructDef, typeId: string, field: ReflectField): string {
|
||||
const copy = { ...def, 반영: { ...reflectOf(def) } } as unknown as StructDef;
|
||||
const own = { ...(reflectOf(copy)[typeId] ?? {}) };
|
||||
delete own[field];
|
||||
reflectOf(copy)[typeId] = own;
|
||||
const row = rowsOf(copy).get(typeId);
|
||||
return row ? valueOf(row, field) : "";
|
||||
}
|
||||
|
||||
/** 반영에 적힌 칸인가(아니면 「기본」). */
|
||||
export const isSet = (def: StructDef, typeId: string, field: ReflectField | "형상칸"): boolean =>
|
||||
field in (reflectOf(def)[typeId] ?? {});
|
||||
|
||||
const dropEmpty = (def: StructDef, typeId: string): void => {
|
||||
const all = reflectOf(def);
|
||||
if (all[typeId] && !Object.keys(all[typeId]).length) delete all[typeId];
|
||||
};
|
||||
|
||||
/** 칸 고침 — 처음 값과 같으면 반영에서 지움 · 목록이면 enabled 도(both 일 때만 참). */
|
||||
export function setReflect(
|
||||
def: StructDef,
|
||||
typeId: string,
|
||||
field: ReflectField,
|
||||
value: string,
|
||||
): void {
|
||||
const type = def.types.find((t) => t.type_id === typeId);
|
||||
if (field === "목록" && type) type.enabled = value === "both";
|
||||
const own = (reflectOf(def)[typeId] ??= {});
|
||||
delete own[field];
|
||||
if (defaultOf(def, typeId, field) !== value) own[field] = value;
|
||||
dropEmpty(def, typeId);
|
||||
}
|
||||
|
||||
/** 형상칸 한 칸 고침(form 글 · weep 참거짓) — 처음 값과 같으면 지움. */
|
||||
export function setShapeOpt(def: StructDef, typeId: string, key: string, value: unknown): void {
|
||||
const own = (reflectOf(def)[typeId] ??= {});
|
||||
const opts = { ...(own.형상칸 ?? {}) };
|
||||
const start = START[typeId]?.형상칸?.[key];
|
||||
if (value === start || value === "" || (value === false && start === undefined)) delete opts[key];
|
||||
else opts[key] = value;
|
||||
if (Object.keys(opts).length) own.형상칸 = opts;
|
||||
else delete own.형상칸;
|
||||
dropEmpty(def, typeId);
|
||||
}
|
||||
|
||||
/** [처음 값으로] — 그 종류 반영 지움 · enabled = 처음 목록 both 일 때만 참. */
|
||||
export function resetRow(def: StructDef, typeId: string): void {
|
||||
delete reflectOf(def)[typeId];
|
||||
const type = def.types.find((t) => t.type_id === typeId);
|
||||
if (type) type.enabled = START[typeId] ? START[typeId].목록 === "both" : type.enabled;
|
||||
}
|
||||
|
||||
/** [저장] 전 — 모든 종류 enabled = 지금 목록 both 일 때만 참(목록 + enabled 함께 씀). */
|
||||
export function syncEnabled(def: StructDef): void {
|
||||
const rows = rowsOf(def);
|
||||
for (const type of def.types) {
|
||||
const row = rows.get(type.type_id);
|
||||
if (row) type.enabled = row.list === "both";
|
||||
}
|
||||
}
|
||||
|
||||
/** 부품 켬끔 — 끔만 남김. */
|
||||
export function setPartOn(def: StructDef, key: string, on: boolean): void {
|
||||
const off = partsOff(def);
|
||||
if (on) delete off[key];
|
||||
else off[key] = false;
|
||||
}
|
||||
|
||||
/** 못 숨김 칸 — 종류가 지금 쓰는 부품들이 읽는 칸 중 기본값도 비워 두는 뜻도 없는 옵션
|
||||
* (숨기면 그림이 받을 값이 없음 · 검사 짝 `M02_SO_Def_Check`). */
|
||||
export function readKeys(def: StructDef, typeId: string): Set<string> {
|
||||
const row = rowsOf(def).get(typeId);
|
||||
if (!row) return new Set();
|
||||
const type = def.types.find((t) => t.type_id === typeId);
|
||||
const bare = new Set(
|
||||
(type?.options ?? [])
|
||||
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
|
||||
.map((o) => o.key),
|
||||
);
|
||||
const used: Array<[Branch, string]> = [
|
||||
["횡단도", row.station],
|
||||
["카드이름", row.title],
|
||||
["형상", row.shape],
|
||||
["3D", row.model3d],
|
||||
];
|
||||
return new Set(
|
||||
used
|
||||
.flatMap(([branch, id]) => PART_BOX[branch][id]?.["읽는 칸"] ?? [])
|
||||
.filter((key) => bare.has(key)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 갈래 부품을 지금 쓰는 종류들. */
|
||||
export function usersOf(def: StructDef, branch: Branch, id: string): CatalogRow[] {
|
||||
const field = ROW_KEY[branch];
|
||||
return [...rowsOf(def).values()].filter((row) => row[field] === id);
|
||||
}
|
||||
|
||||
/** 정의 「표시」 숨김 키(그 종류). */
|
||||
export const hiddenKeys = (def: StructDef, typeId: string): Set<string> =>
|
||||
new Set(
|
||||
Object.entries(def.표시[typeId] ?? {})
|
||||
.filter(([, shown]) => shown === false)
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
/** 옵션 키들 보임 켬끔 — 정의 옵션인 키만(숨김 = false 만 남김). */
|
||||
export function setShown(
|
||||
def: StructDef,
|
||||
typeId: string,
|
||||
keys: readonly string[],
|
||||
on: boolean,
|
||||
): void {
|
||||
const type = def.types.find((t) => t.type_id === typeId);
|
||||
const known = new Set((type?.options ?? []).map((o) => o.key));
|
||||
const row = (def.표시[typeId] ??= {});
|
||||
for (const key of keys.filter((k) => known.has(k))) {
|
||||
if (on) delete row[key];
|
||||
else row[key] = false;
|
||||
}
|
||||
if (!Object.keys(row).length) delete def.표시[typeId];
|
||||
}
|
||||
|
||||
/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */
|
||||
export function tidy(def: StructDef): StructDef {
|
||||
const out = clone(def);
|
||||
syncEnabled(out);
|
||||
const reflect = (out.반영 ?? {}) as Record<string, unknown>;
|
||||
for (const part of [out.그룹, out.배치, out.종류, out.표시, reflect] as Record<string, unknown>[])
|
||||
for (const [k, v] of Object.entries(part))
|
||||
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 열쇠 차례 무관 글 — 같은 값이면 같은 글. */
|
||||
const stable = (value: unknown): string =>
|
||||
Array.isArray(value)
|
||||
? `[${value.map(stable).join(",")}]`
|
||||
: value && typeof value === "object"
|
||||
? `{${Object.keys(value)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${stable((value as Record<string, unknown>)[k])}`)
|
||||
.join(",")}}`
|
||||
: JSON.stringify(value ?? null);
|
||||
|
||||
/** 저장할 것이 있나 — 저장할 꼴(tidy)끼리 견줌 · 빈 반영 · 부품 칸은 없는 셈(바꿨다 되돌리면 꺼짐 · 322-2). */
|
||||
export function differs(work: StructDef, base: StructDef): boolean {
|
||||
const canon = (def: StructDef): string => {
|
||||
const out = tidy(def) as Record<string, unknown>;
|
||||
for (const key of ["반영", "부품"])
|
||||
if (out[key] && !Object.keys(out[key] as object).length) delete out[key];
|
||||
return stable(out);
|
||||
};
|
||||
return canon(work) !== canon(base);
|
||||
}
|
||||
@@ -6,11 +6,13 @@
|
||||
* 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품
|
||||
* (`field` · `optionControl` · 그룹 상자)으로 같은 차례.
|
||||
* 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침.
|
||||
* 줄마다 보임(322-2 · 정의 「표시」 · 컨테이너 = 그 안 칸 전부) · 부품이 읽는 칸 = 「못 숨김」(앞 판에서
|
||||
* 이미 숨긴 칸은 다시 보이기만) · 미리보기도 숨김을 따름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createTag, el } from "@ui/ui_template_elements";
|
||||
import { createSpecForm } from "../../A00_Common/design/design_structure_form";
|
||||
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
|
||||
import { specItemKeys, type StructureSpec } from "../../A00_Common/design/design_structure_spec";
|
||||
import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
|
||||
@@ -20,7 +22,8 @@ import {
|
||||
specNameRows,
|
||||
type SpecNameRow,
|
||||
} from "./M02_SO_Def_Spec_Model";
|
||||
import { head, textBox } from "./M02_SO_Def_Ui";
|
||||
import { hiddenKeys, readKeys, setShown } from "./M02_SO_Def_Reflect";
|
||||
import { checkBox, head, textBox } from "./M02_SO_Def_Ui";
|
||||
import { to, type OptionsTextKey } from "./M02_SO_Text";
|
||||
|
||||
const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
|
||||
@@ -36,7 +39,8 @@ const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
|
||||
/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */
|
||||
function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
|
||||
const names = def.종류[type.type_id]?.이름 ?? {};
|
||||
const options = type.options.filter((o) => o.enabled !== false);
|
||||
const hidden = hiddenKeys(def, type.type_id);
|
||||
const options = type.options.filter((o) => o.enabled !== false && !hidden.has(o.key));
|
||||
const cell = (o: (typeof options)[number]): HTMLElement =>
|
||||
field(
|
||||
o.unit ? `${o.label} (${o.unit})` : o.label,
|
||||
@@ -67,8 +71,45 @@ function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** 이름 줄이 쓰는 정의 옵션 키 — 명세 상자 = 그 안 칸 전부 · 칸 = 저장 · 갈래 키 · 그룹 = 그 옵션들. */
|
||||
function rowKeys(type: TypeDef, spec: StructureSpec | undefined, nameKey: string): string[] {
|
||||
if (!nameKey || nameKey.includes("=")) return [];
|
||||
if (!spec) {
|
||||
if (!nameKey.startsWith("#")) return [nameKey];
|
||||
return [];
|
||||
}
|
||||
const [cid, iid] = nameKey.split(".");
|
||||
const c = spec.containers.find((x) => x.id === cid);
|
||||
const items = (c?.items ?? []).filter((i) => !iid || i.id === iid);
|
||||
const own = new Set(type.options.map((o) => o.key));
|
||||
return items.flatMap(specItemKeys).filter((key) => own.has(key));
|
||||
}
|
||||
|
||||
/** 줄 보임 칸 — 키 없는 줄은 비움 · 부품이 읽는 보이는 칸은 못 숨김. */
|
||||
function shownBox(
|
||||
opt: { def: StructDef; base: StructDef; type: TypeDef; edit: boolean; changed: () => void },
|
||||
keys: string[],
|
||||
): HTMLElement[] {
|
||||
if (!keys.length) return [];
|
||||
const { def, base, type, edit, changed } = opt;
|
||||
const hidden = hiddenKeys(def, type.type_id);
|
||||
const kept = hiddenKeys(base, type.type_id);
|
||||
const read = readKeys(def, type.type_id);
|
||||
const shown = keys.some((key) => !hidden.has(key));
|
||||
const locked = shown && keys.some((key) => read.has(key) && !kept.has(key));
|
||||
return [
|
||||
checkBox(to("ShownCol"), shown, (v) => (setShown(def, type.type_id, keys, v), changed()), {
|
||||
edit: edit && !locked,
|
||||
attr: "data-sod-spec-shown",
|
||||
}),
|
||||
...(locked ? [createTag(to("CannotHide"), "neutral")] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSpecBlock(opt: {
|
||||
def: StructDef;
|
||||
/** 지금 서버 판 — 이미 숨긴 칸(못 숨김 예외)을 가름 */
|
||||
base: StructDef;
|
||||
type: TypeDef;
|
||||
/** 없으면 정의 그룹 꼴(52-12) */
|
||||
spec: StructureSpec | undefined;
|
||||
@@ -90,6 +131,12 @@ export function buildSpecBlock(opt: {
|
||||
: el("span", { text: to("SpecLoose") }),
|
||||
el("small", { className: "m02-opts__key", text: row.key }),
|
||||
...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []),
|
||||
...shownBox(
|
||||
opt,
|
||||
row.group && !spec
|
||||
? ((def.그룹[type.type_id] ?? []).find((g) => g.id === row.group)?.옵션 ?? [])
|
||||
: rowKeys(type, spec, row.nameKey),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -98,7 +145,14 @@ export function buildSpecBlock(opt: {
|
||||
if (spec) {
|
||||
const form = createSpecForm(null);
|
||||
const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름]));
|
||||
form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups);
|
||||
form.write(
|
||||
spec,
|
||||
{},
|
||||
def.종류[type.type_id]?.이름 ?? {},
|
||||
groups,
|
||||
{},
|
||||
hiddenKeys(def, type.type_id),
|
||||
);
|
||||
preview = form.root;
|
||||
} else preview = groupPreview(def, type);
|
||||
preview.inert = true;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Table.ts
|
||||
* 구조물 정의 「한눈 표」 탭(PLAN 322-2) — 종류마다 B05 · B06 반영 한 줄.
|
||||
* 군 · 종류(▲▼) · 이름 · 목록 · 자리꼴 · 정본(읽기만 「고정」) · 횡단도 · 카드 이름 · 형상(+ 형상칸) ·
|
||||
* 알약 색 · 3D · 검사. 흐린 칸 = 처음 값(「기본」) · 진한 칸 = 바꾼 값 · [처음 값으로] = 그 종류 반영 지움.
|
||||
* 칸 손질 = `M02_SO_Def_Reflect` · 검사 글 = `M02_SO_Def_Check`(서버 짝) · 「종류별」 탭이 같은 칸(`reflectCells`)을 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el } from "@ui/ui_template_elements";
|
||||
import type { CatalogRow } from "../../common_util/common_util_structure_catalog";
|
||||
import { stepFamily, stepType, type StructDef, type TypeDef } from "./M02_SO_Def_Model";
|
||||
import { REVET_FORMS } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
isSet,
|
||||
OWNER_KO,
|
||||
PART_BOX,
|
||||
PLACE_KO,
|
||||
shortName,
|
||||
resetRow,
|
||||
rowsOf,
|
||||
setReflect,
|
||||
setShapeOpt,
|
||||
valueOf,
|
||||
type ReflectField,
|
||||
} from "./M02_SO_Def_Reflect";
|
||||
import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
type Pairs = ReadonlyArray<readonly [string, string]>;
|
||||
const LIST_PAIRS = (): Pairs => [
|
||||
["both", to("ListBoth")],
|
||||
["b06", to("ListB06")],
|
||||
["hidden", to("ListHidden")],
|
||||
];
|
||||
/** 갈래 고르기 — 없음(횡단도 = 안 만듦 · 카드 이름 = 종류 이름) + 부품 상자 차례 · 글 = 짧은 이름
|
||||
* (영어 키 · 괄호 설명은 툴팁 · 322-2 다듬기). */
|
||||
const OFF_TEXT: Record<Exclude<ReflectField, "목록">, () => string> = {
|
||||
횡단도: () => to("StationNone"),
|
||||
카드이름: () => to("TitleName"),
|
||||
형상: () => to("OffNone"),
|
||||
"3D": () => to("OffNone"),
|
||||
};
|
||||
const partPairs = (branch: Exclude<ReflectField, "목록">): Pairs => [
|
||||
[branch === "카드이름" ? "name" : "none", OFF_TEXT[branch]()],
|
||||
...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, shortName(part.이름)] as const),
|
||||
];
|
||||
/** 고르기 항목 툴팁 = 키 · 부품 긴 이름. */
|
||||
function tipOptions(box: HTMLSelectElement, branch: ReflectField): void {
|
||||
if (branch === "목록") return;
|
||||
for (const option of Array.from(box.options)) {
|
||||
const part = PART_BOX[branch][option.value];
|
||||
option.title = part ? `${option.value} · ${part.이름}` : option.value;
|
||||
}
|
||||
}
|
||||
|
||||
interface CellCtx {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
row: CatalogRow;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}
|
||||
|
||||
/** 반영 칸 하나 — 처음 값이면 흐리게 「기본」 · 바꿨으면 진하게. */
|
||||
function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement {
|
||||
const { def, type, row, edit, changed } = ctx;
|
||||
const pairs = field === "목록" ? LIST_PAIRS() : partPairs(field);
|
||||
const box = pickBox(
|
||||
pairs,
|
||||
valueOf(row, field),
|
||||
(v) => {
|
||||
setReflect(def, type.type_id, field, v);
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: `data-sod-reflect-${field}` },
|
||||
);
|
||||
tipOptions(box, field);
|
||||
return mark(isSet(def, type.type_id, field), box);
|
||||
}
|
||||
|
||||
/** 흐림(기본) · 진함(바꿈) 꼴. */
|
||||
function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement {
|
||||
return el("div", {
|
||||
className: `m02-sod__cell ${changed ? "is-changed" : "is-default"}`,
|
||||
children: [...children, ...(changed ? [] : [createTag(to("Default"), "neutral")])],
|
||||
});
|
||||
}
|
||||
|
||||
/** 형상칸 — 벽(형상 wall)만: 기본 형태(그림 형태 고르기) · 물빼기. */
|
||||
function shapeOptsCell(ctx: CellCtx): HTMLElement {
|
||||
const { def, type, row, edit, changed } = ctx;
|
||||
if (row.shape !== "wall") return el("span", { className: "m02-side__empty", text: "—" });
|
||||
const form = String(row.shapeOpts.form ?? "");
|
||||
const forms = !form || REVET_FORMS.includes(form) ? REVET_FORMS : [form, ...REVET_FORMS];
|
||||
const box = pickBox(
|
||||
forms.map((f) => [f, f] as const),
|
||||
form,
|
||||
(v) => (setShapeOpt(def, type.type_id, "form", v), changed()),
|
||||
{ edit, attr: "data-sod-shape-form" },
|
||||
);
|
||||
box.title = to("WallForm");
|
||||
return mark(
|
||||
isSet(def, type.type_id, "형상칸"),
|
||||
box,
|
||||
checkBox(
|
||||
to("Weep"),
|
||||
row.shapeOpts.weep === true,
|
||||
(v) => (setShapeOpt(def, type.type_id, "weep", v), changed()),
|
||||
{ edit, attr: "data-sod-shape-weep" },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 알약 색(style.color). */
|
||||
function colorCell(ctx: CellCtx): HTMLElement {
|
||||
const box = el("input", {
|
||||
className: "m02-sod__color",
|
||||
attrs: { type: "color", "data-sod-color": "" },
|
||||
}) as HTMLInputElement;
|
||||
const style = (ctx.type.style ?? {}) as { color?: string };
|
||||
box.value = style.color ?? "#888888";
|
||||
box.disabled = !ctx.edit;
|
||||
box.addEventListener("change", () => {
|
||||
ctx.type.style = { ...style, color: box.value };
|
||||
ctx.changed();
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 자리꼴 · 정본 = 한국어 글 · 「고정」(키는 툴팁). */
|
||||
const fixed = (text: string, key: string): HTMLElement =>
|
||||
el("span", {
|
||||
className: "m02-sod__fixed",
|
||||
attrs: { title: key },
|
||||
children: [el("span", { text }), createTag(to("Fixed"), "neutral")],
|
||||
});
|
||||
|
||||
/** 「종류별」 탭이 쓰는 반영 칸들 — [글, 칸] 짝. */
|
||||
export function reflectCells(
|
||||
def: StructDef,
|
||||
type: TypeDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): Array<[string, HTMLElement]> {
|
||||
const row = rowsOf(def).get(type.type_id);
|
||||
if (!row) return [];
|
||||
const ctx = { def, type, row, edit, changed };
|
||||
return [
|
||||
[to("ColList"), reflectCell(ctx, "목록")],
|
||||
[to("ColPlacement"), fixed(PLACE_KO[row.placement] ?? row.placement, row.placement)],
|
||||
[to("ColOwner"), fixed(OWNER_KO[row.owner] ?? row.owner, row.owner)],
|
||||
[to("ColStation"), reflectCell(ctx, "횡단도")],
|
||||
[to("ColTitle"), reflectCell(ctx, "카드이름")],
|
||||
[to("ColShape"), reflectCell(ctx, "형상")],
|
||||
[to("ColShapeOpts"), shapeOptsCell(ctx)],
|
||||
[to("ColColor"), colorCell(ctx)],
|
||||
[to("Col3D"), reflectCell(ctx, "3D")],
|
||||
];
|
||||
}
|
||||
|
||||
function moveButton(label: string, run: () => boolean, off: boolean, changed: () => void) {
|
||||
const b = createButton({ label, variant: "ghost", size: "sm" });
|
||||
b.disabled = off;
|
||||
b.addEventListener("click", () => run() && changed());
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 그 종류 검사 글 — 이름 · 키가 든 줄. */
|
||||
const issuesOf = (issues: readonly string[], type: TypeDef): string[] =>
|
||||
issues.filter((t) => t.includes(`「${type.name}」`) || t.includes(`「${type.type_id}`));
|
||||
|
||||
export function buildTable(opt: {
|
||||
def: StructDef;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
issues: readonly string[];
|
||||
}): HTMLElement {
|
||||
const { def, edit, changed, issues } = opt;
|
||||
const heads = [
|
||||
"ColFamily",
|
||||
"ColKind",
|
||||
"ColName",
|
||||
"ColList",
|
||||
"ColPlacement",
|
||||
"ColOwner",
|
||||
"ColStation",
|
||||
"ColTitle",
|
||||
"ColShape",
|
||||
"ColShapeOpts",
|
||||
"ColColor",
|
||||
"Col3D",
|
||||
"ColCheck",
|
||||
] as const;
|
||||
const td = (child: HTMLElement | string): HTMLElement =>
|
||||
el("td", typeof child === "string" ? { text: child } : { children: [child] });
|
||||
const body: HTMLElement[] = [];
|
||||
const families = Object.keys(def.구조물군);
|
||||
families.forEach((g, gi) => {
|
||||
const name = textBox(def.구조물군[g], (v) => (def.구조물군[g] = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-family-name",
|
||||
});
|
||||
const familyRow = el("tr", {
|
||||
className: "m02-sod__familyrow",
|
||||
attrs: { "data-sod-table-family": g },
|
||||
children: [
|
||||
el("td", {
|
||||
attrs: { colspan: String(heads.length) },
|
||||
children: [
|
||||
el("strong", { text: g }),
|
||||
name,
|
||||
...(edit
|
||||
? [
|
||||
moveButton(to("GroupUp"), () => stepFamily(def, g, -1), gi === 0, changed),
|
||||
moveButton(
|
||||
to("GroupDown"),
|
||||
() => stepFamily(def, g, 1),
|
||||
gi === families.length - 1,
|
||||
changed,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
body.push(familyRow);
|
||||
const kinds = def.types.filter((t) => t.group === g);
|
||||
kinds.forEach((type, ti) => {
|
||||
const cells = new Map(reflectCells(def, type, edit, changed));
|
||||
const bad = issuesOf(issues, type);
|
||||
const reset = createButton({ label: to("Reset"), variant: "ghost", size: "sm" });
|
||||
reset.disabled = !edit || !(def as { 반영?: Record<string, unknown> }).반영?.[type.type_id];
|
||||
reset.addEventListener("click", () => (resetRow(def, type.type_id), changed()));
|
||||
body.push(
|
||||
el("tr", {
|
||||
attrs: { "data-sod-table-kind": type.type_id },
|
||||
children: [
|
||||
td(""),
|
||||
el("td", {
|
||||
attrs: { title: type.type_id }, // 영어 키는 툴팁만(322-2 다듬기)
|
||||
children: [
|
||||
...(edit
|
||||
? [
|
||||
moveButton(
|
||||
to("GroupUp"),
|
||||
() => stepType(def, type.type_id, -1),
|
||||
ti === 0,
|
||||
changed,
|
||||
),
|
||||
moveButton(
|
||||
to("GroupDown"),
|
||||
() => stepType(def, type.type_id, 1),
|
||||
ti === kinds.length - 1,
|
||||
changed,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
td(
|
||||
textBox(type.name, (v) => (type.name = v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-kind-name",
|
||||
}),
|
||||
),
|
||||
...[
|
||||
"ColList",
|
||||
"ColPlacement",
|
||||
"ColOwner",
|
||||
"ColStation",
|
||||
"ColTitle",
|
||||
"ColShape",
|
||||
"ColShapeOpts",
|
||||
"ColColor",
|
||||
"Col3D",
|
||||
].map((key) => td(cells.get(to(key as (typeof heads)[number])) ?? "")),
|
||||
el("td", {
|
||||
attrs: { "data-sod-table-check": "" },
|
||||
children: [
|
||||
bad.length
|
||||
? el("span", { className: "m02-sod__bad", text: bad.join(" · ") })
|
||||
: createTag(to("Ok"), "accent"),
|
||||
reset,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
return el("div", {
|
||||
className: "m02-sod__tablewrap",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("TableNote") }),
|
||||
el("table", {
|
||||
className: "m02-sod__table",
|
||||
attrs: { "data-sod-table": "" },
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((h) => el("th", { text: to(h) })) })],
|
||||
}),
|
||||
el("tbody", { children: body }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 「종류별」 탭 머리 아래 반영 칸 묶음(한눈 표 한 줄과 같은 칸). */
|
||||
export function buildReflectBlock(
|
||||
def: StructDef,
|
||||
type: TypeDef,
|
||||
edit: boolean,
|
||||
changed: () => void,
|
||||
): HTMLElement {
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
attrs: { "data-sod-reflect-block": "" },
|
||||
children: [
|
||||
el("h4", { className: "m02-opts__group", text: to("ReflectHead") }),
|
||||
el("div", {
|
||||
className: "m02-sod__reflect",
|
||||
children: reflectCells(def, type, edit, changed).map(([label, cell]) =>
|
||||
el("label", {
|
||||
className: "m02-sod__reflectrow",
|
||||
children: [el("span", { text: label }), cell],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -18,10 +18,12 @@ from pydantic import ValidationError
|
||||
from B05_Profile import B05_Profile_Structures_Schema as schema
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
|
||||
|
||||
FILE = "구조물정의.json"
|
||||
HISTORY = "구조물정의_이력"
|
||||
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"}
|
||||
# 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`)
|
||||
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"}
|
||||
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
|
||||
SPOT = ("줄", "칸", "너비")
|
||||
|
||||
@@ -64,7 +66,9 @@ def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, An
|
||||
groups = {}
|
||||
for row in doc.get("types") if isinstance(doc.get("types"), list) else []:
|
||||
try:
|
||||
t = schema.StructureType.model_validate(row)
|
||||
t = schema.StructureType.model_validate(
|
||||
schema.without_retired(row) if isinstance(row, dict) else row
|
||||
)
|
||||
except ValidationError as e:
|
||||
errors.append(
|
||||
f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}"
|
||||
@@ -109,8 +113,11 @@ def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s
|
||||
continue
|
||||
if name == "이름" and _names_ok(value):
|
||||
continue
|
||||
if name == "본보기" and isinstance(value, str) and value in keys:
|
||||
continue # 복사한 종류의 원본(323-7) — 같은 군 등은 `_template_errors`
|
||||
errors.append(
|
||||
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)"
|
||||
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · "
|
||||
"이름 = 글 묶음 · 본보기 = 있는 종류 키)"
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -171,8 +178,8 @@ def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s
|
||||
return errors
|
||||
|
||||
|
||||
def check(doc: Any) -> list[str]:
|
||||
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과)."""
|
||||
def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]:
|
||||
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2)."""
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
|
||||
|
||||
@@ -194,7 +201,8 @@ def check(doc: Any) -> list[str]:
|
||||
fn(doc[section], plain)
|
||||
except store.StoreError as e:
|
||||
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
|
||||
return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
|
||||
errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
|
||||
return errors + ([] if errors else catalog_errors(doc, before))
|
||||
|
||||
|
||||
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
|
||||
@@ -206,7 +214,7 @@ def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) ->
|
||||
if str(version) != str(have):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
doc = {**change(current), "판": have}
|
||||
errors = check(doc)
|
||||
errors = check(doc, current)
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
atomic_write_json(history_path(have), current)
|
||||
@@ -218,6 +226,13 @@ def save(doc: Any, version: Any) -> dict[str, Any]:
|
||||
"""정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의."""
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(400, "정의는 묶음이어야 함")
|
||||
# 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음)
|
||||
types = doc.get("types")
|
||||
if isinstance(types, list):
|
||||
doc = {
|
||||
**doc,
|
||||
"types": [schema.without_retired(t) if isinstance(t, dict) else t for t in types],
|
||||
}
|
||||
return _commit(lambda _: dict(doc), version)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import definition_version
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
@@ -84,6 +85,13 @@ def get_structure_definition(session=Depends(verify_session)) -> dict:
|
||||
return _call(definition.read)
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/version")
|
||||
def get_structure_definition_version(session=Depends(verify_session)) -> dict:
|
||||
"""지금 정의 판 `{판}` — B05 · B06 종류 캐시가 판을 볼 때(322-0 · 한 벌을 다 받지 않음)."""
|
||||
access.require(session, "system", None)
|
||||
return {"판": definition_version()}
|
||||
|
||||
|
||||
@read_router.get("/structure-definition/history")
|
||||
def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]:
|
||||
"""지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2)."""
|
||||
|
||||
@@ -368,3 +368,79 @@
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── 322-2 탭 셋 · 한눈 표 · 부품 ── */
|
||||
.m02-sod__tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
.m02-sod__tablewrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.m02-sod__table {
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
min-width: 100%;
|
||||
}
|
||||
.m02-sod__table th,
|
||||
.m02-sod__table td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--spacing-4);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m02-sod__familyrow td {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
.m02-sod__table tr.is-off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.m02-sod__cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
.m02-sod__cell.is-default {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.m02-sod__cell.is-changed {
|
||||
font-weight: 600;
|
||||
}
|
||||
.m02-sod__fixed {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.m02-sod__color {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
.m02-sod__reflect {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
.m02-sod__reflectrow {
|
||||
display: grid;
|
||||
grid-template-columns: 7em 1fr;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 부품 탭 — 긴 글은 줄바꿈해 표가 화면 안에(322-2 다듬기) */
|
||||
.m02-sod__table--wrap {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
.m02-sod__table--wrap td,
|
||||
.m02-sod__table--wrap th {
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ const TEXT = {
|
||||
GroupName: ["그룹 이름", "Group name"],
|
||||
GroupNew: ["새 그룹 {value}", "New group {value}"],
|
||||
GroupUp: ["위로", "Up"],
|
||||
AddKind: ["종류 더하기", "Add type"],
|
||||
AddKindNote: [
|
||||
"본보기 종류를 그대로 복사한 새 종류(이름 「본보기 2」) — 옵션 · 그룹 · 반영 · 그리는 법이 같음 · 이름 · 반영은 고쳐서 [저장]",
|
||||
"New type copied from the template (name “template 2”) — same options, groups, reflect and drawing; edit name / reflect, then [Save]",
|
||||
],
|
||||
GroupDown: ["아래로", "Down"],
|
||||
GroupDel: ["지우기", "Delete"],
|
||||
GroupDelAsk: [
|
||||
@@ -125,6 +130,49 @@ const TEXT = {
|
||||
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Save: ["저장", "Save"],
|
||||
TabTable: ["한눈 표", "Overview"],
|
||||
TabKinds: ["종류별", "By type"],
|
||||
TabParts: ["부품", "Parts"],
|
||||
TableNote: [
|
||||
"종류마다 B05 · B06 반영 한 줄 — 목록 · 횡단도 · 카드 이름 · 형상 · 3D 는 코드 부품 중에서 고름 · 흐린 칸 = 처음 값(기본) · 진한 칸 = 바꾼 값 · 자리꼴 · 정본은 만든 뒤 고정",
|
||||
"One row per type — pick parts for list · cross section · card name · shape · 3D; faded = default, bold = changed",
|
||||
],
|
||||
ColFamily: ["군", "Family"],
|
||||
ColKind: ["종류", "Type"],
|
||||
ColName: ["이름", "Name"],
|
||||
ColList: ["목록", "List"],
|
||||
ColPlacement: ["자리꼴", "Placement"],
|
||||
ColOwner: ["정본", "Owner"],
|
||||
ColStation: ["횡단도", "Cross section"],
|
||||
ColTitle: ["카드 이름", "Card name"],
|
||||
ColShape: ["형상", "Shape"],
|
||||
ColShapeOpts: ["형상칸", "Shape fields"],
|
||||
ColColor: ["알약 색", "Pill color"],
|
||||
Col3D: ["3D", "3D"],
|
||||
ColCheck: ["검사", "Check"],
|
||||
ListBoth: ["B05 · B06", "B05 · B06"],
|
||||
ListB06: ["B06 만", "B06 only"],
|
||||
ListHidden: ["안 보임", "Hidden"],
|
||||
OffNone: ["없음", "None"],
|
||||
StationNone: ["안 만듦", "Not made"],
|
||||
TitleName: ["종류 이름", "Type name"],
|
||||
Default: ["기본", "Default"],
|
||||
Fixed: ["고정", "Fixed"],
|
||||
Reset: ["처음 값으로", "Reset"],
|
||||
Ok: ["통과", "OK"],
|
||||
WallForm: ["기본 형태", "Default form"],
|
||||
Weep: ["물빼기", "Weep holes"],
|
||||
ReflectHead: ["B05 · B06 반영", "B05 · B06 use"],
|
||||
ShownCol: ["보임", "Shown"],
|
||||
CannotHide: ["못 숨김", "Required"],
|
||||
PartsNote: [
|
||||
"코드가 그리는 방법(부품) — 끄면 그 부품을 쓰는 종류는 그 갈래가 없음으로 · 읽는 칸 = 그 부품이 쓰는 옵션(숨기지 못함)",
|
||||
"Code drawing parts — turning a part off makes its users fall back to none; read fields cannot be hidden",
|
||||
],
|
||||
PartRead: ["읽는 칸", "Reads"],
|
||||
PartFits: ["맞는 자리꼴 · 정본", "Fits"],
|
||||
PartUsers: ["쓰는 종류", "Used by"],
|
||||
PartOn: ["켬", "On"],
|
||||
Stale: [
|
||||
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"Changed meanwhile — reloaded, please edit again",
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
*
|
||||
* 값을 새로 만들지 않는다 — **정본(관 지점 옵션) + 레지스트리 기본값**만 조합한다.
|
||||
* 상수 사본을 두면 B05 폼과 갈라지므로 기본값은 레지스트리에서만 꺼낸다.
|
||||
* 시설마다 그릴 세트 = 구조물 표 형상(322-1 · `common_util_structure_catalog`).
|
||||
* ========================================================================== */
|
||||
|
||||
// `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂)
|
||||
import { catalogRow } from "./common_util_structure_catalog.js";
|
||||
|
||||
/** 관 지점 정본 1건 — `pipe_points.json` 의 한 줄(브라우저는 API 로 같은 것을 받는다). */
|
||||
export interface CulvertPipePoint {
|
||||
chainage_m: number;
|
||||
@@ -129,12 +133,8 @@ export function followPipeHeights(
|
||||
}
|
||||
}
|
||||
|
||||
/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */
|
||||
/** 시설 종류 기본값 — 파이썬 `common_util_drainage_pipes.PIPE_FACILITY_PIPE` 와 같은 문자열. */
|
||||
const FACILITY_PIPE = "pipe";
|
||||
const FACILITY_BOX = "box_culvert";
|
||||
const FACILITY_FORD_PAVEMENT = "ford_pavement";
|
||||
const FACILITY_FORD_BRIDGE = "ford_bridge";
|
||||
const FACILITY_REVET = "revetment";
|
||||
|
||||
/** 숫자 옵션 하나를 정리한다. 문자열 저장분(관경 "1000")도 받는다 — 파이썬 `_number`. */
|
||||
function num(value: unknown, fallback: number | null): number | null {
|
||||
@@ -566,14 +566,15 @@ export function buildCulvertSets(
|
||||
const chainage = num(point.chainage_m, null);
|
||||
if (chainage === null) continue;
|
||||
const values = (point.options ?? {}) as Record<string, unknown>;
|
||||
const facility = point.facility || FACILITY_PIPE;
|
||||
// 그릴 세트 = 표 형상(322-1 · 모르는 시설 = 배관 세트)
|
||||
const shape = catalogRow(point.facility || FACILITY_PIPE)?.shape ?? "pipe_set";
|
||||
let spec: CulvertSetSpec;
|
||||
if (facility === FACILITY_FORD_BRIDGE) spec = fordSet(values, registry);
|
||||
else if (facility === FACILITY_BOX) spec = boxSet(values, registry);
|
||||
else if (facility === FACILITY_FORD_PAVEMENT) spec = fordPavementSet(values, registry);
|
||||
else if (facility === FACILITY_REVET) spec = revetSet(values);
|
||||
else if (text(values.pipe_diameter_mm) === null)
|
||||
continue; // 관경 안 고름 = 관 없음(54-39)
|
||||
if (shape === "ford") spec = fordSet(values, registry);
|
||||
else if (shape === "box") spec = boxSet(values, registry);
|
||||
else if (shape === "ford_pavement") spec = fordPavementSet(values, registry);
|
||||
else if (shape === "hidden_revet") spec = revetSet(values);
|
||||
else if (shape !== "pipe_set" || text(values.pipe_diameter_mm) === null)
|
||||
continue; // 형상 없음 · 관경 안 고름 = 관 없음(54-39)
|
||||
else spec = culvertSet(values, registry);
|
||||
sets.set(round(chainage, 2), spec);
|
||||
}
|
||||
|
||||
@@ -66,13 +66,13 @@ PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교
|
||||
# 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를
|
||||
# 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다.
|
||||
PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김)
|
||||
_KNOWN_FACILITIES = (
|
||||
PIPE_FACILITY_PIPE,
|
||||
PIPE_FACILITY_BOX,
|
||||
PIPE_FACILITY_FORD_PAVEMENT,
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_REVET,
|
||||
)
|
||||
|
||||
|
||||
def _known_facility(facility: str) -> bool:
|
||||
"""관 지점 정본 종류인가 = 구조물 표 정본(323-7 · 옛 손 목록 걷음 · 모르는 종류 = 배관)."""
|
||||
from common_util.common_util_structure_catalog import catalog_row
|
||||
|
||||
return (catalog_row(facility) or {}).get("owner") == "pipe_points"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -221,7 +221,8 @@ def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) ->
|
||||
if line.length <= 0:
|
||||
return points
|
||||
# 새 노선 위 자리도 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 시설 속성은 관과 함께 옮겨 감
|
||||
stations = route_general_stations(vertices[-1].chainage_m)
|
||||
# 끝 = 선 길이(B05 계획선 꼭짓점은 누가거리 0 으로 와서 끝이 0 → 관이 전부 0 m 로 가던 흠)
|
||||
stations = route_general_stations(line.length)
|
||||
for point in points:
|
||||
if point.x is None or point.y is None:
|
||||
continue
|
||||
@@ -354,7 +355,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
PipePoint(
|
||||
chainage_m=float(chainage),
|
||||
source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER,
|
||||
facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE,
|
||||
facility=facility if _known_facility(facility) else PIPE_FACILITY_PIPE,
|
||||
start_m=start,
|
||||
end_m=end,
|
||||
x=float(raw_x) if isinstance(raw_x, (int, float)) else None,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_attach.ts
|
||||
* 형상 얹기 한 벌(323-4) — 측점(횡단) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
|
||||
*
|
||||
* 구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 서버와 같은 차례로 다시 얹음 — 관 세트 → 벽 →
|
||||
* 포장 → 맹암거 → 빈칸 경고. 고르기 = 표 형상(322-1). 화면은 초안 ?? 저장분 현황으로 이것 하나를 씀
|
||||
* (`B06_Section_Section_Drafts.withDraftShapes`) — 저장 전 그림 = 저장 뒤 그림.
|
||||
* 파이썬 짝 = `B06_Section/B06_Section_Engine_Attach.py` `attach_shapes`(서버 상세 읽기 · 재생성 응답) ·
|
||||
* 거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
type CulvertPipePoint,
|
||||
} from "./common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
type WallStructureInput,
|
||||
} from "./common_util_structure_walls";
|
||||
import {
|
||||
attachPavementSpecs,
|
||||
pavementSpecsFrom,
|
||||
type PavementStructureInput,
|
||||
} from "./common_util_structure_pavement";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "./common_util_structure_underdrain";
|
||||
import { catalogRow, fieldName } from "./common_util_structure_catalog";
|
||||
import {
|
||||
blankKeys,
|
||||
planStations,
|
||||
SAME_STATION_M,
|
||||
type PlanPipe,
|
||||
type PlanStructure,
|
||||
type PlanType,
|
||||
} from "./common_util_structure_station";
|
||||
import { SHAPE_KEYS } from "./common_util_structure_shape_keys";
|
||||
|
||||
export { SHAPE_KEYS };
|
||||
|
||||
export interface AttachType extends PlanType {
|
||||
name: string;
|
||||
options: ReadonlyArray<{ key: string; label?: string; default: unknown }>;
|
||||
}
|
||||
|
||||
/** 빈 필수 칸 → 「종류 이름 칸 이름」(칸 이름 = 그 종류 정의 → 다른 종류 정의 → 부품 상자 → 키). */
|
||||
function blankLabels(
|
||||
typeId: string,
|
||||
options: Record<string, unknown> | null | undefined,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): string[] {
|
||||
const name = catalogRow(typeId)?.name ?? typeId;
|
||||
const ordered = [...types.filter((t) => t.type_id === typeId), ...types];
|
||||
return blankKeys(typeId, options).map((key) => {
|
||||
const option = ordered.flatMap((t) => t.options).find((o) => o.key === key);
|
||||
return `${name} ${option?.label ?? fieldName(key) ?? key}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(파이썬 `_attach_blanks` 짝). */
|
||||
function attachBlanks(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): void {
|
||||
const owners = new Map<string, [string, Record<string, unknown> | null | undefined]>();
|
||||
for (const pipe of pipes)
|
||||
owners.set(pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`, [
|
||||
pipe.facility ?? "pipe",
|
||||
pipe.options,
|
||||
]);
|
||||
for (const item of structures)
|
||||
if (item.structure_id) owners.set(item.structure_id, [item.type_id, item.options]);
|
||||
for (const station of planStations(pipes, structures, types)) {
|
||||
if (!station.blank.length) continue;
|
||||
const labels = [
|
||||
...new Set(
|
||||
station.owners.flatMap((owner) => {
|
||||
const hit = owners.get(owner);
|
||||
return hit ? blankLabels(hit[0], hit[1], types) : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (const section of sections)
|
||||
if (Math.abs(Number(section.chainage_m) - station.chainage_m) < SAME_STATION_M)
|
||||
section.blank = labels;
|
||||
}
|
||||
}
|
||||
|
||||
/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */
|
||||
export function attachShapes(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<AttachType>,
|
||||
): void {
|
||||
for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key];
|
||||
const registry = registryDefaults(types);
|
||||
attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry));
|
||||
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
|
||||
// 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음
|
||||
attachWallSpecs(
|
||||
sections,
|
||||
wallSpecsFrom(
|
||||
structures as unknown as WallStructureInput[],
|
||||
new Map(shaped("wall").map((t) => [t.type_id, t.name] as const)),
|
||||
),
|
||||
);
|
||||
attachPavementSpecs(
|
||||
sections,
|
||||
pavementSpecsFrom(
|
||||
structures as unknown as PavementStructureInput[],
|
||||
new Set(shaped("pavement").map((t) => t.type_id)),
|
||||
),
|
||||
);
|
||||
attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[]));
|
||||
attachBlanks(sections, pipes, structures, types);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""구조물 표(catalog · PLAN 322-1) — 종류마다 「무엇을 할지」 한 줄.
|
||||
|
||||
한 줄 = 정의 `반영` ⊕ 부품 상자 처음값 ⊕ 부품 끔. 종류 키 · 군 글자 · 자리꼴 · managed_by 로
|
||||
가리던 자리(측점 · 카드 이름 · 형상)가 이 한 줄을 읽음 · 그리는 함수는 그대로.
|
||||
부품 상자 = `common_util_structure_parts.json`(TS 와 같은 파일) · 줄 열쇠도 TS 와 같은 이름.
|
||||
TS 짝 = `common_util_structure_catalog.ts`
|
||||
거울 시험 = `resources/tester/test_322_1_catalog_mirror.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PARTS_PATH = Path(__file__).with_name("common_util_structure_parts.json")
|
||||
|
||||
# 갈래 → 부품을 끈 값(`부품.{갈래}.{id} == false`)
|
||||
_OFF = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_parts() -> dict[str, Any]:
|
||||
with open(PARTS_PATH, encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def resolve_catalog(
|
||||
definition: dict[str, Any] | None, parts: dict[str, Any] | None = None
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""정의 + 부품 상자 → 종류마다 한 줄(정의 types 차례 · 정의가 없으면 처음값 차례)."""
|
||||
start = (parts or load_parts())["처음값"]
|
||||
definition = definition or {}
|
||||
groups = definition.get("구조물군") or {}
|
||||
group_keys = list(groups)
|
||||
off = {key for key, on in (definition.get("부품") or {}).items() if on is False}
|
||||
reflect = definition.get("반영") or {}
|
||||
types = definition.get("types") or [{"type_id": key} for key in start]
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
for order, item in enumerate(types):
|
||||
type_id = item["type_id"]
|
||||
root = ((definition.get("종류") or {}).get(type_id) or {}).get("본보기") or type_id
|
||||
base = start.get(root) or {} # 복사한 종류 = 본보기 처음값(323-7)
|
||||
mine = reflect.get(type_id) or {}
|
||||
|
||||
def part(branch: str, base=base, mine=mine) -> str:
|
||||
value = mine.get(branch, base.get(branch, _OFF[branch]))
|
||||
return _OFF[branch] if f"{branch}.{value}" in off else value
|
||||
|
||||
group = item.get("group") or ""
|
||||
# 반영이 없으면 옛 짝 — 처음값 hidden 은 숨김 · 그 밖은 정의 enabled 가 가름(꺼짐 = B06 만)
|
||||
if "목록" in mine:
|
||||
listed = mine["목록"]
|
||||
elif base.get("목록") == "hidden":
|
||||
listed = "hidden"
|
||||
else:
|
||||
listed = "b06" if item.get("enabled") is False else "both"
|
||||
rows[type_id] = {
|
||||
"typeId": type_id,
|
||||
"name": item.get("name") or type_id,
|
||||
"group": group,
|
||||
"groupName": groups.get(group, group),
|
||||
"groupOrder": group_keys.index(group) if group in group_keys else len(group_keys),
|
||||
"typeOrder": order,
|
||||
"placement": base.get("자리꼴") or item.get("placement") or "point",
|
||||
"owner": "pipe_points" if item.get("managed_by") == "pipe_points" else "structures",
|
||||
"list": listed,
|
||||
"station": part("횡단도"),
|
||||
"title": part("카드이름"),
|
||||
"shape": part("형상"),
|
||||
"shapeOpts": {**(base.get("형상칸") or {}), **(mine.get("형상칸") or {})},
|
||||
"model3d": part("3D"),
|
||||
"color": (item.get("style") or {}).get("color") or None,
|
||||
"base": root,
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _catalog_at(path: str, mtime: int, size: int) -> dict[str, dict[str, Any]]:
|
||||
from B05_Profile.B05_Profile_Structures_Schema import _read_definition
|
||||
|
||||
return resolve_catalog(_read_definition(path, mtime, size))
|
||||
|
||||
|
||||
def catalog() -> dict[str, dict[str, Any]]:
|
||||
"""지금 정의 판의 표 — 정의 파일이 바뀌면(M02 저장) 다음 부름부터 새 표(파일 시각 캐시)."""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import _stamp
|
||||
|
||||
return _catalog_at(*_stamp())
|
||||
|
||||
|
||||
def catalog_row(type_id: str) -> dict[str, Any] | None:
|
||||
return catalog().get(type_id)
|
||||
|
||||
|
||||
def base_type(type_id: str) -> str:
|
||||
"""손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) — TS `baseType` 짝."""
|
||||
row = catalog_row(type_id)
|
||||
return row["base"] if row else type_id
|
||||
|
||||
|
||||
def type_ids_with(**match: str) -> set[str]:
|
||||
"""표에서 칸 값이 맞는 종류들 — 예 `type_ids_with(shape="pavement")`."""
|
||||
return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())}
|
||||
|
||||
|
||||
def field_name(key: str) -> str | None:
|
||||
"""어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · TS `fieldName` 짝)."""
|
||||
return (load_parts().get("칸 이름") or {}).get(key)
|
||||
|
||||
|
||||
def shape_needs(type_id: str) -> list[str]:
|
||||
"""형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝."""
|
||||
row = catalog_row(type_id)
|
||||
part = load_parts()["parts"]["형상"].get(row["shape"]) if row else None
|
||||
return list(part.get("필수 칸", [])) if part else []
|
||||
|
||||
|
||||
def card_title(type_id: str, options: dict[str, Any] | None) -> str | None:
|
||||
"""카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 이름.
|
||||
|
||||
표에 없는 종류면 None(측점 안 붙임). TS `cardTitle` 짝(옛 빈칸 D800 ↔ 화면 D1000 어긋남 없앰).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
|
||||
row = catalog_row(type_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row["title"] != "pipe":
|
||||
return row["name"]
|
||||
definition = structure_type_map().get(type_id)
|
||||
defaults = {o.key: o.default for o in definition.options} if definition else {}
|
||||
values = options or {}
|
||||
|
||||
def value(key: str) -> Any:
|
||||
own = values.get(key)
|
||||
return defaults.get(key) if own is None or own == "" else own
|
||||
|
||||
try:
|
||||
diameter = math.trunc(float(value("pipe_diameter_mm")))
|
||||
except (TypeError, ValueError):
|
||||
diameter = 0
|
||||
return f"{value('pipe_kind') or ''} D{diameter}"
|
||||
@@ -0,0 +1,206 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_catalog.ts
|
||||
* 구조물 표(catalog · PLAN 322-1) — 종류마다 「무엇을 할지」 한 줄 = 정의 `반영` ⊕ 부품 상자 처음값 ⊕ 부품 끔.
|
||||
*
|
||||
* 종류 키 · 군 글자 · 자리꼴 · managed_by 로 가리던 자리(목록 · 측점 · 카드 이름 · 형상 · 3D · 알약)가 이 한 줄을
|
||||
* 읽음. 그리는 함수는 그대로(그림 자료 열쇠만 봄). 부품 상자 = `common_util_structure_parts.json`(파이썬과 같은 파일).
|
||||
* 정의를 받기 전 = 처음값만으로 선 표(이름 · 군 · 정본 · 색 없음) — 받으면 `useCatalogDefinition` 이 갈아 끼움.
|
||||
* 파이썬 짝 = `common_util_structure_catalog.py` · 거울 시험 = `resources/tester/test_322_1_catalog_mirror.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
import PARTS from "./common_util_structure_parts.json" with { type: "json" };
|
||||
|
||||
/** 고르기 목록 자리 — both = B05 · B06 · b06 = B06 만 · hidden = 안 보임(옛 자료는 그대로 그림). */
|
||||
export type ListPlace = "both" | "b06" | "hidden";
|
||||
export type PipeOwner = "pipe_points" | "structures";
|
||||
|
||||
export interface CatalogRow {
|
||||
typeId: string;
|
||||
name: string;
|
||||
group: string;
|
||||
groupName: string;
|
||||
groupOrder: number;
|
||||
typeOrder: number;
|
||||
placement: string;
|
||||
owner: PipeOwner;
|
||||
list: ListPlace;
|
||||
/** 횡단도 부품 — none · anchor · span3. */
|
||||
station: string;
|
||||
/** 카드 이름 부품 — name · pipe. */
|
||||
title: string;
|
||||
/** 형상 부품 — none · pipe_set · box · ford · ford_pavement · hidden_revet · wall · berm · underdrain · pavement · spoil. */
|
||||
shape: string;
|
||||
shapeOpts: Record<string, unknown>;
|
||||
/** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */
|
||||
model3d: string;
|
||||
color: string | null;
|
||||
/** 본보기 종류 키(323-7 · M02 종류 더하기) — 처음값 · 손코딩 동작을 이 종류에서 이어받음 · 원본이면 자기. */
|
||||
base: string;
|
||||
}
|
||||
|
||||
type Branch = "횡단도" | "카드이름" | "형상" | "3D";
|
||||
type Reflect = Partial<Record<Branch | "목록", string>> & {
|
||||
형상칸?: Record<string, unknown>;
|
||||
자리꼴?: string;
|
||||
};
|
||||
|
||||
/** 정의 한 벌 중 표가 읽는 칸 — `반영` · `부품` 은 M02 [저장] 때 생김(없으면 처음값). */
|
||||
export interface CatalogDefinition {
|
||||
구조물군?: Record<string, string>;
|
||||
types?: ReadonlyArray<{
|
||||
type_id: string;
|
||||
name?: string;
|
||||
group?: string;
|
||||
placement?: string;
|
||||
managed_by?: string | null;
|
||||
enabled?: boolean;
|
||||
style?: { color?: string };
|
||||
options?: ReadonlyArray<{ key: string; choices?: readonly string[] }>;
|
||||
}>;
|
||||
반영?: Record<string, Reflect>;
|
||||
부품?: Record<string, boolean>;
|
||||
/** 종류 덧칸 — 본보기(복사한 종류의 원본 키 · 323-7). */
|
||||
종류?: Record<string, { 본보기?: string }>;
|
||||
}
|
||||
|
||||
interface Parts {
|
||||
parts: Record<Branch, Record<string, unknown>>;
|
||||
처음값: Record<string, Reflect>;
|
||||
}
|
||||
|
||||
/** 갈래 → 부품을 끈 값(`부품.{갈래}.{id} === false`). */
|
||||
const OFF: Record<Branch, string> = {
|
||||
횡단도: "none",
|
||||
카드이름: "name",
|
||||
형상: "none",
|
||||
"3D": "none",
|
||||
};
|
||||
|
||||
/** 정의 + 부품 상자 → 종류마다 한 줄(정의 types 차례 · 정의가 없으면 처음값 차례). */
|
||||
export function resolveCatalog(
|
||||
definition: CatalogDefinition | null,
|
||||
parts: Parts = PARTS as unknown as Parts,
|
||||
): Map<string, CatalogRow> {
|
||||
const start = parts.처음값;
|
||||
const groups = definition?.구조물군 ?? {};
|
||||
const groupKeys = Object.keys(groups);
|
||||
const off = new Set(
|
||||
Object.entries(definition?.부품 ?? {})
|
||||
.filter(([, on]) => on === false)
|
||||
.map(([id]) => id),
|
||||
);
|
||||
const types = definition?.types?.length
|
||||
? definition.types
|
||||
: Object.keys(start).map((type_id) => ({ type_id }) as const);
|
||||
const rows = new Map<string, CatalogRow>();
|
||||
types.forEach((item, typeOrder) => {
|
||||
const id = item.type_id;
|
||||
const root = definition?.종류?.[id]?.본보기 ?? id; // 복사한 종류 = 본보기 처음값(323-7)
|
||||
const base = start[root] ?? {};
|
||||
const mine = definition?.반영?.[id] ?? {};
|
||||
const part = (branch: Branch): string => {
|
||||
const value = mine[branch] ?? base[branch] ?? OFF[branch];
|
||||
return off.has(`${branch}.${value}`) ? OFF[branch] : value;
|
||||
};
|
||||
const group = "group" in item ? (item.group ?? "") : "";
|
||||
const index = groupKeys.indexOf(group);
|
||||
rows.set(id, {
|
||||
typeId: id,
|
||||
name: ("name" in item && item.name) || id,
|
||||
group,
|
||||
groupName: groups[group] ?? group,
|
||||
groupOrder: index < 0 ? groupKeys.length : index,
|
||||
typeOrder,
|
||||
placement: base.자리꼴 ?? ("placement" in item ? item.placement : undefined) ?? "point",
|
||||
owner:
|
||||
"managed_by" in item && item.managed_by === "pipe_points" ? "pipe_points" : "structures",
|
||||
// 반영이 없으면 옛 짝 — 처음값 hidden 은 숨김 · 그 밖은 정의 enabled 가 가름(꺼짐 = B06 만)
|
||||
list: (mine.목록 ??
|
||||
(base.목록 === "hidden"
|
||||
? "hidden"
|
||||
: "enabled" in item && item.enabled === false
|
||||
? "b06"
|
||||
: "both")) as ListPlace,
|
||||
station: part("횡단도"),
|
||||
title: part("카드이름"),
|
||||
shape: part("형상"),
|
||||
shapeOpts: { ...(base.형상칸 ?? {}), ...(mine.형상칸 ?? {}) },
|
||||
model3d: part("3D"),
|
||||
color: ("style" in item && item.style?.color) || null,
|
||||
base: root,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
let current = resolveCatalog(null);
|
||||
let pipeKinds: string[] = [];
|
||||
|
||||
/** 정의를 받으면(종류 레지스트리 · `fetchStructureTypes`) 표를 갈아 끼움 — 못 받았으면 처음값 표 그대로. */
|
||||
export function useCatalogDefinition(definition: CatalogDefinition | null): void {
|
||||
current = resolveCatalog(definition);
|
||||
pipeKinds = [
|
||||
...new Set(
|
||||
(definition?.types ?? [])
|
||||
.filter((type) => current.get(type.type_id)?.title === "pipe")
|
||||
.flatMap(
|
||||
(type) => type.options?.find((option) => option.key === "pipe_kind")?.choices ?? [],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** 종류 한 줄 — 표에 없는 종류면 undefined. */
|
||||
export const catalogRow = (typeId: string): CatalogRow | undefined => current.get(typeId);
|
||||
|
||||
/** 손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) · 원본이면 자기 · 파이썬 `base_type` 짝. */
|
||||
export const baseType = (typeId: string): string => current.get(typeId)?.base ?? typeId;
|
||||
|
||||
/** 고르기 목록에 보일 종류인가 — B05 = both · B06 = both + b06(표에 없는 종류는 보임). */
|
||||
export const listPick =
|
||||
(page: "b05" | "b06") =>
|
||||
(type: { type_id: string }): boolean => {
|
||||
const list = catalogRow(type.type_id)?.list ?? "both";
|
||||
return list === "both" || (page === "b06" && list === "b06");
|
||||
};
|
||||
|
||||
/** 카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 종류 이름. */
|
||||
export function cardTitle(
|
||||
typeId: string,
|
||||
options: Record<string, unknown> | null | undefined,
|
||||
defaults: Record<string, unknown> = {},
|
||||
): string {
|
||||
const row = catalogRow(typeId);
|
||||
if (row?.title !== "pipe") return row?.name ?? typeId;
|
||||
const value = (key: string): unknown => {
|
||||
const own = options?.[key];
|
||||
return own === undefined || own === null || own === "" ? defaults[key] : own;
|
||||
};
|
||||
return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`;
|
||||
}
|
||||
|
||||
/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */
|
||||
export const fieldName = (key: string): string | undefined =>
|
||||
(PARTS as unknown as { "칸 이름"?: Record<string, string> })["칸 이름"]?.[key];
|
||||
|
||||
/**
|
||||
* 종류 바꿈 = 제자리(종류 키만 갈아 끼움)인가(323-6 · 표 규칙) — 정본이 같고, 구조물은 자리꼴도 같아야.
|
||||
* 아니면(관 지점 ↔ 구조물 · E · F 점형 ↔ 구간형) 옛 것을 지우고 같은 기준 측점에 새로(구간 = 기본 길이).
|
||||
* 관 지점 시설은 구간도 옵션(길이 · 전 · 후)이라 자리꼴로 안 가름.
|
||||
*/
|
||||
export function swapsInPlace(fromTypeId: string, toTypeId: string): boolean {
|
||||
const from = catalogRow(fromTypeId);
|
||||
const to = catalogRow(toTypeId);
|
||||
if (!from || !to || from.owner !== to.owner) return false;
|
||||
return from.owner === "pipe_points" || from.placement === to.placement;
|
||||
}
|
||||
|
||||
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
|
||||
export function shapeNeeds(typeId: string): readonly string[] {
|
||||
const shape = catalogRow(typeId)?.shape ?? "none";
|
||||
const box = (PARTS as unknown as Parts).parts.형상 as Record<string, { "필수 칸"?: string[] }>;
|
||||
return Object.hasOwn(box, shape) ? (box[shape]["필수 칸"] ?? []) : [];
|
||||
}
|
||||
|
||||
/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */
|
||||
export const pipeTitleKinds = (): readonly string[] => pipeKinds;
|
||||
@@ -0,0 +1,514 @@
|
||||
{
|
||||
"comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
|
||||
"parts": {
|
||||
"횡단도": {
|
||||
"anchor": {
|
||||
"이름": "기준 한 장",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point", "interval", "site"],
|
||||
"정본": ["pipe_points", "structures"]
|
||||
},
|
||||
"span3": {
|
||||
"이름": "시작 · 기준 · 끝",
|
||||
"읽는 칸": ["length_m", "before_m", "after_m"],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["pipe_points", "structures"]
|
||||
}
|
||||
},
|
||||
"카드이름": {
|
||||
"pipe": {
|
||||
"이름": "관종 D관경",
|
||||
"읽는 칸": ["pipe_kind", "pipe_diameter_mm"],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
}
|
||||
},
|
||||
"형상": {
|
||||
"pipe_set": {
|
||||
"이름": "배관 세트(관 · 유입 · 유출 · 흙막이 · 도수로)",
|
||||
"읽는 칸": [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"revet_foundation",
|
||||
"inlet_type",
|
||||
"inlet_revet_form",
|
||||
"inlet_revet_length_m",
|
||||
"inlet_revet_before_m",
|
||||
"inlet_revet_after_m",
|
||||
"inlet_revet_height_m",
|
||||
"inlet_basin_length_m",
|
||||
"inlet_basin_before_m",
|
||||
"inlet_basin_after_m",
|
||||
"inlet_basin_height_m",
|
||||
"outlet_type",
|
||||
"outlet_revet_form",
|
||||
"outlet_revet_length_m",
|
||||
"outlet_revet_before_m",
|
||||
"outlet_revet_after_m",
|
||||
"outlet_revet_height_m"
|
||||
],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["pipe_diameter_mm"]
|
||||
},
|
||||
"box": {
|
||||
"이름": "BOX 본체 · 날개벽",
|
||||
"읽는 칸": [
|
||||
"body_width_m",
|
||||
"body_height_m",
|
||||
"wall_thickness_m",
|
||||
"slab_thickness_m",
|
||||
"wing_in",
|
||||
"wing_in_height_m",
|
||||
"wing_in_length_m",
|
||||
"wing_in_angle_deg",
|
||||
"wing_out",
|
||||
"wing_out_height_m",
|
||||
"wing_out_length_m",
|
||||
"wing_out_angle_deg"
|
||||
],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"ford": {
|
||||
"이름": "세월교(관 · 월류 · 날개벽)",
|
||||
"읽는 칸": [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"pipe_count",
|
||||
"ford_width_m",
|
||||
"ford_height_m",
|
||||
"wing_in",
|
||||
"wing_in_height_m",
|
||||
"wing_in_length_m",
|
||||
"wing_in_angle_deg",
|
||||
"wing_out",
|
||||
"wing_out_height_m",
|
||||
"wing_out_length_m",
|
||||
"wing_out_angle_deg"
|
||||
],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"ford_pavement": {
|
||||
"이름": "물넘이 포장(파인 노면 · 도수로)",
|
||||
"읽는 칸": ["ford_width_m", "ford_height_m", "ford_slope_deg", "ford_slope_pct"],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["ford_height_m"]
|
||||
},
|
||||
"hidden_revet": {
|
||||
"이름": "독립 기슭막이(관 숨김 벽)",
|
||||
"읽는 칸": [
|
||||
"form",
|
||||
"height_m",
|
||||
"length_m",
|
||||
"before_m",
|
||||
"after_m",
|
||||
"tiers",
|
||||
"side",
|
||||
"foundation",
|
||||
"face_role",
|
||||
"face_slope_ratio"
|
||||
],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["pipe_points"],
|
||||
"필수 칸": ["height_m"]
|
||||
},
|
||||
"wall": {
|
||||
"이름": "벽(옹벽 · 흙막이 · 돌쌓기)",
|
||||
"읽는 칸": [
|
||||
"form",
|
||||
"height_m",
|
||||
"side",
|
||||
"foundation",
|
||||
"tiers",
|
||||
"lift_m",
|
||||
"shift_m",
|
||||
"face_role",
|
||||
"face_slope_ratio"
|
||||
],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["structures"],
|
||||
"필수 칸": ["height_m"]
|
||||
},
|
||||
"berm": {
|
||||
"이름": "소단(사면 계단)",
|
||||
"읽는 칸": ["width_m", "interval_m", "slope_deg", "depth_m"],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["structures"]
|
||||
},
|
||||
"underdrain": {
|
||||
"이름": "맹암거",
|
||||
"읽는 칸": ["culvert_height_m", "culvert_slope_pct"],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["structures"],
|
||||
"필수 칸": ["culvert_height_m"]
|
||||
},
|
||||
"pavement": {
|
||||
"이름": "포장층",
|
||||
"읽는 칸": ["pave_spec", "height_m", "widening_off"],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["structures"]
|
||||
},
|
||||
"spoil": {
|
||||
"이름": "사토(성토 채움)",
|
||||
"읽는 칸": ["capacity_m3"],
|
||||
"자리꼴": ["site"],
|
||||
"정본": ["structures"]
|
||||
}
|
||||
},
|
||||
"3D": {
|
||||
"pipe": {
|
||||
"이름": "관 · 유입 · 유출 벽",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"box": {
|
||||
"이름": "BOX 본체 · 날개벽",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"ford": {
|
||||
"이름": "세월교 몸체 · 관",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"chute": {
|
||||
"이름": "물넘이 도수로 띠",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point"],
|
||||
"정본": ["pipe_points"]
|
||||
},
|
||||
"wall": {
|
||||
"이름": "벽",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["pipe_points", "structures"]
|
||||
},
|
||||
"surface": {
|
||||
"이름": "설계선만(몸체 없음)",
|
||||
"읽는 칸": [],
|
||||
"자리꼴": ["point", "interval", "site"],
|
||||
"정본": ["pipe_points", "structures"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"칸 이름": { "ford_height_m": "월류 높이" },
|
||||
"처음값": {
|
||||
"pipe": {
|
||||
"목록": "both",
|
||||
"횡단도": "anchor",
|
||||
"카드이름": "pipe",
|
||||
"형상": "pipe_set",
|
||||
"3D": "pipe"
|
||||
},
|
||||
"box_culvert": {
|
||||
"목록": "both",
|
||||
"횡단도": "anchor",
|
||||
"카드이름": "name",
|
||||
"형상": "box",
|
||||
"3D": "box"
|
||||
},
|
||||
"ford_pavement": {
|
||||
"목록": "both",
|
||||
"횡단도": "anchor",
|
||||
"카드이름": "name",
|
||||
"형상": "ford_pavement",
|
||||
"3D": "chute"
|
||||
},
|
||||
"ford_bridge": {
|
||||
"목록": "both",
|
||||
"횡단도": "anchor",
|
||||
"카드이름": "name",
|
||||
"형상": "ford",
|
||||
"3D": "ford"
|
||||
},
|
||||
"cross_drain_exposed": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"open_ditch": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"cross_underdrain": {
|
||||
"목록": "both",
|
||||
"횡단도": "anchor",
|
||||
"카드이름": "name",
|
||||
"형상": "underdrain",
|
||||
"3D": "surface"
|
||||
},
|
||||
"ditch_side": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"ditch_ridge": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"ditch_berm": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"chute": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"slope_drain": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"underdrain": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"retaining_wall": {
|
||||
"목록": "both",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": {
|
||||
"form": "콘크리트",
|
||||
"weep": true
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"masonry_wet": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(찰)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"masonry_dry": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(메)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"soil_guard": {
|
||||
"목록": "both",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": {
|
||||
"form": "통나무·목재틀"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"boulder_masonry": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "wall",
|
||||
"형상칸": {
|
||||
"form": "돌쌓기(메)"
|
||||
},
|
||||
"3D": "wall"
|
||||
},
|
||||
"berm": {
|
||||
"목록": "both",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "berm",
|
||||
"3D": "surface"
|
||||
},
|
||||
"erosion_check": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"bed_sill": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"check_dam_small": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"revetment": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "span3",
|
||||
"카드이름": "name",
|
||||
"형상": "hidden_revet",
|
||||
"3D": "wall",
|
||||
"자리꼴": "interval"
|
||||
},
|
||||
"evac_road": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"refuge": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"work_yard": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"turnaround": {
|
||||
"목록": "hidden",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"mirror": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"curb": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"hazard_sign": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"guardrail": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"position_sign": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"water_intake": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"barrier_gate": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"completion_sign": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"spoil_bank": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "spoil",
|
||||
"3D": "surface"
|
||||
},
|
||||
"borrow_pit": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"wildlife_path": {
|
||||
"목록": "b06",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"revegetation": {
|
||||
"목록": "b06",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"pavement_concrete": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "pavement",
|
||||
"3D": "surface"
|
||||
},
|
||||
"gravel_surfacing": {
|
||||
"목록": "b06",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "pavement",
|
||||
"3D": "surface"
|
||||
},
|
||||
"etc": {
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값.
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7)
|
||||
_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
@@ -105,10 +107,10 @@ def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str,
|
||||
def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명)."""
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from common_util.common_util_structure_catalog import type_ids_with
|
||||
|
||||
try:
|
||||
type_ids = {key for key, value in structure_type_map().items() if value.group == "G"}
|
||||
type_ids = type_ids_with(shape="pavement") # 표 형상(322-1 · 옛 G 군)
|
||||
structures = [item.model_dump() for item in load_structures(str(project_root))[1]]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감
|
||||
logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_shape_keys.ts
|
||||
* 측점 그림 자료 열쇠(323-4) — 형상 얹기 한 벌(`common_util_structure_attach.ts`)이 걷고 얹는 것 = 카드 ·
|
||||
* 상세 창 · B07 · 3D 가 읽는 것 = 3D 지문(`B05_Profile_UI_Corridor_Structures_Hash.ts`). 가져오는 것 없음
|
||||
* (3D 도움 파일 로더도 그대로 읽게). 파이썬 짝 = `B06_Section_Engine_Attach.SHAPE_KEYS`.
|
||||
* ========================================================================== */
|
||||
|
||||
export const SHAPE_KEYS = [
|
||||
"culvert",
|
||||
"ford",
|
||||
"box",
|
||||
"ford_pavement",
|
||||
"revetment",
|
||||
"pavement",
|
||||
"underdrain",
|
||||
] as const;
|
||||
@@ -4,7 +4,8 @@
|
||||
만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`).
|
||||
234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167).
|
||||
TS 짝 = `common_util/common_util_structure_station.ts`
|
||||
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김).
|
||||
(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 ·
|
||||
측점 계획 `plan_stations` ↔ `planStations` · 322-3).
|
||||
거울 시험 = `resources/tester/test_234_1_structure_station.py`.
|
||||
일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀.
|
||||
"""
|
||||
@@ -67,3 +68,112 @@ def snap_candidates(
|
||||
x, y, _ = interpolate_vertex(list(vertices), at)
|
||||
snapped.append(replace(candidate, chainage_m=at, x=x, y=y))
|
||||
return snapped
|
||||
|
||||
|
||||
# ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ──
|
||||
|
||||
# ── 같은 자리 거리 한 곳(323-7 · 파이썬은 여기서 가져다 씀 · TS 짝은 이름이 같은 상수) ──
|
||||
#: 같은 측점으로 볼 거리(m) — 측점 계획 · 반올림 차이만 · TS `SAME_STATION_M` 짝
|
||||
SAME_STATION_M = 0.005
|
||||
#: 구간 끝 · 관 세트가 걸치는 측점을 같은 자리로 볼 폭(m) — 정본이 누가거리를 0.01 m 로 끊어 씀 ·
|
||||
#: TS `EDGE_TOLERANCE_M`(벽 · 포장 · 맹암거) · `CHAINAGE_TOLERANCE_M`(관 세트) 짝
|
||||
EDGE_TOLERANCE_M = 0.02
|
||||
#: 관 · 구조물 자리와 측점 · 고름을 같은 자리로 볼 폭(m) — 정수 m 기준 측점 · 옛 소수 자료 · TS
|
||||
#: `design_selection.PIPE_MATCH_M` 짝
|
||||
PIPE_MATCH_M = 0.51
|
||||
|
||||
|
||||
def _empty(value: object) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def blank_keys(type_id: str, options: dict | None) -> list[str]:
|
||||
"""형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝)."""
|
||||
from common_util.common_util_structure_catalog import shape_needs
|
||||
|
||||
return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))]
|
||||
|
||||
|
||||
def _station_marks(
|
||||
start: object, anchor: object, end: object, span3: bool
|
||||
) -> list[tuple[str, float]]:
|
||||
"""TS `marksOf` 짝 — 수인 자리만 (역할, 누가거리)."""
|
||||
marks = [("start", start), ("anchor", anchor), ("end", end)] if span3 else [("anchor", anchor)]
|
||||
return [
|
||||
(role, float(at))
|
||||
for role, at in marks
|
||||
if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at)
|
||||
]
|
||||
|
||||
|
||||
def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]:
|
||||
"""관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`.
|
||||
|
||||
오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름)
|
||||
· 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상
|
||||
부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌.
|
||||
"""
|
||||
from common_util.common_util_structure_catalog import card_title, catalog_row
|
||||
|
||||
blank = blank_keys
|
||||
|
||||
marks: list[dict[str, object]] = []
|
||||
for pipe in pipes:
|
||||
facility = getattr(pipe, "facility", None) or "pipe"
|
||||
row = catalog_row(facility)
|
||||
if row is None:
|
||||
continue
|
||||
options = getattr(pipe, "options", None)
|
||||
chainage = float(getattr(pipe, "chainage_m"))
|
||||
station = row["station"]
|
||||
for role, at in (
|
||||
[]
|
||||
if station == "none"
|
||||
else _station_marks(
|
||||
getattr(pipe, "start_m", None),
|
||||
chainage,
|
||||
getattr(pipe, "end_m", None),
|
||||
station == "span3",
|
||||
)
|
||||
):
|
||||
marks.append(
|
||||
{
|
||||
"chainage_m": at,
|
||||
"role": role,
|
||||
"title": card_title(facility, options),
|
||||
"owner": getattr(pipe, "id", None) or f"pipe-{chainage:.2f}",
|
||||
"blank": blank(facility, options),
|
||||
"pipe": True,
|
||||
}
|
||||
)
|
||||
for item in structures:
|
||||
row = catalog_row(item.type_id)
|
||||
if row is None or row["owner"] == "pipe_points" or row["station"] == "none":
|
||||
continue
|
||||
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
|
||||
found = _station_marks(item.start_m, anchor, item.end_m, row["station"] == "span3")
|
||||
base = next((at for role, at in found if role == "anchor"), 0.0)
|
||||
for role, at in found:
|
||||
marks.append(
|
||||
{
|
||||
"chainage_m": at,
|
||||
"role": role,
|
||||
"title": card_title(item.type_id, item.options),
|
||||
"owner": item.structure_id or f"structure-{base:.2f}",
|
||||
"blank": blank(item.type_id, item.options),
|
||||
"pipe": False,
|
||||
}
|
||||
)
|
||||
marks.sort(key=lambda mark: mark["chainage_m"])
|
||||
out: list[dict[str, object]] = []
|
||||
for mark in marks:
|
||||
last = out[-1] if out else None
|
||||
if last is not None and abs(mark["chainage_m"] - last["chainage_m"]) < SAME_STATION_M:
|
||||
if mark["pipe"] and not last["pipe"]:
|
||||
last["title"], last["pipe"] = mark["title"], True
|
||||
if mark["owner"] not in last["owners"]:
|
||||
last["owners"].append(mark["owner"])
|
||||
last["blank"] += [key for key in mark["blank"] if key not in last["blank"]]
|
||||
continue
|
||||
out.append({**mark, "owners": [mark["owner"]], "blank": list(mark["blank"])})
|
||||
return [{k: v for k, v in station.items() if k not in ("pipe", "owner")} for station in out]
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
* 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 끝(노선 길이)을 넘지 않게 자르는 데만 ·
|
||||
* 상세를 읽을 때 한 번 둔다.
|
||||
*
|
||||
* 심는 측점 = 구조물 표(catalog) 횡단도 부품(322-1) — anchor = 기준 하나 · span3 = 시작 · 기준 · 끝 · none = 없음.
|
||||
*
|
||||
* 측점 계획 한 벌(322-3 · `planStations`) — 관 · 구조물(현황) + 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id ·
|
||||
* 빈칸 경고 · 같은 m 는 하나(관이 먼저 · 이름 = 관 → 앞선 것). 파이썬 짝 = `common_util_structure_station.py`
|
||||
* `plan_stations`(서버 `resolve_extra_stations`) · 거울 시험 = `resources/tester/test_322_3_plan_mirror.py`.
|
||||
* 카드 목록 · B05 종단 · B06 종단 띠 · 3D 막대 · 미리 뜬 횡단 이름이 이 목록 하나를 씀.
|
||||
*
|
||||
* 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations`
|
||||
* (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버
|
||||
* `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 =
|
||||
@@ -16,8 +23,15 @@
|
||||
* 이 목록 하나를 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/* 같은 자리 거리(323-7 · 뜻마다 하나 · 파이썬은 `common_util_structure_station.py` 한 곳에서 가져다 씀):
|
||||
* SAME_STATION_M 0.005 = 같은 측점(반올림 차이) — 여기
|
||||
* EDGE_TOLERANCE_M · CHAINAGE_TOLERANCE_M 0.02 = 구간 끝 · 관 세트가 걸치는 측점(정본 0.01 m 끊음) — 벽 ·
|
||||
* 포장 · 맹암거 · 관 세트 모듈(거울 시험이 tsc ESM 으로 따로 엮어 각자 둠 · 값은 파이썬 `EDGE_TOLERANCE_M`)
|
||||
* PIPE_MATCH_M 0.51 = 관 · 구조물 자리와 측점 · 고름(정수 m · 옛 소수) — `design_selection` */
|
||||
/** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */
|
||||
export const SAME_STATION_M = 0.005;
|
||||
|
||||
@@ -111,39 +125,120 @@ function marksOf(
|
||||
);
|
||||
}
|
||||
|
||||
/** 관 시설 하나가 심는 측점 — 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖(물넘이포장 포함 · 286-1) = 기준. */
|
||||
/** 관 시설 하나가 심는 측점 — 표 횡단도(독립 기슭막이 = span3 · 물넘이포장 포함 그 밖 = anchor · 286-1). */
|
||||
export function pipeStationMarks(pipe: PlantingPipe): StationMark[] {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, facility === "revetment");
|
||||
const station = catalogRow(pipe.facility ?? "pipe")?.station ?? "anchor";
|
||||
if (station === "none") return [];
|
||||
return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, station === "span3");
|
||||
}
|
||||
|
||||
/** 구조물 하나가 심는 측점 — A군 = 기준 · C · D군 구간형 = 시작 · 기준 · 끝 · 다른 정본 소관 · 그 밖 = 없음. */
|
||||
/** 구조물 하나가 심는 측점 — 표 횡단도(anchor = 기준 · span3 = 시작 · 기준 · 끝) · 관 지점 정본 소관 · 모르는 종류 = 없음. */
|
||||
export function structureStationMarks(
|
||||
structure: PlantingStructure,
|
||||
type: PlantingType | undefined,
|
||||
): StationMark[] {
|
||||
if (!type || type.managed_by) return [];
|
||||
const interval = type.placement === "interval";
|
||||
if (type.group !== "A" && !(interval && (type.group === "C" || type.group === "D"))) return [];
|
||||
const row = type && catalogRow(type.type_id);
|
||||
if (!row || row.owner === "pipe_points" || row.station === "none") return [];
|
||||
const anchor = structure.chainage_m ?? structure.start_m;
|
||||
return marksOf(structure.start_m, anchor, structure.end_m, interval);
|
||||
return marksOf(structure.start_m, anchor, structure.end_m, row.station === "span3");
|
||||
}
|
||||
|
||||
/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */
|
||||
export function plantedChainages(
|
||||
pipes: ReadonlyArray<PlantingPipe>,
|
||||
structures: ReadonlyArray<PlantingStructure>,
|
||||
types: ReadonlyArray<PlantingType>,
|
||||
): number[] {
|
||||
/* ── 측점 계획 한 벌(322-3 · 파이썬 `plan_stations` 짝) ── */
|
||||
|
||||
export interface PlanPipe extends PlantingPipe {
|
||||
/** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */
|
||||
id?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlanStructure extends PlantingStructure {
|
||||
structure_id?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PlanType extends PlantingType {
|
||||
options?: ReadonlyArray<{ key: string; default?: unknown }>;
|
||||
}
|
||||
|
||||
/** 측점 한 곳 — 같은 m 에 놓인 관 · 구조물을 함께 덮음. */
|
||||
export interface PlannedStation {
|
||||
chainage_m: number;
|
||||
role: StationMark["role"];
|
||||
/** 카드 · 측점 이름 — 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름). */
|
||||
title: string;
|
||||
/** 덮는 구조물 id(관 = 세션 id · 구조물 = structure_id). */
|
||||
owners: string[];
|
||||
/** 빈칸 경고 — 형상 부품 필수 칸 중 값이 빈 옵션 키(비면 그 그림이 안 섬). */
|
||||
blank: string[];
|
||||
}
|
||||
|
||||
const empty = (value: unknown): boolean => value === undefined || value === null || value === "";
|
||||
|
||||
/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */
|
||||
export const blankKeys = (
|
||||
typeId: string,
|
||||
options: Record<string, unknown> | null | undefined,
|
||||
): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key]));
|
||||
|
||||
/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션
|
||||
* 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */
|
||||
export function planStations(
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
registry?: Readonly<Record<string, Record<string, unknown>>>,
|
||||
): PlannedStation[] {
|
||||
const byId = new Map(types.map((type) => [type.type_id, type]));
|
||||
return [
|
||||
...pipes.flatMap(pipeStationMarks),
|
||||
...structures.flatMap((structure) =>
|
||||
structureStationMarks(structure, byId.get(structure.type_id)),
|
||||
),
|
||||
]
|
||||
.map((mark) => mark.chainage_m)
|
||||
.sort((a, b) => a - b);
|
||||
const defaults = (typeId: string): Record<string, unknown> =>
|
||||
registry?.[typeId] ??
|
||||
Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default]));
|
||||
type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean };
|
||||
const marks: Mark[] = [
|
||||
...pipes.flatMap((pipe) => {
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
if (!catalogRow(facility)) return [];
|
||||
const title = cardTitle(facility, pipe.options, defaults(facility));
|
||||
const owner = pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
const blank = blankKeys(facility, pipe.options);
|
||||
return pipeStationMarks(pipe).map((m) => ({ ...m, title, owner, blank, pipe: true }));
|
||||
}),
|
||||
...structures.flatMap((structure) => {
|
||||
const marks = structureStationMarks(structure, byId.get(structure.type_id));
|
||||
const anchor = marks.find((m) => m.role === "anchor")?.chainage_m ?? 0;
|
||||
const title = cardTitle(structure.type_id, structure.options, defaults(structure.type_id));
|
||||
const owner = structure.structure_id ?? `structure-${anchor.toFixed(2)}`;
|
||||
const blank = blankKeys(structure.type_id, structure.options);
|
||||
return marks.map((m) => ({ ...m, title, owner, blank, pipe: false }));
|
||||
}),
|
||||
].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const out: Array<PlannedStation & { pipe: boolean }> = [];
|
||||
for (const mark of marks) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && Math.abs(mark.chainage_m - last.chainage_m) < SAME_STATION_M) {
|
||||
if (mark.pipe && !last.pipe) Object.assign(last, { title: mark.title, pipe: true });
|
||||
if (!last.owners.includes(mark.owner)) last.owners.push(mark.owner);
|
||||
for (const key of mark.blank) if (!last.blank.includes(key)) last.blank.push(key);
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
chainage_m: mark.chainage_m,
|
||||
role: mark.role,
|
||||
title: mark.title,
|
||||
owners: [mark.owner],
|
||||
blank: [...mark.blank],
|
||||
pipe: mark.pipe,
|
||||
});
|
||||
}
|
||||
return out.map(({ pipe: _pipe, ...station }) => station);
|
||||
}
|
||||
|
||||
/** 측점을 심는 자리 전부(오름차순 · 같은 m 하나) — 측점 계획의 누가거리(서버 `resolve_extra_stations`). */
|
||||
export function plantedChainages(
|
||||
pipes: ReadonlyArray<PlanPipe>,
|
||||
structures: ReadonlyArray<PlanStructure>,
|
||||
types: ReadonlyArray<PlanType>,
|
||||
): number[] {
|
||||
return planStations(pipes, structures, types).map((station) => station.chainage_m);
|
||||
}
|
||||
|
||||
/* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* 기준 측점(0.02 m 안) 횡단 dict 에 `underdrain` = 높이 · 경사 — 횡단도가 돌 무늬 형상을 그리고,
|
||||
* 설계 계산은 그 측점을 측구 없이 지대 낮은 쪽 한 경사로 기울임(`tilted`).
|
||||
* 파이썬 짝 = `B06_Section/B06_Section_Engine_Tilt.py`(`load_underdrains` · `attach_underdrains`).
|
||||
* 맹암거 종류 = 구조물 표 형상 underdrain(322-1).
|
||||
* ========================================================================== */
|
||||
|
||||
export const UNDERDRAIN_TYPE_ID = "cross_underdrain";
|
||||
import { catalogRow } from "./common_util_structure_catalog";
|
||||
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */
|
||||
@@ -35,7 +37,7 @@ const positive = (value: unknown): number | null => {
|
||||
export function underdrainSpecsFrom(structures: readonly UnderdrainInput[]): UnderdrainSpec[] {
|
||||
return structures.flatMap((structure) => {
|
||||
const at = structure.chainage_m ?? structure.start_m;
|
||||
if (structure.type_id !== UNDERDRAIN_TYPE_ID || typeof at !== "number") return [];
|
||||
if (catalogRow(structure.type_id)?.shape !== "underdrain" || typeof at !== "number") return [];
|
||||
const options = structure.options ?? {};
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
* 산식이 같아야 하므로 두 파일 머리에 짝임을 적고 거울 테스트를 둔다.
|
||||
*
|
||||
* 얹는 것은 제원뿐이다 — 도형·면적은 `B06_Section_UI_Cross_Revetment` 가 그린다.
|
||||
* 벽 기본 형태(폼에서 형태를 비웠을 때) = 구조물 표 형상칸.form(322-1 · 옛 `FORM_BY_TYPE`).
|
||||
* ========================================================================== */
|
||||
|
||||
// `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂)
|
||||
import { baseType, catalogRow } from "./common_util_structure_catalog.js";
|
||||
|
||||
/** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */
|
||||
export interface WallStructureInput {
|
||||
structure_id?: string | null;
|
||||
@@ -54,7 +58,7 @@ export interface WallTier {
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
/**
|
||||
* 구조물 종류 → 횡단 기하가 아는 형태 이름 — 파이썬 `_FORM_BY_TYPE` 와 같은 표.
|
||||
* 구조물 종류 → 횡단 기하가 아는 형태 이름 = 표 형상칸.form(파이썬 `_wall_form` 짝).
|
||||
*
|
||||
* ⚠⚠ **「형상 동일」이 「수량 동일」이 아니다**(2026-09-09 사용자 확정 4차 원문:
|
||||
* 「흙막이는 횡단도에서 표현방식들과 옵션들은 동일하게 반영 · **형상은 동일** ·
|
||||
@@ -64,12 +68,9 @@ const EDGE_TOLERANCE_M = 0.02;
|
||||
* ⚠ 반대 방향의 사례가 골막이다 — 그림도 수량도 다르고, 두께식마저 다른 식이었다.
|
||||
* **구조물 계열이 다르면 상수를 나눠 쓸 것.**
|
||||
*/
|
||||
export const FORM_BY_TYPE: Record<string, string> = {
|
||||
masonry_wet: "돌쌓기(찰)",
|
||||
masonry_dry: "돌쌓기(메)",
|
||||
boulder_masonry: "돌쌓기(메)",
|
||||
retaining_wall: "콘크리트",
|
||||
soil_guard: "통나무·목재틀",
|
||||
const formOf = (typeId: string): string | null => {
|
||||
const form = catalogRow(typeId)?.shapeOpts.form;
|
||||
return typeof form === "string" ? form : null;
|
||||
};
|
||||
|
||||
const num = (value: unknown): number | null =>
|
||||
@@ -88,7 +89,11 @@ export function tierExtras(typeId: string, options: Record<string, unknown>): Wa
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
return ns.map((n) => ({
|
||||
form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null,
|
||||
// 옹벽(본보기 포함 · 323-7) 추가 단 = 콘크리트
|
||||
form:
|
||||
baseType(typeId) === "retaining_wall"
|
||||
? "콘크리트"
|
||||
: (options[`tier_${n}_form`] as string) || null,
|
||||
height_m: num(options[`tier_${n}_height`]),
|
||||
}));
|
||||
}
|
||||
@@ -117,7 +122,7 @@ export function wallSpecsFrom(
|
||||
start_m: Math.min(start, end),
|
||||
end_m: Math.max(start, end),
|
||||
anchor_m: num(structure.chainage_m) ?? Math.min(start, end),
|
||||
form: (options.form as string) || FORM_BY_TYPE[structure.type_id] || null,
|
||||
form: (options.form as string) || formOf(structure.type_id),
|
||||
height_m: num(options.height_m),
|
||||
side: (options.side as string) ?? null,
|
||||
// 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다.
|
||||
|
||||
@@ -245,3 +245,22 @@
|
||||
214. (원문 · 10-13) 구조물 변경에서 아직 겹쳐지는 구조물들이 존재함. 내가 봤을때는 캐시데이터 충돌도 있는것같음. 리프레시하면 최종선택한 구조물로 형상이 변경된 그림이 나옴. 확인만 해보자. (이어서) 너가 직접해
|
||||
215. (원문 · 10-13) B06의 횡단도에서 횡단도 하이라이트 기능의 선택영역중에 측구/일반/l형/복합경사/상세 버튼이 존재하는 영역이 누락된것같아. 확인만 해봐.
|
||||
216. (원문 · 10-13) 1. 구조물 종류를 바꿀때 옛 측점별 조정값이 저장기록에 섞이지 않게 해줘. 2. 횡단도의 중심고 표현이 횡단도 하단 띠 좌측에 표현되고 있는데 이걸 띠에서는 삭제하고 그래프 영역의 Y축 라벨에 같이 반영해주는데 박스와 테마에 맞춰 잘보이는 색상의 폰트 색상과 사각라운드를 활용하여 표기해줘. 이때 폰트를 크게할필요는 없고 다른 Y라벨과 동일한 폰트사이즈로 하고 대신 박스가 Y 축선에 침범하지 않게 해줘. 띠에 있던 글자는 삭제하고 암경계조절을 좌측맞춤으로 해줘. 3. 횡단도의 하단띠 하단에 여유공간이 있음. 띠의 높이는 조절하지말고 여유를 없애줘. 아마 컨테이너 외곽이 올라가능 방향이 될거야. 4. 횡단도 하이라이트 영역 조정은 너의 제안대로 해줘. 4. 이것도 너가 직접 작업해줘.
|
||||
217. (원문 · 10-14) 추가수정 1. Y축 숫자에 중심고 표현에서 근처에 있는 정규 Y 값들을 중첩되면 숨김처리하는데 숨김처리하지말고 중심고 라벨을 위해 위에배치하면 괜찮을것같은데? 만약 정규 Y값이 비치면 라벨의 배경색을 안비치게 변경하는것도 좋아. 2. 횡단배수 구조물변경을 할경우 해당 측점에서 맹암거 변경안됨. 만약 횡단도에 횡단배수 구조물 중 형상이 없는 횡단도의 경우 없는 걸로 변경하고 다시 다른것도 변경해야할것같은데?
|
||||
218. (원문 · 10-14) 직접 브라우져확인하고 이번 기회에 구조물 점검을해보자. 지금까지 결과를보면 횡단배수군과 그 외 구조물들로 나눌수 있고 그룹을 넘어가는 경우에는 좌측패널이 리셋됨. 그리고 그룹내 다른 구조물을 선택하면 자유롭게 변경되며 해당선택지의 구조물그림으로 변경함. 이때 구조물이 그림이 없는 경우 횡단도추가 하지않고 있더라도 삭제한다. 일단 정밀 점검해서 테이블로 현재 드롭다운 메뉴의 구조물 리스트를 만들어 현황공유. 내용이 많으면 아티팩트로
|
||||
219. (원문 · 10-14) 내가 판단하기에는 현재의 드롭다운 메뉴가 결국 최신의 구조물 현황되어야해. 하지만 지금 b05,b06의 횡단도, 하이라이트, 횡단도 구조물 형상, 구조물 하이라이트, 종단 측점, 종단 알약, 3d측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘
|
||||
220. (원문 · 10-14) 확인했고 별도의 구조물 관리 테이블을 운용하여 오류를 줄이자는 말이네. 여기에 추가로 검토해줘야 하는네용이 테이블관리하여 내가 제어했으면 좋겠어. 코드야 만들어두고 활성화할지 비활성화할지 어떤 메뉴들이 들어올지 내가 결정할 수있었으면 해. 그래서 m02에 보면 구조물의 옵션 텍스트를 변경할수있게 제어하는 메뉴가 있는데 이걸 통합하여 운영하는거야. 다시 정밀 점검 해봐. 완료되면 신규 계획서를 작성할거야
|
||||
221. (원문 · 10-14) 이번 수정은 일단 랩탑 서브 1에 반영하고나서 내가 직접 확인후 병합여부를 결정할거야. 그러니 조심하고 너도 계획서의 전체 내용을 반영하고 브라우져 검증진행해줘.
|
||||
222. (원문 · 10-14) 너가 브라우져로 접근해서 사용자처럼 전체 변경에 따른 미반영내용이 있는지 오류가 있는지 검증해줘. 오래걸려도 되니 하나씩 하나씩 지정해줘.
|
||||
223. (원문 · 10-14) 1. 고칠 흠은 진행
|
||||
2. M02의 종류더하기에 맹암거와 옛종류들은 일단 두고 비활성화 상태면 괜찮음. 나중에 살릴수도 있음. 활성비활성은 내가 제어하겠음.
|
||||
3. 횡단도에 횡단배수의 맹암거 옵션 기본값 3m, 3% 지정
|
||||
4. 전역깃싱크 할것.
|
||||
5. 전역 깃 싱크 후 추가 검토 업무
|
||||
- 실무문서 중 2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls 파일의 일위대가 탭은 실무자들이 주로 사용하는 일위대가의 전체를 모와둔 자료야. 아마 사용자들이 구조물조합을 구성하기위해 반복적으로 검색과 옵션을 선택해야하는 어려움이 있음. M02 좌측 패널에서 사용자가 파일을 입력할수 있게 버튼을 생성(일위대가 실무자료)해주고 입력받은 엑셀의 일위대가탭을 기준으로 분석을 하는 로직을 추가하였으면함.
|
||||
- 사용자는 구조물조합의 일위대가의 로직추가할 경우 해당 일위대가 실무자료에 있는 호표항목을 불러올수 있는 기능 부여
|
||||
- 이때 중요한 점은 있는 엑셀의 일위대가의 단가산출 이름들과 우리 프로그램의 단가산출 로직의 이름이 상이함. 매칭이 되는 경우에는 엑셀의 단가산출 이름을 일위대가의 별칭으로 입력해주면됨.
|
||||
- 이때 옵션값은 규격열에 명기되어 있음.
|
||||
- 결과적으로 내가 필요한 프로그램 로직은 다른 사람들이 가지고 있는 실무문서의 내용을 가져와 우리의 일위대가로 만들고 싶음.
|
||||
- 단위가 상이한 경우에는 우리의 단가산출 로직에 단위변환에 따른 결과 로직을 부여하여 맞출것
|
||||
- 신규계획서를 작성이 필요하며 작성전 상세분석을 진행하여 코드의 영향을 분석을 진행.
|
||||
6. 4번 전역 깃 싱크를 필히 한후 추가 검토 및 반영 업무 진행할 것.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user