From 09515107cb3c828cedb267cbbab14391f714a444 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 21 Jan 2026 11:40:42 +0900 Subject: [PATCH] generic selection / hover --- .../components/editor/selection-manager.tsx | 282 +++++++----------- packages/viewer/src/store/use-viewer.ts | 72 +++-- 2 files changed, 151 insertions(+), 203 deletions(-) diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index ea3a3435..445e1807 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -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(null); - const itemSelectedAt = useRef(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; - 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); - }; - } + const allowedTypes = PHASE_CONFIG[phase] || []; - 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 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; + 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); } - 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 wallNode = event.node as WallNode; + const onGridClick = () => setSelection({ selectedIds: [] }); + emitter.on("grid:click", onGridClick); - 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; + return () => { + 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]); - 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 ; +}; + +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]; + } + + // 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; }; diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 0634b422..c84b086c 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -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) => void - resetSelection: () => void + setSelection: (updates: Partial) => 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()((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()((set, get) => ({ }), outliner: { selectedObjects: [], hoveredObjects: [] }, -})) +})); -export default useViewer +export default useViewer;