item catalog draft

This commit is contained in:
wass08
2026-01-17 15:40:04 +09:00
parent 87029005f6
commit 162c70552c
11 changed files with 812 additions and 225 deletions
@@ -1,3 +1,4 @@
import useEditor from "@/store/use-editor";
import { import {
emitter, emitter,
GridEvent, GridEvent,
@@ -17,8 +18,12 @@ export const ItemTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null); const cursorRef = useRef<Mesh>(null);
const draftItem = useRef<ItemNode | null>(null); const draftItem = useRef<ItemNode | null>(null);
const gridPosition = useRef(new Vector3(0, 0, 0)); const gridPosition = useRef(new Vector3(0, 0, 0));
const selectedItem = useEditor((state) => state.selectedItem);
useEffect(() => { useEffect(() => {
if (!selectedItem) {
return;
}
const createDraftItem = () => { const createDraftItem = () => {
const { currentLevelId } = useViewer.getState(); const { currentLevelId } = useViewer.getState();
if (!currentLevelId) { if (!currentLevelId) {
@@ -26,13 +31,13 @@ export const ItemTool: React.FC = () => {
} }
useScene.temporal.getState().pause(); useScene.temporal.getState().pause();
draftItem.current = ItemNode.parse({ 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", name: "Draft Item",
asset: { asset: selectedItem,
category: "furniture",
src: "/items/couch-small/model.glb",
dimensions: [2, 1, 3],
},
}); });
useScene.getState().createNode(draftItem.current, currentLevelId); useScene.getState().createNode(draftItem.current, currentLevelId);
}; };
@@ -68,7 +73,11 @@ export const ItemTool: React.FC = () => {
console.log("oh"); console.log("oh");
useScene.temporal.getState().resume(); useScene.temporal.getState().resume();
useScene.getState().updateNode(draftItem.current.id, { 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; draftItem.current = null;
@@ -86,13 +95,13 @@ export const ItemTool: React.FC = () => {
emitter.off("grid:move", onGridMove); emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
}; };
}, []); }, [selectedItem]);
useFrame((_, delta) => { useFrame((_, delta) => {
if (draftItem.current) { if (draftItem.current) {
const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id); const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id);
if (draftItemMesh) { if (draftItemMesh) {
draftItemMesh.position.lerp(gridPosition.current, delta * 10); draftItemMesh.position.lerp(gridPosition.current, delta * 20);
} }
} }
}); });
@@ -8,12 +8,14 @@ import { PhaseSwitcher } from "./phase-switcher";
import { StructureTools } from "./structure-tools"; import { StructureTools } from "./structure-tools";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { ItemCatalog } from "../item-catalog/item-catalog";
// import { ViewToggles } from "./view-toggles"; // import { ViewToggles } from "./view-toggles";
export function ActionMenu({ className }: { className?: string }) { export function ActionMenu({ className }: { className?: string }) {
const phase = useEditor((state) => state.phase); const phase = useEditor((state) => state.phase);
const mode = useEditor((state) => state.mode); const mode = useEditor((state) => state.mode);
const tool = useEditor((state) => state.tool);
const catalogCategory = useEditor((state) => state.catalogCategory);
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -22,20 +24,71 @@ export function ActionMenu({ className }: { className?: string }) {
"-translate-x-1/2 fixed bottom-6 left-1/2 z-50", "-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", "rounded-2xl border border-zinc-800 bg-zinc-950/90 shadow-2xl backdrop-blur-md",
"transition-all duration-200 ease-out", "transition-all duration-200 ease-out",
className className,
)} )}
> >
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{tool === "item" && catalogCategory && (
<motion.div
className={cn(
"overflow-hidden border-zinc-800 max-h-96 border-b px-2 py-2",
)}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
>
<ItemCatalog category={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */} {/* Structure Tools Row - Animated */}
<AnimatePresence> <AnimatePresence>
{phase === "structure" && mode === "build" && ( {phase === "structure" && mode === "build" && (
<motion.div <motion.div
className={cn( className={cn(
'overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2' "overflow-hidden border-zinc-800 max-h-20 border-b px-2 py-2",
)} )}
initial={{ opacity: 0, maxHeight: 0, paddingTop: 0, paddingBottom: 0,borderBottomWidth: 0, }} initial={{
animate={{ opacity: 1, maxHeight: 80, paddingTop: 8, paddingBottom: 8, borderBottomWidth: 1, }} opacity: 0,
exit={{ opacity: 0, maxHeight: 0, paddingTop: 0, paddingBottom: 0, borderBottomWidth: 0, }} maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
> >
<div className="w-max"> <div className="w-max">
<StructureTools /> <StructureTools />
@@ -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",
},
];
@@ -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 (
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src;
const attachmentIcon = getAttachmentIcon(item?.attachTo);
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
isSelected && "ring-2 ring-primary-foreground",
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
src={item.thumbnail}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={
item.attachTo === "ceiling"
? "Ceiling attachment"
: "Wall attachment"
}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
{item.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
+18 -13
View File
@@ -1,12 +1,16 @@
"use client"; "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 { useViewer } from "@pascal-app/viewer";
import { create } from "zustand"; import { create } from "zustand";
export type Phase = "site" | "structure" | "furnish"; export type Phase = "site" | "structure" | "furnish";
export type Mode = "select" | "edit" | "delete" | "build"; export type Mode = "select" | "edit" | "delete" | "build";
// Structure mode tools (building elements) // Structure mode tools (building elements)
@@ -23,23 +27,20 @@ export type StructureTool =
| "zone"; | "zone";
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = export type FurnishTool = "item";
| "item"
// Site mode tools // Site mode tools
export type SiteTool = "property-line"; export type SiteTool = "property-line";
// Catalog categories for furnish mode items // Catalog categories for furnish mode items
export type CatalogCategory = export type CatalogCategory =
| 'furniture' | "furniture"
| 'appliance' | "appliance"
| 'bathroom' | "bathroom"
| 'kitchen' | "kitchen"
| 'outdoor' | "outdoor"
| 'window' | "window"
| 'door' | "door";
// Combined tool type // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool; export type Tool = SiteTool | StructureTool | FurnishTool;
@@ -53,6 +54,8 @@ type EditorState = {
setTool: (tool: Tool | null) => void; setTool: (tool: Tool | null) => void;
catalogCategory: CatalogCategory | null; catalogCategory: CatalogCategory | null;
setCatalogCategory: (category: CatalogCategory | null) => void; setCatalogCategory: (category: CatalogCategory | null) => void;
selectedItem: AssetInput | null;
setSelectedItem: (item: AssetInput) => void;
}; };
const useEditor = create<EditorState>()((set, get) => ({ const useEditor = create<EditorState>()((set, get) => ({
@@ -106,6 +109,8 @@ const useEditor = create<EditorState>()((set, get) => ({
setTool: (tool) => set({ tool }), setTool: (tool) => set({ tool }),
catalogCategory: null, catalogCategory: null,
setCatalogCategory: (category) => set({ catalogCategory: category }), setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
})); }));
export default useEditor; export default useEditor;
+10 -9
View File
@@ -1,16 +1,17 @@
// Base // Base
export { BaseNode, generateId, objectId, nodeType, Material } from "./base" export { BaseNode, generateId, objectId, nodeType, Material } from "./base";
// Nodes // Nodes
export { SiteNode } from "./nodes/site" export { SiteNode } from "./nodes/site";
export { BuildingNode } from "./nodes/building" export { BuildingNode } from "./nodes/building";
export { LevelNode } from "./nodes/level" export { LevelNode } from "./nodes/level";
export { WallNode } from "./nodes/wall" export { WallNode } from "./nodes/wall";
export { ItemNode } from "./nodes/item" export { ItemNode } from "./nodes/item";
export type { AssetInput } from "./nodes/item";
// Union types // Union types
export { AnyNode } from "./types" export { AnyNode } from "./types";
export type { AnyNodeType, AnyNodeId } from "./types" export type { AnyNodeType, AnyNodeId } from "./types";
// Camera // Camera
export { CameraSchema } from "./camera" export { CameraSchema } from "./camera";
+16 -25
View File
@@ -2,6 +2,21 @@ import dedent from "dedent";
import { z } from "zod"; import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base"; 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<typeof assetSchema>;
export const ItemNode = BaseNode.extend({ export const ItemNode = BaseNode.extend({
id: objectId("item"), id: objectId("item"),
type: nodeType("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]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
side: z.enum(["front", "back"]).optional(), side: z.enum(["front", "back"]).optional(),
asset: z asset: assetSchema,
.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,
}),
}).describe(dedent`Item node - used to represent a item in the building }).describe(dedent`Item node - used to represent a item in the building
- position: position in level coordinate system (or parent coordinate system if attached) - position: position in level coordinate system (or parent coordinate system if attached)
- rotation: rotation in level coordinate system (or parent coordinate system if attached) - rotation: rotation in level coordinate system (or parent coordinate system if attached)
@@ -0,0 +1,151 @@
import { AnyNode, AnyNodeId } from "../../schema";
import { SceneState } from "../use-scene";
export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => 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<SceneState>) => void,
get: () => SceneState,
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
) => {
const parentsToUpdate = new Set<string>();
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<SceneState>) => void,
get: () => SceneState,
ids: AnyNodeId[],
) => {
const parentsToMarkDirty = new Set<string>();
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));
};
+13 -142
View File
@@ -6,8 +6,9 @@ import { LevelNode } from "../schema/nodes/level";
import { WallNode } from "../schema/nodes/wall"; import { WallNode } from "../schema/nodes/wall";
import { AnyNode, AnyNodeId } from "../schema/types"; import { AnyNode, AnyNodeId } from "../schema/types";
import { temporal } from "zundo"; 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 // 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>; nodes: Record<AnyNodeId, AnyNode>;
@@ -93,6 +94,8 @@ const useScene = create<SceneState>()(
name: "Window", name: "Window",
position: [2.5, 0.5, 0], position: [2.5, 0.5, 0],
asset: { asset: {
name: "Round Window",
thumbnail: "/items/window-round/thumbnail.png",
category: "windows", category: "windows",
attachTo: "wall", attachTo: "wall",
src: "/items/window-round/model.glb", src: "/items/window-round/model.glb",
@@ -136,152 +139,20 @@ const useScene = create<SceneState>()(
get().dirtyNodes.delete(id); get().dirtyNodes.delete(id);
}, },
createNodes: (ops) => { createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
set((state) => { createNode: (node, parentId) =>
const nextNodes = { ...state.nodes }; nodeActions.createNodesAction(set, get, [{ node, parentId }]),
const nextRootIds = [...state.rootNodeIds];
for (const { node, parentId } of ops) { updateNodes: (updates) =>
// 1. Assign parentId to the child (Safe because BaseNode has parentId) nodeActions.updateNodesAction(set, get, updates),
const newNode = { updateNode: (id, data) =>
...node, nodeActions.updateNodesAction(set, get, [{ id, data }]),
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<string>();
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 }]),
// --- DELETE --- // --- DELETE ---
deleteNodes: (ids) => { deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
const parentsToMarkDirty = new Set<string>();
set((state) => { deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
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]),
}), }),
{ {
partialize: (state) => { partialize: (state) => {
@@ -58,14 +58,14 @@ function updateWallGeometry(wallId: string) {
// Rotate mesh to look at 'end' point // Rotate mesh to look at 'end' point
const angle = Math.atan2( const angle = Math.atan2(
node.end[1] - node.start[1], node.end[1] - node.start[1],
node.end[0] - node.start[0] node.end[0] - node.start[0],
); );
mesh.rotation.y = -angle; mesh.rotation.y = -angle;
} }
export function generateExtrudedWall( export function generateExtrudedWall(
wallNode: WallNode, wallNode: WallNode,
childrenNodes: AnyNode[] childrenNodes: AnyNode[],
) { ) {
// 1. Calculate Wall Dimensions // 1. Calculate Wall Dimensions
const start = new THREE.Vector2(wallNode.start[0], wallNode.start[1]); 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 wallStart: [number, number] = [wallNode.start[0], wallNode.start[1]];
const wallAngle = Math.atan2( const wallAngle = Math.atan2(
wallNode.end[1] - wallNode.start[1], wallNode.end[1] - wallNode.start[1],
wallNode.end[0] - wallNode.start[0] wallNode.end[0] - wallNode.start[0],
); );
childrenNodes.forEach((child) => { childrenNodes.forEach((child) => {
@@ -96,7 +96,10 @@ export function generateExtrudedWall(
if (child.type !== "item") return; if (child.type !== "item") return;
const childMesh = sceneRegistry.nodes.get(child.id); const childMesh = sceneRegistry.nodes.get(child.id);
if (!childMesh) return;
if (!childMesh) {
return;
}
const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh; const cutoutMesh = childMesh.getObjectByName("cutout") as THREE.Mesh;
if (!cutoutMesh) return; if (!cutoutMesh) return;
@@ -132,7 +135,7 @@ export function generateExtrudedWall(
function createPathFromCutout( function createPathFromCutout(
cutoutMesh: THREE.Mesh, cutoutMesh: THREE.Mesh,
wallStart: [number, number], wallStart: [number, number],
wallAngle: number wallAngle: number,
): THREE.Path | null { ): THREE.Path | null {
const geometry = cutoutMesh.geometry; const geometry = cutoutMesh.geometry;
if (!geometry) return null; if (!geometry) return null;
@@ -2,16 +2,27 @@ import { emitter, ItemNode, useRegistry } from "@pascal-app/core";
import { Clone } from "@react-three/drei/core/Clone"; import { Clone } from "@react-three/drei/core/Clone";
import { useGLTF } from "@react-three/drei/core/Gltf"; import { useGLTF } from "@react-three/drei/core/Gltf";
import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; 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 { Group } from "three";
import { useNodeEvents } from "../../../hooks/use-node-events"; import { useNodeEvents } from "../../../hooks/use-node-events";
export const ItemRenderer = ({ node }: { node: ItemNode }) => { export const ItemRenderer = ({ node }: { node: ItemNode }) => {
const ref = useRef<Group>(null!); const ref = useRef<Group>(null!);
const { scene, nodes } = useGLTF(node.asset.src);
useRegistry(node.id, node.type, ref); useRegistry(node.id, node.type, ref);
return (
<group position={node.position} rotation={node.rotation} ref={ref}>
<Suspense>
<ModelRenderer node={node} />
</Suspense>
</group>
);
};
const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes } = useGLTF(node.asset.src);
if (nodes.cutout) { if (nodes.cutout) {
nodes.cutout.visible = false; nodes.cutout.visible = false;
} }
@@ -20,10 +31,10 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
return ( return (
<Clone <Clone
ref={ref}
object={scene} object={scene}
position={node.position} scale={node.asset.scale}
rotation={node.rotation} position={node.asset.offset}
rotation={node.asset.rotation}
{...handlers} {...handlers}
/> />
); );