collections + unify color picker
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { generateId } from './base'
|
||||
import type { AnyNodeId } from './types'
|
||||
|
||||
export type CollectionId = `collection_${string}`
|
||||
|
||||
export type Collection = {
|
||||
id: CollectionId
|
||||
name: string
|
||||
color?: string
|
||||
nodeIds: AnyNodeId[]
|
||||
controlNodeId?: AnyNodeId
|
||||
}
|
||||
|
||||
export const generateCollectionId = (): CollectionId => generateId('collection')
|
||||
@@ -1,5 +1,7 @@
|
||||
// Base
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Collections
|
||||
export { generateCollectionId, type Collection, type CollectionId } from './collections'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import type { CollectionId } from '../collections'
|
||||
|
||||
// --- Control descriptors ---
|
||||
|
||||
@@ -110,6 +111,9 @@ export const ItemNode = BaseNode.extend({
|
||||
wallId: z.string().optional(),
|
||||
wallT: z.number().optional(), // 0-1 parametric position along wall
|
||||
|
||||
// Denormalized references to collections this node belongs to
|
||||
collectionIds: z.array(z.custom<CollectionId>()).optional(),
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import type { CollectionId } from '../../schema/collections'
|
||||
import type { SceneState } from '../use-scene'
|
||||
|
||||
type AnyContainerNode = AnyNode & { children: string[] }
|
||||
@@ -117,6 +118,7 @@ export const deleteNodesAction = (
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
const nextCollections = { ...state.collections }
|
||||
let nextRootIds = [...state.rootNodeIds]
|
||||
|
||||
for (const id of ids) {
|
||||
@@ -139,7 +141,17 @@ export const deleteNodesAction = (
|
||||
// 2. Remove from Root list
|
||||
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
||||
|
||||
// 3. Delete the node itself
|
||||
// 3. Remove from any collections it belongs to
|
||||
if ('collectionIds' in node && node.collectionIds) {
|
||||
for (const cid of node.collectionIds as CollectionId[]) {
|
||||
const col = nextCollections[cid]
|
||||
if (col) {
|
||||
nextCollections[cid] = { ...col, nodeIds: col.nodeIds.filter((nid) => nid !== id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete the node itself
|
||||
delete nextNodes[id]
|
||||
|
||||
// Inside the deleteNodes loop
|
||||
@@ -149,7 +161,7 @@ export const deleteNodesAction = (
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds }
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { temporal } from 'zundo'
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { BuildingNode } from '../schema'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { generateCollectionId } from '../schema/collections'
|
||||
import { LevelNode } from '../schema/nodes/level'
|
||||
import { SiteNode } from '../schema/nodes/site'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
@@ -21,6 +23,9 @@ export type SceneState = {
|
||||
// 3. The "Dirty" Set: For the Wall/Physics systems
|
||||
dirtyNodes: Set<AnyNodeId>
|
||||
|
||||
// 4. Relational metadata — not nodes
|
||||
collections: Record<CollectionId, Collection>
|
||||
|
||||
// Actions
|
||||
loadScene: () => void
|
||||
clearScene: () => void
|
||||
@@ -37,12 +42,19 @@ export type SceneState = {
|
||||
|
||||
deleteNode: (id: AnyNodeId) => void
|
||||
deleteNodes: (ids: AnyNodeId[]) => void
|
||||
|
||||
// Collection actions
|
||||
createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId
|
||||
deleteCollection: (id: CollectionId) => void
|
||||
updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void
|
||||
addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||
removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||
}
|
||||
|
||||
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
||||
|
||||
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds'>>>
|
||||
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>
|
||||
}
|
||||
|
||||
const useScene: UseSceneStore = create<SceneState>()(
|
||||
@@ -58,11 +70,15 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// 3. Dirty set
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
|
||||
// 4. Collections
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
|
||||
clearScene: () => {
|
||||
set({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
get().loadScene() // Default scene
|
||||
},
|
||||
@@ -143,11 +159,98 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
||||
|
||||
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
||||
|
||||
// --- COLLECTIONS ---
|
||||
|
||||
createCollection: (name, nodeIds = []) => {
|
||||
const id = generateCollectionId()
|
||||
const collection: Collection = { id, name, nodeIds }
|
||||
set((state) => {
|
||||
const nextCollections = { ...state.collections, [id]: collection }
|
||||
// Denormalize: stamp collectionId onto each node
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node) continue
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
return id
|
||||
},
|
||||
|
||||
deleteCollection: (id) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
const nextCollections = { ...state.collections }
|
||||
delete nextCollections[id]
|
||||
// Remove collectionId from all member nodes
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of col?.nodeIds ?? []) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) continue
|
||||
nextNodes[nodeId] = {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
} as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
|
||||
updateCollection: (id, data) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
|
||||
})
|
||||
},
|
||||
|
||||
addToCollection: (id, nodeId) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col || col.nodeIds.includes(nodeId)) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node) return { collections: nextCollections }
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
|
||||
removeFromCollection: (id, nodeId) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) return { collections: nextCollections }
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
} as AnyNode,
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
partialize: (state) => {
|
||||
const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
|
||||
return { nodes, rootNodeIds }
|
||||
const { nodes, rootNodeIds, collections } = state
|
||||
return { nodes, rootNodeIds, collections }
|
||||
},
|
||||
limit: 50, // Limit to last 50 actions
|
||||
},
|
||||
@@ -157,7 +260,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
version: 1,
|
||||
// Keep existing local scenes when the persist version changes.
|
||||
migrate: (persistedState) =>
|
||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds'>,
|
||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>,
|
||||
partialize: (state) => ({
|
||||
nodes: Object.fromEntries(
|
||||
Object.entries(state.nodes).filter(([_, node]) => {
|
||||
@@ -168,6 +271,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}),
|
||||
),
|
||||
rootNodeIds: state.rootNodeIds,
|
||||
collections: state.collections,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<SceneState>
|
||||
|
||||
Reference in New Issue
Block a user