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