diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 4666a7bc..fe6a96be 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -6,9 +6,12 @@ import { CameraControls, CameraControlsImpl } from "@react-three/drei"; import { useEffect, useMemo, useRef } from "react"; import { Vector3 } from "three"; +const currentTarget = new Vector3(); + export const CustomCameraControls = () => { const controls = useRef(null!); - const currentLevelId = useViewer((state) => state.currentLevelId); + const currentLevelId = useViewer((state) => state.selection.levelId); + const firstLoad = useRef(true); useEffect(() => { let targetY = 0; @@ -18,7 +21,18 @@ export const CustomCameraControls = () => { targetY = levelMesh.position.y; } } - const currentTarget = new Vector3(); + if (firstLoad.current) { + firstLoad.current = false; + (controls.current as CameraControlsImpl).setLookAt( + 20, + 20, + 20, + 0, + 0, + 0, + true, + ); + } (controls.current as CameraControlsImpl).getTarget(currentTarget); (controls.current as CameraControlsImpl).moveTo( currentTarget.x, diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 4d22aaab..b712e4d7 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -56,7 +56,7 @@ export default function Editor() { - + ); @@ -232,7 +232,7 @@ const Grid = ({ const gridRef = useRef(null!); useFrame((_, delta) => { - const currentLevelId = useViewer.getState().currentLevelId; + const currentLevelId = useViewer.getState().selection.levelId; let targetY = 0; if (currentLevelId) { const levelMesh = sceneRegistry.nodes.get(currentLevelId); diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index a2e4c204..a0366437 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -34,7 +34,7 @@ export const ItemTool: React.FC = () => { let currentWallId: string | null = null; const checkCanPlace = () => { - const currentLevelId = useViewer.getState().currentLevelId; + const currentLevelId = useViewer.getState().selection.levelId; if (currentLevelId && draftItem.current) { let placeable = true; if (draftItem.current.asset.attachTo) { @@ -72,7 +72,7 @@ export const ItemTool: React.FC = () => { } }; const createDraftItem = () => { - const { currentLevelId } = useViewer.getState(); + const currentLevelId = useViewer.getState().selection.levelId; if (!currentLevelId) { return null; } @@ -116,7 +116,7 @@ export const ItemTool: React.FC = () => { } }; const onGridClick = (event: GridEvent) => { - const { currentLevelId } = useViewer.getState(); + const currentLevelId = useViewer.getState().selection.levelId; if (isOnWall.current) return; if (!currentLevelId || !draftItem.current || !checkCanPlace()) return; @@ -133,7 +133,7 @@ export const ItemTool: React.FC = () => { const onWallEnter = (event: WallEvent) => { if ( - useViewer.getState().currentLevelId !== + useViewer.getState().selection.levelId !== resolveLevelId(event.node, useScene.getState().nodes) ) { return; @@ -169,7 +169,7 @@ export const ItemTool: React.FC = () => { currentWallId = null; event.stopPropagation(); if (!draftItem.current) return; - const currentLevelId = useViewer.getState().currentLevelId; + const currentLevelId = useViewer.getState().selection.levelId; draftItem.current.parentId = currentLevelId; useScene.getState().updateNode(draftItem.current.id, { position: [ @@ -186,7 +186,7 @@ export const ItemTool: React.FC = () => { event.stopPropagation(); if (!isOnWall.current) return; - const currentLevelId = useViewer.getState().currentLevelId; + const currentLevelId = useViewer.getState().selection.levelId; if (!currentLevelId || !draftItem.current || !checkCanPlace()) return; useScene.temporal.getState().resume(); diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index f318ac95..d16520f1 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef } from "react"; import { Line, Mesh, Vector3 } from "three"; const commitWallDrawing = (start: [number, number], end: [number, number]) => { - const { currentLevelId } = useViewer.getState(); + const currentLevelId = useViewer.getState().selection.levelId; const { createNode } = useScene.getState(); if (!currentLevelId) return; diff --git a/apps/editor/components/ui/sidebar/app-sidebar.tsx b/apps/editor/components/ui/sidebar/app-sidebar.tsx index 90d9b482..39ad0a21 100644 --- a/apps/editor/components/ui/sidebar/app-sidebar.tsx +++ b/apps/editor/components/ui/sidebar/app-sidebar.tsx @@ -40,7 +40,7 @@ export function AppSidebar() { // const handleDeleteSelected = useEditor((state) => state.handleDeleteSelected); // const serializeLayout = useEditor((state) => state.serializeLayout); const activeTool = useEditor((state) => state.tool); - const currentLevelId = useViewer((state) => state.currentLevelId); + const currentLevelId = useViewer((state) => state.selection.levelId); const setActiveTool = useEditor((state) => state.setTool); const [jsonCollapsed, setJsonCollapsed] = useState(1); diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 8666e42a..10b135bc 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -11,11 +11,11 @@ interface LevelTreeNodeProps { export function LevelTreeNode({ node, depth }: LevelTreeNodeProps) { const [expanded, setExpanded] = useState(true); - const currentLevelId = useViewer((state) => state.currentLevelId); - const setCurrentLevel = useViewer((state) => state.setCurrentLevelId); + const currentLevelId = useViewer((state) => state.selection.levelId); + const setSelection = useViewer((state) => state.setSelection); const handleClick = () => { - setCurrentLevel(node.id); + setSelection({ levelId: node.id }); }; const isSelected = currentLevelId === node.id; diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 035a645c..cc37a570 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -73,25 +73,26 @@ const useEditor = create()((set, get) => ({ switch (phase) { case "site": // In Site mode, we zoom out and deselect specific levels/buildings - viewer.setCurrentBuildingId(null); - viewer.setCurrentLevelId(null); + viewer.resetSelection(); viewer.setLevelMode("stacked"); break; case "structure": // In Structure mode, we often want to focus on a specific building/level // Auto-select the first building if none is selected - if (!viewer.currentBuildingId) { + if (!viewer.selection.buildingId) { const firstBuildingId = scene.rootNodeIds.find((id) => { const node = scene.nodes[id]; return node?.type === "building" || null; }); if (firstBuildingId) { - viewer.setCurrentBuildingId(firstBuildingId as BuildingNode["id"]); + viewer.setSelection({ + buildingId: firstBuildingId as BuildingNode["id"], + }); const buildingNode = scene.nodes[firstBuildingId] as BuildingNode; const firstLevelId = buildingNode.children[0]; if (firstLevelId) { - viewer.setCurrentLevelId(firstLevelId as LevelNode["id"]); + viewer.setSelection({ levelId: firstLevelId as LevelNode["id"] }); } } } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 96b580e9..19197c75 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -15,3 +15,6 @@ export type { AnyNodeType, AnyNodeId } from "./types"; // Camera export { CameraSchema } from "./camera"; + +// Zones +export type { Zone, ZonePolygon } from "./zone"; diff --git a/packages/core/src/schema/zone.ts b/packages/core/src/schema/zone.ts new file mode 100644 index 00000000..bf6abf30 --- /dev/null +++ b/packages/core/src/schema/zone.ts @@ -0,0 +1,35 @@ +import dedent from "dedent"; +import { z } from "zod"; +import { objectId } from "./base"; +import { LevelNode } from "./nodes/level"; + +// Polygon boundary for zone area - array of [x, z] coordinates +export const ZonePolygon = z.array(z.tuple([z.number(), z.number()])); + +export const ZoneSchema = z + .object({ + id: objectId("zone"), + object: z.literal("zone").default("zone"), + levelId: LevelNode.shape.id, // Required - must be attached to a level + name: z.string(), + // Polygon boundary - array of [x, z] coordinates defining the zone + polygon: ZonePolygon, + // Visual styling + color: z.string().default("#3b82f6"), // Default blue + metadata: z.json().optional().default({}), + }) + .describe( + dedent` + Zone schema - a polygon zone attached to a level + - object: "zone" + - id: zone id + - levelId: level this zone is attached to + - name: zone name + - polygon: array of [x, z] points defining the zone boundary + - color: hex color for visual styling + - metadata: zone metadata (optional) + `, + ); + +export type Zone = z.infer; +export type ZonePolygon = z.infer; diff --git a/packages/viewer/src/components/viewer/viewer.tsx b/packages/viewer/src/components/viewer/viewer.tsx index 305827ba..99f3fd33 100644 --- a/packages/viewer/src/components/viewer/viewer.tsx +++ b/packages/viewer/src/components/viewer/viewer.tsx @@ -29,7 +29,7 @@ const Viewer: React.FC = ({ children }) => { return renderer; }} shadows - camera={{ position: [3, 3, 3], fov: 50 }} + camera={{ position: [50, 50, 50], fov: 50 }} > diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 783cb5a1..6d3d9a63 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,25 +1,60 @@ "use client"; -import { BuildingNode, LevelNode } from "@pascal-app/core"; +import { BuildingNode, ItemNode, LevelNode, Zone } from "@pascal-app/core"; 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) +}; + type ViewerState = { + selection: SelectionPath; levelMode: "stacked" | "exploded" | "solo" | "manual"; + + // Actions setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void; - currentBuildingId: BuildingNode["id"] | null; - setCurrentBuildingId: (id: BuildingNode["id"] | null) => void; - currentLevelId: LevelNode["id"] | null; - setCurrentLevelId: (id: LevelNode["id"] | null) => void; + // Smart selection update + setSelection: (updates: Partial) => void; + resetSelection: () => void; }; const useViewer = create()((set, get) => ({ + selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, levelMode: "stacked", setLevelMode: (mode) => set({ levelMode: mode }), - currentLevelId: null, - setCurrentLevelId: (id) => set({ currentLevelId: id }), - currentBuildingId: null, - setCurrentBuildingId: (id) => set({ currentBuildingId: id }), + + setSelection: (updates) => + set((state) => { + 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 = []; + } else if (updates.levelId !== undefined) { + newSelection.zoneId = null; + newSelection.selectedIds = []; + } else if (updates.zoneId !== undefined) { + newSelection.selectedIds = []; + } + + return { selection: newSelection }; + }), + + resetSelection: () => + set({ + selection: { + buildingId: null, + levelId: null, + zoneId: null, + selectedIds: [], + }, + }), })); export default useViewer;