Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pick_profile.ts
|
||||
* 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②).
|
||||
*
|
||||
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
|
||||
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
|
||||
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
|
||||
* 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
nearestWithin,
|
||||
readSelection,
|
||||
subscribeSelection,
|
||||
type DesignSelection,
|
||||
} from "./design_selection";
|
||||
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */
|
||||
export type ProfilePick = { stationId: string | null } | { structureId: string };
|
||||
|
||||
export interface ProfilePickSource {
|
||||
/** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */
|
||||
irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>;
|
||||
/** 관 지점 정본(배수유역도). */
|
||||
pipes: () => ReadonlyArray<{ chainage_m: number }>;
|
||||
/** 일반 측점 카드. */
|
||||
sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>;
|
||||
}
|
||||
|
||||
export interface ProfilePickTarget {
|
||||
setSelectedStation: (stationId: string | null) => void;
|
||||
setSelectedStructure: (structureId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
|
||||
const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
|
||||
|
||||
/**
|
||||
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
|
||||
* ② 고른 측점 그대로(일반 카드 → 구조물 측점) ③ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||
*/
|
||||
export function profilePickOf(
|
||||
selection: DesignSelection | null,
|
||||
source: ProfilePickSource,
|
||||
): ProfilePick {
|
||||
if (!selection || selection.chainageM === null) return { stationId: null };
|
||||
if (selection.structureId) return { structureId: selection.structureId };
|
||||
const at = selection.chainageM;
|
||||
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
|
||||
if (card) return { stationId: card.station_id };
|
||||
const irregular = source.irregular();
|
||||
const own =
|
||||
irregular.find((entry) => sameAt(entry.chainage_m, at)) ??
|
||||
nearestWithin(
|
||||
irregular.filter((entry) => entry.origin === "pipe"),
|
||||
at,
|
||||
(entry) => entry.chainage_m,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
|
||||
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
|
||||
else target.setSelectedStation(pick.stationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음.
|
||||
* 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음.
|
||||
*/
|
||||
export function bindProfilePick(
|
||||
projectId: string | null,
|
||||
target: ProfilePickTarget,
|
||||
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||
source: ProfilePickSource,
|
||||
): { paint: () => void } {
|
||||
const paint = (): void => {
|
||||
if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source));
|
||||
};
|
||||
host.onDispose(subscribeSelection(projectId, paint));
|
||||
return { paint };
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
selectDesign,
|
||||
} from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import { bindProfilePick } from "./design_pick_profile";
|
||||
import {
|
||||
bindDesignView,
|
||||
designReady,
|
||||
@@ -247,6 +248,7 @@ export async function mountProfileView(
|
||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
||||
function restorePick(): void {
|
||||
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
|
||||
if (panel.structures.hasSelection()) return;
|
||||
const pick = viewer.structurePick;
|
||||
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
|
||||
@@ -346,7 +348,7 @@ export async function mountProfileView(
|
||||
if (selectionSyncing) return;
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
||||
profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1)
|
||||
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
@@ -409,6 +411,12 @@ export async function mountProfileView(
|
||||
},
|
||||
isRestoring: () => restoring,
|
||||
});
|
||||
// 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1)
|
||||
const profilePick = bindProfilePick(activeProjectId, profilePanel, host, {
|
||||
irregular: () => bridge.irregularStations(),
|
||||
pipes: () => lastPipes ?? [],
|
||||
sections: () => currentSectionDetail?.cross_sections ?? [],
|
||||
});
|
||||
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
||||
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
||||
|
||||
@@ -566,7 +574,7 @@ export async function mountProfileView(
|
||||
return;
|
||||
}
|
||||
viewer.markers.selectStation(null);
|
||||
profilePanel.setSelectedStation(null);
|
||||
profilePick.paint();
|
||||
},
|
||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
onHide: () => {
|
||||
|
||||
@@ -397,6 +397,8 @@ export function createRouteMarkers(
|
||||
sprite.position.copy(position);
|
||||
sprite.position.y += 3.4;
|
||||
sprite.renderOrder = 10;
|
||||
// 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1)
|
||||
sprite.raycast = () => undefined;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
@@ -477,6 +479,8 @@ export function createRouteMarkers(
|
||||
head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다.
|
||||
head.position.y = PIN_HEIGHT + 1.7;
|
||||
group.add(beam, head);
|
||||
// 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1)
|
||||
beam.raycast = head.raycast = () => undefined;
|
||||
group.renderOrder = 9;
|
||||
group.visible = false;
|
||||
interactionGroup.add(group);
|
||||
@@ -520,7 +524,9 @@ export function createRouteMarkers(
|
||||
deleteSelected,
|
||||
selectObject(object: THREE.Object3D | undefined) {
|
||||
if (typeof object?.userData.stationId === "string") {
|
||||
selectStation(object.userData.stationId);
|
||||
// 같은 띠 다시 누름 = 풂(232-1)
|
||||
const id = object.userData.stationId as string;
|
||||
selectStation(id === selectedStationId ? null : id);
|
||||
selectionListener?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export function wireStructurePick(
|
||||
coverable?: (entry: StructureInstance) => boolean,
|
||||
): void {
|
||||
controls.onPick = (pick: StructurePick | null): void => {
|
||||
// null = 같은 부재 다시 누름(풂) — 빈 곳 누름은 여기까지 안 옴(232-1)
|
||||
if (!pick) {
|
||||
selectDesign(projectId, NO_SELECTION, { from: "b05" });
|
||||
selectStation(null);
|
||||
|
||||
@@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
camera: cameraRig.camera,
|
||||
group: () => corridor.group(),
|
||||
blocked: () => markerInput.blocked(),
|
||||
pressedMarker: () => markerInput.pressedMarker(),
|
||||
});
|
||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
||||
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface MarkerPointerControls {
|
||||
beginMoveSelected: () => void;
|
||||
/** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */
|
||||
blocked: () => boolean;
|
||||
/** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */
|
||||
pressedMarker: () => boolean;
|
||||
/** 등록한 캔버스 이벤트를 모두 떼어낸다. */
|
||||
dispose: () => void;
|
||||
}
|
||||
@@ -43,6 +45,7 @@ export function bindMarkerPointerControls(options: {
|
||||
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
||||
let draggingMarker = false;
|
||||
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
||||
let downOnMarker = false;
|
||||
|
||||
/** 화면 좌표 → 정규화 장치 좌표(-1~1). */
|
||||
function pointerOf(clientX: number, clientY: number): THREE.Vector2 {
|
||||
@@ -86,7 +89,14 @@ export function bindMarkerPointerControls(options: {
|
||||
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera());
|
||||
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
||||
// 숨은 것(꺼 둔 측점선 · 라벨)은 누름을 가로채지 않음 — three 레이캐스트는 visible 을 안 봄(232-1)
|
||||
return raycaster.intersectObject(markers.group, true).find((entry) => shown(entry.object))
|
||||
?.object;
|
||||
}
|
||||
|
||||
function shown(object: THREE.Object3D | null): boolean {
|
||||
for (let node = object; node; node = node.parent) if (!node.visible) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishMarkerInteraction(selectCandidate: boolean): void {
|
||||
@@ -105,6 +115,7 @@ export function bindMarkerPointerControls(options: {
|
||||
function handlePointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return;
|
||||
const hit = markerHit(event);
|
||||
downOnMarker = !!hit;
|
||||
const pointId = markers.pointIdForObject(hit);
|
||||
if (pointId) {
|
||||
dragCandidate = {
|
||||
@@ -184,6 +195,7 @@ export function bindMarkerPointerControls(options: {
|
||||
movingSelected = true;
|
||||
},
|
||||
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
||||
pressedMarker: () => downOnMarker,
|
||||
dispose: () => {
|
||||
canvas.removeEventListener("dragover", handleDragOver);
|
||||
canvas.removeEventListener("drop", handleDrop);
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface StructurePick {
|
||||
}
|
||||
|
||||
export interface StructurePickControls {
|
||||
/** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */
|
||||
/** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */
|
||||
onPick?: (pick: StructurePick | null) => void;
|
||||
/** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */
|
||||
selectAtChainage: (chainageM: number | null) => void;
|
||||
@@ -47,8 +47,10 @@ export function bindStructurePick(options: {
|
||||
group: () => THREE.Object3D | null;
|
||||
/** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */
|
||||
blocked: () => boolean;
|
||||
/** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */
|
||||
pressedMarker?: () => boolean;
|
||||
}): StructurePickControls {
|
||||
const { canvas, camera, group, blocked } = options;
|
||||
const { canvas, camera, group, blocked, pressedMarker } = options;
|
||||
|
||||
let selected: StructurePick | null = null;
|
||||
let down: { x: number; y: number; pointerId: number } | null = null;
|
||||
@@ -94,7 +96,7 @@ export function bindStructurePick(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */
|
||||
/** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=빈 곳)이다. */
|
||||
function pickAt(clientX: number, clientY: number): StructurePick | null {
|
||||
const root = group();
|
||||
if (!root || !root.visible) return null;
|
||||
@@ -120,7 +122,7 @@ export function bindStructurePick(options: {
|
||||
|
||||
function handleDown(event: PointerEvent): void {
|
||||
down =
|
||||
event.button === 0 && !blockedBeforeDown && !blocked()
|
||||
event.button === 0 && !blockedBeforeDown && !blocked() && !pressedMarker?.()
|
||||
? { x: event.clientX, y: event.clientY, pointerId: event.pointerId }
|
||||
: null;
|
||||
}
|
||||
@@ -131,7 +133,14 @@ export function bindStructurePick(options: {
|
||||
if (!start || event.pointerId !== start.pointerId) return;
|
||||
if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return;
|
||||
if (blocked()) return;
|
||||
selected = pickAt(event.clientX, event.clientY);
|
||||
const pick = pickAt(event.clientX, event.clientY);
|
||||
// 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂
|
||||
if (!pick) return;
|
||||
const same =
|
||||
selected !== null &&
|
||||
Math.abs(selected.chainageM - pick.chainageM) < 0.01 &&
|
||||
selected.key === pick.key;
|
||||
selected = same ? null : pick;
|
||||
applyHighlight();
|
||||
controls.onPick?.(selected);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/* 232-1 3D 누름 길 통째 — 진짜 마커(MK3) · 마커 입력(MI) · 3D 부재 고름(SPK) · 선택 동기화(SY) ·
|
||||
* 넘김(PKS) · 고름 한 칸(DSel) 을 three + 가짜 캔버스로 한 누름(창 캡처 → 캔버스 듣개 차례)씩 돌림.
|
||||
* 배선은 `B05_Profile_UI_Viewer.ts` · `design_view_profile.ts` 와 같은 꼴. 표준출력 JSON 한 벌.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 창 · 캔버스 흉내(듣개 차례 = 창 캡처 → 캔버스 등록 차례) ── */
|
||||
const windowListeners = [];
|
||||
globalThis.window = {
|
||||
addEventListener: (type, fn) => windowListeners.push({ type, fn }),
|
||||
removeEventListener: (type, fn) => {
|
||||
const i = windowListeners.findIndex((entry) => entry.type === type && entry.fn === fn);
|
||||
if (i >= 0) windowListeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
globalThis.document = { createElement: () => ({ getContext: () => null, width: 0, height: 0 }) };
|
||||
const canvasListeners = [];
|
||||
const canvas = {
|
||||
addEventListener: (type, fn) => canvasListeners.push({ type, fn }),
|
||||
removeEventListener() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 400 }),
|
||||
setPointerCapture() {},
|
||||
hasPointerCapture: () => false,
|
||||
releasePointerCapture() {},
|
||||
};
|
||||
function fire(type, clientX, clientY) {
|
||||
const event = {
|
||||
type,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX,
|
||||
clientY,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
};
|
||||
for (const entry of windowListeners.filter((e) => e.type === type)) entry.fn(event);
|
||||
for (const entry of canvasListeners.filter((e) => e.type === type)) entry.fn(event);
|
||||
}
|
||||
|
||||
/* ── 진짜 모듈 ── */
|
||||
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 MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", {
|
||||
"@util/common_util_svg": {
|
||||
inferStationInterval: () => 20,
|
||||
stationLabel: (chainage) => `0+${chainage}`,
|
||||
},
|
||||
});
|
||||
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");
|
||||
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
|
||||
"../A00_Common/design/design_selection": DSel,
|
||||
"./B05_Profile_UI_IrregularStations": {
|
||||
irregularStationId: (id) => `irregular:${id}`,
|
||||
isPipeStation: (s) => s.origin === "pipe",
|
||||
},
|
||||
});
|
||||
const PKS = loadTs("B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts", {
|
||||
"../A00_Common/design/design_selection": DSel,
|
||||
});
|
||||
|
||||
/* ── 장면: 지형 범위 · 위에서 내려다보는 직교 카메라 ── */
|
||||
const PID = "p232";
|
||||
const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] };
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 500);
|
||||
camera.position.set(0, 200, 0);
|
||||
camera.up.set(0, 0, -1);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateProjectionMatrix();
|
||||
camera.updateMatrixWorld(true);
|
||||
const clientOf = (x, y, z = 5) => {
|
||||
const p = MK3.modelToScene({ x, y, z }, bounds).project(camera);
|
||||
return [((p.x + 1) / 2) * 400, ((1 - p.y) / 2) * 400];
|
||||
};
|
||||
|
||||
const markers = MK3.createRouteMarkers(scene, () => bounds);
|
||||
const STATIONS = [100, 120].map((at) => ({
|
||||
station_id: `s${at}`,
|
||||
chainage_m: at,
|
||||
center_x: at,
|
||||
center_y: 100,
|
||||
center_z: 5,
|
||||
frame: { left_xy: [0, 1] },
|
||||
kind: "regular",
|
||||
}));
|
||||
markers.renderStationLines(STATIONS, 3);
|
||||
|
||||
// 코리도 — 리본(신원표 없음 · 「빈 곳」) + 부재 하나(측점 160 · inlet)
|
||||
const corridor = new THREE.Group();
|
||||
const ribbon = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(400, 0.1, 400),
|
||||
new THREE.MeshLambertMaterial(),
|
||||
);
|
||||
ribbon.position.y = -0.5;
|
||||
const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial());
|
||||
member.position.copy(MK3.modelToScene({ x: 160, y: 160, z: 6 }, bounds));
|
||||
member.userData = { chainageM: 160, kind: "pipe", key: "inlet" };
|
||||
corridor.add(ribbon, member);
|
||||
scene.add(corridor);
|
||||
scene.updateMatrixWorld(true);
|
||||
|
||||
const markerInput = MI.bindMarkerPointerControls({
|
||||
canvas,
|
||||
camera: () => camera,
|
||||
controls: { enabled: true },
|
||||
markers,
|
||||
getTerrain: () => null,
|
||||
getBounds: () => bounds,
|
||||
});
|
||||
const structurePick = SPK.bindStructurePick({
|
||||
canvas,
|
||||
camera: () => camera,
|
||||
group: () => corridor,
|
||||
blocked: () => markerInput.blocked(),
|
||||
pressedMarker: () => markerInput.pressedMarker(),
|
||||
});
|
||||
|
||||
/* ── 종단 보기 배선 흉내(design_view_profile) ── */
|
||||
let graph = null;
|
||||
let form = null;
|
||||
let syncing = false;
|
||||
const sync = SY.createSelectionSync({
|
||||
stations: () => [],
|
||||
isSyncing: () => syncing,
|
||||
setSyncing: (v) => (syncing = v),
|
||||
selectMarker: (id) => markers.selectStation(id),
|
||||
selectGraph: (id) => (graph = id),
|
||||
selectSidebar: (at) => (form = at),
|
||||
openAtStation: (at) => (form = at),
|
||||
selectBasin() {},
|
||||
markStation() {},
|
||||
selectStructure3D: (at) => structurePick.selectAtChainage(at),
|
||||
regularChainage: (id) => STATIONS.find((s) => s.station_id === id)?.chainage_m,
|
||||
remember: (at) =>
|
||||
DSel.selectDesign(PID, at === null ? DSel.NO_SELECTION : { chainageM: at, structureId: null }),
|
||||
});
|
||||
markers.onStationSelectionChange((id) => {
|
||||
graph = id;
|
||||
sync.syncIrregularSelection(id);
|
||||
});
|
||||
PKS.wireStructurePick(
|
||||
structurePick,
|
||||
PID,
|
||||
{
|
||||
resolveAt: () => ({ structureId: null }),
|
||||
hasSelection: () => form !== null,
|
||||
selectAt: (sel) => (form = sel.chainageM),
|
||||
},
|
||||
sync.selectStationOfPipe,
|
||||
);
|
||||
|
||||
/* ── 읽기 ── */
|
||||
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 state = () => ({
|
||||
s100: "#" + bar("s100").material.color.getHexString(),
|
||||
s120: "#" + bar("s120").material.color.getHexString(),
|
||||
pin: pin().visible,
|
||||
session: DSel.readSelection(PID),
|
||||
graph,
|
||||
member: member.material.emissive.getHex() !== 0,
|
||||
});
|
||||
const press = (xy) => {
|
||||
fire("pointerdown", ...xy);
|
||||
const down = state();
|
||||
fire("pointerup", ...xy);
|
||||
return { down, up: state() };
|
||||
};
|
||||
|
||||
const out = {};
|
||||
const edge120 = clientOf(120, 102); // 띠 끝쪽(가운데 핀 · 라벨 자리 밖)
|
||||
const center120 = clientOf(120, 100);
|
||||
const empty = clientOf(40, 40); // 리본만
|
||||
out.bar = press(edge120);
|
||||
out.empty = press(empty);
|
||||
out.again = press(edge120); // 같은 띠 다시 = 풂
|
||||
out.center = press(center120); // 숨은 핀 · 라벨이 있던 가운데
|
||||
out.memberPick = press(clientOf(160, 160));
|
||||
out.memberEmpty = press(empty);
|
||||
out.memberAgain = press(clientOf(160, 160)); // 같은 부재 다시 = 풂
|
||||
press(edge120);
|
||||
markers.setStationLinesVisible(false);
|
||||
out.hiddenLines = press(clientOf(100, 102)); // 꺼 둔 측점선은 누름을 안 가로챔
|
||||
out.hiddenPressedMarker = markerInput.pressedMarker();
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,117 @@
|
||||
/* 233-1 종단도 강조 = 고름 한 칸에서 파생 — 진짜 `design_pick_profile` · `design_selection` ·
|
||||
* `graphTargetAt`(B05_Profile_UI_Selection) 를 Node 로 돌림. 종단도는 고른 측점 · 알약 두 칸만 흉내.
|
||||
* 표준출력 JSON 한 벌 · 짝 = test_233_1_profile_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"));
|
||||
|
||||
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 (stubs[request]) return stubs[request];
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
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 PID = "p233";
|
||||
const sections = [80, 100, 120, 140, 300].map((at) => ({ station_id: `s${at}`, chainage_m: at }));
|
||||
let pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.4 }];
|
||||
const irregular = [
|
||||
{ id: "p80", chainage_m: 80.17, origin: "pipe" },
|
||||
{ id: "sign", chainage_m: 127.86, origin: "user" },
|
||||
];
|
||||
|
||||
/* 종단도 흉내 — 고른 측점 · 알약 */
|
||||
const profile = { station: null, structure: null, paints: 0 };
|
||||
const target = {
|
||||
setSelectedStation: (id) => {
|
||||
profile.station = id;
|
||||
profile.structure = null;
|
||||
profile.paints += 1;
|
||||
},
|
||||
setSelectedStructure: (id) => {
|
||||
profile.structure = id;
|
||||
profile.station = null;
|
||||
profile.paints += 1;
|
||||
},
|
||||
};
|
||||
let visible = true;
|
||||
const disposers = [];
|
||||
const pick = PP.bindProfilePick(
|
||||
PID,
|
||||
target,
|
||||
{ visible: () => visible, onDispose: (fn) => disposers.push(fn) },
|
||||
{ irregular: () => irregular, pipes: () => pipes, sections: () => sections },
|
||||
);
|
||||
|
||||
const snap = () => ({ station: profile.station, structure: profile.structure });
|
||||
const sel = (chainageM, structureId = null) =>
|
||||
DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId });
|
||||
|
||||
const out = {};
|
||||
sel(120);
|
||||
out.card = snap();
|
||||
sel(127.86);
|
||||
out.irregularSign = snap();
|
||||
sel(80);
|
||||
out.card80 = snap(); // 관 80.17 옆 카드 80 = 카드 선(관 아님)
|
||||
sel(80.17);
|
||||
out.pipe80 = snap();
|
||||
sel(300.2);
|
||||
out.pipePill = snap();
|
||||
pipes = [{ chainage_m: 80.17 }, { chainage_m: 300.45 }]; // 관이 옮겨짐
|
||||
pick.paint();
|
||||
out.pipeMoved = snap();
|
||||
sel(100, "wall");
|
||||
out.structure = snap();
|
||||
// 다른 길이 강조를 끔(저장 뒤 관 폼 닫힘 · 목록 다시 세움) → 값은 그대로 → 다시 얹음
|
||||
target.setSelectedStructure(null);
|
||||
out.clearedByOther = snap();
|
||||
pick.paint();
|
||||
out.repainted = snap();
|
||||
// 숨은 보기는 안 그림 · 보이면 다시 얹음
|
||||
visible = false;
|
||||
sel(140);
|
||||
out.hidden = snap();
|
||||
visible = true;
|
||||
pick.paint();
|
||||
out.shownAgain = snap();
|
||||
// 풂(값 비움) = 선 · 알약 모두 끔
|
||||
sel(null);
|
||||
out.released = snap();
|
||||
// 보기를 떠나면 구독 끊음
|
||||
disposers.forEach((fn) => fn());
|
||||
const before = profile.paints;
|
||||
sel(120);
|
||||
out.paintsAfterDispose = profile.paints - before;
|
||||
out.disposers = disposers.length;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""232-1 3D 띠 강조 바로 꺼짐(요구 104 · 조사 AF a1 · a2) — 3D 누름 길 통째.
|
||||
|
||||
까닭: 띠 누름을 마커 듣개가 켜고(누를 때) 3D 부재 고름이 뗄 때 「빈 곳 누름」으로 읽어 고름 전체를 지움.
|
||||
고침: ① 마커 · 띠에 맞은 누름은 부재 고름이 건너뜀 ② 숨은 핀 · 꺼 둔 측점선 · 라벨은 누름을 안 가로챔
|
||||
③ 3D 빈 곳 누름 = 고름 안 풂(08-05) · 같은 띠 · 같은 부재 다시 누름 = 풂.
|
||||
진짜 TS 를 `helper_232_1_3d_press.cjs` 가 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="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
RED = "#ef4444"
|
||||
YELLOW = "#facc15"
|
||||
AT_120 = {"chainageM": 120, "structureId": None}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_232_1_3d_press.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_띠_누름은_뗀_뒤에도_빨강_핀_기록_유지(out) -> None:
|
||||
for phase in ("down", "up"):
|
||||
state = out["bar"][phase]
|
||||
assert state["s120"] == RED
|
||||
assert state["pin"] is True
|
||||
assert state["session"] == AT_120
|
||||
assert state["graph"] == "s120"
|
||||
|
||||
|
||||
def test_빈_곳_누름은_고름_안_풂(out) -> None:
|
||||
assert out["empty"]["up"]["s120"] == RED
|
||||
assert out["empty"]["up"]["session"] == AT_120
|
||||
assert out["memberEmpty"]["up"]["member"] is True
|
||||
assert out["memberEmpty"]["up"]["session"]["chainageM"] == 160
|
||||
|
||||
|
||||
def test_같은_띠_다시_누름은_풂(out) -> None:
|
||||
up = out["again"]["up"]
|
||||
assert (up["s120"], up["pin"], up["session"], up["graph"]) == (YELLOW, False, None, None)
|
||||
|
||||
|
||||
def test_숨은_핀_라벨이_있던_띠_가운데도_켜짐(out) -> None:
|
||||
assert out["center"]["up"]["s120"] == RED
|
||||
assert out["center"]["up"]["session"] == AT_120
|
||||
|
||||
|
||||
def test_부재_고름과_같은_부재_다시_누름(out) -> None:
|
||||
up = out["memberPick"]["up"]
|
||||
assert up["member"] is True
|
||||
assert up["session"] == {"chainageM": 160, "structureId": None, "partKey": "inlet"}
|
||||
again = out["memberAgain"]["up"]
|
||||
assert again["member"] is False
|
||||
assert again["session"] is None
|
||||
|
||||
|
||||
def test_꺼_둔_측점선은_누름을_안_가로챔(out) -> None:
|
||||
up = out["hiddenLines"]["up"]
|
||||
assert up["s100"] == YELLOW
|
||||
assert up["session"] == AT_120 # 앞 고름 그대로 · 빈 곳 누름이라 안 풂
|
||||
assert out["hiddenPressedMarker"] is False
|
||||
|
||||
|
||||
def test_배선() -> None:
|
||||
with open(
|
||||
os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Viewer.ts"), encoding="utf-8"
|
||||
) as handle:
|
||||
viewer = handle.read()
|
||||
assert "pressedMarker: () => markerInput.pressedMarker()" in viewer
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""233-1 고름 한 칸 = 유일 원천 + 바뀜 알림 · 종단도(측점선 · 알약 · 띠)는 그 값으로 그림(조사 AF (나) ① ②).
|
||||
|
||||
까닭: 알림 장치(`subscribeSelection`)는 있었으나 듣는 곳 0 — 화면끼리 손으로 부르는 사슬이라 한 길이
|
||||
강조를 끄면(저장 뒤 관 폼 닫힘 → 종단 선 · 알약 끔) 값은 그대로인데 종단만 꺼짐 · 관 알약 id(`pipe-`+측점)가
|
||||
옛 관 자리에 묶여 관이 옮겨지면 끊김.
|
||||
고침: `design_pick_profile` — 종단 보기가 고름 한 칸을 구독해 값 → 종단 강조(`profilePickOf`) ·
|
||||
목록이 바뀐 뒤 · 폼이 닫힌 뒤에도 같은 값으로 다시 얹음(`paint`) · 숨은 보기는 안 그림 · 떠나면 구독 끊음.
|
||||
진짜 TS 를 `helper_233_1_profile_pick.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
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_1_profile_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def line(station):
|
||||
return {"station": station, "structure": None}
|
||||
|
||||
|
||||
def pill(structure):
|
||||
return {"station": None, "structure": structure}
|
||||
|
||||
|
||||
def test_측점_값은_그_측점선(out) -> None:
|
||||
assert out["card"] == line("s120")
|
||||
assert out["irregularSign"] == line("irregular:sign") # 구조물 측점(관 아님)도 그대로
|
||||
assert out["card80"] == line("s80") # 관 80.17 옆 카드 80 은 카드 선
|
||||
assert out["pipe80"] == line("irregular:p80")
|
||||
|
||||
|
||||
def test_측점선_없는_관은_지금_관_목록으로_알약(out) -> None:
|
||||
assert out["pipePill"] == pill("pipe-300.40")
|
||||
assert out["pipeMoved"] == pill("pipe-300.45") # 관이 옮겨져도 끊기지 않음
|
||||
|
||||
|
||||
def test_구조물_값은_그_알약(out) -> None:
|
||||
assert out["structure"] == pill("wall")
|
||||
|
||||
|
||||
def test_다른_길이_꺼도_값대로_다시_얹음(out) -> None:
|
||||
assert out["clearedByOther"] == pill(None)
|
||||
assert out["repainted"] == pill("wall")
|
||||
|
||||
|
||||
def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None:
|
||||
assert out["hidden"] == pill("wall")
|
||||
assert out["shownAgain"] == line("s140")
|
||||
|
||||
|
||||
def test_풂과_구독_끊음(out) -> None:
|
||||
assert out["released"] == line(None)
|
||||
assert out["disposers"] == 1
|
||||
assert out["paintsAfterDispose"] == 0
|
||||
|
||||
|
||||
def test_종단_보기_배선() -> None:
|
||||
view = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "bindProfilePick(activeProjectId, profilePanel, host," in view
|
||||
# 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음
|
||||
assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view
|
||||
assert view.count("profilePick.paint()") >= 3
|
||||
assert len(view.splitlines()) <= 700
|
||||
Reference in New Issue
Block a user