diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index dc41b6b9..988c99f5 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,3 +1,4 @@ +import useEditor from "@/store/use-editor"; import { emitter, GridEvent, @@ -17,8 +18,12 @@ export const ItemTool: React.FC = () => { const cursorRef = useRef(null); const draftItem = useRef(null); const gridPosition = useRef(new Vector3(0, 0, 0)); + const selectedItem = useEditor((state) => state.selectedItem); useEffect(() => { + if (!selectedItem) { + return; + } const createDraftItem = () => { const { currentLevelId } = useViewer.getState(); if (!currentLevelId) { @@ -26,13 +31,13 @@ export const ItemTool: React.FC = () => { } useScene.temporal.getState().pause(); draftItem.current = ItemNode.parse({ - position: [randInt(-10, 10), 0, randInt(-10, 10)], + position: [ + gridPosition.current.x, + gridPosition.current.y, + gridPosition.current.z, + ], name: "Draft Item", - asset: { - category: "furniture", - src: "/items/couch-small/model.glb", - dimensions: [2, 1, 3], - }, + asset: selectedItem, }); useScene.getState().createNode(draftItem.current, currentLevelId); }; @@ -68,7 +73,11 @@ export const ItemTool: React.FC = () => { console.log("oh"); useScene.temporal.getState().resume(); useScene.getState().updateNode(draftItem.current.id, { - position: [gridPosition.current.x, 0, gridPosition.current.z], + position: [ + gridPosition.current.x, + gridPosition.current.y, + gridPosition.current.z, + ], }); draftItem.current = null; @@ -86,13 +95,13 @@ export const ItemTool: React.FC = () => { emitter.off("grid:move", onGridMove); emitter.off("grid:click", onGridClick); }; - }, []); + }, [selectedItem]); useFrame((_, delta) => { if (draftItem.current) { const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); if (draftItemMesh) { - draftItemMesh.position.lerp(gridPosition.current, delta * 10); + draftItemMesh.position.lerp(gridPosition.current, delta * 20); } } }); diff --git a/apps/editor/components/ui/action-menu/index.tsx b/apps/editor/components/ui/action-menu/index.tsx index 6000c073..87b1e104 100644 --- a/apps/editor/components/ui/action-menu/index.tsx +++ b/apps/editor/components/ui/action-menu/index.tsx @@ -8,12 +8,14 @@ import { PhaseSwitcher } from "./phase-switcher"; import { StructureTools } from "./structure-tools"; import useEditor from "@/store/use-editor"; import { AnimatePresence, motion } from "motion/react"; +import { ItemCatalog } from "../item-catalog/item-catalog"; // import { ViewToggles } from "./view-toggles"; export function ActionMenu({ className }: { className?: string }) { - const phase = useEditor((state) => state.phase); const mode = useEditor((state) => state.mode); + const tool = useEditor((state) => state.tool); + const catalogCategory = useEditor((state) => state.catalogCategory); return ( @@ -22,26 +24,77 @@ export function ActionMenu({ className }: { className?: string }) { "-translate-x-1/2 fixed bottom-6 left-1/2 z-50", "rounded-2xl border border-zinc-800 bg-zinc-950/90 shadow-2xl backdrop-blur-md", "transition-all duration-200 ease-out", - className + className, )} > + {/* Structure Tools Row - Animated */} + + {tool === "item" && catalogCategory && ( + + + + )} + {/* Structure Tools Row - Animated */} - {phase === "structure" && mode === "build" && ( - -
- -
-
- )} + {phase === "structure" && mode === "build" && ( + +
+ +
+
+ )}
{/* Control Mode Row - Always visible, centered */}
diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx new file mode 100644 index 00000000..9827f4c4 --- /dev/null +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -0,0 +1,400 @@ +import { AssetInput, ItemNode } from "@pascal-app/core"; +export const CATALOG_ITEMS: AssetInput[] = [ + { + category: "furniture", + name: "Couch", + thumbnail: "/items/couch-medium/thumbnail.webp", + src: "/items/couch-medium/model.glb", + scale: [0.4, 0.4, 0.4], + dimensions: [4, 2, 2], + }, + { + category: "furniture", + name: "Small Couch", + thumbnail: "/items/couch-small/thumbnail.webp", + src: "/items/couch-small/model.glb", + scale: [0.4, 0.4, 0.4], + dimensions: [3, 2, 2], + }, + { + category: "furniture", + name: "Desk", + thumbnail: "/items/desk/thumbnail.webp", + src: "/items/desk/model.glb", + scale: [1, 1, 1], + dimensions: [4, 2, 2], + }, + { + category: "furniture", + name: "Table", + thumbnail: "/items/table/thumbnail.webp", + src: "/items/table/model.glb", + scale: [1, 1, 1], + rotation: [0, Math.PI / 2, 0], + dimensions: [6, 2, 3], + }, + { + category: "outdoor", + name: "Fence", + thumbnail: "/items/fence/thumbnail.webp", + src: "/items/fence/model.glb", + scale: [0.8, 0.8, 0.8], + offset: [0, 0, -0.12], + rotation: [0, Math.PI, 0], + dimensions: [6, 2, 1], + }, + { + category: "outdoor", + name: "Parking Spot", + thumbnail: "/items/parking-spot/thumbnail.webp", + src: "/items/parking-spot/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, Math.PI, 0], + dimensions: [12, 2, 6], + }, + { + category: "outdoor", + name: "Hedge", + thumbnail: "/items/hedge/thumbnail.webp", + src: "/items/hedge/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, Math.PI, 0], + dimensions: [4, 2, 3], + }, + { + category: "outdoor", + name: "Tree", + thumbnail: "/items/tree/thumbnail.webp", + src: "/items/tree/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, Math.PI, 0], + dimensions: [2, 2, 2], + }, + { + category: "appliance", + name: "Ceiling Light", + thumbnail: "/items/ceiling-light/thumbnail.webp", + src: "/items/ceiling-light/model.glb", + scale: [1, 1, 1], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Ceiling Fan", + thumbnail: "/items/ceiling-fan/thumbnail.webp", + src: "/items/ceiling-fan/model.glb", + scale: [0.003, 0.003, 0.003], + offset: [0, -0.62, 0], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Rectangular Ceiling Light", + thumbnail: "/items/rectangular-ceiling-light/thumbnail.webp", + src: "/items/rectangular-ceiling-light/model.glb", + scale: [0.04, 0.04, 0.04], + offset: [0.2, -0.1, 0.95], + rotation: [0, Math.PI / 2, 0], + dimensions: [2, 2, 4], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Circular Ceiling Light", + thumbnail: "/items/circular-ceiling-light/thumbnail.webp", + src: "/items/circular-ceiling-light/model.glb", + scale: [1, 1, 1], + offset: [0, -0.1, 0], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Smoke Detector", + thumbnail: "/items/smoke-detector/thumbnail.webp", + src: "/items/smoke-detector/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [Math.PI, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Sprinkler", + thumbnail: "/items/sprinkler/thumbnail.webp", + src: "/items/sprinkler/model.glb", + scale: [3, 3, 3], + offset: [0, 0, 0], + rotation: [Math.PI, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "appliance", + name: "Exit Sign", + thumbnail: "/items/exit-sign/thumbnail.webp", + src: "/items/exit-sign/model.glb", + scale: [0.5, 0.5, 0.5], + offset: [0, -0.3, 0], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "ceiling", + }, + { + category: "furniture", + name: "Wall Art", + thumbnail: "/items/wall-art-06/thumbnail.webp", + src: "/items/wall-art-06/model.glb", + offset: [0, 1, 0.15], + scale: [1, 1, 1], + rotation: [0, Math.PI, 0], + dimensions: [2, 2, 1], + attachTo: "wall-side", + }, + { + category: "furniture", + name: "Flat Screen TV", + thumbnail: "/items/flat-screen-tv/thumbnail.webp", + src: "/items/flat-screen-tv/model.glb", + offset: [-0.2, 1, 0.15], + scale: [0.42, 0.42, 0.42], + rotation: [0, 0, 0], + dimensions: [4, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Air Conditioner", + thumbnail: "/items/air-conditioner/thumbnail.webp", + src: "/items/air-conditioner/model.glb", + offset: [0, 2, 0.3], + scale: [0.0015, 0.0015, 0.0015], + rotation: [0, 0, 0], + dimensions: [3, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "AC Block", + thumbnail: "/items/ac-block/thumbnail.webp", + src: "/items/ac-block/model.glb", + offset: [0, 0, 0], + scale: [1, 1, 1], + rotation: [0, Math.PI, 0], + dimensions: [3, 2, 3], + }, + { + category: "appliance", + name: "Fire extinguisher", + thumbnail: "/items/fire-extinguisher/thumbnail.webp", + src: "/items/fire-extinguisher/model.glb", + offset: [0, 1.5, 0.22], + scale: [0.8, 0.8, 0.8], + rotation: [0, -Math.PI / 2, 0], + dimensions: [1, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Fire alarm ", + thumbnail: "/items/fire-alarm/thumbnail.webp", + src: "/items/fire-alarm/model.glb", + offset: [0, 1, 0.14], + scale: [0.15, 0.15, 0.15], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Fire detector ", + thumbnail: "/items/fire-detector/thumbnail.webp", + src: "/items/fire-detector/model.glb", + offset: [0, 1.1, 0.14], + scale: [2, 2, 2], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Alarm keypad", + thumbnail: "/items/alarm-keypad/thumbnail.webp", + src: "/items/alarm-keypad/model.glb", + offset: [0, 1.2, 0.1], + scale: [2, 2, 2], + rotation: [Math.PI / 2, 0, 0], + dimensions: [1, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Thermostat", + thumbnail: "/items/thermostat/thumbnail.webp", + src: "/items/thermostat/model.glb", + offset: [0, 1.2, 0.1], + scale: [5, 5, 5], + rotation: [0, 0, 0], + dimensions: [1, 2, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Electric panel", + thumbnail: "/items/electric-panel/thumbnail.webp", + src: "/items/electric-panel/model.glb", + offset: [0, 0.4, 0.16], + scale: [1, 1, 1], + rotation: [0, 0, 0], + dimensions: [2, 1, 1], + attachTo: "wall-side", + }, + { + category: "appliance", + name: "Air Conditioner Block", + thumbnail: "/items/air-conditioner-block/thumbnail.webp", + src: "/items/air-conditioner-block/model.glb", + offset: [0, 0.5, -0.06], + scale: [1, 1, 1], + rotation: [0, 0, 0], + dimensions: [2, 2, 2], + attachTo: "wall-side", + }, + { + category: "kitchen", + name: "Kitchen Fridge", + thumbnail: "/items/kitchen-fridge/thumbnail.webp", + src: "/items/kitchen-fridge/model.glb", + offset: [0, 0, 0.0], + scale: [0.7, 0.7, 0.7], + rotation: [0, 0, 0], + dimensions: [2, 2, 2], + }, + { + category: "kitchen", + name: "Freezer", + thumbnail: "/items/freezer/thumbnail.webp", + src: "/items/freezer/model.glb", + offset: [-0.43, 0, 0.25], + scale: [1.8, 1.8, 1.8], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + }, + { + category: "bathroom", + name: "Wall Sink", + thumbnail: "/items/wall-sink/thumbnail.webp", + src: "/items/wall-sink/model.glb", + offset: [0, 0, 0.25], + scale: [0.7, 0.7, 0.7], + rotation: [0, 0, 0], + dimensions: [2, 1, 1], + attachTo: "wall-side", + }, + { + category: "kitchen", + name: "Sink with cabinet", + thumbnail: "/items/sink-cabinet/thumbnail.webp", + src: "/items/sink-cabinet/model.glb", + offset: [0, 0.5, 0], + scale: [1.4, 1.4, 1.4], + rotation: [0, 0, 0], + dimensions: [4, 2, 1], + }, + { + category: "bathroom", + name: "Toilet", + thumbnail: "/items/toilet/thumbnail.webp", + src: "/items/toilet/model.glb", + offset: [0, 0, -0.1], + scale: [0.005, 0.005, 0.005], + rotation: [0, -Math.PI / 4, 0], + dimensions: [1, 2, 1], + }, + { + category: "window", + name: "Window", + thumbnail: "/items/window-small/thumbnail.webp", + src: "/items/window-small/model.glb", + offset: [0, 0.5, 0], + scale: [1, 1, 1], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "window", + name: "Window", + thumbnail: "/items/window-small-2/thumbnail.webp", + src: "/items/window-small-2/model.glb", + offset: [0, 0.5, 0], + scale: [1, 1, 1], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "window", + name: "Window", + thumbnail: "/items/window-round/thumbnail.webp", + src: "/items/window-round/model.glb", + offset: [0, 0.5, 0], + scale: [1, 1, 1], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "window", + name: "Window", + thumbnail: "/items/window1-black-open-1731/thumbnail.webp", + src: "/items/window1-black-open-1731/model.glb", + offset: [0, 0.5, 0], + scale: [0.4, 0.4, 0.4], + rotation: [0, 0, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "door", + name: "Door", + thumbnail: "/items/doorway-front/thumbnail.webp", + src: "/items/doorway-front/model.glb", + offset: [0, 0, 0], + scale: [2, 2, 2], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "door", + name: "Door", + thumbnail: "/items/door/thumbnail.webp", + src: "/items/door/model.glb", + offset: [0, 0, 0], + scale: [0.2, 0.2, 0.2], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, + { + category: "door", + name: "Door with bar", + thumbnail: "/items/door-with-bar/thumbnail.webp", + src: "/items/door-with-bar/model.glb", + offset: [0, 0, 0], + scale: [0.65, 0.65, 0.65], + rotation: [0, Math.PI, 0], + dimensions: [2, 1, 1], + attachTo: "wall", + }, +]; diff --git a/apps/editor/components/ui/item-catalog/item-catalog.tsx b/apps/editor/components/ui/item-catalog/item-catalog.tsx new file mode 100644 index 00000000..0380e6c2 --- /dev/null +++ b/apps/editor/components/ui/item-catalog/item-catalog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Image from "next/image"; +import { useEffect } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/primitives/tooltip"; + +import { cn } from "@/lib/utils"; +import useEditor, { CatalogCategory } from "@/store/use-editor"; +import { CATALOG_ITEMS } from "./catalog-items"; +import { AssetInput } from "@pascal-app/core"; + +export function ItemCatalog({ category }: { category: CatalogCategory }) { + const selectedItem = useEditor((state) => state.selectedItem); + const setSelectedItem = useEditor((state) => state.setSelectedItem); + + const filteredItems = CATALOG_ITEMS.filter( + (item) => item.category === category, + ); + + // Auto-select first item if current selection is not in the filtered list + useEffect(() => { + const isCurrentItemInCategory = filteredItems.some( + (item) => item.src === selectedItem?.src, + ); + if (!isCurrentItemInCategory && filteredItems.length > 0) { + setSelectedItem(filteredItems[0] as AssetInput); + } + }, [filteredItems, selectedItem?.src, setSelectedItem]); + + // Get attachment icon based on attachTo type + const getAttachmentIcon = (attachTo: AssetInput["attachTo"]) => { + if (attachTo === "wall" || attachTo === "wall-side") { + return "/icons/wall.png"; + } + if (attachTo === "ceiling") { + return "/icons/ceiling.png"; + } + return null; + }; + + return ( +
+ {filteredItems.map((item, index) => { + const isSelected = selectedItem?.src === item?.src; + const attachmentIcon = getAttachmentIcon(item?.attachTo); + return ( + + + + + + {item.name} + + + ); + })} +
+ ); +} diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 390b468b..a39d887f 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -1,12 +1,16 @@ "use client"; -import { BuildingNode, LevelNode, useScene } from "@pascal-app/core"; +import { + AssetInput, + BuildingNode, + LevelNode, + useScene, +} from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; import { create } from "zustand"; export type Phase = "site" | "structure" | "furnish"; - export type Mode = "select" | "edit" | "delete" | "build"; // Structure mode tools (building elements) @@ -23,23 +27,20 @@ export type StructureTool = | "zone"; // Furnish mode tools (items and decoration) -export type FurnishTool = - | "item" +export type FurnishTool = "item"; // Site mode tools export type SiteTool = "property-line"; - // Catalog categories for furnish mode items export type CatalogCategory = - | 'furniture' - | 'appliance' - | 'bathroom' - | 'kitchen' - | 'outdoor' - | 'window' - | 'door' - + | "furniture" + | "appliance" + | "bathroom" + | "kitchen" + | "outdoor" + | "window" + | "door"; // Combined tool type export type Tool = SiteTool | StructureTool | FurnishTool; @@ -53,6 +54,8 @@ type EditorState = { setTool: (tool: Tool | null) => void; catalogCategory: CatalogCategory | null; setCatalogCategory: (category: CatalogCategory | null) => void; + selectedItem: AssetInput | null; + setSelectedItem: (item: AssetInput) => void; }; const useEditor = create()((set, get) => ({ @@ -98,7 +101,7 @@ const useEditor = create()((set, get) => ({ // Maybe in furnish mode we force "solo" level view to see inside rooms viewer.setLevelMode("solo"); break; - } + } }, mode: "select", setMode: (mode) => set({ mode }), @@ -106,6 +109,8 @@ const useEditor = create()((set, get) => ({ setTool: (tool) => set({ tool }), catalogCategory: null, setCatalogCategory: (category) => set({ catalogCategory: category }), + selectedItem: null, + setSelectedItem: (item) => set({ selectedItem: item }), })); export default useEditor; diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 326879ca..96b580e9 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,16 +1,17 @@ // Base -export { BaseNode, generateId, objectId, nodeType, Material } from "./base" +export { BaseNode, generateId, objectId, nodeType, Material } from "./base"; // Nodes -export { SiteNode } from "./nodes/site" -export { BuildingNode } from "./nodes/building" -export { LevelNode } from "./nodes/level" -export { WallNode } from "./nodes/wall" -export { ItemNode } from "./nodes/item" +export { SiteNode } from "./nodes/site"; +export { BuildingNode } from "./nodes/building"; +export { LevelNode } from "./nodes/level"; +export { WallNode } from "./nodes/wall"; +export { ItemNode } from "./nodes/item"; +export type { AssetInput } from "./nodes/item"; // Union types -export { AnyNode } from "./types" -export type { AnyNodeType, AnyNodeId } from "./types" +export { AnyNode } from "./types"; +export type { AnyNodeType, AnyNodeId } from "./types"; // Camera -export { CameraSchema } from "./camera" +export { CameraSchema } from "./camera"; diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 558fe955..7b4d1355 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -2,6 +2,21 @@ import dedent from "dedent"; import { z } from "zod"; import { BaseNode, nodeType, objectId } from "../base"; +const assetSchema = z.object({ + category: z.string(), + name: z.string(), + thumbnail: z.string(), + src: z.string(), + dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] + attachTo: z.enum(["wall", "wall-side", "ceiling"]).optional(), + // These are "Corrective" transforms to normalize the GLB + offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), +}); + +export type AssetInput = z.input; + export const ItemNode = BaseNode.extend({ id: objectId("item"), type: nodeType("item"), @@ -9,31 +24,7 @@ export const ItemNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), side: z.enum(["front", "back"]).optional(), - asset: z - .object({ - category: z.string(), - src: z.string(), - dimensions: z - .tuple([z.number(), z.number(), z.number()]) - .default([1, 1, 1]), // [w, h, d] - attachTo: z.enum(["wall", "wall-side", "ceiling"]).optional(), - // These are "Corrective" transforms to normalize the GLB - offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), - rotation: z - .tuple([z.number(), z.number(), z.number()]) - .default([0, 0, 0]), - scale: z - .union([z.number(), z.tuple([z.number(), z.number(), z.number()])]) - .default(1), - }) - .default({ - category: "", - src: "", - dimensions: [1, 1, 1], - offset: [0, 0, 0], - rotation: [0, 0, 0], - scale: 1, - }), + asset: assetSchema, }).describe(dedent`Item node - used to represent a item in the building - position: position in level coordinate system (or parent coordinate system if attached) - rotation: rotation in level coordinate system (or parent coordinate system if attached) diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts new file mode 100644 index 00000000..73b42a45 --- /dev/null +++ b/packages/core/src/store/actions/node-actions.ts @@ -0,0 +1,151 @@ +import { AnyNode, AnyNodeId } from "../../schema"; +import { SceneState } from "../use-scene"; + +export const createNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + ops: { node: AnyNode; parentId?: AnyNodeId }[], +) => { + set((state) => { + const nextNodes = { ...state.nodes }; + const nextRootIds = [...state.rootNodeIds]; + + for (const { node, parentId } of ops) { + // 1. Assign parentId to the child (Safe because BaseNode has parentId) + const newNode = { + ...node, + parentId: parentId ?? null, + }; + + nextNodes[newNode.id] = newNode; + + // 2. Update the Parent's children list + if (parentId && nextNodes[parentId]) { + const parent = nextNodes[parentId]; + + // Type Guard: Check if the parent node is a container that supports children + if ("children" in parent && Array.isArray(parent.children)) { + nextNodes[parentId] = { + ...parent, + // Use Set to prevent duplicate IDs if createNode is called twice + children: Array.from( + new Set([...parent.children, newNode.id]), + ) as any, // We don't verify child types here + }; + } + } else if (!parentId) { + // 3. Handle Root nodes + if (!nextRootIds.includes(newNode.id)) { + nextRootIds.push(newNode.id); + } + } + } + + return { nodes: nextNodes, rootNodeIds: nextRootIds }; + }); + + // 4. System Sync + ops.forEach(({ node, parentId }) => { + get().markDirty(node.id); + if (parentId) get().markDirty(parentId); + }); +}; + +export const updateNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + updates: { id: AnyNodeId; data: Partial }[], +) => { + const parentsToUpdate = new Set(); + + set((state) => { + const nextNodes = { ...state.nodes }; + + for (const { id, data } of updates) { + const currentNode = nextNodes[id]; + if (!currentNode) continue; + + // Handle Reparenting Logic + if ( + data.parentId !== undefined && + data.parentId !== currentNode.parentId + ) { + // 1. Remove from old parent + if (currentNode.parentId && nextNodes[currentNode.parentId]) { + const oldParent = nextNodes[currentNode.parentId] as AnyContainerNode; + nextNodes[oldParent.id] = { + ...oldParent, + children: oldParent.children.filter((childId) => childId !== id), + }; + parentsToUpdate.add(oldParent.id); + } + + // 2. Add to new parent + if (data.parentId && nextNodes[data.parentId]) { + const newParent = nextNodes[data.parentId] as AnyContainerNode; + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newParent.children, id])), + }; + parentsToUpdate.add(newParent.id); + } + } + + // Apply the update + nextNodes[id] = { ...nextNodes[id], ...data }; + } + + return { nodes: nextNodes }; + }); + + // Mark dirty + updates.forEach((u) => get().markDirty(u.id)); + parentsToUpdate.forEach((pId) => get().markDirty(pId)); +}; + +export const deleteNodesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + ids: AnyNodeId[], +) => { + const parentsToMarkDirty = new Set(); + + set((state) => { + const nextNodes = { ...state.nodes }; + let nextRootIds = [...state.rootNodeIds]; + + for (const id of ids) { + const node = nextNodes[id]; + if (!node) continue; + + // 1. Remove reference from Parent + if (node.parentId && nextNodes[node.parentId]) { + const parent = nextNodes[node.parentId] as AnyContainerNode; + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((cid) => cid !== id), + }; + parentsToMarkDirty.add(parent.id); + } + } + + // 2. Remove from Root list + nextRootIds = nextRootIds.filter((rid) => rid !== id); + + // 3. Delete the node itself + delete nextNodes[id]; + + // Inside the deleteNodes loop + if ("children" in node && node.children.length > 0) { + // Recursively delete all children first + get().deleteNodes(node.children); + } + } + + return { nodes: nextNodes, rootNodeIds: nextRootIds }; + }); + + // Notify systems that the parent has changed (e.g. Wall needs to fill a window hole) + parentsToMarkDirty.forEach((pId) => get().markDirty(pId)); +}; diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 50ea2efd..66af6f4e 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -6,8 +6,9 @@ import { LevelNode } from "../schema/nodes/level"; import { WallNode } from "../schema/nodes/wall"; import { AnyNode, AnyNodeId } from "../schema/types"; import { temporal } from "zundo"; +import * as nodeActions from "./actions/node-actions"; -type SceneState = { +export type SceneState = { // 1. The Data: A flat dictionary of all nodes nodes: Record; @@ -93,6 +94,8 @@ const useScene = create()( name: "Window", position: [2.5, 0.5, 0], asset: { + name: "Round Window", + thumbnail: "/items/window-round/thumbnail.png", category: "windows", attachTo: "wall", src: "/items/window-round/model.glb", @@ -136,152 +139,20 @@ const useScene = create()( get().dirtyNodes.delete(id); }, - createNodes: (ops) => { - set((state) => { - const nextNodes = { ...state.nodes }; - const nextRootIds = [...state.rootNodeIds]; + createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), + createNode: (node, parentId) => + nodeActions.createNodesAction(set, get, [{ node, parentId }]), - for (const { node, parentId } of ops) { - // 1. Assign parentId to the child (Safe because BaseNode has parentId) - const newNode = { - ...node, - parentId: parentId ?? null, - }; - - nextNodes[newNode.id] = newNode; - - // 2. Update the Parent's children list - if (parentId && nextNodes[parentId]) { - const parent = nextNodes[parentId]; - - // Type Guard: Check if the parent node is a container that supports children - if ("children" in parent && Array.isArray(parent.children)) { - nextNodes[parentId] = { - ...parent, - // Use Set to prevent duplicate IDs if createNode is called twice - children: Array.from( - new Set([...parent.children, newNode.id]), - ) as any, // We don't verify child types here - }; - } - } else if (!parentId) { - // 3. Handle Root nodes - if (!nextRootIds.includes(newNode.id)) { - nextRootIds.push(newNode.id); - } - } - } - - return { nodes: nextNodes, rootNodeIds: nextRootIds }; - }); - - // 4. System Sync - ops.forEach(({ node, parentId }) => { - get().markDirty(node.id); - if (parentId) get().markDirty(parentId); - }); - }, - - // 3. The CONVENIENCE (Singular) - createNode: (node, parentId) => get().createNodes([{ node, parentId }]), - - updateNodes: (updates) => { - const parentsToUpdate = new Set(); - - set((state) => { - const nextNodes = { ...state.nodes }; - - for (const { id, data } of updates) { - const currentNode = nextNodes[id]; - if (!currentNode) continue; - - // Handle Reparenting Logic - if ( - data.parentId !== undefined && - data.parentId !== currentNode.parentId - ) { - // 1. Remove from old parent - if (currentNode.parentId && nextNodes[currentNode.parentId]) { - const oldParent = nextNodes[ - currentNode.parentId - ] as AnyContainerNode; - nextNodes[oldParent.id] = { - ...oldParent, - children: oldParent.children.filter( - (childId) => childId !== id, - ), - }; - parentsToUpdate.add(oldParent.id); - } - - // 2. Add to new parent - if (data.parentId && nextNodes[data.parentId]) { - const newParent = nextNodes[data.parentId] as AnyContainerNode; - nextNodes[newParent.id] = { - ...newParent, - children: Array.from(new Set([...newParent.children, id])), - }; - parentsToUpdate.add(newParent.id); - } - } - - // Apply the update - nextNodes[id] = { ...nextNodes[id], ...data }; - } - - return { nodes: nextNodes }; - }); - - // Mark dirty - updates.forEach((u) => get().markDirty(u.id)); - parentsToUpdate.forEach((pId) => get().markDirty(pId)); - }, - - updateNode: (id, data) => get().updateNodes([{ id, data }]), + updateNodes: (updates) => + nodeActions.updateNodesAction(set, get, updates), + updateNode: (id, data) => + nodeActions.updateNodesAction(set, get, [{ id, data }]), // --- DELETE --- - deleteNodes: (ids) => { - const parentsToMarkDirty = new Set(); + deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids), - set((state) => { - const nextNodes = { ...state.nodes }; - let nextRootIds = [...state.rootNodeIds]; - - for (const id of ids) { - const node = nextNodes[id]; - if (!node) continue; - - // 1. Remove reference from Parent - if (node.parentId && nextNodes[node.parentId]) { - const parent = nextNodes[node.parentId] as AnyContainerNode; - if (parent.children) { - nextNodes[parent.id] = { - ...parent, - children: parent.children.filter((cid) => cid !== id), - }; - parentsToMarkDirty.add(parent.id); - } - } - - // 2. Remove from Root list - nextRootIds = nextRootIds.filter((rid) => rid !== id); - - // 3. Delete the node itself - delete nextNodes[id]; - - // Note: If you want "Recursive Delete" (deleting a level deletes its walls), - // you would call deleteNodes recursively here for node.children. - } - - return { nodes: nextNodes, rootNodeIds: nextRootIds }; - }); - - // Notify systems that the parent has changed (e.g. Wall needs to fill a window hole) - parentsToMarkDirty.forEach((pId) => get().markDirty(pId)); - }, - - deleteNode: (id) => get().deleteNodes([id]), + deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]), }), { partialize: (state) => { diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index eb6cdc40..1cb2850f 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -58,14 +58,14 @@ function updateWallGeometry(wallId: string) { // Rotate mesh to look at 'end' point const angle = Math.atan2( node.end[1] - node.start[1], - node.end[0] - node.start[0] + node.end[0] - node.start[0], ); mesh.rotation.y = -angle; } export function generateExtrudedWall( wallNode: WallNode, - childrenNodes: AnyNode[] + childrenNodes: AnyNode[], ) { // 1. Calculate Wall Dimensions const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]); @@ -88,7 +88,7 @@ export function generateExtrudedWall( const wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]]; const wallAngle = Math.atan2( wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0] + wallNode.end[0] - wallNode.start[0], ); childrenNodes.forEach((child) => { @@ -96,7 +96,10 @@ export function generateExtrudedWall( if (child.type !== "item") return; const childMesh = sceneRegistry.nodes.get(child.id); - if (!childMesh) return; + + if (!childMesh) { + return; + } const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh; if (!cutoutMesh) return; @@ -132,7 +135,7 @@ export function generateExtrudedWall( function createPathFromCutout( cutoutMesh: THREE.Mesh, wallStart: [number, number], - wallAngle: number + wallAngle: number, ): THREE.Path | null { const geometry = cutoutMesh.geometry; if (!geometry) return null; diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 4b6659a9..f64717ff 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -2,16 +2,27 @@ import { emitter, ItemNode, useRegistry } from "@pascal-app/core"; import { Clone } from "@react-three/drei/core/Clone"; import { useGLTF } from "@react-three/drei/core/Gltf"; import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; -import { useCallback, useRef } from "react"; +import { Suspense, useCallback, useRef } from "react"; import { Group } from "three"; import { useNodeEvents } from "../../../hooks/use-node-events"; export const ItemRenderer = ({ node }: { node: ItemNode }) => { const ref = useRef(null!); - const { scene, nodes } = useGLTF(node.asset.src); useRegistry(node.id, node.type, ref); + return ( + + + + + + ); +}; + +const ModelRenderer = ({ node }: { node: ItemNode }) => { + const { scene, nodes } = useGLTF(node.asset.src); + if (nodes.cutout) { nodes.cutout.visible = false; } @@ -20,10 +31,10 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => { return ( );