From 25c14b54f282c6010e5de5907da747da1c61c69d Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 14 Jan 2026 10:36:29 +0900 Subject: [PATCH] structuring store --- packages/core/package.json | 6 + packages/core/src/schema/base.ts | 33 +++++ packages/core/src/schema/camera.ts | 13 ++ packages/core/src/schema/nodes/building.ts | 21 +++ packages/core/src/schema/nodes/item.ts | 48 +++++++ packages/core/src/schema/nodes/level.ts | 20 +++ packages/core/src/schema/nodes/site.ts | 47 ++++++ packages/core/src/schema/nodes/wall.ts | 32 +++++ packages/core/src/schema/types.ts | 16 +++ packages/core/src/store/useScene.ts | 159 +++++++++++++++++++++ 10 files changed, 395 insertions(+) create mode 100644 packages/core/src/schema/base.ts create mode 100644 packages/core/src/schema/camera.ts create mode 100644 packages/core/src/schema/nodes/building.ts create mode 100644 packages/core/src/schema/nodes/item.ts create mode 100644 packages/core/src/schema/nodes/level.ts create mode 100644 packages/core/src/schema/nodes/site.ts create mode 100644 packages/core/src/schema/nodes/wall.ts create mode 100644 packages/core/src/schema/types.ts create mode 100644 packages/core/src/store/useScene.ts diff --git a/packages/core/package.json b/packages/core/package.json index 41303105..46bc4dee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } } diff --git a/packages/core/src/schema/base.ts b/packages/core/src/schema/base.ts new file mode 100644 index 00000000..154a5fa4 --- /dev/null +++ b/packages/core/src/schema/base.ts @@ -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 = (prefix: T): `${T}_${string}` => + `${prefix}_${customId()}` as `${T}_${string}`; +export const objectId = (prefix: T) => { + const schema = z.templateLiteral([`${prefix}_`, z.string()]); + + return schema.default(() => generateId(prefix) as z.infer); +}; +export const nodeType = (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; diff --git a/packages/core/src/schema/camera.ts b/packages/core/src/schema/camera.ts new file mode 100644 index 00000000..adf74f26 --- /dev/null +++ b/packages/core/src/schema/camera.ts @@ -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; diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts new file mode 100644 index 00000000..096e226f --- /dev/null +++ b/packages/core/src/schema/nodes/building.ts @@ -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; diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts new file mode 100644 index 00000000..ce25c05c --- /dev/null +++ b/packages/core/src/schema/nodes/item.ts @@ -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; diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts new file mode 100644 index 00000000..291c13f4 --- /dev/null +++ b/packages/core/src/schema/nodes/level.ts @@ -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; diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts new file mode 100644 index 00000000..3f4cc312 --- /dev/null +++ b/packages/core/src/schema/nodes/site.ts @@ -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; diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts new file mode 100644 index 00000000..53a3b228 --- /dev/null +++ b/packages/core/src/schema/nodes/wall.ts @@ -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; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts new file mode 100644 index 00000000..7d0300c6 --- /dev/null +++ b/packages/core/src/schema/types.ts @@ -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; +export type AnyNodeType = AnyNode["type"]; +export type AnyNodeId = AnyNode["id"]; diff --git a/packages/core/src/store/useScene.ts b/packages/core/src/store/useScene.ts new file mode 100644 index 00000000..dd4882a2 --- /dev/null +++ b/packages/core/src/store/useScene.ts @@ -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; + + // 2. The Root: Which nodes are at the top level? + rootNodeIds: AnyNodeId[]; + + // 3. The "Dirty" Set: For the Wall/Physics systems + dirtyNodes: Set; + + // 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()((set, get) => ({ + // 1. Flat dictionary of all nodes + nodes: {}, + + // 2. Root node IDs + rootNodeIds: [], + + // 3. Dirty set + dirtyNodes: new Set(), + + 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 = { + // 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;