Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pick_profile.ts
|
||||
* 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②).
|
||||
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
|
||||
* 3D(측점 띠 · 핀 · 부재 · 233-2) (조사 AF (나) ② ③).
|
||||
*
|
||||
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
|
||||
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
|
||||
@@ -70,18 +71,53 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick):
|
||||
else target.setSelectedStation(pick.stationId);
|
||||
}
|
||||
|
||||
export interface ViewerPickTarget {
|
||||
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
|
||||
structurePick: {
|
||||
select: (pick: { chainageM: number; kind: string; key?: string } | null) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
|
||||
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
|
||||
export function viewerPickOf(
|
||||
selection: (DesignSelection & { partKey?: string }) | null,
|
||||
source: ProfilePickSource,
|
||||
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } {
|
||||
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
|
||||
const at = selection.chainageM;
|
||||
const pick = profilePickOf(selection, source);
|
||||
const own = selection.structureId
|
||||
? 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 } };
|
||||
}
|
||||
|
||||
export function paintViewerPick(
|
||||
target: ViewerPickTarget,
|
||||
pick: ReturnType<typeof viewerPickOf>,
|
||||
): void {
|
||||
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
|
||||
target.structurePick.select(pick.member);
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음.
|
||||
* 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음.
|
||||
* 종단 보기(종단도 · 3D)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 ·
|
||||
* 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) 안 그림 — 그 보기가
|
||||
* 제 규칙으로 그림 · 보기를 떠나면 구독 끊음.
|
||||
*/
|
||||
export function bindProfilePick(
|
||||
projectId: string | null,
|
||||
target: ProfilePickTarget,
|
||||
target: { profile: ProfilePickTarget; viewer?: ViewerPickTarget },
|
||||
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||
source: ProfilePickSource,
|
||||
): { paint: () => void } {
|
||||
const paint = (): void => {
|
||||
if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source));
|
||||
if (!host.visible()) return;
|
||||
const selection = readSelection(projectId);
|
||||
paintProfilePick(target.profile, profilePickOf(selection, source));
|
||||
if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source));
|
||||
};
|
||||
host.onDispose(subscribeSelection(projectId, paint));
|
||||
return { paint };
|
||||
|
||||
@@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi
|
||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
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 {
|
||||
@@ -387,7 +388,7 @@ export async function mountCrossView(
|
||||
: null;
|
||||
if (!target) {
|
||||
structuresPanel.showAtChainage(null);
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
|
||||
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
|
||||
return;
|
||||
}
|
||||
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
|
||||
@@ -494,23 +495,20 @@ export async function mountCrossView(
|
||||
if (applied) appliedPick = signature;
|
||||
}
|
||||
|
||||
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
|
||||
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
|
||||
const crossPick = bindCrossPick(projectId, host, {
|
||||
sections: () => sectionDetail?.cross_sections ?? [],
|
||||
controls: stationControls,
|
||||
releasePipe: () => structuresPanel.releasePipe(),
|
||||
view: sectionView,
|
||||
reapply: () => {
|
||||
appliedPick = null;
|
||||
restoreStructurePick();
|
||||
},
|
||||
});
|
||||
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
|
||||
function releaseCross(): void {
|
||||
const { revetOffset, ford, box } = stationControls;
|
||||
const lit = (sectionDetail?.cross_sections ?? []).filter(
|
||||
(s) =>
|
||||
revetOffset.highlightFor(s) ||
|
||||
ford.selectedFor(s.chainage_m) ||
|
||||
box.selectedFor(s.chainage_m),
|
||||
);
|
||||
for (const s of lit) {
|
||||
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
|
||||
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
|
||||
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
|
||||
}
|
||||
structuresPanel.releasePipe();
|
||||
sectionView.focusStation(null);
|
||||
sectionView.refreshCards(lit.map((s) => s.chainage_m));
|
||||
crossPick.release();
|
||||
appliedPick = null;
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b06" });
|
||||
}
|
||||
|
||||
@@ -411,8 +411,8 @@ export async function mountProfileView(
|
||||
},
|
||||
isRestoring: () => restoring,
|
||||
});
|
||||
// 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1)
|
||||
const profilePick = bindProfilePick(activeProjectId, profilePanel, host, {
|
||||
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
|
||||
const profilePick = bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host, {
|
||||
irregular: () => bridge.irregularStations(),
|
||||
pipes: () => lastPipes ?? [],
|
||||
sections: () => currentSectionDetail?.cross_sections ?? [],
|
||||
@@ -573,7 +573,6 @@ export async function mountProfileView(
|
||||
);
|
||||
return;
|
||||
}
|
||||
viewer.markers.selectStation(null);
|
||||
profilePick.paint();
|
||||
},
|
||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
|
||||
@@ -494,7 +494,8 @@ export function createRouteMarkers(
|
||||
if (center) selectionPin.position.copy(center);
|
||||
}
|
||||
|
||||
function selectStation(stationId: string | null): void {
|
||||
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */
|
||||
function selectStation(stationId: string | null, notify = true): void {
|
||||
selectedStationId = stationId;
|
||||
stationGroup.children.forEach((object) => {
|
||||
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
|
||||
@@ -503,7 +504,7 @@ export function createRouteMarkers(
|
||||
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
|
||||
});
|
||||
syncSelectionPin();
|
||||
stationSelectionListener?.(selectedStationId);
|
||||
if (notify) stationSelectionListener?.(selectedStationId);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function selectionSource(
|
||||
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface DetailSelect {
|
||||
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */
|
||||
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */
|
||||
redraw: () => void;
|
||||
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
|
||||
setMode: (kind: "area" | "length" | null) => void;
|
||||
@@ -152,6 +152,10 @@ export interface DetailSelect {
|
||||
detach: () => void;
|
||||
}
|
||||
|
||||
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
|
||||
const pieceKey = (piece: SplitPiece | undefined): string =>
|
||||
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
|
||||
|
||||
export function attachDetailSelect(input: {
|
||||
cad: HTMLElement;
|
||||
design: () => CrossDesign | undefined;
|
||||
@@ -209,10 +213,12 @@ export function attachDetailSelect(input: {
|
||||
|
||||
return {
|
||||
redraw() {
|
||||
selected = [];
|
||||
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
|
||||
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
|
||||
const axes = input.axes();
|
||||
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
||||
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
||||
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
|
||||
const toPx = axes ? mToPx(axes) : null;
|
||||
hits = toPx
|
||||
? pieces.map((piece) => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Pick_Sync.ts
|
||||
* 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④).
|
||||
*
|
||||
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을
|
||||
* 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로
|
||||
* 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음.
|
||||
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
|
||||
* 풀기(카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 관 · 범위 테두리 ·
|
||||
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
|
||||
* ========================================================================== */
|
||||
|
||||
import { subscribeSelection } from "../A00_Common/design/design_selection";
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
|
||||
export interface CrossPickDeps {
|
||||
sections: () => ReadonlyArray<CrossSection>;
|
||||
controls: Pick<StationControls, "revetOffset" | "ford" | "box">;
|
||||
releasePipe: () => void;
|
||||
view: {
|
||||
focusStation: (stationId: string | null) => void;
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
selectMark: (structureId: string | null, span?: boolean) => void;
|
||||
};
|
||||
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
|
||||
reapply: () => void;
|
||||
}
|
||||
|
||||
let releasing = false;
|
||||
|
||||
/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림).
|
||||
* 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */
|
||||
export function releaseCrossHighlights(deps: CrossPickDeps): void {
|
||||
if (releasing) return;
|
||||
releasing = true;
|
||||
try {
|
||||
releaseAll(deps);
|
||||
} finally {
|
||||
releasing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseAll(deps: CrossPickDeps): void {
|
||||
const { revetOffset, ford, box } = deps.controls;
|
||||
const lit = deps
|
||||
.sections()
|
||||
.filter(
|
||||
(s) =>
|
||||
revetOffset.highlightFor(s) ||
|
||||
ford.selectedFor(s.chainage_m) ||
|
||||
box.selectedFor(s.chainage_m),
|
||||
);
|
||||
for (const s of lit) {
|
||||
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
|
||||
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
|
||||
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
|
||||
}
|
||||
deps.releasePipe();
|
||||
deps.view.focusStation(null);
|
||||
deps.view.selectMark(null);
|
||||
deps.view.refreshCards(lit.map((s) => s.chainage_m));
|
||||
}
|
||||
|
||||
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
|
||||
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
|
||||
export function bindCrossPick(
|
||||
projectId: string | null,
|
||||
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||
deps: CrossPickDeps,
|
||||
): { release: () => void } {
|
||||
host.onDispose(
|
||||
subscribeSelection(projectId, (selection, from) => {
|
||||
if (!host.visible() || from === "b06") return;
|
||||
if (selection.chainageM === null) releaseCrossHighlights(deps);
|
||||
else deps.reapply();
|
||||
}),
|
||||
);
|
||||
return { release: () => releaseCrossHighlights(deps) };
|
||||
}
|
||||
@@ -67,7 +67,7 @@ let visible = true;
|
||||
const disposers = [];
|
||||
const pick = PP.bindProfilePick(
|
||||
PID,
|
||||
target,
|
||||
{ profile: target },
|
||||
{ visible: () => visible, onDispose: (fn) => disposers.push(fn) },
|
||||
{ irregular: () => irregular, pipes: () => pipes, sections: () => sections },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/* 233-2 3D · 횡단 카드 · [상세] 강조 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림.
|
||||
* ① 3D: `bindProfilePick`(종단 보기 구독) + 진짜 마커(MK3) · 3D 부재 고름(SPK) · three.
|
||||
* ② 횡단: `bindCrossPick` · `releaseCrossHighlights`(B06_Section_UI_Page_Pick_Sync) + 카드 · 형상 흉내.
|
||||
* ③ [상세]: `attachDetailSelect` 다시 그림(창 크기 바뀜) 뒤 고른 선 조각 유지.
|
||||
* 표준출력 JSON 한 벌 · 짝 = test_233_2_view_pick.py.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const NM = path.join(ROOT, "config", "node_modules");
|
||||
const ts = require(path.join(NM, "typescript"));
|
||||
const THREE = require(path.join(NM, "three", "build", "three.cjs"));
|
||||
|
||||
function loadTs(file, stubs = {}) {
|
||||
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (request === "three") return THREE;
|
||||
if (stubs[request]) return stubs[request];
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
/* ── 가짜 DOM(라벨 캔버스 · [상세] 값 칸 · 조각) ── */
|
||||
const node = (tag) => {
|
||||
const self = {
|
||||
tag,
|
||||
hidden: false,
|
||||
textContent: "",
|
||||
attrs: {},
|
||||
classes: new Set(),
|
||||
parentElement: null,
|
||||
setAttribute: (k, v) => {
|
||||
self.attrs[k] = String(v);
|
||||
if (k === "class")
|
||||
String(v)
|
||||
.split(" ")
|
||||
.forEach((c) => self.classes.add(c));
|
||||
},
|
||||
getAttribute: (k) => self.attrs[k] ?? null,
|
||||
addEventListener() {},
|
||||
classList: {
|
||||
toggle: (c, on) => (on ? self.classes.add(c) : self.classes.delete(c)),
|
||||
contains: (c) => self.classes.has(c),
|
||||
},
|
||||
closest: (sel) => (self.classes.has(sel.slice(1)) ? self : null),
|
||||
getContext: () => null,
|
||||
};
|
||||
return self;
|
||||
};
|
||||
globalThis.document = {
|
||||
createElement: node,
|
||||
createElementNS: (_ns, tag) => node(tag),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
globalThis.window = { addEventListener() {}, removeEventListener() {} };
|
||||
|
||||
const store = new Map();
|
||||
const DSel = loadTs("A00_Common/design/design_selection.ts", {
|
||||
"../b_page_state": {
|
||||
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
|
||||
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
|
||||
},
|
||||
});
|
||||
const IRR = {
|
||||
irregularStationId: (id) => `irregular:${id}`,
|
||||
isPipeStation: (s) => s.origin === "pipe",
|
||||
};
|
||||
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
|
||||
"../A00_Common/design/design_selection": DSel,
|
||||
"./B05_Profile_UI_IrregularStations": IRR,
|
||||
});
|
||||
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
|
||||
"./design_selection": DSel,
|
||||
"../../B05_Profile/B05_Profile_UI_Selection": SY,
|
||||
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
|
||||
});
|
||||
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");
|
||||
const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", {
|
||||
"../A00_Common/design/design_selection": DSel,
|
||||
});
|
||||
const CROSS_PAD = Function(
|
||||
`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`,
|
||||
)();
|
||||
// [상세] 조각 모듈은 helper_209_1 과 같은 꼴로 딸린 모듈까지 진짜로 읽음(카드 공용만 흉내)
|
||||
const DEEP_STUBS = {
|
||||
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD },
|
||||
};
|
||||
const resolveTs = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadDeep(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (DEEP_STUBS[request]) return DEEP_STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith("@util/"))
|
||||
return loadDeep(resolveTs(path.join(ROOT, "common_util", request.slice(6))));
|
||||
return loadDeep(resolveTs(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const DTS = loadDeep(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Select.ts"));
|
||||
|
||||
const out = {};
|
||||
const PID = "p233b";
|
||||
const sel = (chainageM, structureId = null, options = {}) =>
|
||||
DSel.selectDesign(
|
||||
PID,
|
||||
chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId },
|
||||
options,
|
||||
);
|
||||
|
||||
/* ── ① 3D ── */
|
||||
const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] };
|
||||
const scene = new THREE.Scene();
|
||||
const markers = MK3.createRouteMarkers(scene, () => bounds);
|
||||
const STATIONS = [
|
||||
...[100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at, center_x: at })),
|
||||
{ station_id: "irregular:sign", chainage_m: 127.86, center_x: 127.86 },
|
||||
].map((s) => ({ ...s, center_y: 100, center_z: 5, frame: { left_xy: [0, 1] }, kind: "regular" }));
|
||||
markers.renderStationLines(STATIONS, 3);
|
||||
let notified = 0;
|
||||
markers.onStationSelectionChange(() => (notified += 1));
|
||||
const corridor = new THREE.Group();
|
||||
const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial());
|
||||
member.userData = { chainageM: 160, kind: "pipe", key: "inlet" };
|
||||
const wall = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial());
|
||||
wall.userData = { chainageM: 120, kind: "wall", key: "own" };
|
||||
corridor.add(member, wall);
|
||||
const structurePick = SPK.bindStructurePick({
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
camera: () => null,
|
||||
group: () => corridor,
|
||||
blocked: () => false,
|
||||
});
|
||||
const profile = { station: null, structure: null };
|
||||
let b05Visible = true;
|
||||
const disposers = [];
|
||||
PP.bindProfilePick(
|
||||
PID,
|
||||
{
|
||||
profile: {
|
||||
setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }),
|
||||
setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }),
|
||||
},
|
||||
viewer: { markers, structurePick },
|
||||
},
|
||||
{ visible: () => b05Visible, onDispose: (fn) => disposers.push(fn) },
|
||||
{
|
||||
irregular: () => [{ id: "sign", chainage_m: 127.86, origin: "user" }],
|
||||
pipes: () => [],
|
||||
sections: () => STATIONS.filter((s) => s.station_id.startsWith("s")),
|
||||
},
|
||||
);
|
||||
const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id);
|
||||
const pin = () => markers.group.children.find((g) => g.renderOrder === 9);
|
||||
const lit = (mesh) => mesh.material.emissive.getHex() !== 0;
|
||||
const view3d = () => ({
|
||||
red: STATIONS.map((s) => s.station_id).filter(
|
||||
(id) => bar(id).material.color.getHexString() === "ef4444",
|
||||
),
|
||||
pin: pin().visible,
|
||||
member: lit(member),
|
||||
wall: lit(wall),
|
||||
profile: { ...profile },
|
||||
});
|
||||
sel(120, null, { from: "b06" }); // 횡단 카드 120 고름 → 종단 보기도 값을 받음
|
||||
out.card120 = view3d();
|
||||
sel(160, null, { partKey: "inlet", from: "b06" });
|
||||
out.part160 = view3d();
|
||||
sel(127.86, "sign", { from: "b05" }); // 목록 표지 — 그 자리 구조물 측점 띠
|
||||
out.sign = view3d();
|
||||
sel(null);
|
||||
out.released3d = view3d();
|
||||
out.notified = notified; // 칠하기만 — 마커 알림으로 사슬이 다시 돌지 않음
|
||||
disposers.forEach((fn) => fn());
|
||||
|
||||
/* ── ② 횡단 카드 · 범위 · 형상 ── */
|
||||
const calls = [];
|
||||
const sections = [100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at }));
|
||||
const revetLit = new Set([120]);
|
||||
let cardSelected = "s120";
|
||||
let crossVisible = true;
|
||||
let releaseCross;
|
||||
const deps = {
|
||||
sections: () => sections,
|
||||
controls: {
|
||||
revetOffset: {
|
||||
highlightFor: (s) => revetLit.has(s.chainage_m),
|
||||
selectedFor: (s) => revetLit.has(s.chainage_m),
|
||||
select: (at) => revetLit.delete(at),
|
||||
},
|
||||
ford: { selectedFor: () => false, select() {} },
|
||||
box: { selectedFor: () => false, select() {} },
|
||||
},
|
||||
releasePipe: () => calls.push("releasePipe"),
|
||||
view: {
|
||||
focusStation: (id) => {
|
||||
calls.push(`focus:${id}`);
|
||||
if (id === null && cardSelected !== null) {
|
||||
cardSelected = null;
|
||||
releaseCross(); // 카드 리스너(null) → 풀기 한 길 다시 부름 — 되돌아온 부름은 건너뜀
|
||||
}
|
||||
},
|
||||
refreshCards: (list) => calls.push(`refresh:${list.join(",")}`),
|
||||
selectMark: (id) => calls.push(`mark:${id}`),
|
||||
},
|
||||
reapply: () => calls.push("reapply"),
|
||||
};
|
||||
const crossDisposers = [];
|
||||
const crossPick = SYNC.bindCrossPick(
|
||||
PID,
|
||||
{ visible: () => crossVisible, onDispose: (fn) => crossDisposers.push(fn) },
|
||||
deps,
|
||||
);
|
||||
releaseCross = () => {
|
||||
crossPick.release();
|
||||
sel(null, null, { from: "b06" });
|
||||
};
|
||||
sel(120, null, { from: "b06" });
|
||||
out.ownPick = calls.splice(0);
|
||||
sel(140, null, { from: "esc" });
|
||||
out.otherPick = calls.splice(0);
|
||||
releaseCross(); // 카드 다시 누름 · 목록 다시 누름 · 보기 오감
|
||||
out.release = calls.splice(0);
|
||||
out.revetAfter = [...revetLit];
|
||||
revetLit.add(100);
|
||||
cardSelected = "s100";
|
||||
sel(null, null, { from: "esc" }); // 다른 곳 풀기(Esc)
|
||||
out.otherRelease = calls.splice(0);
|
||||
crossVisible = false;
|
||||
sel(100, null, { from: "esc" });
|
||||
out.hidden = calls.splice(0);
|
||||
crossDisposers.forEach((fn) => fn());
|
||||
|
||||
/* ── ③ [상세] 선 조각 — 다시 그린 뒤 고름 유지 ── */
|
||||
let axes = {
|
||||
maxOffset: 10,
|
||||
pixelsPerMeter: 10,
|
||||
displayMax: 110,
|
||||
elevationMid: 100,
|
||||
exaggeration: 1,
|
||||
};
|
||||
const groundM = [
|
||||
[
|
||||
[-5, 100],
|
||||
[0, 101],
|
||||
],
|
||||
[
|
||||
[0, 101],
|
||||
[5, 99],
|
||||
],
|
||||
];
|
||||
const toPx = ([o, e]) =>
|
||||
[
|
||||
CROSS_PAD.left + (axes.maxOffset - o) * axes.pixelsPerMeter,
|
||||
CROSS_PAD.top + (axes.displayMax - e) * axes.pixelsPerMeter,
|
||||
].join(",");
|
||||
const layerChildren = [];
|
||||
const layer = {
|
||||
querySelectorAll: () => [...layerChildren],
|
||||
append: (hit) => layerChildren.push(hit),
|
||||
};
|
||||
const drawGround = () => {
|
||||
layerChildren.length = 0;
|
||||
for (const pts of groundM) {
|
||||
const el = node("polyline");
|
||||
el.tagName = "polyline";
|
||||
el.setAttribute("class", "b06-chart__cross-profile");
|
||||
el.setAttribute("points", pts.map(toPx).join(" "));
|
||||
el.hasAttribute = () => false;
|
||||
layerChildren.push(el);
|
||||
}
|
||||
};
|
||||
let click = null;
|
||||
const cad = {
|
||||
addEventListener: (_type, fn) => (click = fn),
|
||||
querySelector: (s) => (s === "g.b06-chart__plot-layer" ? layer : null),
|
||||
};
|
||||
const select = DTS.attachDetailSelect({
|
||||
cad,
|
||||
design: () => undefined,
|
||||
axes: () => axes,
|
||||
picked() {},
|
||||
commit() {},
|
||||
});
|
||||
drawGround();
|
||||
select.redraw();
|
||||
const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS));
|
||||
click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} });
|
||||
out.detailPicked = select.source("length")?.pieces.length ?? 0;
|
||||
axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림
|
||||
drawGround();
|
||||
select.redraw();
|
||||
out.detailAfterRedraw = select.source("length")?.pieces.length ?? 0;
|
||||
groundM.splice(0, 1, [
|
||||
[-6, 100],
|
||||
[0, 102],
|
||||
]); // 그 조각 자리가 바뀌면 고름도 빠짐
|
||||
drawGround();
|
||||
select.redraw();
|
||||
out.detailAfterChange = select.source("length")?.pieces.length ?? 0;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -86,7 +86,7 @@ def test_풂과_구독_끊음(out) -> None:
|
||||
|
||||
def test_종단_보기_배선() -> None:
|
||||
view = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "bindProfilePick(activeProjectId, profilePanel, host," in view
|
||||
assert "bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host," in view
|
||||
# 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음
|
||||
assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view
|
||||
assert view.count("profilePick.paint()") >= 3
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""233-2 3D(띠 · 핀 · 부재) · 횡단 카드(고름 · --span · 형상 · 포장층) · [상세] 선 조각 = 고름 한 칸에서 파생.
|
||||
|
||||
까닭: 3D 는 화면끼리 손으로 부르는 사슬로만 칠해져 길마다 빠짐(구조물 고름 뒤 옛 띠 남음 · 횡단 카드
|
||||
고름은 부재를 안 켬) · 횡단 카드를 풀어도 `--span` · 포장층이 남음(a4) · 풀기 길마다 끄는 범위가 다름 ·
|
||||
[상세] 선 조각은 다시 그릴 때(창 크기 · 카드 다시 세움) 고름을 지워 바로 꺼짐.
|
||||
고침: 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 횡단 보기 구독(`bindCrossPick`)은
|
||||
다른 곳이 바꾼 값을 넘김 적용 길로 다시 세우고 · 풀기는 한 길(`releaseCrossHighlights` — 카드 · 폼 관 ·
|
||||
범위 테두리 · 포장층 · 형상) · [상세] 다시 그림은 같은 자리 조각 고름 유지.
|
||||
진짜 TS 를 `helper_233_2_view_pick.cjs` 가 Node(three 포함)로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_233_2_view_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_3D_횡단_카드_고름은_띠_핀_그_측점_부재(out) -> None:
|
||||
card = out["card120"]
|
||||
assert (card["red"], card["pin"], card["wall"], card["member"]) == (["s120"], True, True, False)
|
||||
assert card["profile"]["station"] == "s120"
|
||||
|
||||
|
||||
def test_3D_부재키_고름은_그_부재만_띠는_없음(out) -> None:
|
||||
part = out["part160"]
|
||||
assert (part["red"], part["pin"], part["member"], part["wall"]) == ([], False, True, False)
|
||||
|
||||
|
||||
def test_3D_구조물_고름은_그_자리_구조물_측점_띠(out) -> None:
|
||||
assert out["sign"]["red"] == ["irregular:sign"]
|
||||
assert out["sign"]["profile"]["structure"] == "sign"
|
||||
|
||||
|
||||
def test_3D_풂과_알림_없음(out) -> None:
|
||||
released = out["released3d"]
|
||||
assert (released["red"], released["pin"], released["member"], released["wall"]) == (
|
||||
[],
|
||||
False,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
assert out["notified"] == 0 # 칠하기만 — 마커 알림이 사슬을 다시 돌리지 않음
|
||||
|
||||
|
||||
def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(out) -> None:
|
||||
assert out["ownPick"] == []
|
||||
assert out["otherPick"] == ["reapply"]
|
||||
assert out["hidden"] == [] # 숨은 보기는 안 받음
|
||||
|
||||
|
||||
def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None:
|
||||
for key in ("release", "otherRelease"):
|
||||
assert out[key] == ["releasePipe", "focus:null", "mark:null", out[key][-1]]
|
||||
assert out["release"][-1] == "refresh:120"
|
||||
assert out["revetAfter"] == []
|
||||
|
||||
|
||||
def test_상세_다시_그려도_같은_자리_조각_고름_유지(out) -> None:
|
||||
assert out["detailPicked"] == 1
|
||||
assert out["detailAfterRedraw"] == 1
|
||||
assert out["detailAfterChange"] == 0
|
||||
|
||||
|
||||
def test_배선() -> None:
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "bindCrossPick(projectId, host," in cross
|
||||
assert "crossPick.release()" in cross
|
||||
# 카드 다시 누름 = 풀기 한 길
|
||||
assert "releaseCross(); // 카드 다시 누름" in cross
|
||||
assert len(cross.splitlines()) <= 685
|
||||
profile = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "{ profile: profilePanel, viewer }" in profile
|
||||
assert len(profile.splitlines()) <= 700
|
||||
Reference in New Issue
Block a user