feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침

- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 18:06:13 +09:00
co-authored by Claude Opus 5.5
parent c45814f910
commit cb4bab7bf2
37 changed files with 488 additions and 81 deletions
+3
View File
@@ -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);
}
@@ -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);
}
+5
View File
@@ -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,
@@ -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);
});
// 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를
+12 -11
View File
@@ -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 });
@@ -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);
@@ -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);
}
/**