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 {
type BuildingNode,
emitter,
type GridEvent,
type ItemEvent,
type ItemNode,
sceneRegistry,
useScene,
type WallEvent,
type WallNode,
} from "@pascal-app/core";
import { emitter, sceneRegistry } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
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 = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null);
const itemSelectedAt = useRef<number>(0);
const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode);
const phase = useEditor((s) => s.phase);
const mode = useEditor((s) => s.mode);
const { setSelection, selection } = useViewer();
useEffect(() => {
if (mode !== "select") {
return;
if (mode !== "select") 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) => {
useViewer.getState().outliner.hoveredObjects.length = 0;
};
const onLeave = () => useViewer.getState().setHoveredId(null);
const onItemClick = (event: ItemEvent) => {
const onClick = (event: any) => {
event.stopPropagation();
useViewer
.getState()
.outliner.selectedObjects.push(
sceneRegistry.nodes.get(event.node.id),
);
};
const { id, type } = event.node;
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 (!allowedTypes.includes(type)) return;
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 isShift = event.nativeEvent?.shiftKey;
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),
);
if (isShift) {
const isSelected = selection.selectedIds.includes(id);
const nextIds = isSelected
? selection.selectedIds.filter((i) => i !== id)
: [...selection.selectedIds, id];
setSelection({ selectedIds: nextIds });
} 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);
}
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);
});
const onGridClick = () => setSelection({ selectedIds: [] });
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);
allowedTypes.forEach((type) => {
emitter.off(`${type}:enter`, onEnter);
emitter.off(`${type}:leave`, onLeave);
emitter.off(`${type}:click`, onClick);
});
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) => {
// 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;
// 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);
}
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;
outliner.hoveredObjects.length = 0;
if (hoveredId) {
const obj = sceneRegistry.nodes.get(hoveredId);
if (obj) outliner.hoveredObjects.push(obj);
}
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);
});
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]);
}, [phase, selection, hoveredId, outliner]);
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 { Object3D } from 'three'
import type {
AnyNode,
BaseNode,
BuildingNode,
LevelNode,
Zone,
} from "@pascal-app/core";
import type { Object3D } from "three";
import { create } from 'zustand'
import { create } from "zustand";
type SelectionPath = {
buildingId: BuildingNode['id'] | null
levelId: LevelNode['id'] | null
zoneId: Zone['id'] | null
selectedIds: ItemNode['id'][] // For items/assets (multi-select)
}
buildingId: BuildingNode["id"] | null;
levelId: LevelNode["id"] | null;
zoneId: Zone["id"] | null;
selectedIds: BaseNode["id"][]; // For items/assets (multi-select)
};
type Outliner = {
selectedObjects: Object3D[]
hoveredObjects: Object3D[]
}
selectedObjects: Object3D[];
hoveredObjects: Object3D[];
};
type ViewerState = {
selection: SelectionPath
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
selection: SelectionPath;
hoveredId: AnyNode["id"] | null;
setHoveredId: (id: AnyNode["id"] | null) => void;
// Actions
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
levelMode: "stacked" | "exploded" | "solo" | "manual";
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
// Smart selection update
setSelection: (updates: Partial<SelectionPath>) => void
resetSelection: () => void
setSelection: (updates: Partial<SelectionPath>) => 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) => ({
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
levelMode: 'stacked',
hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }),
levelMode: "stacked",
setLevelMode: (mode) => set({ levelMode: mode }),
setSelection: (updates) =>
set((state) => {
const newSelection = { ...state.selection, ...updates }
const newSelection = { ...state.selection, ...updates };
// Hierarchy Guard: If we change a high-level parent, reset the children
if (updates.buildingId !== undefined) {
newSelection.levelId = null
newSelection.zoneId = null
newSelection.selectedIds = []
newSelection.levelId = null;
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.levelId !== undefined) {
newSelection.zoneId = null
newSelection.selectedIds = []
newSelection.zoneId = null;
newSelection.selectedIds = [];
} else if (updates.zoneId !== undefined) {
newSelection.selectedIds = []
newSelection.selectedIds = [];
}
return { selection: newSelection }
return { selection: newSelection };
}),
resetSelection: () =>
@@ -66,6 +76,6 @@ const useViewer = create<ViewerState>()((set, get) => ({
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
}))
}));
export default useViewer
export default useViewer;