structuring store

This commit is contained in:
wass08
2026-01-14 10:36:29 +09:00
parent f5391e27fc
commit 25c14b54f2
10 changed files with 395 additions and 0 deletions
+6
View File
@@ -22,5 +22,11 @@
"@repo/typescript-config": "*",
"three": "^0.176",
"typescript": "5.9.2"
},
"dependencies": {
"dedent": "^1.7.1",
"nanoid": "^5.1.6",
"zod": "^4.3.5",
"zustand": "^5.0.10"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { CameraSchema } from "./camera";
const customId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 16);
/**
* Material preset name reference
* @example 'white', 'brick', 'wood', 'glass', 'preview-valid'
*/
export const Material = z.string().optional();
export const generateId = <T extends string>(prefix: T): `${T}_${string}` =>
`${prefix}_${customId()}` as `${T}_${string}`;
export const objectId = <T extends string>(prefix: T) => {
const schema = z.templateLiteral([`${prefix}_`, z.string()]);
return schema.default(() => generateId(prefix) as z.infer<typeof schema>);
};
export const nodeType = <T extends string>(type: T) =>
z.literal(type).default(type);
export const BaseNode = z.object({
object: z.literal("node").default("node"),
id: z.string(), // objectId('node'), @Aymericr: Thing is if we specify objectId here, when using BaseNode.extend, TS complains that the id is not assignable to the more specific type in the extended node
type: nodeType("node"),
name: z.string().optional(),
parentId: z.string().nullable().default(null),
visible: z.boolean().optional().default(true),
camera: CameraSchema.optional(),
metadata: z.json().optional().default({}),
});
export type BaseNode = z.infer<typeof BaseNode>;
+13
View File
@@ -0,0 +1,13 @@
import { z } from "zod";
const Vector3Schema = z.tuple([z.number(), z.number(), z.number()]);
export const CameraSchema = z.object({
position: Vector3Schema,
target: Vector3Schema,
mode: z.enum(["perspective", "orthographic"]).default("perspective"),
fov: z.number().optional(), // For perspective
zoom: z.number().optional(), // For orthographic
});
export type Camera = z.infer<typeof CameraSchema>;
@@ -0,0 +1,21 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { LevelNode } from "./level";
export const BuildingNode = BaseNode.extend({
id: objectId("building"),
type: nodeType("building"),
children: z.array(LevelNode).default([LevelNode.parse({})]),
position: 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]),
}).describe(
dedent`
Building node - used to represent a building
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes (each level is a tree of floor and wall nodes)
`
);
export type BuildingNode = z.infer<typeof BuildingNode>;
+48
View File
@@ -0,0 +1,48 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
export const ItemNode = BaseNode.extend({
id: objectId("item"),
type: nodeType("item"),
position: 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(),
asset: z
.object({
category: z.string(),
src: z.string(),
dimensions: z.tuple([z.number(), z.number(), z.number()]), // [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
- position: position in level coordinate system (or parent coordinate system if attached)
- rotation: rotation in level coordinate system (or parent coordinate system if attached)
- asset: asset data
- category: category of the item
- dimensions: size in level coordinate system
- src: url of the model
- attachTo: where to attach the item (wall, wall-side, ceiling)
- offset: corrective position offset for the model
- rotation: corrective rotation for the model
- scale: corrective scale for the model
`);
export type ItemNode = z.infer<typeof ItemNode>;
+20
View File
@@ -0,0 +1,20 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { WallNode } from "./wall";
export const LevelNode = BaseNode.extend({
id: objectId("level"),
type: nodeType("level"),
children: z.array(z.discriminatedUnion("type", [WallNode])).default([]),
// Specific props
level: z.number().default(0),
}).describe(
dedent`
Level node - used to represent a level in the building
- children: array of floor, wall, ceiling, roof, item nodes
- level: level number
`
);
export type LevelNode = z.infer<typeof LevelNode>;
+47
View File
@@ -0,0 +1,47 @@
// lib/scenegraph/schema/nodes/site.ts
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
import { BuildingNode } from "./building";
import { ItemNode } from "./item";
// 2D Polygon
const PropertyLineData = z.object({
type: z.literal("polygon"),
points: z.array(z.tuple([z.number(), z.number()])),
});
// 3D Polygon/Mesh
// const TerrainData = z.object({
// type: z.literal('terrain'),
// points: z.array(z.tuple([z.number(), z.number(), z.number()])),
// })
export const SiteNode = BaseNode.extend({
id: objectId("site"),
type: nodeType("site"),
// Specific props
polygon: PropertyLineData.optional().default({
type: "polygon",
// Default 30x30 square matching GRID_SIZE
points: [
[0, 0],
[30, 0],
[30, 30],
[0, 30],
],
}),
// terrain: TerrainData,
children: z
.array(z.discriminatedUnion("type", [BuildingNode, ItemNode]))
.default([BuildingNode.parse({})]),
}).describe(
dedent`
Site node - used to represent a site
- polygon: polygon data
- children: array of building and item nodes
`
);
export type SiteNode = z.infer<typeof SiteNode>;
+32
View File
@@ -0,0 +1,32 @@
import dedent from "dedent";
import { z } from "zod";
import { BaseNode, nodeType, objectId } from "../base";
// import { DoorNode } from "./door";
// import { ItemNode } from "./item";
// import { WindowNode } from "./window";
export const WallNode = BaseNode.extend({
id: objectId("wall"),
type: nodeType("wall"),
// get children() {
// return z
// .array(z.discriminatedUnion("type", [DoorNode, WindowNode, ItemNode]))
// .default([]);
// },
// Specific props
thickness: z.number().optional(),
height: z.number().optional(),
// e.g., start/end points for path
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
}).describe(
dedent`
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- height: height in meters
- start: start point of the wall in level coordinate system
- end: end point of the wall in level coordinate system
- size: size of the wall in grid units
`
);
export type WallNode = z.infer<typeof WallNode>;
+16
View File
@@ -0,0 +1,16 @@
import z from "zod";
import { BuildingNode } from "./nodes/building";
import { LevelNode } from "./nodes/level";
import { SiteNode } from "./nodes/site";
import { WallNode } from "./nodes/wall";
export const AnyNode = z.discriminatedUnion("type", [
SiteNode,
BuildingNode,
LevelNode,
WallNode,
]);
export type AnyNode = z.infer<typeof AnyNode>;
export type AnyNodeType = AnyNode["type"];
export type AnyNodeId = AnyNode["id"];
+159
View File
@@ -0,0 +1,159 @@
import { create } from "zustand";
import { ItemNode } from "../schema/nodes/item";
import { LevelNode } from "../schema/nodes/level";
import { WallNode } from "../schema/nodes/wall";
import { AnyNode, AnyNodeId } from "../schema/types";
type SceneState = {
// 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>;
// 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[];
// 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>;
// Actions
loadScene: () => void;
markDirty: (id: AnyNodeId) => void;
clearDirty: (id: AnyNodeId) => void;
levelMode: "stacked" | "exploded" | "solo" | "manual";
setLevelMode: (mode: "stacked" | "exploded" | "solo" | "manual") => void;
};
const useScene = create<SceneState>()((set, get) => ({
// 1. Flat dictionary of all nodes
nodes: {},
// 2. Root node IDs
rootNodeIds: [],
// 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(),
loadScene: () => {
const level0 = LevelNode.parse({
level: 0,
children: [],
});
const level1 = LevelNode.parse({
level: 1,
children: [],
});
const level2 = LevelNode.parse({
level: 2,
children: [],
});
const wall0 = WallNode.parse({
start: [0, 0],
end: [5, 0],
children: [],
});
const wall1 = WallNode.parse({
start: [0, 0],
end: [0, 5],
children: [],
});
const wall2 = WallNode.parse({
start: [5, 5],
end: [0, 5],
children: [],
});
const wall3 = WallNode.parse({
start: [5, 5],
end: [5, 0],
children: [],
});
level0.children.push(wall0.id, wall1.id, wall2.id, wall3.id);
// Define all nodes flat
const nodes: Record<AnyNodeId, AnyNode> = {
// Level 0
wall_0_0: WallNode.parse({
id: "wall_0_0",
start: [0, 0],
end: [5, 0],
children: ["item_1_1", "item_1_2", "item_1_3"],
}),
wall_0_1: WallNode.parse({
id: "wall_0_1",
start: [0, 0],
end: [0, 5],
}),
wall_0_2: WallNode.parse({
id: "wall_0_2",
start: [5, 5],
end: [0, 5],
}),
wall_0_3: WallNode.parse({
id: "wall_0_3",
start: [5, 5],
end: [5, 0],
}),
// Level 1
level_1: LevelNode.parse({
id: "level_1",
level: 1,
children: ["item_1_0"],
}),
item_1_0: ItemNode.parse({
id: "item_1_0",
position: [-1, 0, 0],
}),
item_1_1: ItemNode.parse({
id: "item_1_1",
parentId: "wall_0_0",
position: [2.5, 0.8, 0],
}),
item_1_2: ItemNode.parse({
id: "item_1_2",
parentId: "wall_0_0",
position: [1, 0.8, 0],
}),
item_1_3: ItemNode.parse({
id: "item_1_3",
parentId: "wall_0_0",
position: [4, 0.8, 0],
}),
// Level 2
level_2: LevelNode.parse({
id: "level_2",
level: 2,
children: [],
}),
};
// Root nodes are the levels
const rootNodeIds = ["level_0", "level_1", "level_2"];
get().dirtyNodes.add("wall_0_0");
get().dirtyNodes.add("wall_0_1");
get().dirtyNodes.add("wall_0_2");
get().dirtyNodes.add("wall_0_3");
set({ nodes, rootNodeIds });
},
markDirty: (id: string) => {
get().dirtyNodes.add(id);
},
clearDirty: (id: string) => {
get().dirtyNodes.delete(id);
},
levelMode: "exploded",
setLevelMode: (mode) => set({ levelMode: mode }),
}));
useScene.getState().loadScene();
export default useScene;