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 {
emitter,
GridEvent,
@@ -17,8 +18,12 @@ export const ItemTool: React.FC = () => {
const cursorRef = useRef<Mesh>(null);
const draftItem = useRef<ItemNode | null>(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);
}
}
});
+69 -16
View File
@@ -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 (
<TooltipProvider>
@@ -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 */}
<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 */}
<AnimatePresence>
{phase === "structure" && mode === "build" && (
<motion.div
className={cn(
'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, }}
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">
<StructureTools />
</div>
</motion.div>
)}
{phase === "structure" && mode === "build" && (
<motion.div
className={cn(
"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,
}}
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">
<StructureTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
@@ -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>
);
}
+19 -14
View File
@@ -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<EditorState>()((set, get) => ({
@@ -98,7 +101,7 @@ const useEditor = create<EditorState>()((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<EditorState>()((set, get) => ({
setTool: (tool) => set({ tool }),
catalogCategory: null,
setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }),
}));
export default useEditor;