@pascal-app/core 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/events/bus.d.ts +14 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/scene-registry.d.ts +5 -1
  4. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  5. package/dist/hooks/scene-registry/scene-registry.js +10 -1
  6. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +8 -8
  7. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  8. package/dist/hooks/spatial-grid/spatial-grid-manager.js +88 -36
  9. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +1 -1
  10. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  11. package/dist/hooks/spatial-grid/spatial-grid-sync.js +16 -8
  12. package/dist/hooks/spatial-grid/spatial-grid.d.ts +3 -3
  13. package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -1
  14. package/dist/hooks/spatial-grid/spatial-grid.js +2 -2
  15. package/dist/hooks/spatial-grid/use-spatial-query.d.ts.map +1 -1
  16. package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts +2 -2
  17. package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts.map +1 -1
  18. package/dist/hooks/spatial-grid/wall-spatial-grid.js +2 -2
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/lib/space-detection.d.ts.map +1 -1
  23. package/dist/lib/space-detection.js +1 -1
  24. package/dist/schema/collections.d.ts +11 -0
  25. package/dist/schema/collections.d.ts.map +1 -0
  26. package/dist/schema/collections.js +2 -0
  27. package/dist/schema/index.d.ts +11 -8
  28. package/dist/schema/index.d.ts.map +1 -1
  29. package/dist/schema/index.js +11 -7
  30. package/dist/schema/nodes/door.d.ts +78 -0
  31. package/dist/schema/nodes/door.d.ts.map +1 -0
  32. package/dist/schema/nodes/door.js +67 -0
  33. package/dist/schema/nodes/item.d.ts +234 -0
  34. package/dist/schema/nodes/item.d.ts.map +1 -1
  35. package/dist/schema/nodes/item.js +65 -1
  36. package/dist/schema/nodes/level.d.ts.map +1 -1
  37. package/dist/schema/nodes/level.js +11 -1
  38. package/dist/schema/nodes/roof-segment.d.ts +51 -0
  39. package/dist/schema/nodes/roof-segment.d.ts.map +1 -0
  40. package/dist/schema/nodes/roof-segment.js +36 -0
  41. package/dist/schema/nodes/roof.d.ts +1 -4
  42. package/dist/schema/nodes/roof.d.ts.map +1 -1
  43. package/dist/schema/nodes/roof.js +9 -16
  44. package/dist/schema/nodes/site.d.ts +46 -0
  45. package/dist/schema/nodes/site.d.ts.map +1 -1
  46. package/dist/schema/types.d.ts +191 -4
  47. package/dist/schema/types.d.ts.map +1 -1
  48. package/dist/schema/types.js +4 -0
  49. package/dist/store/actions/node-actions.d.ts.map +1 -1
  50. package/dist/store/actions/node-actions.js +23 -4
  51. package/dist/store/use-interactive.d.ts +18 -0
  52. package/dist/store/use-interactive.d.ts.map +1 -0
  53. package/dist/store/use-interactive.js +50 -0
  54. package/dist/store/use-scene.d.ts +10 -1
  55. package/dist/store/use-scene.d.ts.map +1 -1
  56. package/dist/store/use-scene.js +180 -57
  57. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  58. package/dist/systems/ceiling/ceiling-system.js +5 -0
  59. package/dist/systems/door/door-system.d.ts +2 -0
  60. package/dist/systems/door/door-system.d.ts.map +1 -0
  61. package/dist/systems/door/door-system.js +211 -0
  62. package/dist/systems/item/item-system.js +3 -2
  63. package/dist/systems/roof/roof-system.d.ts +11 -3
  64. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  65. package/dist/systems/roof/roof-system.js +705 -210
  66. package/dist/systems/slab/slab-system.js +3 -3
  67. package/dist/systems/wall/wall-mitering.js +2 -2
  68. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  69. package/dist/systems/wall/wall-system.js +6 -6
  70. package/dist/systems/window/window-system.js +3 -3
  71. package/package.json +6 -6
@@ -1,12 +1,15 @@
1
1
  import type { TemporalState } from 'zundo';
2
2
  import { type StoreApi, type UseBoundStore } from 'zustand';
3
+ import type { Collection, CollectionId } from '../schema/collections';
3
4
  import type { AnyNode, AnyNodeId } from '../schema/types';
4
5
  export type SceneState = {
5
6
  nodes: Record<AnyNodeId, AnyNode>;
6
7
  rootNodeIds: AnyNodeId[];
7
8
  dirtyNodes: Set<AnyNodeId>;
9
+ collections: Record<CollectionId, Collection>;
8
10
  loadScene: () => void;
9
11
  clearScene: () => void;
12
+ unloadScene: () => void;
10
13
  setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void;
11
14
  markDirty: (id: AnyNodeId) => void;
12
15
  clearDirty: (id: AnyNodeId) => void;
@@ -22,10 +25,16 @@ export type SceneState = {
22
25
  }[]) => void;
23
26
  deleteNode: (id: AnyNodeId) => void;
24
27
  deleteNodes: (ids: AnyNodeId[]) => void;
28
+ createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId;
29
+ deleteCollection: (id: CollectionId) => void;
30
+ updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void;
31
+ addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void;
32
+ removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void;
25
33
  };
26
34
  type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
27
- temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds'>>>;
35
+ temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>;
28
36
  };
29
37
  declare const useScene: UseSceneStore;
30
38
  export default useScene;
39
+ export declare function clearSceneHistory(): void;
31
40
  //# sourceMappingURL=use-scene.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-scene.d.ts","sourceRoot":"","sources":["../../src/store/use-scene.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAE1C,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAA;AAKnE,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAIzD,MAAM,MAAM,UAAU,GAAG;IAEvB,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IAGjC,WAAW,EAAE,SAAS,EAAE,CAAA;IAGxB,UAAU,EAAE,GAAG,CAAC,SAAS,CAAC,CAAA;IAG1B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,IAAI,CAAA;IAE/E,SAAS,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IAClC,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IAEnC,UAAU,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,KAAK,IAAI,CAAA;IACzD,WAAW,EAAE,CAAC,GAAG,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;KAAE,EAAE,KAAK,IAAI,CAAA;IAErE,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAA;IAC3D,WAAW,EAAE,CAAC,OAAO,EAAE;QAAE,EAAE,EAAE,SAAS,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,EAAE,KAAK,IAAI,CAAA;IAE3E,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IACnC,WAAW,EAAE,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,IAAI,CAAA;CACxC,CAAA;AAID,KAAK,aAAa,GAAG,aAAa,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,GAAG;IACzD,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;CAC7E,CAAA;AAED,QAAA,MAAM,QAAQ,EAAE,aA6Jf,CAAA;AAED,eAAe,QAAQ,CAAA"}
1
+ {"version":3,"file":"use-scene.d.ts","sourceRoot":"","sources":["../../src/store/use-scene.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAE1C,OAAO,EAAU,KAAK,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,SAAS,CAAA;AAEnE,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAIrE,OAAO,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AA8CzD,MAAM,MAAM,UAAU,GAAG;IAEvB,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA;IAGjC,WAAW,EAAE,SAAS,EAAE,CAAA;IAGxB,UAAU,EAAE,GAAG,CAAC,SAAS,CAAC,CAAA;IAG1B,WAAW,EAAE,MAAM,CAAC,YAAY,EAAE,UAAU,CAAC,CAAA;IAG7C,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,WAAW,EAAE,MAAM,IAAI,CAAA;IACvB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,IAAI,CAAA;IAE/E,SAAS,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IAClC,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IAEnC,UAAU,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,KAAK,IAAI,CAAA;IACzD,WAAW,EAAE,CAAC,GAAG,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,EAAE,SAAS,CAAA;KAAE,EAAE,KAAK,IAAI,CAAA;IAErE,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAA;IAC3D,WAAW,EAAE,CAAC,OAAO,EAAE;QAAE,EAAE,EAAE,SAAS,CAAC;QAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,EAAE,KAAK,IAAI,CAAA;IAE3E,UAAU,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAA;IACnC,WAAW,EAAE,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,IAAI,CAAA;IAGvC,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,KAAK,YAAY,CAAA;IACvE,gBAAgB,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,IAAI,CAAA;IAC5C,gBAAgB,EAAE,CAAC,EAAE,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC,KAAK,IAAI,CAAA;IACnF,eAAe,EAAE,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,KAAK,IAAI,CAAA;IAC9D,oBAAoB,EAAE,CAAC,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,KAAK,IAAI,CAAA;CACpE,CAAA;AAID,KAAK,aAAa,GAAG,aAAa,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,GAAG;IACzD,QAAQ,EAAE,QAAQ,CAAC,aAAa,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;CAC7F,CAAA;AAED,QAAA,MAAM,QAAQ,EAAE,aAqMf,CAAA;AAED,eAAe,QAAQ,CAAA;AAOvB,wBAAgB,iBAAiB,SAKhC"}
@@ -1,35 +1,82 @@
1
1
  'use client';
2
2
  import { temporal } from 'zundo';
3
3
  import { create } from 'zustand';
4
- import { persist } from 'zustand/middleware';
5
4
  import { BuildingNode } from '../schema';
5
+ import { generateCollectionId } from '../schema/collections';
6
6
  import { LevelNode } from '../schema/nodes/level';
7
7
  import { SiteNode } from '../schema/nodes/site';
8
- import { isObject } from '../utils/types';
9
8
  import * as nodeActions from './actions/node-actions';
10
- const useScene = create()(persist(temporal((set, get) => ({
9
+ function migrateNodes(nodes) {
10
+ const patchedNodes = { ...nodes };
11
+ for (const [id, node] of Object.entries(patchedNodes)) {
12
+ // 1. Item scale migration
13
+ if (node.type === 'item' && !('scale' in node)) {
14
+ patchedNodes[id] = { ...node, scale: [1, 1, 1] };
15
+ }
16
+ // 2. Old roof to new roof + segment migration
17
+ if (node.type === 'roof' && !('children' in node)) {
18
+ const oldRoof = node;
19
+ const suffix = id.includes('_') ? id.split('_')[1] : Math.random().toString(36).slice(2);
20
+ const segmentId = `rseg_${suffix}`;
21
+ const segment = {
22
+ object: 'node',
23
+ id: segmentId,
24
+ type: 'roof-segment',
25
+ parentId: id,
26
+ visible: oldRoof.visible ?? true,
27
+ metadata: {},
28
+ position: [0, 0, 0],
29
+ rotation: 0,
30
+ roofType: 'gable',
31
+ width: oldRoof.length ?? 8,
32
+ depth: (oldRoof.leftWidth ?? 2.2) + (oldRoof.rightWidth ?? 2.2),
33
+ wallHeight: 0,
34
+ roofHeight: oldRoof.height ?? 2.5,
35
+ wallThickness: 0.1,
36
+ deckThickness: 0.1,
37
+ overhang: 0.3,
38
+ shingleThickness: 0.05,
39
+ };
40
+ patchedNodes[segmentId] = segment;
41
+ patchedNodes[id] = {
42
+ ...oldRoof,
43
+ children: [segmentId],
44
+ };
45
+ }
46
+ }
47
+ return patchedNodes;
48
+ }
49
+ const useScene = create()(temporal((set, get) => ({
11
50
  // 1. Flat dictionary of all nodes
12
51
  nodes: {},
13
52
  // 2. Root node IDs
14
53
  rootNodeIds: [],
15
54
  // 3. Dirty set
16
55
  dirtyNodes: new Set(),
17
- clearScene: () => {
56
+ // 4. Collections
57
+ collections: {},
58
+ unloadScene: () => {
18
59
  set({
19
60
  nodes: {},
20
61
  rootNodeIds: [],
21
62
  dirtyNodes: new Set(),
63
+ collections: {},
22
64
  });
65
+ },
66
+ clearScene: () => {
67
+ get().unloadScene();
23
68
  get().loadScene(); // Default scene
24
69
  },
25
70
  setScene: (nodes, rootNodeIds) => {
71
+ // Apply backward compatibility migrations
72
+ const patchedNodes = migrateNodes(nodes);
26
73
  set({
27
- nodes,
74
+ nodes: patchedNodes,
28
75
  rootNodeIds,
29
76
  dirtyNodes: new Set(),
30
77
  });
31
78
  // Mark all nodes as dirty to trigger re-validation
32
- Object.values(nodes).forEach((node) => {
79
+ Object.values(patchedNodes).forEach((node) => {
33
80
  get().markDirty(node.id);
34
81
  });
35
82
  },
@@ -75,60 +122,112 @@ const useScene = create()(persist(temporal((set, get) => ({
75
122
  // --- DELETE ---
76
123
  deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
77
124
  deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
125
+ // --- COLLECTIONS ---
126
+ createCollection: (name, nodeIds = []) => {
127
+ const id = generateCollectionId();
128
+ const collection = { id, name, nodeIds };
129
+ set((state) => {
130
+ const nextCollections = { ...state.collections, [id]: collection };
131
+ // Denormalize: stamp collectionId onto each node
132
+ const nextNodes = { ...state.nodes };
133
+ for (const nodeId of nodeIds) {
134
+ const node = nextNodes[nodeId];
135
+ if (!node)
136
+ continue;
137
+ const existing = ('collectionIds' in node ? node.collectionIds : undefined) ?? [];
138
+ nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] };
139
+ }
140
+ return { collections: nextCollections, nodes: nextNodes };
141
+ });
142
+ return id;
143
+ },
144
+ deleteCollection: (id) => {
145
+ set((state) => {
146
+ const col = state.collections[id];
147
+ const nextCollections = { ...state.collections };
148
+ delete nextCollections[id];
149
+ // Remove collectionId from all member nodes
150
+ const nextNodes = { ...state.nodes };
151
+ for (const nodeId of col?.nodeIds ?? []) {
152
+ const node = nextNodes[nodeId];
153
+ if (!(node && 'collectionIds' in node))
154
+ continue;
155
+ nextNodes[nodeId] = {
156
+ ...node,
157
+ collectionIds: node.collectionIds.filter((cid) => cid !== id),
158
+ };
159
+ }
160
+ return { collections: nextCollections, nodes: nextNodes };
161
+ });
162
+ },
163
+ updateCollection: (id, data) => {
164
+ set((state) => {
165
+ const col = state.collections[id];
166
+ if (!col)
167
+ return state;
168
+ return { collections: { ...state.collections, [id]: { ...col, ...data } } };
169
+ });
170
+ },
171
+ addToCollection: (id, nodeId) => {
172
+ set((state) => {
173
+ const col = state.collections[id];
174
+ if (!col || col.nodeIds.includes(nodeId))
175
+ return state;
176
+ const nextCollections = {
177
+ ...state.collections,
178
+ [id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },
179
+ };
180
+ const node = state.nodes[nodeId];
181
+ if (!node)
182
+ return { collections: nextCollections };
183
+ const existing = ('collectionIds' in node ? node.collectionIds : undefined) ?? [];
184
+ const nextNodes = {
185
+ ...state.nodes,
186
+ [nodeId]: { ...node, collectionIds: [...existing, id] },
187
+ };
188
+ return { collections: nextCollections, nodes: nextNodes };
189
+ });
190
+ },
191
+ removeFromCollection: (id, nodeId) => {
192
+ set((state) => {
193
+ const col = state.collections[id];
194
+ if (!col)
195
+ return state;
196
+ const nextCollections = {
197
+ ...state.collections,
198
+ [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
199
+ };
200
+ const node = state.nodes[nodeId];
201
+ if (!(node && 'collectionIds' in node))
202
+ return { collections: nextCollections };
203
+ const nextNodes = {
204
+ ...state.nodes,
205
+ [nodeId]: {
206
+ ...node,
207
+ collectionIds: node.collectionIds.filter((cid) => cid !== id),
208
+ },
209
+ };
210
+ return { collections: nextCollections, nodes: nextNodes };
211
+ });
212
+ },
78
213
  }), {
79
214
  partialize: (state) => {
80
- const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history
81
- return { nodes, rootNodeIds };
215
+ const { nodes, rootNodeIds, collections } = state;
216
+ return { nodes, rootNodeIds, collections };
82
217
  },
83
218
  limit: 50, // Limit to last 50 actions
84
- }), {
85
- name: 'editor-storage',
86
- partialize: (state) => ({
87
- nodes: Object.fromEntries(Object.entries(state.nodes).filter(([_, node]) => {
88
- const meta = node.metadata;
89
- const isTransient = isObject(meta) && 'isTransient' in meta && meta.isTransient === true;
90
- return !isTransient;
91
- })),
92
- rootNodeIds: state.rootNodeIds,
93
- }),
94
- onRehydrateStorage: (state) => {
95
- console.log('hydrating...');
96
- return (state, error) => {
97
- if (error) {
98
- console.log('an error happened during hydration', error);
99
- return;
100
- }
101
- if (!state) {
102
- console.log('hydration finished - no state');
103
- return;
104
- }
105
- // Migration: Wrap old scenes (where root is not a SiteNode) in a SiteNode
106
- const rootId = state.rootNodeIds?.[0];
107
- const rootNode = rootId ? state.nodes[rootId] : null;
108
- if (rootNode && rootNode.type !== 'site') {
109
- console.log('Migrating old scene: wrapping in SiteNode');
110
- // Collect existing root nodes (should be BuildingNode or ItemNode)
111
- const existingRoots = (state.rootNodeIds || [])
112
- .map(id => state.nodes[id])
113
- .filter(node => node?.type === 'building' || node?.type === 'item');
114
- // Create a new SiteNode with existing roots as children
115
- const site = SiteNode.parse({
116
- children: existingRoots,
117
- });
118
- // Add site to nodes
119
- state.nodes[site.id] = site;
120
- // Update root to be the site
121
- state.rootNodeIds = [site.id];
122
- console.log('Migration complete: scene now has SiteNode as root');
123
- }
124
- console.log('hydration finished');
125
- };
126
- },
127
219
  }));
128
220
  export default useScene;
129
- // Track previous temporal state lengths
221
+ // Track previous temporal state lengths and node snapshot for diffing
130
222
  let prevPastLength = 0;
131
223
  let prevFutureLength = 0;
224
+ let prevNodesSnapshot = null;
225
+ export function clearSceneHistory() {
226
+ useScene.temporal.getState().clear();
227
+ prevPastLength = 0;
228
+ prevFutureLength = 0;
229
+ prevNodesSnapshot = null;
230
+ }
132
231
  // Subscribe to the temporal store (Undo/Redo events)
133
232
  useScene.temporal.subscribe((state) => {
134
233
  const currentPastLength = state.pastStates.length;
@@ -138,16 +237,40 @@ useScene.temporal.subscribe((state) => {
138
237
  const didUndo = currentFutureLength > prevFutureLength;
139
238
  const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength;
140
239
  if (didUndo || didRedo) {
240
+ // Capture the previous snapshot before RAF fires
241
+ const snapshotBefore = prevNodesSnapshot;
141
242
  // Use RAF to ensure all middleware and store updates are complete
142
243
  requestAnimationFrame(() => {
143
244
  const currentNodes = useScene.getState().nodes;
144
- // Trigger a full scene re-validation after undo/redo
145
- Object.values(currentNodes).forEach((node) => {
146
- useScene.getState().markDirty(node.id);
147
- });
245
+ const { markDirty } = useScene.getState();
246
+ if (snapshotBefore) {
247
+ // Diff: only mark nodes that actually changed
248
+ for (const [id, node] of Object.entries(currentNodes)) {
249
+ if (snapshotBefore[id] !== node) {
250
+ markDirty(id);
251
+ // Also mark parent so merged geometries update
252
+ if (node.parentId)
253
+ markDirty(node.parentId);
254
+ }
255
+ }
256
+ // Nodes that were deleted (exist in prev but not current)
257
+ for (const [id, node] of Object.entries(snapshotBefore)) {
258
+ if (!currentNodes[id]) {
259
+ if (node.parentId)
260
+ markDirty(node.parentId);
261
+ }
262
+ }
263
+ }
264
+ else {
265
+ // No snapshot to diff against — fall back to marking all
266
+ for (const node of Object.values(currentNodes)) {
267
+ markDirty(node.id);
268
+ }
269
+ }
148
270
  });
149
271
  }
150
- // Update tracked lengths
272
+ // Update tracked lengths and snapshot
151
273
  prevPastLength = currentPastLength;
152
274
  prevFutureLength = currentFutureLength;
275
+ prevNodesSnapshot = useScene.getState().nodes;
153
276
  });
@@ -1 +1 @@
1
- {"version":3,"file":"ceiling-system.d.ts","sourceRoot":"","sources":["../../../src/systems/ceiling/ceiling-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAa,WAAW,EAAE,MAAM,cAAc,CAAA;AAO1D,eAAO,MAAM,aAAa,YAuBzB,CAAA;AAeD;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,WAAW,GAAG,KAAK,CAAC,cAAc,CA+CtF"}
1
+ {"version":3,"file":"ceiling-system.d.ts","sourceRoot":"","sources":["../../../src/systems/ceiling/ceiling-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAa,WAAW,EAAE,MAAM,cAAc,CAAA;AAO1D,eAAO,MAAM,aAAa,YAuBzB,CAAA;AAqBD;;GAEG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,WAAW,GAAG,KAAK,CAAC,cAAc,CA+CtF"}
@@ -34,6 +34,11 @@ function updateCeilingGeometry(node, mesh) {
34
34
  const newGeo = generateCeilingGeometry(node);
35
35
  mesh.geometry.dispose();
36
36
  mesh.geometry = newGeo;
37
+ const gridMesh = mesh.getObjectByName('ceiling-grid');
38
+ if (gridMesh) {
39
+ gridMesh.geometry.dispose();
40
+ gridMesh.geometry = newGeo;
41
+ }
37
42
  // Position at the ceiling height
38
43
  mesh.position.y = (node.height ?? 2.5) - 0.01; // Slight offset to avoid z-fighting with upper-level slabs
39
44
  }
@@ -0,0 +1,2 @@
1
+ export declare const DoorSystem: () => null;
2
+ //# sourceMappingURL=door-system.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"door-system.d.ts","sourceRoot":"","sources":["../../../src/systems/door/door-system.tsx"],"names":[],"mappings":"AA4BA,eAAO,MAAM,UAAU,YA2BtB,CAAA"}
@@ -0,0 +1,211 @@
1
+ import { useFrame } from '@react-three/fiber';
2
+ import * as THREE from 'three';
3
+ import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu';
4
+ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
5
+ import useScene from '../../store/use-scene';
6
+ const baseMaterial = new MeshStandardNodeMaterial({
7
+ name: 'door-base',
8
+ color: '#f2f0ed',
9
+ roughness: 0.5,
10
+ metalness: 0,
11
+ });
12
+ const glassMaterial = new MeshStandardNodeMaterial({
13
+ name: 'door-glass',
14
+ color: 'lightblue',
15
+ roughness: 0.05,
16
+ metalness: 0.1,
17
+ transparent: true,
18
+ opacity: 0.35,
19
+ side: DoubleSide,
20
+ depthWrite: false,
21
+ });
22
+ // Invisible material for root mesh — used as selection hitbox only
23
+ const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false });
24
+ export const DoorSystem = () => {
25
+ const dirtyNodes = useScene((state) => state.dirtyNodes);
26
+ const clearDirty = useScene((state) => state.clearDirty);
27
+ useFrame(() => {
28
+ if (dirtyNodes.size === 0)
29
+ return;
30
+ const nodes = useScene.getState().nodes;
31
+ dirtyNodes.forEach((id) => {
32
+ const node = nodes[id];
33
+ if (!node || node.type !== 'door')
34
+ return;
35
+ const mesh = sceneRegistry.nodes.get(id);
36
+ if (!mesh)
37
+ return; // Keep dirty until mesh mounts
38
+ updateDoorMesh(node, mesh);
39
+ clearDirty(id);
40
+ // Rebuild the parent wall so its cutout reflects the updated door geometry
41
+ if (node.parentId) {
42
+ useScene.getState().dirtyNodes.add(node.parentId);
43
+ }
44
+ });
45
+ }, 3);
46
+ return null;
47
+ };
48
+ function addBox(parent, material, w, h, d, x, y, z) {
49
+ const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
50
+ m.position.set(x, y, z);
51
+ parent.add(m);
52
+ }
53
+ function updateDoorMesh(node, mesh) {
54
+ // Root mesh is an invisible hitbox; all visuals live in child meshes
55
+ mesh.geometry.dispose();
56
+ mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth);
57
+ mesh.material = hitboxMaterial;
58
+ // Sync transform from node (React may lag behind the system by a frame during drag)
59
+ mesh.position.set(node.position[0], node.position[1], node.position[2]);
60
+ mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]);
61
+ // Dispose and remove all old visual children; preserve 'cutout'
62
+ for (const child of [...mesh.children]) {
63
+ if (child.name === 'cutout')
64
+ continue;
65
+ if (child instanceof THREE.Mesh)
66
+ child.geometry.dispose();
67
+ mesh.remove(child);
68
+ }
69
+ const { width, height, frameThickness, frameDepth, threshold, thresholdHeight, segments, handle, handleHeight, handleSide, doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide, } = node;
70
+ // Leaf occupies the full opening (no bottom frame bar — door opens to floor)
71
+ const leafW = width - 2 * frameThickness;
72
+ const leafH = height - frameThickness; // only top frame
73
+ const leafDepth = 0.04;
74
+ // Leaf center is shifted down from door center by half the top frame
75
+ const leafCenterY = -frameThickness / 2;
76
+ // ── Frame members ──
77
+ // Left post — full height
78
+ addBox(mesh, baseMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0);
79
+ // Right post — full height
80
+ addBox(mesh, baseMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0);
81
+ // Head (top bar) — full width
82
+ addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0);
83
+ // ── Threshold (inside the frame) ──
84
+ if (threshold) {
85
+ addBox(mesh, baseMaterial, leafW, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0);
86
+ }
87
+ // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
88
+ const cpX = contentPadding[0];
89
+ const cpY = contentPadding[1];
90
+ if (cpY > 0) {
91
+ // Top strip
92
+ addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0);
93
+ // Bottom strip
94
+ addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0);
95
+ }
96
+ if (cpX > 0) {
97
+ const innerH = leafH - 2 * cpY;
98
+ // Left strip
99
+ addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0);
100
+ // Right strip
101
+ addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0);
102
+ }
103
+ // Content area inside padding
104
+ const contentW = leafW - 2 * cpX;
105
+ const contentH = leafH - 2 * cpY;
106
+ // ── Segments (stacked top to bottom within content area) ──
107
+ const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0);
108
+ const contentTop = leafCenterY + contentH / 2;
109
+ let segY = contentTop;
110
+ for (const seg of segments) {
111
+ const segH = (seg.heightRatio / totalRatio) * contentH;
112
+ const segCenterY = segY - segH / 2;
113
+ const numCols = seg.columnRatios.length;
114
+ const colSum = seg.columnRatios.reduce((a, b) => a + b, 0);
115
+ const usableW = contentW - (numCols - 1) * seg.dividerThickness;
116
+ const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW);
117
+ // Column x-centers (relative to mesh center)
118
+ const colXCenters = [];
119
+ let cx = -contentW / 2;
120
+ for (let c = 0; c < numCols; c++) {
121
+ colXCenters.push(cx + colWidths[c] / 2);
122
+ cx += colWidths[c];
123
+ if (c < numCols - 1)
124
+ cx += seg.dividerThickness;
125
+ }
126
+ // Column dividers within this segment
127
+ cx = -contentW / 2;
128
+ for (let c = 0; c < numCols - 1; c++) {
129
+ cx += colWidths[c];
130
+ addBox(mesh, baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0);
131
+ cx += seg.dividerThickness;
132
+ }
133
+ // Segment content per column
134
+ for (let c = 0; c < numCols; c++) {
135
+ const colW = colWidths[c];
136
+ const colX = colXCenters[c];
137
+ if (seg.type === 'glass') {
138
+ // Glass only — no opaque backing so it's truly transparent
139
+ const glassDepth = Math.max(0.004, leafDepth * 0.15);
140
+ addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0);
141
+ }
142
+ else if (seg.type === 'panel') {
143
+ // Opaque leaf backing for this column
144
+ addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
145
+ // Raised panel detail
146
+ const panelW = colW - 2 * seg.panelInset;
147
+ const panelH = segH - 2 * seg.panelInset;
148
+ if (panelW > 0.01 && panelH > 0.01) {
149
+ const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth);
150
+ const panelZ = leafDepth / 2 + effectiveDepth / 2;
151
+ addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ);
152
+ }
153
+ }
154
+ else {
155
+ // 'empty' — opaque backing, no detail
156
+ addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0);
157
+ }
158
+ }
159
+ segY -= segH;
160
+ }
161
+ // ── Handle ──
162
+ if (handle) {
163
+ // Convert from floor-based height to mesh-center-based Y
164
+ const handleY = handleHeight - height / 2;
165
+ // Handle grip sits on the front face (+Z) of the leaf
166
+ const faceZ = leafDepth / 2;
167
+ // X position: handleSide refers to which side the grip is on
168
+ const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045;
169
+ // Backplate
170
+ addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005);
171
+ // Grip lever
172
+ addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025);
173
+ }
174
+ // ── Door closer (commercial hardware at top) ──
175
+ if (doorCloser) {
176
+ const closerY = leafCenterY + leafH / 2 - 0.04;
177
+ // Body
178
+ addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03);
179
+ // Arm (simplified as thin bar to frame side)
180
+ addBox(mesh, baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015);
181
+ }
182
+ // ── Panic bar ──
183
+ if (panicBar) {
184
+ const barY = panicBarHeight - height / 2;
185
+ addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03);
186
+ }
187
+ // ── Hinges (3 knuckle-style hinges on the hinge side) ──
188
+ {
189
+ const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012;
190
+ const hingeZ = 0; // centered in leaf depth
191
+ const hingeH = 0.1;
192
+ const hingeW = 0.024;
193
+ const hingeD = leafDepth + 0.016;
194
+ // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top
195
+ const leafBottom = leafCenterY - leafH / 2;
196
+ const leafTop = leafCenterY + leafH / 2;
197
+ addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ);
198
+ addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ);
199
+ addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ);
200
+ }
201
+ // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
202
+ let cutout = mesh.getObjectByName('cutout');
203
+ if (!cutout) {
204
+ cutout = new THREE.Mesh();
205
+ cutout.name = 'cutout';
206
+ mesh.add(cutout);
207
+ }
208
+ cutout.geometry.dispose();
209
+ cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0);
210
+ cutout.visible = false;
211
+ }
@@ -2,6 +2,7 @@ import { useFrame } from '@react-three/fiber';
2
2
  import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
3
3
  import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager';
4
4
  import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync';
5
+ import { getScaledDimensions } from '../../schema';
5
6
  import useScene from '../../store/use-scene';
6
7
  // ============================================================================
7
8
  // ITEM SYSTEM
@@ -36,12 +37,12 @@ export const ItemSystem = () => {
36
37
  if (parentNode?.type !== 'item') {
37
38
  // Floor item: elevate by slab height (using full footprint overlap)
38
39
  const levelId = resolveLevelId(item, nodes);
39
- const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, item.position, item.asset.dimensions, item.rotation);
40
+ const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, item.position, getScaledDimensions(item), item.rotation);
40
41
  mesh.position.y = slabElevation + item.position[1];
41
42
  }
42
43
  }
43
44
  clearDirty(id);
44
45
  });
45
- });
46
+ }, 2);
46
47
  return null;
47
48
  };
@@ -1,8 +1,16 @@
1
1
  import * as THREE from 'three';
2
- import type { RoofNode } from '../../schema';
2
+ import { Brush } from 'three-bvh-csg';
3
+ import type { RoofSegmentNode } from '../../schema';
3
4
  export declare const RoofSystem: () => null;
4
5
  /**
5
- * Generates detailed gable roof geometry with layers, walls, and overhangs
6
+ * Generate complete hollow-shell geometry for a roof segment.
7
+ * Ports the prototype's CSG approach using three-bvh-csg.
6
8
  */
7
- export declare function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry;
9
+ export declare function getRoofSegmentBrushes(node: RoofSegmentNode): {
10
+ deckSlab: Brush;
11
+ shinSlab: Brush;
12
+ wallBrush: Brush;
13
+ innerBrush: Brush;
14
+ } | null;
15
+ export declare function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry;
8
16
  //# sourceMappingURL=roof-system.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"roof-system.d.ts","sourceRoot":"","sources":["../../../src/systems/roof/roof-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAa,QAAQ,EAAE,MAAM,cAAc,CAAA;AAmBvD,eAAO,MAAM,UAAU,YAwBtB,CAAA;AAqJD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,KAAK,CAAC,cAAc,CAsH7E"}
1
+ {"version":3,"file":"roof-system.d.ts","sourceRoot":"","sources":["../../../src/systems/roof/roof-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAY,KAAK,EAA0B,MAAM,eAAe,CAAA;AAGvE,OAAO,KAAK,EAAgC,eAAe,EAAE,MAAM,cAAc,CAAA;AAwBjF,eAAO,MAAM,UAAU,YAqFtB,CAAA;AAmLD;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,eAAe,GACpB;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,QAAQ,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,KAAK,CAAA;CAAE,GAAG,IAAI,CAkRlF;AAED,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,eAAe,GAAG,KAAK,CAAC,cAAc,CAoDvF"}