@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,466 @@
|
|
|
1
|
+
import { SpatialGrid } from './spatial-grid';
|
|
2
|
+
import { WallSpatialGrid } from './wall-spatial-grid';
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// GEOMETRY HELPERS
|
|
5
|
+
// ============================================================================
|
|
6
|
+
/**
|
|
7
|
+
* Point-in-polygon test using ray casting algorithm.
|
|
8
|
+
*/
|
|
9
|
+
export function pointInPolygon(px, pz, polygon) {
|
|
10
|
+
let inside = false;
|
|
11
|
+
const n = polygon.length;
|
|
12
|
+
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
13
|
+
const xi = polygon[i][0], zi = polygon[i][1];
|
|
14
|
+
const xj = polygon[j][0], zj = polygon[j][1];
|
|
15
|
+
if ((zi > pz) !== (zj > pz) && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
|
16
|
+
inside = !inside;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return inside;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
|
|
23
|
+
*/
|
|
24
|
+
function getItemFootprint(position, dimensions, rotation, inset = 0) {
|
|
25
|
+
const [x, , z] = position;
|
|
26
|
+
const [w, , d] = dimensions;
|
|
27
|
+
const yRot = rotation[1];
|
|
28
|
+
const halfW = Math.max(0, w / 2 - inset);
|
|
29
|
+
const halfD = Math.max(0, d / 2 - inset);
|
|
30
|
+
const cos = Math.cos(yRot);
|
|
31
|
+
const sin = Math.sin(yRot);
|
|
32
|
+
return [
|
|
33
|
+
[x + (-halfW * cos + halfD * sin), z + (-halfW * sin - halfD * cos)],
|
|
34
|
+
[x + (halfW * cos + halfD * sin), z + (halfW * sin - halfD * cos)],
|
|
35
|
+
[x + (halfW * cos - halfD * sin), z + (halfW * sin + halfD * cos)],
|
|
36
|
+
[x + (-halfW * cos - halfD * sin), z + (-halfW * sin + halfD * cos)],
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
|
41
|
+
*/
|
|
42
|
+
function segmentsIntersect(ax1, az1, ax2, az2, bx1, bz1, bx2, bz2) {
|
|
43
|
+
const cross = (ox, oz, ax, az, bx, bz) => (ax - ox) * (bz - oz) - (az - oz) * (bx - ox);
|
|
44
|
+
const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1);
|
|
45
|
+
const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2);
|
|
46
|
+
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1);
|
|
47
|
+
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2);
|
|
48
|
+
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
49
|
+
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Collinear touching cases
|
|
53
|
+
const onSeg = (px, pz, qx, qz, rx, rz) => Math.min(px, qx) <= rx && rx <= Math.max(px, qx) &&
|
|
54
|
+
Math.min(pz, qz) <= rz && rz <= Math.max(pz, qz);
|
|
55
|
+
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1))
|
|
56
|
+
return true;
|
|
57
|
+
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2))
|
|
58
|
+
return true;
|
|
59
|
+
if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1))
|
|
60
|
+
return true;
|
|
61
|
+
if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2))
|
|
62
|
+
return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Test if a line segment intersects any edge of a polygon.
|
|
67
|
+
*/
|
|
68
|
+
function segmentIntersectsPolygon(sx1, sz1, sx2, sz2, polygon) {
|
|
69
|
+
const n = polygon.length;
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
const j = (i + 1) % n;
|
|
72
|
+
if (segmentsIntersect(sx1, sz1, sx2, sz2, polygon[i][0], polygon[i][1], polygon[j][0], polygon[j][1])) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Test if an item's footprint overlaps with a polygon.
|
|
80
|
+
* Checks: any item corner inside polygon, or any polygon vertex inside item AABB, or edges intersect.
|
|
81
|
+
*/
|
|
82
|
+
export function itemOverlapsPolygon(position, dimensions, rotation, polygon, inset = 0) {
|
|
83
|
+
const corners = getItemFootprint(position, dimensions, rotation, inset);
|
|
84
|
+
// Check if any item corner is inside the polygon
|
|
85
|
+
for (const [cx, cz] of corners) {
|
|
86
|
+
if (pointInPolygon(cx, cz, polygon))
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
// Check if any polygon vertex is inside the item footprint
|
|
90
|
+
// (handles case where slab is fully inside a large item)
|
|
91
|
+
for (const [px, pz] of polygon) {
|
|
92
|
+
if (pointInPolygon(px, pz, corners))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
// Check if any item edge intersects any polygon edge
|
|
96
|
+
for (let i = 0; i < 4; i++) {
|
|
97
|
+
const j = (i + 1) % 4;
|
|
98
|
+
if (segmentIntersectsPolygon(corners[i][0], corners[i][1], corners[j][0], corners[j][1], polygon))
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Check if wall segment (a) is substantially on polygon edge segment (b).
|
|
105
|
+
* Returns true only if BOTH endpoints of the wall are on or very close to the edge.
|
|
106
|
+
* This prevents walls that just touch one point from being detected.
|
|
107
|
+
*/
|
|
108
|
+
function segmentsCollinearAndOverlap(ax1, az1, ax2, az2, bx1, bz1, bx2, bz2) {
|
|
109
|
+
const EPSILON = 1e-6;
|
|
110
|
+
// Cross product to check collinearity
|
|
111
|
+
const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1);
|
|
112
|
+
const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1);
|
|
113
|
+
if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {
|
|
114
|
+
return false; // Not collinear
|
|
115
|
+
}
|
|
116
|
+
// Check if a point is on segment b
|
|
117
|
+
const onSegment = (px, pz, qx, qz, rx, rz) => Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
|
118
|
+
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON;
|
|
119
|
+
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
|
120
|
+
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1);
|
|
121
|
+
const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2);
|
|
122
|
+
return a1OnB && a2OnB;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Test if a wall segment overlaps with a polygon.
|
|
126
|
+
* A wall is considered to overlap if:
|
|
127
|
+
* - Its midpoint is inside the polygon (wall crosses through)
|
|
128
|
+
* - At least one endpoint is inside (wall partially or fully in slab)
|
|
129
|
+
* - It's collinear with and overlaps a polygon edge (wall on slab boundary)
|
|
130
|
+
*
|
|
131
|
+
* Note: A wall with just one endpoint touching the edge but the rest outside
|
|
132
|
+
* is NOT considered overlapping (adjacent only).
|
|
133
|
+
*/
|
|
134
|
+
export function wallOverlapsPolygon(start, end, polygon) {
|
|
135
|
+
const startInside = pointInPolygon(start[0], start[1], polygon);
|
|
136
|
+
const endInside = pointInPolygon(end[0], end[1], polygon);
|
|
137
|
+
// At least one endpoint strictly inside the polygon
|
|
138
|
+
if (startInside || endInside)
|
|
139
|
+
return true;
|
|
140
|
+
// Check if midpoint is inside (catches walls crossing through)
|
|
141
|
+
const midX = (start[0] + end[0]) / 2;
|
|
142
|
+
const midZ = (start[1] + end[1]) / 2;
|
|
143
|
+
if (pointInPolygon(midX, midZ, polygon))
|
|
144
|
+
return true;
|
|
145
|
+
// Check if the wall is collinear with and overlaps any polygon edge
|
|
146
|
+
const n = polygon.length;
|
|
147
|
+
for (let i = 0; i < n; i++) {
|
|
148
|
+
const j = (i + 1) % n;
|
|
149
|
+
const [p1x, p1z] = polygon[i];
|
|
150
|
+
const [p2x, p2z] = polygon[j];
|
|
151
|
+
if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
export class SpatialGridManager {
|
|
158
|
+
cellSize;
|
|
159
|
+
floorGrids = new Map(); // levelId -> grid
|
|
160
|
+
wallGrids = new Map(); // levelId -> wall grid
|
|
161
|
+
walls = new Map(); // wallId -> wall data (for length calculations)
|
|
162
|
+
slabsByLevel = new Map(); // levelId -> (slabId -> slab)
|
|
163
|
+
ceilingGrids = new Map(); // ceilingId -> grid
|
|
164
|
+
ceilings = new Map(); // ceilingId -> ceiling data
|
|
165
|
+
itemCeilingMap = new Map(); // itemId -> ceilingId (reverse lookup)
|
|
166
|
+
constructor(cellSize = 0.5) {
|
|
167
|
+
this.cellSize = cellSize;
|
|
168
|
+
}
|
|
169
|
+
getFloorGrid(levelId) {
|
|
170
|
+
if (!this.floorGrids.has(levelId)) {
|
|
171
|
+
this.floorGrids.set(levelId, new SpatialGrid({ cellSize: this.cellSize }));
|
|
172
|
+
}
|
|
173
|
+
return this.floorGrids.get(levelId);
|
|
174
|
+
}
|
|
175
|
+
getWallGrid(levelId) {
|
|
176
|
+
if (!this.wallGrids.has(levelId)) {
|
|
177
|
+
this.wallGrids.set(levelId, new WallSpatialGrid());
|
|
178
|
+
}
|
|
179
|
+
return this.wallGrids.get(levelId);
|
|
180
|
+
}
|
|
181
|
+
getWallLength(wallId) {
|
|
182
|
+
const wall = this.walls.get(wallId);
|
|
183
|
+
if (!wall)
|
|
184
|
+
return 0;
|
|
185
|
+
const dx = wall.end[0] - wall.start[0];
|
|
186
|
+
const dy = wall.end[1] - wall.start[1];
|
|
187
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
188
|
+
}
|
|
189
|
+
getWallHeight(wallId) {
|
|
190
|
+
const wall = this.walls.get(wallId);
|
|
191
|
+
return wall?.height ?? 2.5; // Default wall height
|
|
192
|
+
}
|
|
193
|
+
getCeilingGrid(ceilingId) {
|
|
194
|
+
if (!this.ceilingGrids.has(ceilingId)) {
|
|
195
|
+
this.ceilingGrids.set(ceilingId, new SpatialGrid({ cellSize: this.cellSize }));
|
|
196
|
+
}
|
|
197
|
+
return this.ceilingGrids.get(ceilingId);
|
|
198
|
+
}
|
|
199
|
+
getSlabMap(levelId) {
|
|
200
|
+
if (!this.slabsByLevel.has(levelId)) {
|
|
201
|
+
this.slabsByLevel.set(levelId, new Map());
|
|
202
|
+
}
|
|
203
|
+
return this.slabsByLevel.get(levelId);
|
|
204
|
+
}
|
|
205
|
+
// Called when nodes change
|
|
206
|
+
handleNodeCreated(node, levelId) {
|
|
207
|
+
if (node.type === 'slab') {
|
|
208
|
+
this.getSlabMap(levelId).set(node.id, node);
|
|
209
|
+
}
|
|
210
|
+
else if (node.type === 'ceiling') {
|
|
211
|
+
this.ceilings.set(node.id, node);
|
|
212
|
+
}
|
|
213
|
+
else if (node.type === 'wall') {
|
|
214
|
+
const wall = node;
|
|
215
|
+
this.walls.set(wall.id, wall);
|
|
216
|
+
}
|
|
217
|
+
else if (node.type === 'item') {
|
|
218
|
+
const item = node;
|
|
219
|
+
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
|
220
|
+
// Wall-attached item - use parentId as the wall ID
|
|
221
|
+
const wallId = item.parentId;
|
|
222
|
+
if (wallId && this.walls.has(wallId)) {
|
|
223
|
+
const wallLength = this.getWallLength(wallId);
|
|
224
|
+
if (wallLength > 0) {
|
|
225
|
+
const [width, height] = item.asset.dimensions;
|
|
226
|
+
const halfW = width / wallLength / 2;
|
|
227
|
+
// Calculate t from local X position (position[0] is distance along wall)
|
|
228
|
+
const t = item.position[0] / wallLength;
|
|
229
|
+
// position[1] is the bottom of the item
|
|
230
|
+
this.getWallGrid(levelId).insert({
|
|
231
|
+
itemId: item.id,
|
|
232
|
+
wallId: wallId,
|
|
233
|
+
tStart: t - halfW,
|
|
234
|
+
tEnd: t + halfW,
|
|
235
|
+
yStart: item.position[1],
|
|
236
|
+
yEnd: item.position[1] + height,
|
|
237
|
+
attachType: item.asset.attachTo,
|
|
238
|
+
side: item.side,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else if (item.asset.attachTo === 'ceiling') {
|
|
244
|
+
// Ceiling item - use parentId as the ceiling ID
|
|
245
|
+
const ceilingId = item.parentId;
|
|
246
|
+
if (ceilingId && this.ceilings.has(ceilingId)) {
|
|
247
|
+
this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation);
|
|
248
|
+
this.itemCeilingMap.set(item.id, ceilingId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else if (!item.asset.attachTo) {
|
|
252
|
+
// Floor item
|
|
253
|
+
this.getFloorGrid(levelId).insert(item.id, item.position, item.asset.dimensions, item.rotation);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
handleNodeUpdated(node, levelId) {
|
|
258
|
+
if (node.type === 'slab') {
|
|
259
|
+
this.getSlabMap(levelId).set(node.id, node);
|
|
260
|
+
}
|
|
261
|
+
else if (node.type === 'ceiling') {
|
|
262
|
+
this.ceilings.set(node.id, node);
|
|
263
|
+
}
|
|
264
|
+
else if (node.type === 'wall') {
|
|
265
|
+
const wall = node;
|
|
266
|
+
this.walls.set(wall.id, wall);
|
|
267
|
+
}
|
|
268
|
+
else if (node.type === 'item') {
|
|
269
|
+
const item = node;
|
|
270
|
+
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
|
|
271
|
+
// Remove old placement and re-insert
|
|
272
|
+
this.getWallGrid(levelId).removeByItemId(item.id);
|
|
273
|
+
const wallId = item.parentId;
|
|
274
|
+
if (wallId && this.walls.has(wallId)) {
|
|
275
|
+
const wallLength = this.getWallLength(wallId);
|
|
276
|
+
if (wallLength > 0) {
|
|
277
|
+
const [width, height] = item.asset.dimensions;
|
|
278
|
+
const halfW = width / wallLength / 2;
|
|
279
|
+
// Calculate t from local X position (position[0] is distance along wall)
|
|
280
|
+
const t = item.position[0] / wallLength;
|
|
281
|
+
// position[1] is the bottom of the item
|
|
282
|
+
this.getWallGrid(levelId).insert({
|
|
283
|
+
itemId: item.id,
|
|
284
|
+
wallId: wallId,
|
|
285
|
+
tStart: t - halfW,
|
|
286
|
+
tEnd: t + halfW,
|
|
287
|
+
yStart: item.position[1],
|
|
288
|
+
yEnd: item.position[1] + height,
|
|
289
|
+
attachType: item.asset.attachTo,
|
|
290
|
+
side: item.side,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (item.asset.attachTo === 'ceiling') {
|
|
296
|
+
// Remove from old ceiling grid
|
|
297
|
+
const oldCeilingId = this.itemCeilingMap.get(item.id);
|
|
298
|
+
if (oldCeilingId) {
|
|
299
|
+
this.getCeilingGrid(oldCeilingId).remove(item.id);
|
|
300
|
+
this.itemCeilingMap.delete(item.id);
|
|
301
|
+
}
|
|
302
|
+
// Insert into new ceiling grid
|
|
303
|
+
const ceilingId = item.parentId;
|
|
304
|
+
if (ceilingId && this.ceilings.has(ceilingId)) {
|
|
305
|
+
this.getCeilingGrid(ceilingId).insert(item.id, item.position, item.asset.dimensions, item.rotation);
|
|
306
|
+
this.itemCeilingMap.set(item.id, ceilingId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else if (!item.asset.attachTo) {
|
|
310
|
+
this.getFloorGrid(levelId).update(item.id, item.position, item.asset.dimensions, item.rotation);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
handleNodeDeleted(nodeId, nodeType, levelId) {
|
|
315
|
+
if (nodeType === 'slab') {
|
|
316
|
+
this.getSlabMap(levelId).delete(nodeId);
|
|
317
|
+
}
|
|
318
|
+
else if (nodeType === 'ceiling') {
|
|
319
|
+
this.ceilings.delete(nodeId);
|
|
320
|
+
this.ceilingGrids.delete(nodeId);
|
|
321
|
+
}
|
|
322
|
+
else if (nodeType === 'wall') {
|
|
323
|
+
this.walls.delete(nodeId);
|
|
324
|
+
// Remove all items attached to this wall from the spatial grid
|
|
325
|
+
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId);
|
|
326
|
+
return removedItemIds; // Caller can use this to delete the items from scene
|
|
327
|
+
}
|
|
328
|
+
else if (nodeType === 'item') {
|
|
329
|
+
this.getFloorGrid(levelId).remove(nodeId);
|
|
330
|
+
this.getWallGrid(levelId).removeByItemId(nodeId);
|
|
331
|
+
// Also clean up ceiling grid
|
|
332
|
+
const oldCeilingId = this.itemCeilingMap.get(nodeId);
|
|
333
|
+
if (oldCeilingId) {
|
|
334
|
+
this.getCeilingGrid(oldCeilingId).remove(nodeId);
|
|
335
|
+
this.itemCeilingMap.delete(nodeId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
// Query methods
|
|
341
|
+
canPlaceOnFloor(levelId, position, dimensions, rotation, ignoreIds) {
|
|
342
|
+
const grid = this.getFloorGrid(levelId);
|
|
343
|
+
return grid.canPlace(position, dimensions, rotation, ignoreIds);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Check if an item can be placed on a wall
|
|
347
|
+
* @param levelId - the level containing the wall
|
|
348
|
+
* @param wallId - the wall to check
|
|
349
|
+
* @param localX - X position in wall-local space (distance from wall start)
|
|
350
|
+
* @param localY - Y position (height from floor)
|
|
351
|
+
* @param dimensions - item dimensions [width, height, depth]
|
|
352
|
+
* @param attachType - 'wall' (needs both sides) or 'wall-side' (needs one side)
|
|
353
|
+
* @param side - which side for 'wall-side' items
|
|
354
|
+
* @param ignoreIds - item IDs to ignore in collision check
|
|
355
|
+
*/
|
|
356
|
+
canPlaceOnWall(levelId, wallId, localX, localY, dimensions, attachType = 'wall', side, ignoreIds) {
|
|
357
|
+
const wallLength = this.getWallLength(wallId);
|
|
358
|
+
if (wallLength === 0) {
|
|
359
|
+
return { valid: false, conflictIds: [] };
|
|
360
|
+
}
|
|
361
|
+
const wallHeight = this.getWallHeight(wallId);
|
|
362
|
+
// Convert local X position to parametric t (0-1)
|
|
363
|
+
const tCenter = localX / wallLength;
|
|
364
|
+
const [itemWidth, itemHeight] = dimensions;
|
|
365
|
+
return this.getWallGrid(levelId).canPlaceOnWall(wallId, wallLength, wallHeight, tCenter, itemWidth, localY, itemHeight, attachType, side, ignoreIds);
|
|
366
|
+
}
|
|
367
|
+
getWallForItem(levelId, itemId) {
|
|
368
|
+
return this.getWallGrid(levelId).getWallForItem(itemId);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Get the total slab elevation at a given (x, z) position on a level.
|
|
372
|
+
* Returns the highest slab elevation if the point is inside any slab polygon, otherwise 0.
|
|
373
|
+
*/
|
|
374
|
+
getSlabElevationAt(levelId, x, z) {
|
|
375
|
+
const slabMap = this.slabsByLevel.get(levelId);
|
|
376
|
+
if (!slabMap)
|
|
377
|
+
return 0;
|
|
378
|
+
let maxElevation = 0;
|
|
379
|
+
for (const slab of slabMap.values()) {
|
|
380
|
+
if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {
|
|
381
|
+
const elevation = slab.elevation ?? 0.05;
|
|
382
|
+
if (elevation > maxElevation) {
|
|
383
|
+
maxElevation = elevation;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return maxElevation;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Get the slab elevation for an item using its full footprint (bounding box).
|
|
391
|
+
* Checks if any part of the item's rotated footprint overlaps with any slab polygon.
|
|
392
|
+
* Returns the highest overlapping slab elevation, or 0 if none.
|
|
393
|
+
*/
|
|
394
|
+
getSlabElevationForItem(levelId, position, dimensions, rotation) {
|
|
395
|
+
const slabMap = this.slabsByLevel.get(levelId);
|
|
396
|
+
if (!slabMap)
|
|
397
|
+
return 0;
|
|
398
|
+
let maxElevation = -Infinity;
|
|
399
|
+
for (const slab of slabMap.values()) {
|
|
400
|
+
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
|
401
|
+
const elevation = slab.elevation ?? 0.05;
|
|
402
|
+
if (elevation > maxElevation) {
|
|
403
|
+
maxElevation = elevation;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return maxElevation === -Infinity ? 0 : maxElevation;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon.
|
|
411
|
+
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
|
412
|
+
* Returns the highest slab elevation found, or 0 if none.
|
|
413
|
+
*/
|
|
414
|
+
getSlabElevationForWall(levelId, start, end) {
|
|
415
|
+
const slabMap = this.slabsByLevel.get(levelId);
|
|
416
|
+
if (!slabMap)
|
|
417
|
+
return 0;
|
|
418
|
+
let maxElevation = -Infinity;
|
|
419
|
+
for (const slab of slabMap.values()) {
|
|
420
|
+
if (slab.polygon.length < 3)
|
|
421
|
+
continue;
|
|
422
|
+
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
|
423
|
+
const elevation = slab.elevation ?? 0.05;
|
|
424
|
+
if (elevation > maxElevation) {
|
|
425
|
+
maxElevation = elevation;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return maxElevation === -Infinity ? 0 : maxElevation;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Check if an item can be placed on a ceiling.
|
|
433
|
+
* Validates that the footprint is within the ceiling polygon and doesn't overlap other ceiling items.
|
|
434
|
+
*/
|
|
435
|
+
canPlaceOnCeiling(ceilingId, position, dimensions, rotation, ignoreIds) {
|
|
436
|
+
const ceiling = this.ceilings.get(ceilingId);
|
|
437
|
+
if (!ceiling || ceiling.polygon.length < 3) {
|
|
438
|
+
return { valid: false, conflictIds: [] };
|
|
439
|
+
}
|
|
440
|
+
// Check that the item footprint is entirely within the ceiling polygon
|
|
441
|
+
const corners = getItemFootprint(position, dimensions, rotation);
|
|
442
|
+
for (const [cx, cz] of corners) {
|
|
443
|
+
if (!pointInPolygon(cx, cz, ceiling.polygon)) {
|
|
444
|
+
return { valid: false, conflictIds: [] };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Check for overlaps with other ceiling items
|
|
448
|
+
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds);
|
|
449
|
+
}
|
|
450
|
+
clearLevel(levelId) {
|
|
451
|
+
this.floorGrids.delete(levelId);
|
|
452
|
+
this.wallGrids.delete(levelId);
|
|
453
|
+
this.slabsByLevel.delete(levelId);
|
|
454
|
+
}
|
|
455
|
+
clear() {
|
|
456
|
+
this.floorGrids.clear();
|
|
457
|
+
this.wallGrids.clear();
|
|
458
|
+
this.walls.clear();
|
|
459
|
+
this.slabsByLevel.clear();
|
|
460
|
+
this.ceilingGrids.clear();
|
|
461
|
+
this.ceilings.clear();
|
|
462
|
+
this.itemCeilingMap.clear();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Singleton instance
|
|
466
|
+
export const spatialGridManager = new SpatialGridManager();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spatial-grid-sync.d.ts","sourceRoot":"","sources":["../../../src/hooks/spatial-grid/spatial-grid-sync.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAA2C,MAAM,cAAc,CAAA;AAIpF,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAmBpF;AAGD,wBAAgB,mBAAmB,SAmElC"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import useScene from '../../store/use-scene';
|
|
2
|
+
import { itemOverlapsPolygon, spatialGridManager, wallOverlapsPolygon } from './spatial-grid-manager';
|
|
3
|
+
export function resolveLevelId(node, nodes) {
|
|
4
|
+
// If the node itself is a level
|
|
5
|
+
if (node.type === 'level')
|
|
6
|
+
return node.id;
|
|
7
|
+
// Walk up parent chain to find level
|
|
8
|
+
// This assumes you track parentId or can derive it
|
|
9
|
+
let current = node;
|
|
10
|
+
while (current) {
|
|
11
|
+
if (current.type === 'level')
|
|
12
|
+
return current.id;
|
|
13
|
+
// Find parent (you might need to add parentId to your schema or derive it)
|
|
14
|
+
if (!current.parentId) {
|
|
15
|
+
current = undefined;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
current = nodes[current.parentId];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 'default'; // fallback for orphaned items
|
|
22
|
+
}
|
|
23
|
+
// Call this once at app initialization
|
|
24
|
+
export function initSpatialGridSync() {
|
|
25
|
+
const store = useScene;
|
|
26
|
+
// 1. Initial sync - process all existing nodes
|
|
27
|
+
const state = store.getState();
|
|
28
|
+
for (const node of Object.values(state.nodes)) {
|
|
29
|
+
const levelId = resolveLevelId(node, state.nodes);
|
|
30
|
+
spatialGridManager.handleNodeCreated(node, levelId);
|
|
31
|
+
}
|
|
32
|
+
// 2. Then subscribe to future changes
|
|
33
|
+
const markDirty = (id) => store.getState().markDirty(id);
|
|
34
|
+
// Subscribe to all changes
|
|
35
|
+
store.subscribe((state, prevState) => {
|
|
36
|
+
// Detect added nodes
|
|
37
|
+
for (const [id, node] of Object.entries(state.nodes)) {
|
|
38
|
+
if (!prevState.nodes[id]) {
|
|
39
|
+
const levelId = resolveLevelId(node, state.nodes);
|
|
40
|
+
spatialGridManager.handleNodeCreated(node, levelId);
|
|
41
|
+
// When a slab is added, mark overlapping items/walls dirty
|
|
42
|
+
if (node.type === 'slab') {
|
|
43
|
+
markNodesOverlappingSlab(node, state.nodes, markDirty);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Detect removed nodes
|
|
48
|
+
for (const [id, node] of Object.entries(prevState.nodes)) {
|
|
49
|
+
if (!state.nodes[id]) {
|
|
50
|
+
const levelId = resolveLevelId(node, prevState.nodes);
|
|
51
|
+
spatialGridManager.handleNodeDeleted(id, node.type, levelId);
|
|
52
|
+
// When a slab is removed, mark items/walls that were on it dirty (using current state)
|
|
53
|
+
if (node.type === 'slab') {
|
|
54
|
+
markNodesOverlappingSlab(node, state.nodes, markDirty);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Detect updated nodes (items with position/rotation/parentId/side changes, slabs with polygon/elevation changes)
|
|
59
|
+
for (const [id, node] of Object.entries(state.nodes)) {
|
|
60
|
+
const prev = prevState.nodes[id];
|
|
61
|
+
if (!prev)
|
|
62
|
+
continue;
|
|
63
|
+
if (node.type === 'item' && prev.type === 'item') {
|
|
64
|
+
if (!arraysEqual(node.position, prev.position) ||
|
|
65
|
+
!arraysEqual(node.rotation, prev.rotation) ||
|
|
66
|
+
node.parentId !== prev.parentId ||
|
|
67
|
+
node.side !== prev.side) {
|
|
68
|
+
const levelId = resolveLevelId(node, state.nodes);
|
|
69
|
+
spatialGridManager.handleNodeUpdated(node, levelId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else if (node.type === 'slab' && prev.type === 'slab') {
|
|
73
|
+
if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
|
|
74
|
+
const levelId = resolveLevelId(node, state.nodes);
|
|
75
|
+
spatialGridManager.handleNodeUpdated(node, levelId);
|
|
76
|
+
// Mark nodes overlapping old polygon and new polygon as dirty
|
|
77
|
+
markNodesOverlappingSlab(prev, state.nodes, markDirty);
|
|
78
|
+
markNodesOverlappingSlab(node, state.nodes, markDirty);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function arraysEqual(a, b) {
|
|
85
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Mark all floor items and walls that overlap a slab polygon as dirty.
|
|
89
|
+
*/
|
|
90
|
+
function markNodesOverlappingSlab(slab, nodes, markDirty) {
|
|
91
|
+
if (slab.polygon.length < 3)
|
|
92
|
+
return;
|
|
93
|
+
const slabLevelId = resolveLevelId(slab, nodes);
|
|
94
|
+
for (const node of Object.values(nodes)) {
|
|
95
|
+
if (node.type === 'item') {
|
|
96
|
+
const item = node;
|
|
97
|
+
// Only floor items are affected by slabs
|
|
98
|
+
if (item.asset.attachTo)
|
|
99
|
+
continue;
|
|
100
|
+
if (resolveLevelId(node, nodes) !== slabLevelId)
|
|
101
|
+
continue;
|
|
102
|
+
if (itemOverlapsPolygon(item.position, item.asset.dimensions, item.rotation, slab.polygon, 0.01)) {
|
|
103
|
+
markDirty(node.id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (node.type === 'wall') {
|
|
107
|
+
const wall = node;
|
|
108
|
+
if (resolveLevelId(node, nodes) !== slabLevelId)
|
|
109
|
+
continue;
|
|
110
|
+
if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {
|
|
111
|
+
markDirty(node.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface SpatialGridConfig {
|
|
2
|
+
cellSize: number;
|
|
3
|
+
}
|
|
4
|
+
export declare class SpatialGrid {
|
|
5
|
+
private config;
|
|
6
|
+
private cells;
|
|
7
|
+
private itemCells;
|
|
8
|
+
constructor(config: SpatialGridConfig);
|
|
9
|
+
private posToCell;
|
|
10
|
+
private cellKey;
|
|
11
|
+
private getItemCells;
|
|
12
|
+
insert(itemId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number]): void;
|
|
13
|
+
remove(itemId: string): void;
|
|
14
|
+
update(itemId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number]): void;
|
|
15
|
+
canPlace(position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[]): {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
conflictIds: string[];
|
|
18
|
+
};
|
|
19
|
+
queryRadius(x: number, z: number, radius: number): string[];
|
|
20
|
+
getItemCount(): number;
|
|
21
|
+
}
|
|
22
|
+
export {};
|
|
23
|
+
//# sourceMappingURL=spatial-grid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spatial-grid.d.ts","sourceRoot":"","sources":["../../../src/hooks/spatial-grid/spatial-grid.ts"],"names":[],"mappings":"AAMA,UAAU,iBAAiB;IACzB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,qBAAa,WAAW;IAIV,OAAO,CAAC,MAAM;IAH1B,OAAO,CAAC,KAAK,CAA+B;IAC5C,OAAO,CAAC,SAAS,CAAkC;gBAE/B,MAAM,EAAE,iBAAiB;IAE7C,OAAO,CAAC,SAAS;IAIjB,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,YAAY;IAsCpB,MAAM,CACJ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAClC,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EACpC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;IAepC,MAAM,CAAC,MAAM,EAAE,MAAM;IAiBrB,MAAM,CACJ,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAClC,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EACpC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;IAOpC,QAAQ,CACN,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAClC,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EACpC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAClC,SAAS,GAAE,MAAM,EAAO,GACvB;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,MAAM,EAAE,CAAA;KAAE;IAuB5C,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE;IAkB3D,YAAY,IAAI,MAAM;CAGvB"}
|