item catalog draft
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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));
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user