generic selection / hover
This commit is contained in:
@@ -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;
|
|
||||||
|
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) => {
|
const onLeave = () => useViewer.getState().setHoveredId(null);
|
||||||
useViewer.getState().outliner.hoveredObjects.length = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onItemClick = (event: ItemEvent) => {
|
const onClick = (event: any) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
useViewer
|
const { id, type } = event.node;
|
||||||
.getState()
|
|
||||||
.outliner.selectedObjects.push(
|
|
||||||
sceneRegistry.nodes.get(event.node.id),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
emitter.on("item:enter", onItemEnter);
|
if (!allowedTypes.includes(type)) return;
|
||||||
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 isShift = event.nativeEvent?.shiftKey;
|
||||||
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) => {
|
if (isShift) {
|
||||||
event.stopPropagation();
|
const isSelected = selection.selectedIds.includes(id);
|
||||||
if (!event.nativeEvent.shiftKey) {
|
const nextIds = isSelected
|
||||||
useViewer.getState().outliner.selectedObjects.length = 0;
|
? selection.selectedIds.filter((i) => i !== id)
|
||||||
useViewer
|
: [...selection.selectedIds, id];
|
||||||
.getState()
|
setSelection({ selectedIds: nextIds });
|
||||||
.outliner.selectedObjects.push(
|
|
||||||
sceneRegistry.nodes.get(event.node.id),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// check if already selected
|
setSelection({ selectedIds: [id] });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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("grid:click", onGridClick);
|
||||||
emitter.on("wall:enter", onWallEnter);
|
|
||||||
emitter.on("wall:leave", onWallLeave);
|
|
||||||
emitter.on("wall:click", onWallClick);
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off("wall:enter", onWallEnter);
|
allowedTypes.forEach((type) => {
|
||||||
emitter.off("wall:leave", onWallLeave);
|
emitter.off(`${type}:enter`, onEnter);
|
||||||
emitter.off("wall:click", onWallClick);
|
emitter.off(`${type}:leave`, onLeave);
|
||||||
|
emitter.off(`${type}:click`, onClick);
|
||||||
|
});
|
||||||
emitter.off("grid:click", onGridClick);
|
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) => {
|
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||||
// event.stopPropagation();
|
outliner.selectedObjects.length = 0;
|
||||||
// if (Date.now() - itemSelectedAt.current < 50) {
|
for (const id of idsToHighlight) {
|
||||||
// return;
|
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);
|
|
||||||
// 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) {
|
outliner.hoveredObjects.length = 0;
|
||||||
selectedItemId.current = null;
|
if (hoveredId) {
|
||||||
console.log("Deselected item:", event.node.id);
|
const obj = sceneRegistry.nodes.get(hoveredId);
|
||||||
useViewer.getState().outliner.selectedObjects.length = 0;
|
if (obj) outliner.hoveredObjects.push(obj);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
selectedItemId.current = event.node.id;
|
}, [phase, selection, hoveredId, outliner]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user