generic selection / hover

This commit is contained in:
wass08
2026-01-21 11:40:42 +09:00
parent b1d6f61e4f
commit 09515107cb
2 changed files with 151 additions and 203 deletions
@@ -1,188 +1,126 @@
import { import { emitter, sceneRegistry } from "@pascal-app/core";
type BuildingNode,
emitter,
type GridEvent,
type ItemEvent,
type ItemNode,
sceneRegistry,
useScene,
type WallEvent,
type WallNode,
} from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
const PHASE_CONFIG = {
site: ["building"],
structure: ["wall", "slab", "door", "window", "column", "stair"],
furnish: ["item"], // furniture, decor, etc.
} as const;
type PhaseType = keyof typeof PHASE_CONFIG;
export const SelectionManager = () => { export const SelectionManager = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null); const phase = useEditor((s) => s.phase);
const itemSelectedAt = useRef<number>(0); const mode = useEditor((s) => s.mode);
const phase = useEditor((state) => state.phase); const { setSelection, selection } = useViewer();
const mode = useEditor((state) => state.mode);
useEffect(() => { useEffect(() => {
if (mode !== "select") { if (mode !== "select") return;
return;
const allowedTypes = PHASE_CONFIG[phase] || [];
const onEnter = (event: any) => {
// Only hover if the node type is allowed in this phase
if (allowedTypes.includes(event.node.type)) {
useViewer.getState().setHoveredId(event.node.id);
} }
if (phase === "site") {
const onBuildingEnter = (event: { node: BuildingNode }) => {
const itemMesh = sceneRegistry.nodes.get(event.node.id);
useViewer.getState().outliner.hoveredObjects.length = 0;
useViewer.getState().outliner.hoveredObjects.push(itemMesh);
};
const onBuildingLeave = (event: { node: BuildingNode }) => {
useViewer.getState().outliner.hoveredObjects.length = 0;
};
emitter.on("building:enter", onBuildingEnter);
emitter.on("building:leave", onBuildingLeave);
return () => {
emitter.off("building:enter", onBuildingEnter);
emitter.off("building:leave", onBuildingLeave);
};
}
if (phase === "furnish") {
const onItemEnter = (event: ItemEvent) => {
const itemMesh = sceneRegistry.nodes.get(event.node.id);
useViewer.getState().outliner.hoveredObjects.length = 0;
useViewer.getState().outliner.hoveredObjects.push(itemMesh);
}; };
const onItemLeave = (event: ItemEvent) => { const onLeave = () => useViewer.getState().setHoveredId(null);
useViewer.getState().outliner.hoveredObjects.length = 0;
};
const onItemClick = (event: ItemEvent) => { const onClick = (event: any) => {
event.stopPropagation(); event.stopPropagation();
useViewer const { id, type } = event.node;
.getState()
.outliner.selectedObjects.push(
sceneRegistry.nodes.get(event.node.id),
);
};
emitter.on("item:enter", onItemEnter); if (!allowedTypes.includes(type)) return;
emitter.on("item:leave", onItemLeave);
emitter.on("item:click", onItemClick);
return () => {
emitter.off("item:enter", onItemEnter);
emitter.off("item:leave", onItemLeave);
emitter.off("item:click", onItemClick);
};
}
if (phase === "structure") { const isShift = event.nativeEvent?.shiftKey;
const onGridClick = (event: GridEvent) => {
useViewer.getState().outliner.selectedObjects.length = 0;
};
const onWallEnter = (event: WallEvent) => {
event.stopPropagation();
const itemMesh = sceneRegistry.nodes.get(event.node.id);
useViewer.getState().outliner.hoveredObjects.length = 0;
useViewer.getState().outliner.hoveredObjects.push(itemMesh);
};
const onWallLeave = (event: WallEvent) => {
useViewer.getState().outliner.hoveredObjects.length = 0;
};
const onWallClick = (event: WallEvent) => { if (isShift) {
event.stopPropagation(); const isSelected = selection.selectedIds.includes(id);
if (!event.nativeEvent.shiftKey) { const nextIds = isSelected
useViewer.getState().outliner.selectedObjects.length = 0; ? selection.selectedIds.filter((i) => i !== id)
useViewer : [...selection.selectedIds, id];
.getState() setSelection({ selectedIds: nextIds });
.outliner.selectedObjects.push(
sceneRegistry.nodes.get(event.node.id),
);
} else { } else {
// check if already selected setSelection({ selectedIds: [id] });
const selectedObjects = useViewer.getState().outliner.selectedObjects;
const wallMesh = sceneRegistry.nodes.get(event.node.id);
const index = selectedObjects.indexOf(wallMesh);
if (index > -1) {
// already selected, deselect
selectedObjects.splice(index, 1);
} else {
// not selected, add to selection
selectedObjects.push(wallMesh);
}
} }
}; };
// BINDING: Loop through all allowed types for this phase
allowedTypes.forEach((type) => {
emitter.on(`${type}:enter`, onEnter);
emitter.on(`${type}:leave`, onLeave);
emitter.on(`${type}:click`, onClick);
});
const onGridClick = () => setSelection({ selectedIds: [] });
emitter.on("grid:click", onGridClick); emitter.on("grid:click", onGridClick);
emitter.on("wall:enter", onWallEnter);
emitter.on("wall:leave", onWallLeave);
emitter.on("wall:click", onWallClick);
return () => { return () => {
emitter.off("wall:enter", onWallEnter); allowedTypes.forEach((type) => {
emitter.off("wall:leave", onWallLeave); emitter.off(`${type}:enter`, onEnter);
emitter.off("wall:click", onWallClick); emitter.off(`${type}:leave`, onLeave);
emitter.off(`${type}:click`, onClick);
});
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
}; };
}, [phase, mode, selection.selectedIds, setSelection]);
return <EditorOutlinerSync />;
};
const EditorOutlinerSync = () => {
const phase = useEditor((s) => s.phase);
const selection = useViewer((s) => s.selection);
const hoveredId = useViewer((s) => s.hoveredId);
const outliner = useViewer((s) => s.outliner);
useEffect(() => {
let idsToHighlight: string[] = [];
// 1. Determine what should be highlighted based on Phase
switch (phase) {
case "site":
// Only highlight the building if one is selected
if (selection.buildingId) idsToHighlight = [selection.buildingId];
break;
case "structure":
// Highlight selected items (walls/slabs)
// We IGNORE buildingId even if it's set in the store
idsToHighlight = selection.selectedIds;
break;
case "furnish":
// Highlight selected furniture/items
idsToHighlight = selection.selectedIds;
break;
default:
// Pure Viewer mode: Highlight based on the "deepest" selection
if (selection.selectedIds.length > 0)
idsToHighlight = selection.selectedIds;
else if (selection.levelId) idsToHighlight = [selection.levelId];
else if (selection.buildingId) idsToHighlight = [selection.buildingId];
} }
// emitter.on("item:click", (event) => { // 2. Sync with the imperative outliner arrays (mutate in place to keep references)
// event.stopPropagation(); outliner.selectedObjects.length = 0;
// if (Date.now() - itemSelectedAt.current < 50) { for (const id of idsToHighlight) {
// return; const obj = sceneRegistry.nodes.get(id);
// } if (obj) outliner.selectedObjects.push(obj);
// itemSelectedAt.current = Date.now();
// if (selectedItemId.current === event.node.id) {
// selectedItemId.current = null;
// console.log("Deselected item:", event.node.id);
// selectedObjects.length = 0;
// return;
// }
// selectedItemId.current = event.node.id;
// const itemMesh = sceneRegistry.nodes.get(event.node.id);
// if (!itemMesh) return;
// selectedObjects.push(itemMesh);
// console.log("Selected item:", event.node.id);
// });
emitter.on("wall:click", (event) => {
if (Date.now() - itemSelectedAt.current < 50) {
return;
} }
itemSelectedAt.current = Date.now();
if (selectedItemId.current === event.node.id) { outliner.hoveredObjects.length = 0;
selectedItemId.current = null; if (hoveredId) {
console.log("Deselected item:", event.node.id); const obj = sceneRegistry.nodes.get(hoveredId);
useViewer.getState().outliner.selectedObjects.length = 0; if (obj) outliner.hoveredObjects.push(obj);
return;
} }
selectedItemId.current = event.node.id; }, [phase, selection, hoveredId, outliner]);
const itemMesh = sceneRegistry.nodes.get(event.node.id);
if (!itemMesh) return;
useViewer.getState().outliner.selectedObjects.push(itemMesh);
console.log("Selected item:", event.node.id);
});
emitter.on("wall:move", (event) => {
const wallNode = event.node as WallNode;
if (wallNode.children.length === 0) return;
const itemId = wallNode.children[0];
if (!itemId) return;
if (selectedItemId.current !== itemId) return;
const itemNode = useScene.getState().nodes[itemId];
const itemMesh = sceneRegistry.nodes.get(itemId);
if (!itemNode || !itemMesh) return;
itemMesh.position.set(
event.position[0],
event.position[1],
event.position[2],
);
useScene.getState().dirtyNodes.add(wallNode.id);
console.log(
"Wall move event:",
wallNode.id,
"Point position:",
event.position,
);
});
}, [mode, phase]);
return null; return null;
}; };
+41 -31
View File
@@ -1,58 +1,68 @@
'use client' "use client";
import type { BuildingNode, ItemNode, LevelNode, Zone } from '@pascal-app/core' import type {
import type { Object3D } from 'three' AnyNode,
BaseNode,
BuildingNode,
LevelNode,
Zone,
} from "@pascal-app/core";
import type { Object3D } from "three";
import { create } from 'zustand' import { create } from "zustand";
type SelectionPath = { type SelectionPath = {
buildingId: BuildingNode['id'] | null buildingId: BuildingNode["id"] | null;
levelId: LevelNode['id'] | null levelId: LevelNode["id"] | null;
zoneId: Zone['id'] | null zoneId: Zone["id"] | null;
selectedIds: ItemNode['id'][] // For items/assets (multi-select) selectedIds: BaseNode["id"][]; // For items/assets (multi-select)
} };
type Outliner = { type Outliner = {
selectedObjects: Object3D[] selectedObjects: Object3D[];
hoveredObjects: Object3D[] hoveredObjects: Object3D[];
} };
type ViewerState = { type ViewerState = {
selection: SelectionPath selection: SelectionPath;
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' hoveredId: AnyNode["id"] | null;
setHoveredId: (id: AnyNode["id"] | null) => void;
// Actions levelMode: "stacked" | "exploded" | "solo" | "manual";
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
// Smart selection update // Smart selection update
setSelection: (updates: Partial<SelectionPath>) => void setSelection: (updates: Partial<SelectionPath>) => void;
resetSelection: () => void resetSelection: () => void;
outliner: Outliner // No setter as we will manipulate directly the arrays outliner: Outliner; // No setter as we will manipulate directly the arrays
} };
const useViewer = create<ViewerState>()((set, get) => ({ const useViewer = create<ViewerState>()((set, get) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
levelMode: 'stacked', hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
levelMode: "stacked",
setLevelMode: (mode) => set({ levelMode: mode }), setLevelMode: (mode) => set({ levelMode: mode }),
setSelection: (updates) => setSelection: (updates) =>
set((state) => { set((state) => {
const newSelection = { ...state.selection, ...updates } const newSelection = { ...state.selection, ...updates };
// Hierarchy Guard: If we change a high-level parent, reset the children // Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) { if (updates.buildingId !== undefined) {
newSelection.levelId = null newSelection.levelId = null;
newSelection.zoneId = null newSelection.zoneId = null;
newSelection.selectedIds = [] newSelection.selectedIds = [];
} else if (updates.levelId !== undefined) { } else if (updates.levelId !== undefined) {
newSelection.zoneId = null newSelection.zoneId = null;
newSelection.selectedIds = [] newSelection.selectedIds = [];
} else if (updates.zoneId !== undefined) { } else if (updates.zoneId !== undefined) {
newSelection.selectedIds = [] newSelection.selectedIds = [];
} }
return { selection: newSelection } return { selection: newSelection };
}), }),
resetSelection: () => resetSelection: () =>
@@ -66,6 +76,6 @@ const useViewer = create<ViewerState>()((set, get) => ({
}), }),
outliner: { selectedObjects: [], hoveredObjects: [] }, outliner: { selectedObjects: [], hoveredObjects: [] },
})) }));
export default useViewer export default useViewer;