diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 30571f6dc..b3745a035 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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). [저장]·[확정] 뒤 비움. */ diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 701ab2e34..2b2a93ec6 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -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"); diff --git a/A00_Common/design/design_pick_match.ts b/A00_Common/design/design_pick_match.ts new file mode 100644 index 000000000..09bd321bf --- /dev/null +++ b/A00_Common/design/design_pick_match.ts @@ -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 & { 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("[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 }), + ); + } +} diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index f03582571..795dea194 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -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( diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts new file mode 100644 index 000000000..7ce5f39a6 --- /dev/null +++ b/A00_Common/design/design_pipe_ids.ts @@ -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("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( + pipes: ReadonlyArray, + projectId: string | null = active, +): Array { + 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 }, + ); +} diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 3f83c04d1..df7a528ef 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -188,17 +188,3 @@ export function selectDesign( dispatching = false; } } - -/** 관을 옮김 — 그 관을 골라 둔 고름이면 새 자리로(322-3 · 관 id = 자리 이름 `pipe-{누가거리}` 라 id 도 - * 새 자리로 · 옮겨도 고름 유지 · 부재키 그대로). 다른 것을 골랐으면 그대로. */ -export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void { - 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 : `pipe-${toM.toFixed(2)}` }, - { partKey: picked.partKey }, - ); -} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index b61eec9e9..1fc2a3709 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -4,7 +4,7 @@ * → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) → * 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴). * - * 구조물 id = 구조물은 structure_id · 관은 자리 이름 `pipe-{누가거리 2자리}`(파일 모양 그대로 · 저장 안 함). + * 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함). * ========================================================================== */ import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -18,9 +18,6 @@ export function structureStatus( return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] }; } -/** 관 id(현황 · 알약 · 고름이 같은 값) — 자리 이름. */ -export const pipeId = (chainageM: number): string => `pipe-${chainageM.toFixed(2)}`; - /** * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` · * 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 = diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 30eed5fd1..0dae09d4a 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -18,6 +18,7 @@ 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 { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { pipeIdAt } from "./design_pipe_ids"; import { fetchSectionContext, getSections, @@ -399,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(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index d3b44d3d0..3bb3c6acb 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -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 { @@ -60,6 +59,7 @@ import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations, onStaleStructureTypes, + structureAnchorM, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { @@ -80,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, @@ -134,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); }; @@ -552,11 +551,8 @@ export async function mountProfileView( }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - host.onDispose( - onStaleStructureTypes(() => { - if (host.visible() && !restoring) void bridge.load(); - }), - ); + const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load()); + host.onDispose(onStaleStructureTypes(reloadIfShown)); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 570cc0018..8f1e945cd 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -15,6 +15,7 @@ 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 { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { type DefinedRule, @@ -324,7 +325,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, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 5f216cc58..b25371a7f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -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) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 47ee633c5..00ffd48cc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -86,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]>; /** @@ -288,6 +290,7 @@ export function buildCorridorStructures( // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). // C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것) const wallSweep = wallSweepAt(section, crossSections); + const wallId = section.revetment?.structure_id; const revetLayout = wallSweep && ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) && @@ -369,8 +372,10 @@ export function buildCorridorStructures( if (revetLayout && wallSweep) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. + 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; } /** diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 6b4e43dad..d8d32dcd5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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 { followMovedPipe, 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"; diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index ee5004ade..669192248 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -35,6 +35,7 @@ import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; 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 { @@ -119,7 +120,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { if (!planTypes.length) return; const interval = deps.panel().values().stationInterval || 20; applyIrregularStations( - plannedIrregular(planStations(planPipes, ownStructures, planTypes), interval), + plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval), ); } @@ -138,9 +139,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) } /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. @@ -161,7 +160,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, diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index b48cd5216..6dc1f1cc4 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -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; 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(); }, diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index e044cebbb..84da01ef3 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -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); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 226e9014b..325e00c3b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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); } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 62082530c..f01e99057 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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) diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 1f12fa437..7b1e747c0 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 14a9abba0..74e608c56 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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, @@ -90,6 +91,8 @@ export interface RevetmentLayout { heightM: number; /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */ chainageM: number; + /** 벽 구조물 id — 그린 벽 신원표(323-5). */ + structureId: string | null; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ @@ -315,6 +318,7 @@ export function computeRevetmentLayout( side, heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, + structureId: spec.structure_id ?? null, requestedTiers, top: jt, walls, @@ -376,6 +380,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); }); // 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를 diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts index 79e5a1cd0..5a0cb65cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -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("[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("[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 }); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index ccb74a095..ea213c922 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -45,7 +45,6 @@ import { coversChainage, NO_SELECTION, PIPE_MATCH_M, - followMovedPipe, readSelection, resolveSelection, selectDesign, @@ -64,6 +63,7 @@ import { 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(종단) 화면에서 합니다."; @@ -348,7 +348,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?.(), }, @@ -650,7 +650,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 { @@ -666,7 +666,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); diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 50611b2e8..b102316d9 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -49,6 +49,7 @@ import { } 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; @@ -107,7 +108,8 @@ function planMarkStations( options: mark.options, })); const structures = marks.filter((mark) => !isPipeMark(mark)); - return plannedIrregular(planStations(pipeList, structures, types), intervalM); + // 덮는 구조물 id = 관 세션 id(323-5 · 알약 id 와 같음) + return plannedIrregular(planStations(withPipeIds(pipeList), structures, types), intervalM); } /** diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 3eee4915c..3a82fb648 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -133,6 +133,8 @@ const panelModule = loadTs( })), structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, writePendingStructures() {}, + dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0) + staleDefinitionNotice: () => null, // 판 알림(322-2) }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null, @@ -165,6 +167,15 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../B05_Profile/B05_Profile_UI_Structures_Snap": { + snapNotice() {}, + snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }), + }, + "../A00_Common/design/design_pipe_ids": { + pipeIdAt: (at) => `pipe-${at.toFixed(2)}`, + followMovedPipe() {}, + }, // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 70d7a0fd8..836b3c577 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -152,6 +152,8 @@ const panelModule = loadTs( })), structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, writePendingStructures() {}, + dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0) + staleDefinitionNotice: () => null, // 판 알림(322-2) }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null, @@ -184,6 +186,15 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../B05_Profile/B05_Profile_UI_Structures_Snap": { + snapNotice() {}, + snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }), + }, + "../A00_Common/design/design_pipe_ids": { + pipeIdAt: (at) => `pipe-${at.toFixed(2)}`, + followMovedPipe() {}, + }, // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs index 26ebb5991..190e3cbde 100644 --- a/resources/tester/helper_232_1_3d_press.cjs +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -73,7 +73,12 @@ const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", { "./B05_Profile_UI_Markers": MK3, }); -const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match`(같은 자리 = 고름 모듈 거리) +const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel }); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", { + "../A00_Common/design/design_pick_match": PM, + "../A00_Common/design/design_selection": DSel, +}); const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { "../A00_Common/design/design_selection": DSel, "./B05_Profile_UI_IrregularStations": { diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index 22811c127..29e6833fd 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -39,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index f4c8623ca..aacd677f8 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -78,6 +78,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, @@ -85,7 +86,12 @@ const PP = loadTs("A00_Common/design/design_pick_profile.ts", { const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { "@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` }, }); -const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match` +const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel }); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", { + "../A00_Common/design/design_pick_match": PM, + "../A00_Common/design/design_selection": DSel, +}); const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { "../A00_Common/design/design_selection": DSel, "../A00_Common/design/design_pick_escape": ESC, diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs index ff6114e70..6166dfb3a 100644 --- a/resources/tester/helper_233_3_release.cjs +++ b/resources/tester/helper_233_3_release.cjs @@ -55,6 +55,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_270_1_cross_map.cjs b/resources/tester/helper_270_1_cross_map.cjs index 9252a7b2a..b73bc2b8f 100644 --- a/resources/tester/helper_270_1_cross_map.cjs +++ b/resources/tester/helper_270_1_cross_map.cjs @@ -42,6 +42,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index 85d319513..2c1e1c3be 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -31,6 +31,14 @@ const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pa { "./B06_Section_UI_Cross_Culvert_Const": {} }, ), "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, + // 관 세션 id · 신원표(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../A00_Common/design/design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, + "../A00_Common/design/design_pick_match": { + tagShape: (el, id, part) => { + if (id) el.setAttribute("data-structure-id", id); + if (part) el.setAttribute("data-part", part); + }, + }, }); // 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts")); diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index cb9753b12..0dddc8d54 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -183,13 +183,14 @@ async function run(setup, detail) { out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept); // 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴) out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept); - // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로 + // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · id = 관 세션 id 그대로(323-5) · 다른 고름은 그대로 const sel = load(file("A00_Common", "design", "design_selection.ts")); - sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" }); - sel.followMovedPipe("P", 61, 75); + const pipeIds = load(file("A00_Common", "design", "design_pipe_ids.ts")); + sel.selectDesign("P", { chainageM: 61, structureId: pipeIds.pipeIdAt(61, "P") }); + pipeIds.followMovedPipe("P", 61, 75); const moved = sel.readSelection("P"); sel.selectDesign("P", { chainageM: 101, structureId: "w1" }); - sel.followMovedPipe("P", 101, 120); + pipeIds.followMovedPipe("P", 101, 120); out.follow = [moved, sel.readSelection("P")]; // 옛 소수 관(검증_sub1 견본) — B06 을 바로 열어도 B05 와 같은 정수 m 초안(`snapPlan` · 요구 167) const snap = load(file("B05_Profile", "B05_Profile_UI_Structures_Snap.ts")); diff --git a/resources/tester/helper_323_5_pick.cjs b/resources/tester/helper_323_5_pick.cjs new file mode 100644 index 000000000..05c8f6afa --- /dev/null +++ b/resources/tester/helper_323_5_pick.cjs @@ -0,0 +1,94 @@ +/* PLAN 323-5 고름 한 칸 헬퍼 — 진짜 `design_pipe_ids.ts`(관 세션 id) · `design_selection.ts`(고름 · 관 옮김 따라가기) · + * `design_pick_match.ts`(고름 강조 한 규칙)를 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. + * 짝 = `test_323_5_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => load(path.join(path.dirname(full), `${spec}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const D = (name) => path.join(ROOT, "A00_Common", "design", `${name}.ts`); +const ids = load(D("design_pipe_ids")); +const sel = load(D("design_selection")); +const match = load(D("design_pick_match")); + +const out = {}; +// ① 관 세션 id — 자리마다 차례 · 같은 자리(0.51 안)면 같은 id · 프로젝트 없으면 옛 자리 이름 +ids.usePipeIds("P"); +const a = ids.pipeIdAt(85.05); +out.ids = [a, ids.pipeIdAt(85), ids.pipeIdAt(173), ids.pipeChainageOf(a)]; +ids.usePipeIds(null); +out.legacy = [ids.pipeIdAt(85), ids.pipeChainageOf("pipe-85.00"), ids.pipeChainageOf("w1")]; +ids.usePipeIds("P"); +// ② 옮겨도 id 그대로 — 고름도 자리만 새로(부재키 그대로) · 새 자리 id = 그 id +sel.selectDesign("P", { chainageM: 85.05, structureId: a }, { partKey: "inlet" }); +ids.followMovedPipe("P", 85.05, 90); +out.moved = [sel.readSelection("P"), ids.pipeIdAt(90), ids.pipeChainageOf(a)]; +// 다른 것을 골랐으면 고름 그대로 · 관 id 는 따라감 +const b = ids.pipeIdAt(173); +sel.selectDesign("P", { chainageM: 300, structureId: "w1" }); +ids.followMovedPipe("P", 173, 180); +out.other = [sel.readSelection("P"), ids.pipeIdAt(180) === b]; +// ③ 강조 한 규칙 +const L = match.pickLights; +out.lights = { + idOverPlace: L({ chainageM: 93, structureId: "w1" }, { chainageM: 101, structureId: "w1" }), + idDiffers: L({ chainageM: 101, structureId: "w2" }, { chainageM: 101, structureId: "w1" }), + placeNear: L({ chainageM: 85, structureId: null }, { chainageM: 85.4 }), + placeFar: L({ chainageM: 85, structureId: null }, { chainageM: 85.6 }), + partOnly: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "outlet" }), + partSame: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "inlet" }), + partNoTag: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85 }), + wholePick: L({ chainageM: 85 }, { chainageM: 85, part: "outlet" }), + none: L(null, { chainageM: 85 }), +}; +// ④ 카드 통째 형상 칠하기 — 부분 · 관 계열 · 포장층은 제 손잡이 몫 +class Shape { + constructor(attrs) { + this.attrs = attrs; + this.on = false; + this.classList = { toggle: (_c, v) => (this.on = v) }; + } + getAttribute(k) { + return this.attrs[k] ?? null; + } +} +const shapes = [ + new Shape({ "data-structure-id": "ud-1" }), + new Shape({ "data-structure-id": "w1", "data-part": "own" }), + new Shape({ "data-structure-id": a, "data-part": "concrete" }), + new Shape({ "data-structure-id": "pave-1", "data-pave-type": "pavement_concrete" }), +]; +const root = { querySelectorAll: () => shapes }; +match.paintPicked(root, { structureId: "ud-1" }); +const lit1 = shapes.map((s) => s.on); +match.paintPicked(root, { structureId: "w1" }); +out.paint = [lit1, shapes.map((s) => s.on)]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index f13aa4ab6..8ce44b2f3 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -116,7 +116,9 @@ def test_배선(): assert "openId" not in panel # 범위 안 빈 카드 = 연 노면공 지킴(165-1) 없앰 · 189-1 # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") - assert at < panel.index("deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);", at) + assert at < panel.index( + "deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));", at + ) view = _read("B06_Section", "B06_Section_UI_Section_View.ts") assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view assert "held" not in view # 범위 카드 재누름 = 그대로(167-3) 없앰 · 189-1 diff --git a/resources/tester/test_307_pick_highlight.py b/resources/tester/test_307_pick_highlight.py index e14f611de..1f047f337 100644 --- a/resources/tester/test_307_pick_highlight.py +++ b/resources/tester/test_307_pick_highlight.py @@ -60,13 +60,20 @@ def test_물넘이포장_측점길은_콘크리트_안켬(out): view = _read("B06_Section", "B06_Section_UI_Section_View.ts") assert "highlightShapes(card, pickedMarkId);" in view shape = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") - assert 'if (id.startsWith("pipe-")) continue;' in shape + assert "paintPicked(root, { structureId })" in shape # 고름 강조 한 규칙(323-5) + paint = _read("A00_Common", "design", "design_pick_match.ts") + assert ( + 'if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;' in paint + ) # 네 길이 모이는 자리 — 카드 고름 리스너 · 목록 관 줄 · 폼 관 열기 · 측점 없는 관 cross = _read("A00_Common", "design", "design_view_cross.ts") assert "sectionView.selectMark(mark, false);" in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);" in panel # 카드 → 폼 - assert "deps.markStructure?.(chainageM === null ? null : `pipe-${" in panel # 목록 관 줄 + # 알약 id = 관 세션 id(323-5) + assert ( + "deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));" in panel + ) # 카드 → 폼 + assert "deps.markStructure?.(chainageM === null ? null : pipeIdAt(" in panel # 목록 관 줄 # 측점 길은 「관」 벽 고름을 안 엶 — 카드만 고르면 벽 고름을 비움(그대로) assert "stationControls.revetOffset.select(target.chainage_m, null);" in cross diff --git a/resources/tester/test_322_3_flow.py b/resources/tester/test_322_3_flow.py index b902004e0..7c85d1c5d 100644 --- a/resources/tester/test_322_3_flow.py +++ b/resources/tester/test_322_3_flow.py @@ -74,7 +74,7 @@ def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None: def test_pick_follows_moved_pipe(flows: dict) -> None: moved, other = flows["follow"] - assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-75.00") + assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-s1") # 세션 id 그대로(323-5) assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로 diff --git a/resources/tester/test_323_5_pick.py b/resources/tester/test_323_5_pick.py new file mode 100644 index 000000000..2b373539e --- /dev/null +++ b/resources/tester/test_323_5_pick.py @@ -0,0 +1,67 @@ +"""PLAN 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(`pickLights`). + +- 관 id = 자리마다 차례(`pipe-s1` …) · 같은 자리(PIPE_MATCH_M 안)면 같은 id · 프로젝트 없으면 옛 자리 이름. +- 관을 옮기면 id 가 새 자리로 · 그 관을 고른 고름은 자리만 새로(부재키 그대로) · 다른 고름은 그대로. +- 강조 = id 둘 다 있으면 id · 아니면 같은 자리 · 고른 부분이 있으면 그 부분만 — 카드 형상 · 3D 같은 함수. +- 카드 통째 형상 칠하기는 부분(조정창 부재) · 관 계열 · 포장층을 안 건드림(307). +TS 쪽 = `helper_323_5_pick.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_5_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def test_session_ids(out: dict) -> None: + assert out["ids"] == ["pipe-s1", "pipe-s1", "pipe-s2", 85.05] + assert out["legacy"] == ["pipe-85.00", 85, None] + + +def test_move_keeps_id_and_pick(out: dict) -> None: + picked, at90, moved_to = out["moved"] + assert picked == {"chainageM": 90, "structureId": "pipe-s1", "partKey": "inlet"} + assert (at90, moved_to) == ("pipe-s1", 90) + other, same = out["other"] + assert other["structureId"] == "w1" and other["chainageM"] == 300 and same is True + + +def test_one_highlight_rule(out: dict) -> None: + assert out["lights"] == { + "idOverPlace": True, + "idDiffers": False, + "placeNear": True, + "placeFar": False, + "partOnly": False, + "partSame": True, + "partNoTag": False, + "wholePick": True, + "none": False, + } + + +def test_card_paint_whole_shapes_only(out: dict) -> None: + assert out["paint"] == [[True, False, False, False], [False, False, False, False]]