@pascal-app/core 0.1.3
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.
- package/dist/events/bus.d.ts +42 -0
- package/dist/events/bus.d.ts.map +1 -0
- package/dist/events/bus.js +13 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts +18 -0
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -0
- package/dist/hooks/scene-registry/scene-registry.js +35 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +90 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +466 -0
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +4 -0
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +115 -0
- package/dist/hooks/spatial-grid/spatial-grid.d.ts +23 -0
- package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/spatial-grid.js +115 -0
- package/dist/hooks/spatial-grid/use-spatial-query.d.ts +16 -0
- package/dist/hooks/spatial-grid/use-spatial-query.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/use-spatial-query.js +14 -0
- package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts +47 -0
- package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts.map +1 -0
- package/dist/hooks/spatial-grid/wall-spatial-grid.js +113 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/lib/asset-storage.d.ts +11 -0
- package/dist/lib/asset-storage.d.ts.map +1 -0
- package/dist/lib/asset-storage.js +48 -0
- package/dist/lib/space-detection.d.ts +34 -0
- package/dist/lib/space-detection.d.ts.map +1 -0
- package/dist/lib/space-detection.js +499 -0
- package/dist/schema/base.d.ts +30 -0
- package/dist/schema/base.d.ts.map +1 -0
- package/dist/schema/base.js +25 -0
- package/dist/schema/camera.d.ts +13 -0
- package/dist/schema/camera.d.ts.map +1 -0
- package/dist/schema/camera.js +9 -0
- package/dist/schema/index.d.ts +17 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +18 -0
- package/dist/schema/nodes/building.d.ts +25 -0
- package/dist/schema/nodes/building.d.ts.map +1 -0
- package/dist/schema/nodes/building.js +16 -0
- package/dist/schema/nodes/ceiling.d.ts +25 -0
- package/dist/schema/nodes/ceiling.d.ts.map +1 -0
- package/dist/schema/nodes/ceiling.js +16 -0
- package/dist/schema/nodes/guide.d.ts +27 -0
- package/dist/schema/nodes/guide.d.ts.map +1 -0
- package/dist/schema/nodes/guide.js +11 -0
- package/dist/schema/nodes/item.d.ts +65 -0
- package/dist/schema/nodes/item.d.ts.map +1 -0
- package/dist/schema/nodes/item.js +38 -0
- package/dist/schema/nodes/level.d.ts +24 -0
- package/dist/schema/nodes/level.d.ts.map +1 -0
- package/dist/schema/nodes/level.js +21 -0
- package/dist/schema/nodes/roof.d.ts +28 -0
- package/dist/schema/nodes/roof.d.ts.map +1 -0
- package/dist/schema/nodes/roof.js +28 -0
- package/dist/schema/nodes/scan.d.ts +27 -0
- package/dist/schema/nodes/scan.d.ts.map +1 -0
- package/dist/schema/nodes/scan.js +11 -0
- package/dist/schema/nodes/site.d.ts +90 -0
- package/dist/schema/nodes/site.d.ts.map +1 -0
- package/dist/schema/nodes/site.js +39 -0
- package/dist/schema/nodes/slab.d.ts +24 -0
- package/dist/schema/nodes/slab.d.ts.map +1 -0
- package/dist/schema/nodes/slab.js +15 -0
- package/dist/schema/nodes/wall.d.ts +37 -0
- package/dist/schema/nodes/wall.d.ts.map +1 -0
- package/dist/schema/nodes/wall.js +30 -0
- package/dist/schema/nodes/zone.d.ts +24 -0
- package/dist/schema/nodes/zone.d.ts.map +1 -0
- package/dist/schema/nodes/zone.js +22 -0
- package/dist/schema/types.d.ts +339 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/types.js +25 -0
- package/dist/store/actions/node-actions.d.ts +12 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -0
- package/dist/store/actions/node-actions.js +121 -0
- package/dist/store/use-scene.d.ts +31 -0
- package/dist/store/use-scene.d.ts.map +1 -0
- package/dist/store/use-scene.js +127 -0
- package/dist/systems/ceiling/ceiling-system.d.ts +8 -0
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -0
- package/dist/systems/ceiling/ceiling-system.js +65 -0
- package/dist/systems/item/item-system.d.ts +2 -0
- package/dist/systems/item/item-system.d.ts.map +1 -0
- package/dist/systems/item/item-system.js +43 -0
- package/dist/systems/roof/roof-system.d.ts +8 -0
- package/dist/systems/roof/roof-system.d.ts.map +1 -0
- package/dist/systems/roof/roof-system.js +254 -0
- package/dist/systems/slab/slab-system.d.ts +8 -0
- package/dist/systems/slab/slab-system.d.ts.map +1 -0
- package/dist/systems/slab/slab-system.js +117 -0
- package/dist/systems/wall/wall-mitering.d.ts +32 -0
- package/dist/systems/wall/wall-mitering.d.ts.map +1 -0
- package/dist/systems/wall/wall-mitering.js +214 -0
- package/dist/systems/wall/wall-system.d.ts +12 -0
- package/dist/systems/wall/wall-system.d.ts.map +1 -0
- package/dist/systems/wall/wall-system.js +286 -0
- package/dist/utils/types.d.ts +6 -0
- package/dist/utils/types.d.ts.map +1 -0
- package/dist/utils/types.js +7 -0
- package/package.json +58 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { temporal } from 'zundo';
|
|
3
|
+
import { create } from 'zustand';
|
|
4
|
+
import { persist } from 'zustand/middleware';
|
|
5
|
+
import { BuildingNode } from '../schema';
|
|
6
|
+
import { LevelNode } from '../schema/nodes/level';
|
|
7
|
+
import { isObject } from '../utils/types';
|
|
8
|
+
import * as nodeActions from './actions/node-actions';
|
|
9
|
+
const useScene = create()(persist(temporal((set, get) => ({
|
|
10
|
+
// 1. Flat dictionary of all nodes
|
|
11
|
+
nodes: {},
|
|
12
|
+
// 2. Root node IDs
|
|
13
|
+
rootNodeIds: [],
|
|
14
|
+
// 3. Dirty set
|
|
15
|
+
dirtyNodes: new Set(),
|
|
16
|
+
clearScene: () => {
|
|
17
|
+
set({
|
|
18
|
+
nodes: {},
|
|
19
|
+
rootNodeIds: [],
|
|
20
|
+
dirtyNodes: new Set(),
|
|
21
|
+
});
|
|
22
|
+
get().loadScene(); // Default scene
|
|
23
|
+
},
|
|
24
|
+
setScene: (nodes, rootNodeIds) => {
|
|
25
|
+
set({
|
|
26
|
+
nodes,
|
|
27
|
+
rootNodeIds,
|
|
28
|
+
dirtyNodes: new Set(),
|
|
29
|
+
});
|
|
30
|
+
// Mark all nodes as dirty to trigger re-validation
|
|
31
|
+
Object.values(nodes).forEach((node) => {
|
|
32
|
+
get().markDirty(node.id);
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
loadScene: () => {
|
|
36
|
+
if (get().rootNodeIds.length > 0) {
|
|
37
|
+
// Assign all nodes as dirty to force re-validation
|
|
38
|
+
Object.values(get().nodes).forEach((node) => {
|
|
39
|
+
get().markDirty(node.id);
|
|
40
|
+
});
|
|
41
|
+
return; // Scene already loaded
|
|
42
|
+
}
|
|
43
|
+
const building = BuildingNode.parse({
|
|
44
|
+
children: [],
|
|
45
|
+
});
|
|
46
|
+
const level0 = LevelNode.parse({
|
|
47
|
+
level: 0,
|
|
48
|
+
children: [],
|
|
49
|
+
});
|
|
50
|
+
building.children.push(level0.id);
|
|
51
|
+
// Define all nodes flat
|
|
52
|
+
const nodes = {
|
|
53
|
+
[building.id]: building,
|
|
54
|
+
[level0.id]: level0,
|
|
55
|
+
};
|
|
56
|
+
// Root nodes are the levels
|
|
57
|
+
const rootNodeIds = [building.id];
|
|
58
|
+
set({ nodes, rootNodeIds });
|
|
59
|
+
},
|
|
60
|
+
markDirty: (id) => {
|
|
61
|
+
get().dirtyNodes.add(id);
|
|
62
|
+
},
|
|
63
|
+
clearDirty: (id) => {
|
|
64
|
+
get().dirtyNodes.delete(id);
|
|
65
|
+
},
|
|
66
|
+
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
|
67
|
+
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
|
68
|
+
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
|
69
|
+
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
|
70
|
+
// --- DELETE ---
|
|
71
|
+
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
|
72
|
+
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
|
73
|
+
}), {
|
|
74
|
+
partialize: (state) => {
|
|
75
|
+
const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history
|
|
76
|
+
return { nodes, rootNodeIds };
|
|
77
|
+
},
|
|
78
|
+
limit: 50, // Limit to last 50 actions
|
|
79
|
+
}), {
|
|
80
|
+
name: 'editor-storage',
|
|
81
|
+
partialize: (state) => ({
|
|
82
|
+
nodes: Object.fromEntries(Object.entries(state.nodes).filter(([_, node]) => {
|
|
83
|
+
const meta = node.metadata;
|
|
84
|
+
const isTransient = isObject(meta) && 'isTransient' in meta && meta.isTransient === true;
|
|
85
|
+
return !isTransient;
|
|
86
|
+
})),
|
|
87
|
+
rootNodeIds: state.rootNodeIds,
|
|
88
|
+
}),
|
|
89
|
+
onRehydrateStorage: (state) => {
|
|
90
|
+
console.log('hydrating...');
|
|
91
|
+
// optional
|
|
92
|
+
return (state, error) => {
|
|
93
|
+
if (error) {
|
|
94
|
+
console.log('an error happened during hydration', error);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log('hydration finished');
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
export default useScene;
|
|
103
|
+
// Track previous temporal state lengths
|
|
104
|
+
let prevPastLength = 0;
|
|
105
|
+
let prevFutureLength = 0;
|
|
106
|
+
// Subscribe to the temporal store (Undo/Redo events)
|
|
107
|
+
useScene.temporal.subscribe((state) => {
|
|
108
|
+
const currentPastLength = state.pastStates.length;
|
|
109
|
+
const currentFutureLength = state.futureStates.length;
|
|
110
|
+
// Undo: futureStates increases (state moved from past to future)
|
|
111
|
+
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
|
|
112
|
+
const didUndo = currentFutureLength > prevFutureLength;
|
|
113
|
+
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength;
|
|
114
|
+
if (didUndo || didRedo) {
|
|
115
|
+
// Use RAF to ensure all middleware and store updates are complete
|
|
116
|
+
requestAnimationFrame(() => {
|
|
117
|
+
const currentNodes = useScene.getState().nodes;
|
|
118
|
+
// Trigger a full scene re-validation after undo/redo
|
|
119
|
+
Object.values(currentNodes).forEach((node) => {
|
|
120
|
+
useScene.getState().markDirty(node.id);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Update tracked lengths
|
|
125
|
+
prevPastLength = currentPastLength;
|
|
126
|
+
prevFutureLength = currentFutureLength;
|
|
127
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { CeilingNode } from '../../schema';
|
|
3
|
+
export declare const CeilingSystem: () => null;
|
|
4
|
+
/**
|
|
5
|
+
* Generates flat ceiling geometry from polygon (no extrusion)
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry;
|
|
8
|
+
//# sourceMappingURL=ceiling-system.d.ts.map
|
|
@@ -0,0 +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,CA6BtF"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useFrame } from '@react-three/fiber';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
|
|
4
|
+
import useScene from '../../store/use-scene';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// CEILING SYSTEM
|
|
7
|
+
// ============================================================================
|
|
8
|
+
export const CeilingSystem = () => {
|
|
9
|
+
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
10
|
+
const clearDirty = useScene((state) => state.clearDirty);
|
|
11
|
+
useFrame(() => {
|
|
12
|
+
if (dirtyNodes.size === 0)
|
|
13
|
+
return;
|
|
14
|
+
const nodes = useScene.getState().nodes;
|
|
15
|
+
// Process dirty ceilings
|
|
16
|
+
dirtyNodes.forEach((id) => {
|
|
17
|
+
const node = nodes[id];
|
|
18
|
+
if (!node || node.type !== 'ceiling')
|
|
19
|
+
return;
|
|
20
|
+
const mesh = sceneRegistry.nodes.get(id);
|
|
21
|
+
if (mesh) {
|
|
22
|
+
updateCeilingGeometry(node, mesh);
|
|
23
|
+
clearDirty(id);
|
|
24
|
+
}
|
|
25
|
+
// If mesh not found, keep it dirty for next frame
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Updates the geometry for a single ceiling
|
|
32
|
+
*/
|
|
33
|
+
function updateCeilingGeometry(node, mesh) {
|
|
34
|
+
const newGeo = generateCeilingGeometry(node);
|
|
35
|
+
mesh.geometry.dispose();
|
|
36
|
+
mesh.geometry = newGeo;
|
|
37
|
+
// Position at the ceiling height
|
|
38
|
+
mesh.position.y = (node.height ?? 2.5) - 0.01; // Slight offset to avoid z-fighting with upper-level slabs
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates flat ceiling geometry from polygon (no extrusion)
|
|
42
|
+
*/
|
|
43
|
+
export function generateCeilingGeometry(ceilingNode) {
|
|
44
|
+
const polygon = ceilingNode.polygon;
|
|
45
|
+
if (polygon.length < 3) {
|
|
46
|
+
return new THREE.BufferGeometry();
|
|
47
|
+
}
|
|
48
|
+
// Create shape from polygon
|
|
49
|
+
// Shape is in X-Y plane, we'll rotate to X-Z plane
|
|
50
|
+
const shape = new THREE.Shape();
|
|
51
|
+
const firstPt = polygon[0];
|
|
52
|
+
// Negate Y (which becomes Z) to get correct orientation after rotation
|
|
53
|
+
shape.moveTo(firstPt[0], -firstPt[1]);
|
|
54
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
55
|
+
const pt = polygon[i];
|
|
56
|
+
shape.lineTo(pt[0], -pt[1]);
|
|
57
|
+
}
|
|
58
|
+
shape.closePath();
|
|
59
|
+
// Create flat shape geometry (no extrusion)
|
|
60
|
+
const geometry = new THREE.ShapeGeometry(shape);
|
|
61
|
+
// Rotate so the shape lies flat in X-Z plane
|
|
62
|
+
geometry.rotateX(-Math.PI / 2);
|
|
63
|
+
geometry.computeVertexNormals();
|
|
64
|
+
return geometry;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"item-system.d.ts","sourceRoot":"","sources":["../../../src/systems/item/item-system.tsx"],"names":[],"mappings":"AAYA,eAAO,MAAM,UAAU,YAyCtB,CAAA"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useFrame } from '@react-three/fiber';
|
|
2
|
+
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
|
|
3
|
+
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager';
|
|
4
|
+
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync';
|
|
5
|
+
import useScene from '../../store/use-scene';
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// ITEM SYSTEM
|
|
8
|
+
// ============================================================================
|
|
9
|
+
export const ItemSystem = () => {
|
|
10
|
+
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
11
|
+
const clearDirty = useScene((state) => state.clearDirty);
|
|
12
|
+
useFrame(() => {
|
|
13
|
+
if (dirtyNodes.size === 0)
|
|
14
|
+
return;
|
|
15
|
+
const nodes = useScene.getState().nodes;
|
|
16
|
+
dirtyNodes.forEach((id) => {
|
|
17
|
+
const node = nodes[id];
|
|
18
|
+
if (!node || node.type !== 'item')
|
|
19
|
+
return;
|
|
20
|
+
const item = node;
|
|
21
|
+
const mesh = sceneRegistry.nodes.get(id);
|
|
22
|
+
if (!mesh)
|
|
23
|
+
return;
|
|
24
|
+
if (item.asset.attachTo === 'wall-side') {
|
|
25
|
+
// Wall-attached item: offset Z by half the parent wall's thickness
|
|
26
|
+
const parentWall = item.parentId ? nodes[item.parentId] : undefined;
|
|
27
|
+
if (parentWall && parentWall.type === 'wall') {
|
|
28
|
+
const wallThickness = parentWall.thickness ?? 0.1;
|
|
29
|
+
const side = item.side === 'front' ? 1 : -1;
|
|
30
|
+
mesh.position.z = (wallThickness / 2) * side;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else if (!item.asset.attachTo) {
|
|
34
|
+
// Floor item: elevate by slab height (using full footprint overlap)
|
|
35
|
+
const levelId = resolveLevelId(item, nodes);
|
|
36
|
+
const slabElevation = spatialGridManager.getSlabElevationForItem(levelId, item.position, item.asset.dimensions, item.rotation);
|
|
37
|
+
mesh.position.y = slabElevation;
|
|
38
|
+
}
|
|
39
|
+
clearDirty(id);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { RoofNode } from '../../schema';
|
|
3
|
+
export declare const RoofSystem: () => null;
|
|
4
|
+
/**
|
|
5
|
+
* Generates detailed gable roof geometry with layers, walls, and overhangs
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry;
|
|
8
|
+
//# sourceMappingURL=roof-system.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { useFrame } from '@react-three/fiber';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
|
|
4
|
+
import useScene from '../../store/use-scene';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// ROOF GEOMETRY CONSTANTS
|
|
7
|
+
// ============================================================================
|
|
8
|
+
const THICKNESS_A = 0.05; // Roof cover thickness (5cm)
|
|
9
|
+
const THICKNESS_B = 0.1; // Structure thickness (10cm)
|
|
10
|
+
const ROOF_COVER_OVERHANG = 0.05; // Extension of cover past structure (5cm)
|
|
11
|
+
const EAVE_OVERHANG = 0.4; // Horizontal eave overhang (40cm)
|
|
12
|
+
const RAKE_OVERHANG = 0.3; // Overhang at gable ends (30cm)
|
|
13
|
+
const WALL_THICKNESS = 0.2; // Gable wall thickness (20cm)
|
|
14
|
+
const BASE_HEIGHT = 0.5; // Base height / knee wall / truss heel (50cm)
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// ROOF SYSTEM
|
|
17
|
+
// ============================================================================
|
|
18
|
+
export const RoofSystem = () => {
|
|
19
|
+
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
20
|
+
const clearDirty = useScene((state) => state.clearDirty);
|
|
21
|
+
useFrame(() => {
|
|
22
|
+
if (dirtyNodes.size === 0)
|
|
23
|
+
return;
|
|
24
|
+
const nodes = useScene.getState().nodes;
|
|
25
|
+
// Process dirty roofs
|
|
26
|
+
dirtyNodes.forEach((id) => {
|
|
27
|
+
const node = nodes[id];
|
|
28
|
+
if (!node || node.type !== 'roof')
|
|
29
|
+
return;
|
|
30
|
+
const mesh = sceneRegistry.nodes.get(id);
|
|
31
|
+
if (mesh) {
|
|
32
|
+
updateRoofGeometry(node, mesh);
|
|
33
|
+
clearDirty(id);
|
|
34
|
+
}
|
|
35
|
+
// If mesh not found, keep it dirty for next frame
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Updates the geometry and transform for a single roof
|
|
42
|
+
*/
|
|
43
|
+
function updateRoofGeometry(node, mesh) {
|
|
44
|
+
const newGeo = generateRoofGeometry(node);
|
|
45
|
+
mesh.geometry.dispose();
|
|
46
|
+
mesh.geometry = newGeo;
|
|
47
|
+
// Update position and rotation
|
|
48
|
+
mesh.position.set(node.position[0], node.position[1], node.position[2]);
|
|
49
|
+
mesh.rotation.y = node.rotation;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Helper to solve pitch angle analytically given rise, run and thicknesses
|
|
53
|
+
* Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise
|
|
54
|
+
*/
|
|
55
|
+
function solvePitch(rise, run, thickA, thickB) {
|
|
56
|
+
const T = thickA + thickB;
|
|
57
|
+
if (run < 0.01)
|
|
58
|
+
return 0;
|
|
59
|
+
const R = Math.sqrt(run * run + rise * rise);
|
|
60
|
+
if (R <= T) {
|
|
61
|
+
return Math.atan2(rise, run) * 0.5; // Fallback
|
|
62
|
+
}
|
|
63
|
+
const phi = Math.atan2(rise, run);
|
|
64
|
+
const shift = Math.asin(T / R);
|
|
65
|
+
return phi - shift;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Helper to create a Three.js Shape from polygon points
|
|
69
|
+
*/
|
|
70
|
+
function createShape(points) {
|
|
71
|
+
const shape = new THREE.Shape();
|
|
72
|
+
if (points.length === 0)
|
|
73
|
+
return shape;
|
|
74
|
+
const firstPoint = points[0];
|
|
75
|
+
if (!firstPoint)
|
|
76
|
+
return shape;
|
|
77
|
+
shape.moveTo(firstPoint.x, firstPoint.y);
|
|
78
|
+
for (let i = 1; i < points.length; i++) {
|
|
79
|
+
const point = points[i];
|
|
80
|
+
if (point) {
|
|
81
|
+
shape.lineTo(point.x, point.y);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
shape.closePath();
|
|
85
|
+
return shape;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Generate profile for one side of the roof (left or right)
|
|
89
|
+
*/
|
|
90
|
+
function getSideProfile(dir, width, roofHeight) {
|
|
91
|
+
const halfWall = WALL_THICKNESS / 2;
|
|
92
|
+
const rise = Math.max(0, roofHeight - BASE_HEIGHT);
|
|
93
|
+
const run = width - halfWall;
|
|
94
|
+
const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B);
|
|
95
|
+
const tanA = Math.tan(angle);
|
|
96
|
+
const cosA = Math.cos(angle);
|
|
97
|
+
const sinA = Math.sin(angle);
|
|
98
|
+
const ridgeUnderY = BASE_HEIGHT + run * tanA;
|
|
99
|
+
const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA;
|
|
100
|
+
const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA;
|
|
101
|
+
const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA;
|
|
102
|
+
const overhangDx = EAVE_OVERHANG * cosA;
|
|
103
|
+
const eaveTopZ = width + halfWall + overhangDx;
|
|
104
|
+
const eaveTopY = ridgeTopY - eaveTopZ * tanA;
|
|
105
|
+
const coverExtDx = ROOF_COVER_OVERHANG * cosA;
|
|
106
|
+
const coverExtDy = ROOF_COVER_OVERHANG * sinA;
|
|
107
|
+
const eaveTopExtZ = eaveTopZ + coverExtDx;
|
|
108
|
+
const eaveTopExtY = eaveTopY - coverExtDy;
|
|
109
|
+
const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA;
|
|
110
|
+
const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA;
|
|
111
|
+
const eaveInterfaceZ = eaveTopZ;
|
|
112
|
+
const eaveBottomZ = eaveTopZ;
|
|
113
|
+
const eaveBottomY = ridgeUnderY - eaveTopZ * tanA;
|
|
114
|
+
// Layer A (Cover)
|
|
115
|
+
const pointsA = [
|
|
116
|
+
{ x: 0, y: ridgeTopY },
|
|
117
|
+
{ x: dir * eaveTopExtZ, y: eaveTopExtY },
|
|
118
|
+
{ x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY },
|
|
119
|
+
{ x: 0, y: ridgeInterfaceY },
|
|
120
|
+
];
|
|
121
|
+
// Layer B (Structure)
|
|
122
|
+
const pointsB = [
|
|
123
|
+
{ x: 0, y: ridgeInterfaceY },
|
|
124
|
+
{ x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA },
|
|
125
|
+
{ x: dir * eaveBottomZ, y: eaveBottomY },
|
|
126
|
+
{ x: 0, y: ridgeUnderY },
|
|
127
|
+
];
|
|
128
|
+
// Side Wall
|
|
129
|
+
const zInner = width - halfWall;
|
|
130
|
+
const zOuter = width + halfWall;
|
|
131
|
+
const pointsSide = [
|
|
132
|
+
{ x: dir * zInner, y: 0 },
|
|
133
|
+
{ x: dir * zOuter, y: 0 },
|
|
134
|
+
{ x: dir * zOuter, y: Math.max(0, wallOuterTopY) },
|
|
135
|
+
{ x: dir * zInner, y: BASE_HEIGHT },
|
|
136
|
+
];
|
|
137
|
+
// Gable Top (C1)
|
|
138
|
+
const pointsC1 = [
|
|
139
|
+
{ x: 0, y: BASE_HEIGHT },
|
|
140
|
+
{ x: dir * zInner, y: BASE_HEIGHT },
|
|
141
|
+
{ x: dir * zInner, y: BASE_HEIGHT },
|
|
142
|
+
{ x: 0, y: ridgeUnderY },
|
|
143
|
+
];
|
|
144
|
+
// Gable Base (C2)
|
|
145
|
+
const pointsC2 = [
|
|
146
|
+
{ x: 0, y: 0 },
|
|
147
|
+
{ x: dir * zInner, y: 0 },
|
|
148
|
+
{ x: dir * zInner, y: BASE_HEIGHT },
|
|
149
|
+
{ x: 0, y: BASE_HEIGHT },
|
|
150
|
+
];
|
|
151
|
+
return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Generates detailed gable roof geometry with layers, walls, and overhangs
|
|
155
|
+
*/
|
|
156
|
+
export function generateRoofGeometry(roofNode) {
|
|
157
|
+
const { length, height, leftWidth, rightWidth } = roofNode;
|
|
158
|
+
const ridgeLength = length;
|
|
159
|
+
// Get profiles for both sides
|
|
160
|
+
const leftP = getSideProfile(1, leftWidth, height);
|
|
161
|
+
const rightP = getSideProfile(-1, rightWidth, height);
|
|
162
|
+
// Create shapes from profiles
|
|
163
|
+
const shapes = {
|
|
164
|
+
ALeft: createShape(leftP.pointsA),
|
|
165
|
+
ARight: createShape(rightP.pointsA),
|
|
166
|
+
BLeft: createShape(leftP.pointsB),
|
|
167
|
+
BRight: createShape(rightP.pointsB),
|
|
168
|
+
SideLeft: createShape(leftP.pointsSide),
|
|
169
|
+
SideRight: createShape(rightP.pointsSide),
|
|
170
|
+
C1Left: createShape(leftP.pointsC1),
|
|
171
|
+
C1Right: createShape(rightP.pointsC1),
|
|
172
|
+
C2Left: createShape(leftP.pointsC2),
|
|
173
|
+
C2Right: createShape(rightP.pointsC2),
|
|
174
|
+
};
|
|
175
|
+
// Calculate extrusion lengths and offsets
|
|
176
|
+
const lengths = {
|
|
177
|
+
A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS,
|
|
178
|
+
B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS,
|
|
179
|
+
Side: ridgeLength + WALL_THICKNESS,
|
|
180
|
+
Gable: WALL_THICKNESS,
|
|
181
|
+
};
|
|
182
|
+
const offsets = {
|
|
183
|
+
A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2,
|
|
184
|
+
B: -RAKE_OVERHANG - WALL_THICKNESS / 2,
|
|
185
|
+
Side: -WALL_THICKNESS / 2,
|
|
186
|
+
GableFront: -WALL_THICKNESS / 2,
|
|
187
|
+
GableBack: ridgeLength - WALL_THICKNESS / 2,
|
|
188
|
+
};
|
|
189
|
+
// Helper to create and position extruded geometry
|
|
190
|
+
const createPart = (shape, depth, xOffset) => {
|
|
191
|
+
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
|
192
|
+
// Rotate to align: extrusion goes along X axis
|
|
193
|
+
geo.rotateY(Math.PI / 2);
|
|
194
|
+
geo.translate(xOffset, 0, 0);
|
|
195
|
+
return geo;
|
|
196
|
+
};
|
|
197
|
+
// Create all parts
|
|
198
|
+
const geometries = [];
|
|
199
|
+
// Layer A (Cover) - both sides
|
|
200
|
+
geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A));
|
|
201
|
+
geometries.push(createPart(shapes.ARight, lengths.A, offsets.A));
|
|
202
|
+
// Layer B (Structure) - both sides
|
|
203
|
+
geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B));
|
|
204
|
+
geometries.push(createPart(shapes.BRight, lengths.B, offsets.B));
|
|
205
|
+
// Side Walls - both sides
|
|
206
|
+
geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side));
|
|
207
|
+
geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side));
|
|
208
|
+
// Gable Walls (Front)
|
|
209
|
+
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront));
|
|
210
|
+
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront));
|
|
211
|
+
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront));
|
|
212
|
+
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront));
|
|
213
|
+
// Gable Walls (Back)
|
|
214
|
+
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack));
|
|
215
|
+
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack));
|
|
216
|
+
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack));
|
|
217
|
+
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack));
|
|
218
|
+
// Merge all geometries
|
|
219
|
+
const mergedGeometry = new THREE.BufferGeometry();
|
|
220
|
+
const positions = [];
|
|
221
|
+
const normals = [];
|
|
222
|
+
const uvs = [];
|
|
223
|
+
for (const geo of geometries) {
|
|
224
|
+
const posAttr = geo.getAttribute('position');
|
|
225
|
+
const normAttr = geo.getAttribute('normal');
|
|
226
|
+
const uvAttr = geo.getAttribute('uv');
|
|
227
|
+
if (posAttr) {
|
|
228
|
+
for (let i = 0; i < posAttr.count; i++) {
|
|
229
|
+
positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (normAttr) {
|
|
233
|
+
for (let i = 0; i < normAttr.count; i++) {
|
|
234
|
+
normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (uvAttr) {
|
|
238
|
+
for (let i = 0; i < uvAttr.count; i++) {
|
|
239
|
+
uvs.push(uvAttr.getX(i), uvAttr.getY(i));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
geo.dispose();
|
|
243
|
+
}
|
|
244
|
+
mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
245
|
+
mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
246
|
+
if (uvs.length > 0) {
|
|
247
|
+
mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
248
|
+
}
|
|
249
|
+
mergedGeometry.computeVertexNormals();
|
|
250
|
+
// Center the geometry at X=0 (translate by -ridgeLength/2)
|
|
251
|
+
// This matches the old geometry centering behavior
|
|
252
|
+
mergedGeometry.translate(-ridgeLength / 2, 0, 0);
|
|
253
|
+
return mergedGeometry;
|
|
254
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import type { SlabNode } from '../../schema';
|
|
3
|
+
export declare const SlabSystem: () => null;
|
|
4
|
+
/**
|
|
5
|
+
* Generates extruded slab geometry from polygon
|
|
6
|
+
*/
|
|
7
|
+
export declare function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry;
|
|
8
|
+
//# sourceMappingURL=slab-system.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slab-system.d.ts","sourceRoot":"","sources":["../../../src/systems/slab/slab-system.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,KAAK,EAAa,QAAQ,EAAE,MAAM,cAAc,CAAA;AAOvD,eAAO,MAAM,UAAU,YAwBtB,CAAA;AAkED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,KAAK,CAAC,cAAc,CAiC7E"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useFrame } from '@react-three/fiber';
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
|
|
4
|
+
import useScene from '../../store/use-scene';
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// SLAB SYSTEM
|
|
7
|
+
// ============================================================================
|
|
8
|
+
export const SlabSystem = () => {
|
|
9
|
+
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
10
|
+
const clearDirty = useScene((state) => state.clearDirty);
|
|
11
|
+
useFrame(() => {
|
|
12
|
+
if (dirtyNodes.size === 0)
|
|
13
|
+
return;
|
|
14
|
+
const nodes = useScene.getState().nodes;
|
|
15
|
+
// Process dirty slabs
|
|
16
|
+
dirtyNodes.forEach((id) => {
|
|
17
|
+
const node = nodes[id];
|
|
18
|
+
if (!node || node.type !== 'slab')
|
|
19
|
+
return;
|
|
20
|
+
const mesh = sceneRegistry.nodes.get(id);
|
|
21
|
+
if (mesh) {
|
|
22
|
+
updateSlabGeometry(node, mesh);
|
|
23
|
+
clearDirty(id);
|
|
24
|
+
}
|
|
25
|
+
// If mesh not found, keep it dirty for next frame
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Updates the geometry for a single slab
|
|
32
|
+
*/
|
|
33
|
+
function updateSlabGeometry(node, mesh) {
|
|
34
|
+
const newGeo = generateSlabGeometry(node);
|
|
35
|
+
mesh.geometry.dispose();
|
|
36
|
+
mesh.geometry = newGeo;
|
|
37
|
+
}
|
|
38
|
+
/** Half of default wall thickness — used to extend slab geometry under walls */
|
|
39
|
+
const SLAB_OUTSET = 0.05;
|
|
40
|
+
/**
|
|
41
|
+
* Expand a polygon outward by a uniform distance.
|
|
42
|
+
* Offsets each edge outward then intersects consecutive offset edges.
|
|
43
|
+
*/
|
|
44
|
+
function outsetPolygon(polygon, amount) {
|
|
45
|
+
const n = polygon.length;
|
|
46
|
+
if (n < 3)
|
|
47
|
+
return polygon;
|
|
48
|
+
// Determine winding via signed area
|
|
49
|
+
let area2 = 0;
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
const j = (i + 1) % n;
|
|
52
|
+
area2 += polygon[i][0] * polygon[j][1] - polygon[j][0] * polygon[i][1];
|
|
53
|
+
}
|
|
54
|
+
const s = area2 >= 0 ? 1 : -1;
|
|
55
|
+
// Offset each edge outward by amount
|
|
56
|
+
const offEdges = [];
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
const j = (i + 1) % n;
|
|
59
|
+
const dx = polygon[j][0] - polygon[i][0];
|
|
60
|
+
const dz = polygon[j][1] - polygon[i][1];
|
|
61
|
+
const len = Math.sqrt(dx * dx + dz * dz);
|
|
62
|
+
if (len < 1e-9) {
|
|
63
|
+
offEdges.push([polygon[i][0], polygon[i][1], dx, dz]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const nx = (s * dz / len) * amount;
|
|
67
|
+
const nz = (s * -dx / len) * amount;
|
|
68
|
+
offEdges.push([polygon[i][0] + nx, polygon[i][1] + nz, dx, dz]);
|
|
69
|
+
}
|
|
70
|
+
// Intersect consecutive offset edges to get new vertices
|
|
71
|
+
const result = [];
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
const j = (i + 1) % n;
|
|
74
|
+
const [ax, az, adx, adz] = offEdges[i];
|
|
75
|
+
const [bx, bz, bdx, bdz] = offEdges[j];
|
|
76
|
+
const denom = adx * bdz - adz * bdx;
|
|
77
|
+
if (Math.abs(denom) < 1e-9) {
|
|
78
|
+
// Parallel edges — use offset endpoint
|
|
79
|
+
result.push([ax + adx, az + adz]);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const t = ((bx - ax) * bdz - (bz - az) * bdx) / denom;
|
|
83
|
+
result.push([ax + t * adx, az + t * adz]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Generates extruded slab geometry from polygon
|
|
90
|
+
*/
|
|
91
|
+
export function generateSlabGeometry(slabNode) {
|
|
92
|
+
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET);
|
|
93
|
+
const elevation = slabNode.elevation ?? 0.05;
|
|
94
|
+
if (polygon.length < 3) {
|
|
95
|
+
return new THREE.BufferGeometry();
|
|
96
|
+
}
|
|
97
|
+
// Create shape from polygon
|
|
98
|
+
// Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion
|
|
99
|
+
const shape = new THREE.Shape();
|
|
100
|
+
const firstPt = polygon[0];
|
|
101
|
+
// Negate Y (which becomes Z) to get correct orientation after rotation
|
|
102
|
+
shape.moveTo(firstPt[0], -firstPt[1]);
|
|
103
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
104
|
+
const pt = polygon[i];
|
|
105
|
+
shape.lineTo(pt[0], -pt[1]);
|
|
106
|
+
}
|
|
107
|
+
shape.closePath();
|
|
108
|
+
// Extrude the shape by elevation
|
|
109
|
+
const geometry = new THREE.ExtrudeGeometry(shape, {
|
|
110
|
+
depth: elevation,
|
|
111
|
+
bevelEnabled: false,
|
|
112
|
+
});
|
|
113
|
+
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
|
114
|
+
geometry.rotateX(-Math.PI / 2);
|
|
115
|
+
geometry.computeVertexNormals();
|
|
116
|
+
return geometry;
|
|
117
|
+
}
|