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;
}
if (phase === "site") { const allowedTypes = PHASE_CONFIG[phase] || [];
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 onEnter = (event: any) => {
const onItemEnter = (event: ItemEvent) => { // Only hover if the node type is allowed in this phase
const itemMesh = sceneRegistry.nodes.get(event.node.id); if (allowedTypes.includes(event.node.type)) {
useViewer.getState().outliner.hoveredObjects.length = 0; useViewer.getState().setHoveredId(event.node.id);
useViewer.getState().outliner.hoveredObjects.push(itemMesh);
};
const onItemLeave = (event: ItemEvent) => {
useViewer.getState().outliner.hoveredObjects.length = 0;
};
const onItemClick = (event: ItemEvent) => {
event.stopPropagation();
useViewer
.getState()
.outliner.selectedObjects.push(
sceneRegistry.nodes.get(event.node.id),
);
};
emitter.on("item:enter", onItemEnter);
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 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) => {
event.stopPropagation();
if (!event.nativeEvent.shiftKey) {
useViewer.getState().outliner.selectedObjects.length = 0;
useViewer
.getState()
.outliner.selectedObjects.push(
sceneRegistry.nodes.get(event.node.id),
);
} else {
// check if already selected
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);
}
}
};
emitter.on("grid:click", onGridClick);
emitter.on("wall:enter", onWallEnter);
emitter.on("wall:leave", onWallLeave);
emitter.on("wall:click", onWallClick);
return () => {
emitter.off("wall:enter", onWallEnter);
emitter.off("wall:leave", onWallLeave);
emitter.off("wall:click", onWallClick);
emitter.off("grid:click", onGridClick);
};
}
// emitter.on("item:click", (event) => {
// event.stopPropagation();
// if (Date.now() - itemSelectedAt.current < 50) {
// return;
// }
// 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) {
selectedItemId.current = null;
console.log("Deselected item:", event.node.id);
useViewer.getState().outliner.selectedObjects.length = 0;
return;
}
selectedItemId.current = event.node.id;
const itemMesh = sceneRegistry.nodes.get(event.node.id);
if (!itemMesh) return;
useViewer.getState().outliner.selectedObjects.push(itemMesh);
console.log("Selected item:", event.node.id); const onLeave = () => useViewer.getState().setHoveredId(null);
const onClick = (event: any) => {
event.stopPropagation();
const { id, type } = event.node;
if (!allowedTypes.includes(type)) return;
const isShift = event.nativeEvent?.shiftKey;
if (isShift) {
const isSelected = selection.selectedIds.includes(id);
const nextIds = isSelected
? selection.selectedIds.filter((i) => i !== id)
: [...selection.selectedIds, id];
setSelection({ selectedIds: nextIds });
} else {
setSelection({ selectedIds: [id] });
}
};
// 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);
}); });
emitter.on("wall:move", (event) => { const onGridClick = () => setSelection({ selectedIds: [] });
const wallNode = event.node as WallNode; emitter.on("grid:click", onGridClick);
if (wallNode.children.length === 0) return; return () => {
const itemId = wallNode.children[0]; allowedTypes.forEach((type) => {
if (!itemId) return; emitter.off(`${type}:enter`, onEnter);
if (selectedItemId.current !== itemId) return; emitter.off(`${type}:leave`, onLeave);
const itemNode = useScene.getState().nodes[itemId]; emitter.off(`${type}:click`, onClick);
const itemMesh = sceneRegistry.nodes.get(itemId); });
if (!itemNode || !itemMesh) return; emitter.off("grid:click", onGridClick);
};
}, [phase, mode, selection.selectedIds, setSelection]);
itemMesh.position.set( return <EditorOutlinerSync />;
event.position[0], };
event.position[1],
event.position[2], const EditorOutlinerSync = () => {
); const phase = useEditor((s) => s.phase);
useScene.getState().dirtyNodes.add(wallNode.id); const selection = useViewer((s) => s.selection);
console.log( const hoveredId = useViewer((s) => s.hoveredId);
"Wall move event:", const outliner = useViewer((s) => s.outliner);
wallNode.id,
"Point position:", useEffect(() => {
event.position, let idsToHighlight: string[] = [];
);
}); // 1. Determine what should be highlighted based on Phase
}, [mode, 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];
}
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
outliner.selectedObjects.length = 0;
for (const id of idsToHighlight) {
const obj = sceneRegistry.nodes.get(id);
if (obj) outliner.selectedObjects.push(obj);
}
outliner.hoveredObjects.length = 0;
if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId);
if (obj) outliner.hoveredObjects.push(obj);
}
}, [phase, selection, hoveredId, outliner]);
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;