hover / selection manager

This commit is contained in:
wass08
2026-01-21 10:45:48 +09:00
parent c0da9701b4
commit b1d6f61e4f
5 changed files with 246 additions and 135 deletions
@@ -1,28 +1,123 @@
import { import {
type BuildingNode,
emitter, emitter,
type GridEvent,
type ItemEvent,
type ItemNode, type ItemNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallEvent,
type WallNode, type WallNode,
} from "@pascal-app/core"; } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import useEditor from "@/store/use-editor";
export const SelectionManager = () => { export const SelectionManager = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null); const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null);
const itemSelectedAt = useRef<number>(0); const itemSelectedAt = useRef<number>(0);
const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode);
useEffect(() => { useEffect(() => {
emitter.on("building:enter", (event) => { if (mode !== "select") {
// console.log("Entered building:", event.node.id); return;
}
if (phase === "site") {
const onBuildingEnter = (event: { node: BuildingNode }) => {
const itemMesh = sceneRegistry.nodes.get(event.node.id); const itemMesh = sceneRegistry.nodes.get(event.node.id);
useViewer.getState().outliner.hoveredObjects.length = 0; useViewer.getState().outliner.hoveredObjects.length = 0;
useViewer.getState().outliner.hoveredObjects.push(itemMesh); useViewer.getState().outliner.hoveredObjects.push(itemMesh);
}); };
emitter.on("building:leave", (event) => { const onBuildingLeave = (event: { node: BuildingNode }) => {
// console.log("Leaving building:", event.node.id);
useViewer.getState().outliner.hoveredObjects.length = 0; 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) => { // emitter.on("item:click", (event) => {
// event.stopPropagation(); // event.stopPropagation();
@@ -87,7 +182,7 @@ export const SelectionManager = () => {
event.position, event.position,
); );
}); });
}, []); }, [mode, phase]);
return null; return null;
}; };
+33 -30
View File
@@ -1,49 +1,52 @@
import mitt from 'mitt' import type { ThreeEvent } from "@react-three/fiber";
import type { BuildingNode, ItemNode, WallNode } from '../schema' import mitt from "mitt";
import type { AnyNode } from '../schema/types' import type { BuildingNode, ItemNode, WallNode } from "../schema";
import type { AnyNode } from "../schema/types";
// Base event interfaces // Base event interfaces
export interface GridEvent { export interface GridEvent {
position: [number, number, number] position: [number, number, number];
nativeEvent: ThreeEvent<PointerEvent>;
} }
export interface NodeEvent<T extends AnyNode = AnyNode> { export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T node: T;
position: [number, number, number] position: [number, number, number];
localPosition: [number, number, number] localPosition: [number, number, number];
normal?: [number, number, number] normal?: [number, number, number];
stopPropagation: () => void stopPropagation: () => void;
nativeEvent: ThreeEvent<PointerEvent>;
} }
export type WallEvent = NodeEvent<WallNode> export type WallEvent = NodeEvent<WallNode>;
export type ItemEvent = NodeEvent<ItemNode> export type ItemEvent = NodeEvent<ItemNode>;
export type BuildingEvent = NodeEvent<BuildingNode> export type BuildingEvent = NodeEvent<BuildingNode>;
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
'click', "click",
'move', "move",
'enter', "enter",
'leave', "leave",
'pointerdown', "pointerdown",
'pointerup', "pointerup",
'context-menu', "context-menu",
'double-click', "double-click",
] as const ] as const;
export type EventSuffix = (typeof eventSuffixes)[number] export type EventSuffix = (typeof eventSuffixes)[number];
type NodeEvents<T extends string, E> = { type NodeEvents<T extends string, E> = {
[K in `${T}:${EventSuffix}`]: E [K in `${T}:${EventSuffix}`]: E;
} };
type GridEvents = { type GridEvents = {
[K in `grid:${EventSuffix}`]: GridEvent [K in `grid:${EventSuffix}`]: GridEvent;
} };
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<"wall", WallEvent> &
NodeEvents<'item', ItemEvent> & NodeEvents<"item", ItemEvent> &
NodeEvents<'building', BuildingEvent> NodeEvents<"building", BuildingEvent>;
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>();
@@ -1,93 +1,99 @@
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef } from 'react' import { useEffect, useRef } from "react";
import { Color } from 'three' import { Color } from "three";
import { outline } from 'three/addons/tsl/display/OutlineNode.js' import { outline } from "three/addons/tsl/display/OutlineNode.js";
import { oscSine, pass, time, uniform } from 'three/tsl' import { oscSine, pass, time, uniform } from "three/tsl";
import { PostProcessing, type WebGPURenderer } from 'three/webgpu' import { PostProcessing, type WebGPURenderer } from "three/webgpu";
import useViewer from '../../store/use-viewer' import useViewer from "../../store/use-viewer";
const PostProcessingPasses = ({}) => { const PostProcessingPasses = () => {
const { gl: renderer, scene, camera } = useThree() const { gl: renderer, scene, camera } = useThree();
const postProcessingRef = useRef<PostProcessing | null>(null) const postProcessingRef = useRef<PostProcessing | null>(null);
useEffect(() => { useEffect(() => {
if (!renderer || !scene || !camera) { if (!renderer || !scene || !camera) {
return return;
} }
const scenePass = pass(scene, camera) const scenePass = pass(scene, camera);
function generateSelectedOutlinePass() { function generateSelectedOutlinePass() {
const edgeStrength = uniform(3) const edgeStrength = uniform(3);
const edgeGlow = uniform(0) const edgeGlow = uniform(0);
const edgeThickness = uniform(1) const edgeThickness = uniform(1);
const visibleEdgeColor = uniform(new Color(0xffffff)) const visibleEdgeColor = uniform(new Color(0xffffff));
const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const hiddenEdgeColor = uniform(new Color(0xf3ff47));
const outlinePass = outline(scene, camera, { const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.selectedObjects, selectedObjects: useViewer.getState().outliner.selectedObjects,
edgeGlow, edgeGlow,
edgeThickness, edgeThickness,
}) });
const { visibleEdge, hiddenEdge } = outlinePass const { visibleEdge, hiddenEdge } = outlinePass;
const outlineColor = visibleEdge const outlineColor = visibleEdge
.mul(visibleEdgeColor) .mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor)) .add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength) .mul(edgeStrength);
return outlineColor return outlineColor;
} }
function generateHoverOutlinePass() { function generateHoverOutlinePass() {
const edgeStrength = uniform(5) const edgeStrength = uniform(5);
const edgeGlow = uniform(0.5) const edgeGlow = uniform(0.5);
const edgeThickness = uniform(1.5) const edgeThickness = uniform(1.5);
const pulsePeriod = uniform(3) const pulsePeriod = uniform(3);
const visibleEdgeColor = uniform(new Color(0x00aaff)) const visibleEdgeColor = uniform(new Color(0x00aaff));
const hiddenEdgeColor = uniform(new Color(0xf3ff47)) const hiddenEdgeColor = uniform(new Color(0xf3ff47));
const outlinePass = outline(scene, camera, { const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.hoveredObjects, selectedObjects: useViewer.getState().outliner.hoveredObjects,
edgeGlow, edgeGlow,
edgeThickness, edgeThickness,
}) });
const { visibleEdge, hiddenEdge } = outlinePass const { visibleEdge, hiddenEdge } = outlinePass;
const period = time.div(pulsePeriod).mul(2) const period = time.div(pulsePeriod).mul(2);
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ]
const outlineColor = visibleEdge const outlineColor = visibleEdge
.mul(visibleEdgeColor) .mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor)) .add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength) .mul(edgeStrength);
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) const outlinePulse = pulsePeriod
return outlinePulse .greaterThan(0)
.select(outlineColor.mul(osc), outlineColor);
return outlinePulse;
} }
// Setup post-processing // 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 selectedOutlinePass = generateSelectedOutlinePass();
const hoverOutlinePass = generateHoverOutlinePass() const hoverOutlinePass = generateHoverOutlinePass();
postProcessing.outputNode = selectedOutlinePass.add(hoverOutlinePass).add(scenePass) postProcessing.outputNode = selectedOutlinePass
postProcessingRef.current = postProcessing .add(hoverOutlinePass)
.add(scenePass);
postProcessingRef.current = postProcessing;
return () => { return () => {
if (postProcessingRef.current) { if (postProcessingRef.current) {
postProcessingRef.current.dispose() postProcessingRef.current.dispose();
} }
postProcessingRef.current = null postProcessingRef.current = null;
} };
}, [renderer, scene, camera]) }, [renderer, scene, camera]);
useFrame(() => { useFrame(() => {
if (postProcessingRef.current) { if (postProcessingRef.current) {
postProcessingRef.current.render() postProcessingRef.current.render();
} }
}, 1) }, 1);
return null return null;
} };
export default PostProcessingPasses export default PostProcessingPasses;
+19 -18
View File
@@ -1,33 +1,34 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' import { type EventSuffix, emitter, type GridEvent } from "@pascal-app/core";
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from "@react-three/fiber";
export function useGridEvents() { export function useGridEvents() {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `grid:${suffix}` as `grid:${EventSuffix}` const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`;
const payload: GridEvent = { const payload: GridEvent = {
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
} nativeEvent: e,
};
emitter.emit(eventKey, payload) emitter.emit(eventKey, payload);
} };
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('pointerdown', e) emit("pointerdown", e);
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('pointerup', e) emit("pointerup", e);
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('click', e) emit("click", e);
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e), onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e), onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
} };
} }
+33 -27
View File
@@ -6,50 +6,56 @@ import {
type ItemNode, type ItemNode,
type WallEvent, type WallEvent,
type WallNode, type WallNode,
} from '@pascal-app/core' } from "@pascal-app/core";
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from "@react-three/fiber";
import type { BuildingEvent } from '../../../core/src/events/bus' import type { BuildingEvent } from "../../../core/src/events/bus";
type NodeConfig = { type NodeConfig = {
item: { node: ItemNode; event: ItemEvent } item: { node: ItemNode; event: ItemEvent };
wall: { node: WallNode; event: WallEvent } wall: { node: WallNode; event: WallEvent };
building: { node: BuildingNode; event: BuildingEvent } building: { node: BuildingNode; event: BuildingEvent };
} };
type NodeType = keyof NodeConfig type NodeType = keyof NodeConfig;
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], type: T) { export function useNodeEvents<T extends NodeType>(
node: NodeConfig[T]["node"],
type: T,
) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}` const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`;
const localPoint = e.object.worldToLocal(e.point.clone()) const localPoint = e.object.worldToLocal(e.point.clone());
const payload = { const payload = {
node, node,
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.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(), stopPropagation: () => e.stopPropagation(),
} as NodeConfig[T]['event'] nativeEvent: e,
} as NodeConfig[T]["event"];
emitter.emit(eventKey, payload) emitter.emit(eventKey, payload);
} };
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('pointerdown', e) emit("pointerdown", e);
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('pointerup', e) emit("pointerup", e);
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return if (e.button !== 0) return;
emit('click', e) emit("click", e);
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e), onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e), onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
} };
} }