item catalog draft
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof assetSchema>;
|
||||
|
||||
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)
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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<AnyNodeId, AnyNode>;
|
||||
|
||||
@@ -93,6 +94,8 @@ const useScene = create<SceneState>()(
|
||||
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<SceneState>()(
|
||||
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<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 }]),
|
||||
updateNodes: (updates) =>
|
||||
nodeActions.updateNodesAction(set, get, updates),
|
||||
updateNode: (id, data) =>
|
||||
nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
||||
|
||||
// --- DELETE ---
|
||||
|
||||
deleteNodes: (ids) => {
|
||||
const parentsToMarkDirty = new Set<string>();
|
||||
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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Group>(null!);
|
||||
const { scene, nodes } = useGLTF(node.asset.src);
|
||||
|
||||
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) {
|
||||
nodes.cutout.visible = false;
|
||||
}
|
||||
@@ -20,10 +31,10 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
||||
|
||||
return (
|
||||
<Clone
|
||||
ref={ref}
|
||||
object={scene}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.asset.scale}
|
||||
position={node.asset.offset}
|
||||
rotation={node.asset.rotation}
|
||||
{...handlers}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user