From b1d6f61e4fe8cd9fc247a23d7ca5f5c7de698d66 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 21 Jan 2026 10:45:48 +0900 Subject: [PATCH] hover / selection manager --- .../components/editor/selection-manager.tsx | 119 ++++++++++++++++-- packages/core/src/events/bus.ts | 63 +++++----- .../src/components/viewer/post-processing.tsx | 102 ++++++++------- packages/viewer/src/hooks/use-grid-events.ts | 37 +++--- packages/viewer/src/hooks/use-node-events.ts | 60 +++++---- 5 files changed, 246 insertions(+), 135 deletions(-) diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index e75d124b..ea3a3435 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -1,28 +1,123 @@ import { + 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 { useEffect, useRef } from "react"; +import useEditor from "@/store/use-editor"; export const SelectionManager = () => { const selectedItemId = useRef(null); const itemSelectedAt = useRef(0); + const phase = useEditor((state) => state.phase); + const mode = useEditor((state) => state.mode); useEffect(() => { - emitter.on("building:enter", (event) => { - // console.log("Entered building:", event.node.id); - const itemMesh = sceneRegistry.nodes.get(event.node.id); - useViewer.getState().outliner.hoveredObjects.length = 0; - useViewer.getState().outliner.hoveredObjects.push(itemMesh); - }); - emitter.on("building:leave", (event) => { - // console.log("Leaving building:", event.node.id); - useViewer.getState().outliner.hoveredObjects.length = 0; - }); + 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); + }; + } + + 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(); @@ -87,7 +182,7 @@ export const SelectionManager = () => { event.position, ); }); - }, []); + }, [mode, phase]); return null; }; diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 400dfebe..b2f1abca 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,49 +1,52 @@ -import mitt from 'mitt' -import type { BuildingNode, ItemNode, WallNode } from '../schema' -import type { AnyNode } from '../schema/types' +import type { ThreeEvent } from "@react-three/fiber"; +import mitt from "mitt"; +import type { BuildingNode, ItemNode, WallNode } from "../schema"; +import type { AnyNode } from "../schema/types"; // Base event interfaces export interface GridEvent { - position: [number, number, number] + position: [number, number, number]; + nativeEvent: ThreeEvent; } export interface NodeEvent { - node: T - position: [number, number, number] - localPosition: [number, number, number] - normal?: [number, number, number] - stopPropagation: () => void + node: T; + position: [number, number, number]; + localPosition: [number, number, number]; + normal?: [number, number, number]; + stopPropagation: () => void; + nativeEvent: ThreeEvent; } -export type WallEvent = NodeEvent -export type ItemEvent = NodeEvent -export type BuildingEvent = NodeEvent +export type WallEvent = NodeEvent; +export type ItemEvent = NodeEvent; +export type BuildingEvent = NodeEvent; // Event suffixes - exported for use in hooks export const eventSuffixes = [ - 'click', - 'move', - 'enter', - 'leave', - 'pointerdown', - 'pointerup', - 'context-menu', - 'double-click', -] as const + "click", + "move", + "enter", + "leave", + "pointerdown", + "pointerup", + "context-menu", + "double-click", +] as const; -export type EventSuffix = (typeof eventSuffixes)[number] +export type EventSuffix = (typeof eventSuffixes)[number]; type NodeEvents = { - [K in `${T}:${EventSuffix}`]: E -} + [K in `${T}:${EventSuffix}`]: E; +}; type GridEvents = { - [K in `grid:${EventSuffix}`]: GridEvent -} + [K in `grid:${EventSuffix}`]: GridEvent; +}; type EditorEvents = GridEvents & - NodeEvents<'wall', WallEvent> & - NodeEvents<'item', ItemEvent> & - NodeEvents<'building', BuildingEvent> + NodeEvents<"wall", WallEvent> & + NodeEvents<"item", ItemEvent> & + NodeEvents<"building", BuildingEvent>; -export const emitter = mitt() +export const emitter = mitt(); diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 811ef60f..b6c179dc 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,93 +1,99 @@ -import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useRef } from 'react' -import { Color } from 'three' -import { outline } from 'three/addons/tsl/display/OutlineNode.js' -import { oscSine, pass, time, uniform } from 'three/tsl' -import { PostProcessing, type WebGPURenderer } from 'three/webgpu' -import useViewer from '../../store/use-viewer' +import { useFrame, useThree } from "@react-three/fiber"; +import { useEffect, useRef } from "react"; +import { Color } from "three"; +import { outline } from "three/addons/tsl/display/OutlineNode.js"; +import { oscSine, pass, time, uniform } from "three/tsl"; +import { PostProcessing, type WebGPURenderer } from "three/webgpu"; +import useViewer from "../../store/use-viewer"; -const PostProcessingPasses = ({}) => { - const { gl: renderer, scene, camera } = useThree() - const postProcessingRef = useRef(null) +const PostProcessingPasses = () => { + const { gl: renderer, scene, camera } = useThree(); + const postProcessingRef = useRef(null); useEffect(() => { if (!renderer || !scene || !camera) { - return + return; } - const scenePass = pass(scene, camera) + const scenePass = pass(scene, camera); function generateSelectedOutlinePass() { - const edgeStrength = uniform(3) - const edgeGlow = uniform(0) - const edgeThickness = uniform(1) - const visibleEdgeColor = uniform(new Color(0xffffff)) - const hiddenEdgeColor = uniform(new Color(0xf3ff47)) + const edgeStrength = uniform(3); + const edgeGlow = uniform(0); + const edgeThickness = uniform(1); + const visibleEdgeColor = uniform(new Color(0xffffff)); + const hiddenEdgeColor = uniform(new Color(0xf3ff47)); const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.selectedObjects, edgeGlow, edgeThickness, - }) - const { visibleEdge, hiddenEdge } = outlinePass + }); + const { visibleEdge, hiddenEdge } = outlinePass; const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) + .mul(edgeStrength); - return outlineColor + return outlineColor; } function generateHoverOutlinePass() { - const edgeStrength = uniform(5) - const edgeGlow = uniform(0.5) - const edgeThickness = uniform(1.5) - const pulsePeriod = uniform(3) - const visibleEdgeColor = uniform(new Color(0x00aaff)) - const hiddenEdgeColor = uniform(new Color(0xf3ff47)) + const edgeStrength = uniform(5); + const edgeGlow = uniform(0.5); + const edgeThickness = uniform(1.5); + const pulsePeriod = uniform(3); + const visibleEdgeColor = uniform(new Color(0x00aaff)); + const hiddenEdgeColor = uniform(new Color(0xf3ff47)); const outlinePass = outline(scene, camera, { selectedObjects: useViewer.getState().outliner.hoveredObjects, edgeGlow, edgeThickness, - }) - const { visibleEdge, hiddenEdge } = outlinePass + }); + const { visibleEdge, hiddenEdge } = outlinePass; - const period = time.div(pulsePeriod).mul(2) - const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] + const period = time.div(pulsePeriod).mul(2); + const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ] const outlineColor = visibleEdge .mul(visibleEdgeColor) .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) - const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) - return outlinePulse + .mul(edgeStrength); + const outlinePulse = pulsePeriod + .greaterThan(0) + .select(outlineColor.mul(osc), outlineColor); + return outlinePulse; } // Setup post-processing - const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer) + const postProcessing = new PostProcessing( + renderer as unknown as WebGPURenderer, + ); - const selectedOutlinePass = generateSelectedOutlinePass() - const hoverOutlinePass = generateHoverOutlinePass() + const selectedOutlinePass = generateSelectedOutlinePass(); + const hoverOutlinePass = generateHoverOutlinePass(); - postProcessing.outputNode = selectedOutlinePass.add(hoverOutlinePass).add(scenePass) - postProcessingRef.current = postProcessing + postProcessing.outputNode = selectedOutlinePass + .add(hoverOutlinePass) + .add(scenePass); + postProcessingRef.current = postProcessing; return () => { if (postProcessingRef.current) { - postProcessingRef.current.dispose() + postProcessingRef.current.dispose(); } - postProcessingRef.current = null - } - }, [renderer, scene, camera]) + postProcessingRef.current = null; + }; + }, [renderer, scene, camera]); useFrame(() => { if (postProcessingRef.current) { - postProcessingRef.current.render() + postProcessingRef.current.render(); } - }, 1) + }, 1); - return null -} + return null; +}; -export default PostProcessingPasses +export default PostProcessingPasses; diff --git a/packages/viewer/src/hooks/use-grid-events.ts b/packages/viewer/src/hooks/use-grid-events.ts index ebcb21d9..08196a86 100644 --- a/packages/viewer/src/hooks/use-grid-events.ts +++ b/packages/viewer/src/hooks/use-grid-events.ts @@ -1,33 +1,34 @@ -import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' -import type { ThreeEvent } from '@react-three/fiber' +import { type EventSuffix, emitter, type GridEvent } from "@pascal-app/core"; +import type { ThreeEvent } from "@react-three/fiber"; export function useGridEvents() { const emit = (suffix: EventSuffix, e: ThreeEvent) => { - const eventKey = `grid:${suffix}` as `grid:${EventSuffix}` + const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`; const payload: GridEvent = { position: [e.point.x, e.point.y, e.point.z], - } + nativeEvent: e, + }; - emitter.emit(eventKey, payload) - } + emitter.emit(eventKey, payload); + }; return { onPointerDown: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('pointerdown', e) + if (e.button !== 0) return; + emit("pointerdown", e); }, onPointerUp: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('pointerup', e) + if (e.button !== 0) return; + emit("pointerup", e); }, onClick: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('click', e) + if (e.button !== 0) return; + emit("click", e); }, - onPointerEnter: (e: ThreeEvent) => emit('enter', e), - onPointerLeave: (e: ThreeEvent) => emit('leave', e), - onPointerMove: (e: ThreeEvent) => emit('move', e), - onDoubleClick: (e: ThreeEvent) => emit('double-click', e), - onContextMenu: (e: ThreeEvent) => emit('context-menu', e), - } + onPointerEnter: (e: ThreeEvent) => emit("enter", e), + onPointerLeave: (e: ThreeEvent) => emit("leave", e), + onPointerMove: (e: ThreeEvent) => emit("move", e), + onDoubleClick: (e: ThreeEvent) => emit("double-click", e), + onContextMenu: (e: ThreeEvent) => emit("context-menu", e), + }; } diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index f0dd948e..7b1acf24 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -6,50 +6,56 @@ import { type ItemNode, type WallEvent, type WallNode, -} from '@pascal-app/core' -import type { ThreeEvent } from '@react-three/fiber' -import type { BuildingEvent } from '../../../core/src/events/bus' +} from "@pascal-app/core"; +import type { ThreeEvent } from "@react-three/fiber"; +import type { BuildingEvent } from "../../../core/src/events/bus"; type NodeConfig = { - item: { node: ItemNode; event: ItemEvent } - wall: { node: WallNode; event: WallEvent } - building: { node: BuildingNode; event: BuildingEvent } -} + item: { node: ItemNode; event: ItemEvent }; + wall: { node: WallNode; event: WallEvent }; + building: { node: BuildingNode; event: BuildingEvent }; +}; -type NodeType = keyof NodeConfig +type NodeType = keyof NodeConfig; -export function useNodeEvents(node: NodeConfig[T]['node'], type: T) { +export function useNodeEvents( + node: NodeConfig[T]["node"], + type: T, +) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { - const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` - const localPoint = e.object.worldToLocal(e.point.clone()) + const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; + const localPoint = e.object.worldToLocal(e.point.clone()); const payload = { node, position: [e.point.x, e.point.y, e.point.z], localPosition: [localPoint.x, localPoint.y, localPoint.z], - normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, + normal: e.face + ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] + : undefined, stopPropagation: () => e.stopPropagation(), - } as NodeConfig[T]['event'] + nativeEvent: e, + } as NodeConfig[T]["event"]; - emitter.emit(eventKey, payload) - } + emitter.emit(eventKey, payload); + }; return { onPointerDown: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('pointerdown', e) + if (e.button !== 0) return; + emit("pointerdown", e); }, onPointerUp: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('pointerup', e) + if (e.button !== 0) return; + emit("pointerup", e); }, onClick: (e: ThreeEvent) => { - if (e.button !== 0) return - emit('click', e) + if (e.button !== 0) return; + emit("click", e); }, - onPointerEnter: (e: ThreeEvent) => emit('enter', e), - onPointerLeave: (e: ThreeEvent) => emit('leave', e), - onPointerMove: (e: ThreeEvent) => emit('move', e), - onDoubleClick: (e: ThreeEvent) => emit('double-click', e), - onContextMenu: (e: ThreeEvent) => emit('context-menu', e), - } + onPointerEnter: (e: ThreeEvent) => emit("enter", e), + onPointerLeave: (e: ThreeEvent) => emit("leave", e), + onPointerMove: (e: ThreeEvent) => emit("move", e), + onDoubleClick: (e: ThreeEvent) => emit("double-click", e), + onContextMenu: (e: ThreeEvent) => emit("context-menu", e), + }; }