@holoscript/engine 6.0.3 → 6.0.4
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/AutoMesher-CK47F6AV.js +17 -0
- package/dist/GPUBuffers-2LHBCD7X.js +9 -0
- package/dist/WebGPUContext-TNEUYU2Y.js +11 -0
- package/dist/animation/index.cjs +38 -38
- package/dist/animation/index.d.cts +1 -1
- package/dist/animation/index.d.ts +1 -1
- package/dist/animation/index.js +1 -1
- package/dist/audio/index.cjs +16 -6
- package/dist/audio/index.d.cts +1 -1
- package/dist/audio/index.d.ts +1 -1
- package/dist/audio/index.js +1 -1
- package/dist/camera/index.cjs +23 -23
- package/dist/camera/index.d.cts +1 -1
- package/dist/camera/index.d.ts +1 -1
- package/dist/camera/index.js +1 -1
- package/dist/character/index.cjs +6 -4
- package/dist/character/index.js +1 -1
- package/dist/choreography/index.cjs +1194 -0
- package/dist/choreography/index.d.cts +687 -0
- package/dist/choreography/index.d.ts +687 -0
- package/dist/choreography/index.js +1156 -0
- package/dist/chunk-2CSNRI2N.js +217 -0
- package/dist/chunk-33T2WINR.js +266 -0
- package/dist/chunk-35R73OFM.js +1257 -0
- package/dist/chunk-4MMDSUNP.js +1256 -0
- package/dist/chunk-5V6HOU72.js +319 -0
- package/dist/chunk-6QOP6PYF.js +1038 -0
- package/dist/chunk-7KMJVHIL.js +8944 -0
- package/dist/chunk-7VPUC62U.js +1106 -0
- package/dist/chunk-A2Y6RCAT.js +1878 -0
- package/dist/chunk-AHM42MK6.js +8944 -0
- package/dist/chunk-BL7IDTHE.js +218 -0
- package/dist/chunk-CITOMSWL.js +10462 -0
- package/dist/chunk-CXDPKW2K.js +8944 -0
- package/dist/chunk-CXZPLD4S.js +223 -0
- package/dist/chunk-CZYJE7IH.js +5169 -0
- package/dist/chunk-D2OP7YC7.js +6325 -0
- package/dist/chunk-EDRVQHUU.js +1544 -0
- package/dist/chunk-EJSLOOW2.js +3589 -0
- package/dist/chunk-F53SFGW5.js +1878 -0
- package/dist/chunk-HCFPELPY.js +919 -0
- package/dist/chunk-HNEE36PY.js +93 -0
- package/dist/chunk-HYXNV36F.js +1256 -0
- package/dist/chunk-IB7KHVFY.js +821 -0
- package/dist/chunk-IBBO7YYG.js +690 -0
- package/dist/chunk-ILIBGINU.js +5470 -0
- package/dist/chunk-IS4MHLKN.js +5479 -0
- package/dist/chunk-JT2PFKWD.js +5479 -0
- package/dist/chunk-K4CUB4NY.js +1038 -0
- package/dist/chunk-KATDQXRJ.js +10462 -0
- package/dist/chunk-KBQE6ZFJ.js +8944 -0
- package/dist/chunk-KBVD5K7E.js +560 -0
- package/dist/chunk-KCDPVQRY.js +4088 -0
- package/dist/chunk-KN4QJPKN.js +8944 -0
- package/dist/chunk-KWJ3ROSI.js +8944 -0
- package/dist/chunk-L45VF6DD.js +919 -0
- package/dist/chunk-LY4T37YK.js +307 -0
- package/dist/chunk-MDN5WZXA.js +1544 -0
- package/dist/chunk-MGCDP6VU.js +928 -0
- package/dist/chunk-NCX7X6G2.js +8681 -0
- package/dist/chunk-OF54BPVD.js +913 -0
- package/dist/chunk-OWSN2Q3Q.js +690 -0
- package/dist/chunk-PRRB5TTA.js +406 -0
- package/dist/chunk-PXWVQF76.js +4086 -0
- package/dist/chunk-PYCOIDT2.js +812 -0
- package/dist/chunk-PZCSADOV.js +928 -0
- package/dist/chunk-Q2XBVS2K.js +1038 -0
- package/dist/chunk-QDZRXWN5.js +1776 -0
- package/dist/chunk-RNWOZ6WQ.js +913 -0
- package/dist/chunk-ROLFT4CJ.js +1693 -0
- package/dist/chunk-SLTJRZ2N.js +266 -0
- package/dist/chunk-SRUS5XSU.js +4088 -0
- package/dist/chunk-TKCA3WZ5.js +5409 -0
- package/dist/chunk-TNRMXYI2.js +1650 -0
- package/dist/chunk-TQB3GJGM.js +9763 -0
- package/dist/chunk-TUFGXG6K.js +510 -0
- package/dist/chunk-U6KMTGQJ.js +632 -0
- package/dist/chunk-VMGJQST6.js +8681 -0
- package/dist/chunk-X4F4TCG4.js +5470 -0
- package/dist/chunk-ZIFROE75.js +1544 -0
- package/dist/chunk-ZIJQYHSQ.js +1204 -0
- package/dist/combat/index.cjs +4 -4
- package/dist/combat/index.d.cts +1 -1
- package/dist/combat/index.d.ts +1 -1
- package/dist/combat/index.js +1 -1
- package/dist/ecs/index.cjs +1 -1
- package/dist/ecs/index.js +1 -1
- package/dist/environment/index.cjs +14 -14
- package/dist/environment/index.d.cts +1 -1
- package/dist/environment/index.d.ts +1 -1
- package/dist/environment/index.js +1 -1
- package/dist/gpu/index.cjs +4810 -0
- package/dist/gpu/index.js +3714 -0
- package/dist/hologram/index.cjs +27 -1
- package/dist/hologram/index.js +1 -1
- package/dist/index-B2PIsAmR.d.cts +2180 -0
- package/dist/index-B2PIsAmR.d.ts +2180 -0
- package/dist/index-BHySEPX7.d.cts +2921 -0
- package/dist/index-BJV21zuy.d.cts +341 -0
- package/dist/index-BJV21zuy.d.ts +341 -0
- package/dist/index-BQutTphC.d.cts +790 -0
- package/dist/index-ByIq2XrS.d.cts +3910 -0
- package/dist/index-BysHjDSO.d.cts +224 -0
- package/dist/index-BysHjDSO.d.ts +224 -0
- package/dist/index-CKwAJGck.d.ts +455 -0
- package/dist/index-CUl3QstQ.d.cts +3006 -0
- package/dist/index-CUl3QstQ.d.ts +3006 -0
- package/dist/index-CmYtNiI-.d.cts +953 -0
- package/dist/index-CmYtNiI-.d.ts +953 -0
- package/dist/index-CnRzWxi_.d.cts +522 -0
- package/dist/index-CnRzWxi_.d.ts +522 -0
- package/dist/index-CwRWbSC7.d.ts +2921 -0
- package/dist/index-CxKIBstO.d.ts +790 -0
- package/dist/index-DJ6-R8vh.d.cts +455 -0
- package/dist/index-DQKisbcI.d.cts +4968 -0
- package/dist/index-DQKisbcI.d.ts +4968 -0
- package/dist/index-DRT2zJez.d.ts +3910 -0
- package/dist/index-DfNLiAka.d.cts +192 -0
- package/dist/index-DfNLiAka.d.ts +192 -0
- package/dist/index-nMvkoRm8.d.cts +405 -0
- package/dist/index-nMvkoRm8.d.ts +405 -0
- package/dist/index-s9yOFU37.d.cts +604 -0
- package/dist/index-s9yOFU37.d.ts +604 -0
- package/dist/index.cjs +22966 -6960
- package/dist/index.d.cts +864 -20
- package/dist/index.d.ts +864 -20
- package/dist/index.js +3062 -48
- package/dist/input/index.cjs +1 -1
- package/dist/input/index.js +1 -1
- package/dist/orbital/index.cjs +3 -3
- package/dist/orbital/index.d.cts +1 -1
- package/dist/orbital/index.d.ts +1 -1
- package/dist/orbital/index.js +1 -1
- package/dist/particles/index.cjs +16 -16
- package/dist/particles/index.d.cts +1 -1
- package/dist/particles/index.d.ts +1 -1
- package/dist/particles/index.js +1 -1
- package/dist/physics/index.cjs +2377 -21
- package/dist/physics/index.d.cts +1 -1
- package/dist/physics/index.d.ts +1 -1
- package/dist/physics/index.js +35 -1
- package/dist/postfx/index.cjs +3491 -0
- package/dist/postfx/index.js +93 -0
- package/dist/procedural/index.cjs +1 -1
- package/dist/procedural/index.js +1 -1
- package/dist/puppeteer-5VF6KDVO.js +52197 -0
- package/dist/puppeteer-IZVZ3SG4.js +52197 -0
- package/dist/rendering/index.cjs +33 -32
- package/dist/rendering/index.d.cts +1 -1
- package/dist/rendering/index.d.ts +1 -1
- package/dist/rendering/index.js +8 -6
- package/dist/runtime/index.cjs +23 -13
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +8 -6
- package/dist/runtime/protocols/index.cjs +349 -0
- package/dist/runtime/protocols/index.js +15 -0
- package/dist/scene/index.cjs +8 -8
- package/dist/scene/index.d.cts +1 -1
- package/dist/scene/index.d.ts +1 -1
- package/dist/scene/index.js +1 -1
- package/dist/shader/index.cjs +3087 -0
- package/dist/shader/index.js +3044 -0
- package/dist/simulation/index.cjs +10680 -0
- package/dist/simulation/index.d.cts +3 -0
- package/dist/simulation/index.d.ts +3 -0
- package/dist/simulation/index.js +307 -0
- package/dist/spatial/index.cjs +2443 -0
- package/dist/spatial/index.d.cts +1545 -0
- package/dist/spatial/index.d.ts +1545 -0
- package/dist/spatial/index.js +2400 -0
- package/dist/terrain/index.cjs +1 -1
- package/dist/terrain/index.d.cts +1 -1
- package/dist/terrain/index.d.ts +1 -1
- package/dist/terrain/index.js +1 -1
- package/dist/transformers.node-4NKAPD5U.js +45620 -0
- package/dist/vm/index.cjs +7 -8
- package/dist/vm/index.d.cts +1 -1
- package/dist/vm/index.d.ts +1 -1
- package/dist/vm/index.js +1 -1
- package/dist/vm-bridge/index.cjs +2 -2
- package/dist/vm-bridge/index.d.cts +2 -2
- package/dist/vm-bridge/index.d.ts +2 -2
- package/dist/vm-bridge/index.js +1 -1
- package/dist/vr/index.cjs +6 -6
- package/dist/vr/index.js +1 -1
- package/dist/world/index.cjs +3 -3
- package/dist/world/index.d.cts +1 -1
- package/dist/world/index.d.ts +1 -1
- package/dist/world/index.js +1 -1
- package/package.json +53 -21
- package/LICENSE +0 -21
|
@@ -0,0 +1,2443 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/spatial/index.ts
|
|
21
|
+
var spatial_exports = {};
|
|
22
|
+
__export(spatial_exports, {
|
|
23
|
+
DEFAULT_SPATIAL_CONFIG: () => DEFAULT_SPATIAL_CONFIG,
|
|
24
|
+
OctreeLODSystem: () => OctreeLODSystem,
|
|
25
|
+
OctreeSystem: () => OctreeSystem,
|
|
26
|
+
SpatialConstraintValidator: () => SpatialConstraintValidator,
|
|
27
|
+
SpatialContextProvider: () => SpatialContextProvider,
|
|
28
|
+
SpatialQueryExecutor: () => SpatialQueryExecutor,
|
|
29
|
+
add: () => add,
|
|
30
|
+
boxesOverlap: () => boxesOverlap,
|
|
31
|
+
cross: () => cross,
|
|
32
|
+
distance: () => distance,
|
|
33
|
+
distanceSquared: () => distanceSquared,
|
|
34
|
+
dot: () => dot,
|
|
35
|
+
getBoxCenter: () => getBoxCenter,
|
|
36
|
+
isPointInBox: () => isPointInBox,
|
|
37
|
+
isPointInSphere: () => isPointInSphere,
|
|
38
|
+
lerp: () => lerp,
|
|
39
|
+
normalize: () => normalize,
|
|
40
|
+
scale: () => scale,
|
|
41
|
+
subtract: () => subtract
|
|
42
|
+
});
|
|
43
|
+
module.exports = __toCommonJS(spatial_exports);
|
|
44
|
+
|
|
45
|
+
// src/spatial/SpatialTypes.ts
|
|
46
|
+
var DEFAULT_SPATIAL_CONFIG = {
|
|
47
|
+
updateRate: 30,
|
|
48
|
+
perceptionRadius: 10,
|
|
49
|
+
visibilityRadius: 50,
|
|
50
|
+
trackRegions: true,
|
|
51
|
+
computeSightLines: true,
|
|
52
|
+
entityTypeFilter: [],
|
|
53
|
+
movementThreshold: 0.01
|
|
54
|
+
};
|
|
55
|
+
function distance(a, b) {
|
|
56
|
+
const dx = b.x - a.x;
|
|
57
|
+
const dy = b.y - a.y;
|
|
58
|
+
const dz = b.z - a.z;
|
|
59
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
60
|
+
}
|
|
61
|
+
function distanceSquared(a, b) {
|
|
62
|
+
const dx = b.x - a.x;
|
|
63
|
+
const dy = b.y - a.y;
|
|
64
|
+
const dz = b.z - a.z;
|
|
65
|
+
return dx * dx + dy * dy + dz * dz;
|
|
66
|
+
}
|
|
67
|
+
function isPointInBox(point, box) {
|
|
68
|
+
return point.x >= box.min.x && point.x <= box.max.x && point.y >= box.min.y && point.y <= box.max.y && point.z >= box.min.z && point.z <= box.max.z;
|
|
69
|
+
}
|
|
70
|
+
function isPointInSphere(point, sphere) {
|
|
71
|
+
return distance(point, sphere.center) <= sphere.radius;
|
|
72
|
+
}
|
|
73
|
+
function getBoxCenter(box) {
|
|
74
|
+
return {
|
|
75
|
+
x: (box.min.x + box.max.x) / 2,
|
|
76
|
+
y: (box.min.y + box.max.y) / 2,
|
|
77
|
+
z: (box.min.z + box.max.z) / 2
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function boxesOverlap(a, b) {
|
|
81
|
+
return a.min.x <= b.max.x && a.max.x >= b.min.x && a.min.y <= b.max.y && a.max.y >= b.min.y && a.min.z <= b.max.z && a.max.z >= b.min.z;
|
|
82
|
+
}
|
|
83
|
+
function normalize(v) {
|
|
84
|
+
const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
|
|
85
|
+
if (len === 0) return { x: 0, y: 0, z: 0 };
|
|
86
|
+
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
|
87
|
+
}
|
|
88
|
+
function subtract(a, b) {
|
|
89
|
+
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
|
90
|
+
}
|
|
91
|
+
function add(a, b) {
|
|
92
|
+
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
|
|
93
|
+
}
|
|
94
|
+
function scale(v, s) {
|
|
95
|
+
return { x: v.x * s, y: v.y * s, z: v.z * s };
|
|
96
|
+
}
|
|
97
|
+
function dot(a, b) {
|
|
98
|
+
return a.x * b.x + a.y * b.y + a.z * b.z;
|
|
99
|
+
}
|
|
100
|
+
function cross(a, b) {
|
|
101
|
+
return {
|
|
102
|
+
x: a.y * b.z - a.z * b.y,
|
|
103
|
+
y: a.z * b.x - a.x * b.z,
|
|
104
|
+
z: a.x * b.y - a.y * b.x
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function lerp(a, b, t) {
|
|
108
|
+
return {
|
|
109
|
+
x: a.x + (b.x - a.x) * t,
|
|
110
|
+
y: a.y + (b.y - a.y) * t,
|
|
111
|
+
z: a.z + (b.z - a.z) * t
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/spatial/SpatialQuery.ts
|
|
116
|
+
var SpatialQueryExecutor = class {
|
|
117
|
+
entities = /* @__PURE__ */ new Map();
|
|
118
|
+
regions = /* @__PURE__ */ new Map();
|
|
119
|
+
spatialIndex;
|
|
120
|
+
constructor() {
|
|
121
|
+
this.spatialIndex = new SpatialIndex();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Update the entity set
|
|
125
|
+
*/
|
|
126
|
+
updateEntities(entities) {
|
|
127
|
+
this.entities.clear();
|
|
128
|
+
for (const entity of entities) {
|
|
129
|
+
this.entities.set(entity.id, entity);
|
|
130
|
+
}
|
|
131
|
+
this.spatialIndex.rebuild(entities);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Update regions
|
|
135
|
+
*/
|
|
136
|
+
updateRegions(regions) {
|
|
137
|
+
this.regions.clear();
|
|
138
|
+
for (const region of regions) {
|
|
139
|
+
this.regions.set(region.id, region);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Execute a spatial query
|
|
144
|
+
*/
|
|
145
|
+
execute(query) {
|
|
146
|
+
let candidates = Array.from(this.entities.values());
|
|
147
|
+
if (query.entityTypeFilter && query.entityTypeFilter.length > 0) {
|
|
148
|
+
candidates = candidates.filter((e) => query.entityTypeFilter.includes(e.type));
|
|
149
|
+
}
|
|
150
|
+
let results;
|
|
151
|
+
switch (query.type) {
|
|
152
|
+
case "nearest":
|
|
153
|
+
results = this.executeNearest(query, candidates);
|
|
154
|
+
break;
|
|
155
|
+
case "within":
|
|
156
|
+
results = this.executeWithin(query, candidates);
|
|
157
|
+
break;
|
|
158
|
+
case "visible":
|
|
159
|
+
results = this.executeVisible(query, candidates);
|
|
160
|
+
break;
|
|
161
|
+
case "reachable":
|
|
162
|
+
results = this.executeReachable(query, candidates);
|
|
163
|
+
break;
|
|
164
|
+
case "in_region":
|
|
165
|
+
results = this.executeInRegion(query, candidates);
|
|
166
|
+
break;
|
|
167
|
+
case "by_type":
|
|
168
|
+
results = this.executeByType(query, candidates);
|
|
169
|
+
break;
|
|
170
|
+
case "raycast":
|
|
171
|
+
results = this.executeRaycast(query, candidates);
|
|
172
|
+
break;
|
|
173
|
+
default:
|
|
174
|
+
results = [];
|
|
175
|
+
}
|
|
176
|
+
if (query.maxResults && results.length > query.maxResults) {
|
|
177
|
+
results = results.slice(0, query.maxResults);
|
|
178
|
+
}
|
|
179
|
+
return results;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Find nearest entities
|
|
183
|
+
*/
|
|
184
|
+
executeNearest(query, candidates) {
|
|
185
|
+
const results = candidates.map((entity) => ({
|
|
186
|
+
entity,
|
|
187
|
+
distance: distance(query.from, entity.position),
|
|
188
|
+
direction: normalize(subtract(entity.position, query.from))
|
|
189
|
+
}));
|
|
190
|
+
results.sort((a, b) => a.distance - b.distance);
|
|
191
|
+
const count = query.count || 1;
|
|
192
|
+
return results.slice(0, count);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Find entities within radius
|
|
196
|
+
*/
|
|
197
|
+
executeWithin(query, candidates) {
|
|
198
|
+
const _radiusSq = query.radius * query.radius;
|
|
199
|
+
return candidates.map((entity) => {
|
|
200
|
+
const dist = distance(query.from, entity.position);
|
|
201
|
+
return {
|
|
202
|
+
entity,
|
|
203
|
+
distance: dist,
|
|
204
|
+
direction: normalize(subtract(entity.position, query.from))
|
|
205
|
+
};
|
|
206
|
+
}).filter((r) => {
|
|
207
|
+
if (query.includePartial && r.entity.bounds) {
|
|
208
|
+
return r.distance - this.getEntityRadius(r.entity) <= query.radius;
|
|
209
|
+
}
|
|
210
|
+
return r.distance <= query.radius;
|
|
211
|
+
}).sort((a, b) => a.distance - b.distance);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Find visible entities
|
|
215
|
+
*/
|
|
216
|
+
executeVisible(query, candidates) {
|
|
217
|
+
const results = [];
|
|
218
|
+
const maxDist = query.maxDistance || Infinity;
|
|
219
|
+
for (const entity of candidates) {
|
|
220
|
+
const dir = subtract(entity.position, query.from);
|
|
221
|
+
const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
|
|
222
|
+
if (dist > maxDist) continue;
|
|
223
|
+
if (dist === 0) continue;
|
|
224
|
+
if (query.direction && query.fov !== void 0) {
|
|
225
|
+
const normalizedDir = normalize(dir);
|
|
226
|
+
const dotProduct = dot(normalize(query.direction), normalizedDir);
|
|
227
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, dotProduct))) * (180 / Math.PI);
|
|
228
|
+
if (angle > query.fov / 2) continue;
|
|
229
|
+
}
|
|
230
|
+
const sightLine = this.checkSightLine(query.from, entity.position, candidates, entity.id);
|
|
231
|
+
if (!sightLine.blocked) {
|
|
232
|
+
results.push({
|
|
233
|
+
entity,
|
|
234
|
+
distance: dist,
|
|
235
|
+
direction: normalize(dir),
|
|
236
|
+
sightLine
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return results.sort((a, b) => a.distance - b.distance);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Find reachable entities (simplified - no full pathfinding)
|
|
244
|
+
*/
|
|
245
|
+
executeReachable(query, candidates) {
|
|
246
|
+
const maxDist = query.maxDistance || Infinity;
|
|
247
|
+
const obstacles = query.obstacles || [];
|
|
248
|
+
return candidates.map((entity) => ({
|
|
249
|
+
entity,
|
|
250
|
+
distance: distance(query.from, entity.position),
|
|
251
|
+
direction: normalize(subtract(entity.position, query.from))
|
|
252
|
+
})).filter((r) => {
|
|
253
|
+
if (r.distance > maxDist) return false;
|
|
254
|
+
const sight = this.checkSightLine(query.from, r.entity.position, obstacles, r.entity.id);
|
|
255
|
+
return !sight.blocked;
|
|
256
|
+
}).sort((a, b) => a.distance - b.distance);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Find entities in a region
|
|
260
|
+
*/
|
|
261
|
+
executeInRegion(query, candidates) {
|
|
262
|
+
return candidates.filter((entity) => this.isInRegion(entity.position, query.region)).map((entity) => ({
|
|
263
|
+
entity,
|
|
264
|
+
distance: distance(query.from, entity.position),
|
|
265
|
+
direction: normalize(subtract(entity.position, query.from))
|
|
266
|
+
})).sort((a, b) => a.distance - b.distance);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Find entities by type
|
|
270
|
+
*/
|
|
271
|
+
executeByType(query, candidates) {
|
|
272
|
+
let filtered = candidates.filter((e) => query.entityTypes.includes(e.type));
|
|
273
|
+
if (query.radius !== void 0) {
|
|
274
|
+
filtered = filtered.filter((e) => distance(query.from, e.position) <= query.radius);
|
|
275
|
+
}
|
|
276
|
+
return filtered.map((entity) => ({
|
|
277
|
+
entity,
|
|
278
|
+
distance: distance(query.from, entity.position),
|
|
279
|
+
direction: normalize(subtract(entity.position, query.from))
|
|
280
|
+
})).sort((a, b) => a.distance - b.distance);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Execute raycast
|
|
284
|
+
*/
|
|
285
|
+
executeRaycast(query, candidates) {
|
|
286
|
+
const results = [];
|
|
287
|
+
const dir = normalize(query.direction);
|
|
288
|
+
for (const entity of candidates) {
|
|
289
|
+
const hit = this.raycastEntity(query.from, dir, entity, query.maxDistance);
|
|
290
|
+
if (hit) {
|
|
291
|
+
results.push({
|
|
292
|
+
entity,
|
|
293
|
+
distance: hit.distance,
|
|
294
|
+
direction: dir
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
results.sort((a, b) => a.distance - b.distance);
|
|
299
|
+
if (query.hitFirst && results.length > 0) {
|
|
300
|
+
return [results[0]];
|
|
301
|
+
}
|
|
302
|
+
return results;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Check line of sight between two points
|
|
306
|
+
*/
|
|
307
|
+
checkSightLine(from, to, entities, excludeId) {
|
|
308
|
+
const dir = subtract(to, from);
|
|
309
|
+
const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
|
|
310
|
+
const normalizedDir = dist > 0 ? scale(dir, 1 / dist) : { x: 0, y: 0, z: 0 };
|
|
311
|
+
for (const entity of entities) {
|
|
312
|
+
if (entity.id === excludeId) continue;
|
|
313
|
+
const hit = this.raycastEntity(from, normalizedDir, entity, dist);
|
|
314
|
+
if (hit && hit.distance < dist - 1e-3) {
|
|
315
|
+
return {
|
|
316
|
+
from,
|
|
317
|
+
to,
|
|
318
|
+
blocked: true,
|
|
319
|
+
blockingEntity: entity.id,
|
|
320
|
+
distance: dist
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { from, to, blocked: false, distance: dist };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Raycast against a single entity
|
|
328
|
+
*/
|
|
329
|
+
raycastEntity(origin, direction, entity, maxDistance) {
|
|
330
|
+
const radius = this.getEntityRadius(entity);
|
|
331
|
+
const center = entity.position;
|
|
332
|
+
const oc = subtract(origin, center);
|
|
333
|
+
const a = dot(direction, direction);
|
|
334
|
+
const b = 2 * dot(oc, direction);
|
|
335
|
+
const c = dot(oc, oc) - radius * radius;
|
|
336
|
+
const discriminant = b * b - 4 * a * c;
|
|
337
|
+
if (discriminant < 0) return null;
|
|
338
|
+
const t = (-b - Math.sqrt(discriminant)) / (2 * a);
|
|
339
|
+
if (t < 0 || t > maxDistance) return null;
|
|
340
|
+
const point = add(origin, scale(direction, t));
|
|
341
|
+
return {
|
|
342
|
+
entity,
|
|
343
|
+
point,
|
|
344
|
+
distance: t,
|
|
345
|
+
normal: normalize(subtract(point, center))
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Get approximate radius of an entity
|
|
350
|
+
*/
|
|
351
|
+
getEntityRadius(entity) {
|
|
352
|
+
if (!entity.bounds) return 0.5;
|
|
353
|
+
if ("radius" in entity.bounds) {
|
|
354
|
+
return entity.bounds.radius;
|
|
355
|
+
}
|
|
356
|
+
const box = entity.bounds;
|
|
357
|
+
const dx = box.max.x - box.min.x;
|
|
358
|
+
const dy = box.max.y - box.min.y;
|
|
359
|
+
const dz = box.max.z - box.min.z;
|
|
360
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Check if point is in region
|
|
364
|
+
*/
|
|
365
|
+
isInRegion(point, region) {
|
|
366
|
+
if ("radius" in region.bounds) {
|
|
367
|
+
return isPointInSphere(point, region.bounds);
|
|
368
|
+
}
|
|
369
|
+
return isPointInBox(point, region.bounds);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
var SpatialIndex = class {
|
|
373
|
+
cellSize = 10;
|
|
374
|
+
cells = /* @__PURE__ */ new Map();
|
|
375
|
+
/**
|
|
376
|
+
* Rebuild the index with new entities
|
|
377
|
+
*/
|
|
378
|
+
rebuild(entities) {
|
|
379
|
+
this.cells.clear();
|
|
380
|
+
for (const entity of entities) {
|
|
381
|
+
const cellKey = this.getCellKey(entity.position);
|
|
382
|
+
const cell = this.cells.get(cellKey) || [];
|
|
383
|
+
cell.push(entity);
|
|
384
|
+
this.cells.set(cellKey, cell);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Get entities in neighboring cells
|
|
389
|
+
*/
|
|
390
|
+
getNeighbors(position, radius) {
|
|
391
|
+
const results = [];
|
|
392
|
+
const cellRadius = Math.ceil(radius / this.cellSize);
|
|
393
|
+
const cx = Math.floor(position.x / this.cellSize);
|
|
394
|
+
const cy = Math.floor(position.y / this.cellSize);
|
|
395
|
+
const cz = Math.floor(position.z / this.cellSize);
|
|
396
|
+
for (let dx = -cellRadius; dx <= cellRadius; dx++) {
|
|
397
|
+
for (let dy = -cellRadius; dy <= cellRadius; dy++) {
|
|
398
|
+
for (let dz = -cellRadius; dz <= cellRadius; dz++) {
|
|
399
|
+
const key = `${cx + dx},${cy + dy},${cz + dz}`;
|
|
400
|
+
const cell = this.cells.get(key);
|
|
401
|
+
if (cell) {
|
|
402
|
+
results.push(...cell);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return results;
|
|
408
|
+
}
|
|
409
|
+
getCellKey(position) {
|
|
410
|
+
const cx = Math.floor(position.x / this.cellSize);
|
|
411
|
+
const cy = Math.floor(position.y / this.cellSize);
|
|
412
|
+
const cz = Math.floor(position.z / this.cellSize);
|
|
413
|
+
return `${cx},${cy},${cz}`;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// src/spatial/SpatialContextProvider.ts
|
|
418
|
+
var import_events = require("events");
|
|
419
|
+
var SpatialContextProvider = class extends import_events.EventEmitter {
|
|
420
|
+
agents = /* @__PURE__ */ new Map();
|
|
421
|
+
entities = /* @__PURE__ */ new Map();
|
|
422
|
+
regions = /* @__PURE__ */ new Map();
|
|
423
|
+
queryExecutor;
|
|
424
|
+
updateInterval = null;
|
|
425
|
+
isRunning = false;
|
|
426
|
+
constructor() {
|
|
427
|
+
super();
|
|
428
|
+
this.queryExecutor = new SpatialQueryExecutor();
|
|
429
|
+
}
|
|
430
|
+
// ===========================================================================
|
|
431
|
+
// LIFECYCLE
|
|
432
|
+
// ===========================================================================
|
|
433
|
+
/**
|
|
434
|
+
* Start the spatial context provider
|
|
435
|
+
*/
|
|
436
|
+
start() {
|
|
437
|
+
if (this.isRunning) return;
|
|
438
|
+
this.isRunning = true;
|
|
439
|
+
const minInterval = this.getMinUpdateInterval();
|
|
440
|
+
if (minInterval > 0) {
|
|
441
|
+
this.updateInterval = setInterval(() => this.update(), minInterval);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Stop the spatial context provider
|
|
446
|
+
*/
|
|
447
|
+
stop() {
|
|
448
|
+
if (!this.isRunning) return;
|
|
449
|
+
this.isRunning = false;
|
|
450
|
+
if (this.updateInterval) {
|
|
451
|
+
clearInterval(this.updateInterval);
|
|
452
|
+
this.updateInterval = null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Manual update (for testing or custom update loops)
|
|
457
|
+
*/
|
|
458
|
+
update() {
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
for (const [agentId, state] of this.agents) {
|
|
461
|
+
this.updateAgentContext(agentId, state, now);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// ===========================================================================
|
|
465
|
+
// AGENT REGISTRATION
|
|
466
|
+
// ===========================================================================
|
|
467
|
+
/**
|
|
468
|
+
* Register an agent for spatial awareness
|
|
469
|
+
*/
|
|
470
|
+
registerAgent(agentId, position, config = {}) {
|
|
471
|
+
const fullConfig = { ...DEFAULT_SPATIAL_CONFIG, ...config };
|
|
472
|
+
const state = {
|
|
473
|
+
id: agentId,
|
|
474
|
+
position,
|
|
475
|
+
config: fullConfig,
|
|
476
|
+
lastContext: null,
|
|
477
|
+
trackedEntities: /* @__PURE__ */ new Map(),
|
|
478
|
+
currentRegions: /* @__PURE__ */ new Set(),
|
|
479
|
+
subscriptions: /* @__PURE__ */ new Map()
|
|
480
|
+
};
|
|
481
|
+
this.agents.set(agentId, state);
|
|
482
|
+
if (this.isRunning) {
|
|
483
|
+
this.stop();
|
|
484
|
+
this.start();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Unregister an agent
|
|
489
|
+
*/
|
|
490
|
+
unregisterAgent(agentId) {
|
|
491
|
+
this.agents.delete(agentId);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Update agent position
|
|
495
|
+
*/
|
|
496
|
+
updateAgentPosition(agentId, position, velocity) {
|
|
497
|
+
const state = this.agents.get(agentId);
|
|
498
|
+
if (!state) return;
|
|
499
|
+
state.position = position;
|
|
500
|
+
state.velocity = velocity;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Get agent's current spatial context
|
|
504
|
+
*/
|
|
505
|
+
getContext(agentId) {
|
|
506
|
+
const state = this.agents.get(agentId);
|
|
507
|
+
return state?.lastContext || null;
|
|
508
|
+
}
|
|
509
|
+
// ===========================================================================
|
|
510
|
+
// ENTITY MANAGEMENT
|
|
511
|
+
// ===========================================================================
|
|
512
|
+
/**
|
|
513
|
+
* Add or update an entity
|
|
514
|
+
*/
|
|
515
|
+
setEntity(entity) {
|
|
516
|
+
this.entities.set(entity.id, entity);
|
|
517
|
+
this.rebuildQueryIndex();
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Remove an entity
|
|
521
|
+
*/
|
|
522
|
+
removeEntity(entityId) {
|
|
523
|
+
this.entities.delete(entityId);
|
|
524
|
+
this.rebuildQueryIndex();
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Batch update entities
|
|
528
|
+
*/
|
|
529
|
+
setEntities(entities) {
|
|
530
|
+
this.entities.clear();
|
|
531
|
+
for (const entity of entities) {
|
|
532
|
+
this.entities.set(entity.id, entity);
|
|
533
|
+
}
|
|
534
|
+
this.rebuildQueryIndex();
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Get all entities
|
|
538
|
+
*/
|
|
539
|
+
getEntities() {
|
|
540
|
+
return Array.from(this.entities.values());
|
|
541
|
+
}
|
|
542
|
+
// ===========================================================================
|
|
543
|
+
// REGION MANAGEMENT
|
|
544
|
+
// ===========================================================================
|
|
545
|
+
/**
|
|
546
|
+
* Add or update a region
|
|
547
|
+
*/
|
|
548
|
+
setRegion(region) {
|
|
549
|
+
this.regions.set(region.id, region);
|
|
550
|
+
this.queryExecutor.updateRegions(Array.from(this.regions.values()));
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Remove a region
|
|
554
|
+
*/
|
|
555
|
+
removeRegion(regionId) {
|
|
556
|
+
this.regions.delete(regionId);
|
|
557
|
+
this.queryExecutor.updateRegions(Array.from(this.regions.values()));
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Subscribe to region events for an agent
|
|
561
|
+
*/
|
|
562
|
+
subscribeToRegion(agentId, regionId, callback) {
|
|
563
|
+
const state = this.agents.get(agentId);
|
|
564
|
+
if (!state) return;
|
|
565
|
+
state.subscriptions.set(regionId, { regionId, callback });
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Unsubscribe from region events
|
|
569
|
+
*/
|
|
570
|
+
unsubscribeFromRegion(agentId, regionId) {
|
|
571
|
+
const state = this.agents.get(agentId);
|
|
572
|
+
if (!state) return;
|
|
573
|
+
state.subscriptions.delete(regionId);
|
|
574
|
+
}
|
|
575
|
+
// ===========================================================================
|
|
576
|
+
// QUERIES
|
|
577
|
+
// ===========================================================================
|
|
578
|
+
/**
|
|
579
|
+
* Execute a spatial query
|
|
580
|
+
*/
|
|
581
|
+
query(query) {
|
|
582
|
+
return this.queryExecutor.execute(query);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Find nearest entities to position
|
|
586
|
+
*/
|
|
587
|
+
findNearest(from, count = 1, typeFilter) {
|
|
588
|
+
return this.query({
|
|
589
|
+
type: "nearest",
|
|
590
|
+
from,
|
|
591
|
+
count,
|
|
592
|
+
entityTypeFilter: typeFilter
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Find entities within radius
|
|
597
|
+
*/
|
|
598
|
+
findWithin(from, radius, typeFilter) {
|
|
599
|
+
return this.query({
|
|
600
|
+
type: "within",
|
|
601
|
+
from,
|
|
602
|
+
radius,
|
|
603
|
+
entityTypeFilter: typeFilter
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Find visible entities from position
|
|
608
|
+
*/
|
|
609
|
+
findVisible(from, direction, fov, maxDistance) {
|
|
610
|
+
return this.query({
|
|
611
|
+
type: "visible",
|
|
612
|
+
from,
|
|
613
|
+
direction,
|
|
614
|
+
fov,
|
|
615
|
+
maxDistance
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// ===========================================================================
|
|
619
|
+
// PRIVATE METHODS
|
|
620
|
+
// ===========================================================================
|
|
621
|
+
/**
|
|
622
|
+
* Update context for a single agent
|
|
623
|
+
*/
|
|
624
|
+
updateAgentContext(agentId, state, now) {
|
|
625
|
+
const { config, position } = state;
|
|
626
|
+
const nearbyEntities = this.findEntitiesInRadius(position, config.perceptionRadius);
|
|
627
|
+
const filteredEntities = config.entityTypeFilter.length > 0 ? nearbyEntities.filter((e) => config.entityTypeFilter.includes(e.type)) : nearbyEntities;
|
|
628
|
+
this.checkEntityEvents(agentId, state, filteredEntities, now);
|
|
629
|
+
const currentRegions = this.findRegionsContaining(position);
|
|
630
|
+
this.checkRegionEvents(agentId, state, currentRegions, now);
|
|
631
|
+
const sightLines = config.computeSightLines ? this.computeSightLines(position, filteredEntities) : [];
|
|
632
|
+
const context = {
|
|
633
|
+
agentPosition: position,
|
|
634
|
+
agentVelocity: state.velocity,
|
|
635
|
+
nearbyEntities: filteredEntities,
|
|
636
|
+
currentRegions,
|
|
637
|
+
allRegions: Array.from(this.regions.values()),
|
|
638
|
+
sightLines,
|
|
639
|
+
timestamp: now,
|
|
640
|
+
updateRate: config.updateRate
|
|
641
|
+
};
|
|
642
|
+
state.lastContext = context;
|
|
643
|
+
this.emit("context:updated", agentId, context);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Find entities within radius of position
|
|
647
|
+
*/
|
|
648
|
+
findEntitiesInRadius(position, radius) {
|
|
649
|
+
const radiusSq = radius * radius;
|
|
650
|
+
return Array.from(this.entities.values()).filter(
|
|
651
|
+
(e) => distanceSquared(position, e.position) <= radiusSq
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Find regions containing position
|
|
656
|
+
*/
|
|
657
|
+
findRegionsContaining(position) {
|
|
658
|
+
return Array.from(this.regions.values()).filter((r) => this.isInRegion(position, r));
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Check if position is in region
|
|
662
|
+
*/
|
|
663
|
+
isInRegion(position, region) {
|
|
664
|
+
if ("radius" in region.bounds) {
|
|
665
|
+
return isPointInSphere(position, region.bounds);
|
|
666
|
+
}
|
|
667
|
+
return isPointInBox(position, region.bounds);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Check for entity enter/exit events
|
|
671
|
+
*/
|
|
672
|
+
checkEntityEvents(agentId, state, currentEntities, now) {
|
|
673
|
+
const currentIds = new Set(currentEntities.map((e) => e.id));
|
|
674
|
+
for (const entity of currentEntities) {
|
|
675
|
+
if (!state.trackedEntities.has(entity.id)) {
|
|
676
|
+
const event = {
|
|
677
|
+
type: "entity_entered",
|
|
678
|
+
entity,
|
|
679
|
+
distance: distance(state.position, entity.position),
|
|
680
|
+
timestamp: now
|
|
681
|
+
};
|
|
682
|
+
this.emit("entity:entered", agentId, event);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
for (const [entityId, cachedEntity] of state.trackedEntities) {
|
|
686
|
+
if (!currentIds.has(entityId)) {
|
|
687
|
+
const event = {
|
|
688
|
+
type: "entity_exited",
|
|
689
|
+
entity: cachedEntity,
|
|
690
|
+
timestamp: now
|
|
691
|
+
};
|
|
692
|
+
this.emit("entity:exited", agentId, event);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const newTrackedEntities = /* @__PURE__ */ new Map();
|
|
696
|
+
for (const entity of currentEntities) {
|
|
697
|
+
newTrackedEntities.set(entity.id, entity);
|
|
698
|
+
}
|
|
699
|
+
state.trackedEntities = newTrackedEntities;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Check for region enter/exit events
|
|
703
|
+
*/
|
|
704
|
+
checkRegionEvents(agentId, state, currentRegions, now) {
|
|
705
|
+
const currentIds = new Set(currentRegions.map((r) => r.id));
|
|
706
|
+
for (const region of currentRegions) {
|
|
707
|
+
if (!state.currentRegions.has(region.id)) {
|
|
708
|
+
const event = {
|
|
709
|
+
type: "region_entered",
|
|
710
|
+
region,
|
|
711
|
+
timestamp: now
|
|
712
|
+
};
|
|
713
|
+
this.emit("region:entered", agentId, event);
|
|
714
|
+
const sub = state.subscriptions.get(region.id);
|
|
715
|
+
if (sub) {
|
|
716
|
+
sub.callback(event);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (const regionId of state.currentRegions) {
|
|
721
|
+
if (!currentIds.has(regionId)) {
|
|
722
|
+
const region = this.regions.get(regionId);
|
|
723
|
+
if (region) {
|
|
724
|
+
const event = {
|
|
725
|
+
type: "region_exited",
|
|
726
|
+
region,
|
|
727
|
+
timestamp: now
|
|
728
|
+
};
|
|
729
|
+
this.emit("region:exited", agentId, event);
|
|
730
|
+
const sub = state.subscriptions.get(regionId);
|
|
731
|
+
if (sub) {
|
|
732
|
+
sub.callback(event);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
state.currentRegions = currentIds;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Compute sight lines to entities
|
|
741
|
+
*/
|
|
742
|
+
computeSightLines(from, entities) {
|
|
743
|
+
const allEntities = Array.from(this.entities.values());
|
|
744
|
+
return entities.map((target) => {
|
|
745
|
+
const dist = distance(from, target.position);
|
|
746
|
+
const blocked = this.isLineBlocked(from, target.position, allEntities, target.id);
|
|
747
|
+
return {
|
|
748
|
+
from,
|
|
749
|
+
to: target.position,
|
|
750
|
+
blocked: blocked.blocked,
|
|
751
|
+
blockingEntity: blocked.blockerId,
|
|
752
|
+
distance: dist
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Check if line between two points is blocked
|
|
758
|
+
*/
|
|
759
|
+
isLineBlocked(from, to, entities, excludeId) {
|
|
760
|
+
const dir = normalize(subtract(to, from));
|
|
761
|
+
const maxDist = distance(from, to);
|
|
762
|
+
for (const entity of entities) {
|
|
763
|
+
if (entity.id === excludeId) continue;
|
|
764
|
+
const radius = this.getEntityRadius(entity);
|
|
765
|
+
const toEntity = subtract(entity.position, from);
|
|
766
|
+
const projection = this.dot3(toEntity, dir);
|
|
767
|
+
if (projection < 0 || projection > maxDist) continue;
|
|
768
|
+
const closest = {
|
|
769
|
+
x: from.x + dir.x * projection,
|
|
770
|
+
y: from.y + dir.y * projection,
|
|
771
|
+
z: from.z + dir.z * projection
|
|
772
|
+
};
|
|
773
|
+
const distToLine = distance(closest, entity.position);
|
|
774
|
+
if (distToLine < radius) {
|
|
775
|
+
return { blocked: true, blockerId: entity.id };
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return { blocked: false };
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Get entity radius
|
|
782
|
+
*/
|
|
783
|
+
getEntityRadius(entity) {
|
|
784
|
+
if (!entity.bounds) return 0.5;
|
|
785
|
+
if ("radius" in entity.bounds) {
|
|
786
|
+
return entity.bounds.radius;
|
|
787
|
+
}
|
|
788
|
+
const box = entity.bounds;
|
|
789
|
+
const dx = box.max.x - box.min.x;
|
|
790
|
+
const dy = box.max.y - box.min.y;
|
|
791
|
+
const dz = box.max.z - box.min.z;
|
|
792
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz) / 2;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Dot product
|
|
796
|
+
*/
|
|
797
|
+
dot3(a, b) {
|
|
798
|
+
return a.x * b.x + a.y * b.y + a.z * b.z;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Rebuild the query executor index
|
|
802
|
+
*/
|
|
803
|
+
rebuildQueryIndex() {
|
|
804
|
+
this.queryExecutor.updateEntities(Array.from(this.entities.values()));
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Get minimum update interval
|
|
808
|
+
*/
|
|
809
|
+
getMinUpdateInterval() {
|
|
810
|
+
let minRate = 0;
|
|
811
|
+
for (const state of this.agents.values()) {
|
|
812
|
+
if (state.config.updateRate > minRate) {
|
|
813
|
+
minRate = state.config.updateRate;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return minRate > 0 ? 1e3 / minRate : 0;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
// src/spatial/SpatialConstraintValidator.ts
|
|
821
|
+
var SpatialConstraintValidator = class {
|
|
822
|
+
declarations = /* @__PURE__ */ new Map();
|
|
823
|
+
diagnostics = [];
|
|
824
|
+
constraintMap = /* @__PURE__ */ new Map();
|
|
825
|
+
// -------------------------------------------------------------------------
|
|
826
|
+
// Public API
|
|
827
|
+
// -------------------------------------------------------------------------
|
|
828
|
+
/**
|
|
829
|
+
* Validate all spatial constraints across a set of declarations.
|
|
830
|
+
*/
|
|
831
|
+
validate(declarations) {
|
|
832
|
+
this.reset();
|
|
833
|
+
for (const decl of declarations) {
|
|
834
|
+
this.declarations.set(decl.entityId, decl);
|
|
835
|
+
this.constraintMap.set(decl.entityId, [...decl.constraints]);
|
|
836
|
+
}
|
|
837
|
+
this.resolveReferences();
|
|
838
|
+
this.detectCircularReferences();
|
|
839
|
+
for (const decl of declarations) {
|
|
840
|
+
for (const constraint of decl.constraints) {
|
|
841
|
+
switch (constraint.kind) {
|
|
842
|
+
case "spatial_adjacent":
|
|
843
|
+
this.validateAdjacent(decl, constraint);
|
|
844
|
+
break;
|
|
845
|
+
case "spatial_contains":
|
|
846
|
+
this.validateContains(decl, constraint);
|
|
847
|
+
break;
|
|
848
|
+
case "spatial_reachable":
|
|
849
|
+
this.validateReachable(decl, constraint);
|
|
850
|
+
break;
|
|
851
|
+
case "spatial_temporal_adjacent":
|
|
852
|
+
this.validateTemporalAdjacent(decl, constraint);
|
|
853
|
+
break;
|
|
854
|
+
case "spatial_temporal_reachable":
|
|
855
|
+
this.validateTemporalReachable(decl, constraint);
|
|
856
|
+
break;
|
|
857
|
+
case "spatial_trajectory":
|
|
858
|
+
this.validateTrajectory(decl, constraint);
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
this.validateCrossConstraintConsistency();
|
|
864
|
+
const errors = this.diagnostics.filter((d) => d.severity === "error");
|
|
865
|
+
const warnings = this.diagnostics.filter((d) => d.severity === "warning");
|
|
866
|
+
const allConstraints = declarations.flatMap((d) => d.constraints);
|
|
867
|
+
return {
|
|
868
|
+
valid: errors.length === 0,
|
|
869
|
+
diagnostics: [...this.diagnostics],
|
|
870
|
+
constraintMap: new Map(this.constraintMap),
|
|
871
|
+
stats: {
|
|
872
|
+
totalConstraints: allConstraints.length,
|
|
873
|
+
adjacentCount: allConstraints.filter((c) => c.kind === "spatial_adjacent").length,
|
|
874
|
+
containsCount: allConstraints.filter((c) => c.kind === "spatial_contains").length,
|
|
875
|
+
reachableCount: allConstraints.filter((c) => c.kind === "spatial_reachable").length,
|
|
876
|
+
temporalAdjacentCount: allConstraints.filter((c) => c.kind === "spatial_temporal_adjacent").length,
|
|
877
|
+
temporalReachableCount: allConstraints.filter(
|
|
878
|
+
(c) => c.kind === "spatial_temporal_reachable"
|
|
879
|
+
).length,
|
|
880
|
+
trajectoryCount: allConstraints.filter((c) => c.kind === "spatial_trajectory").length,
|
|
881
|
+
errorsCount: errors.length,
|
|
882
|
+
warningsCount: warnings.length
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Validate a single constraint in isolation (useful for incremental checks).
|
|
888
|
+
*/
|
|
889
|
+
validateSingle(source, constraint, allDeclarations) {
|
|
890
|
+
this.reset();
|
|
891
|
+
for (const decl of allDeclarations) {
|
|
892
|
+
this.declarations.set(decl.entityId, decl);
|
|
893
|
+
}
|
|
894
|
+
switch (constraint.kind) {
|
|
895
|
+
case "spatial_adjacent":
|
|
896
|
+
this.validateAdjacent(source, constraint);
|
|
897
|
+
break;
|
|
898
|
+
case "spatial_contains":
|
|
899
|
+
this.validateContains(source, constraint);
|
|
900
|
+
break;
|
|
901
|
+
case "spatial_reachable":
|
|
902
|
+
this.validateReachable(source, constraint);
|
|
903
|
+
break;
|
|
904
|
+
case "spatial_temporal_adjacent":
|
|
905
|
+
this.validateTemporalAdjacent(source, constraint);
|
|
906
|
+
break;
|
|
907
|
+
case "spatial_temporal_reachable":
|
|
908
|
+
this.validateTemporalReachable(source, constraint);
|
|
909
|
+
break;
|
|
910
|
+
case "spatial_trajectory":
|
|
911
|
+
this.validateTrajectory(source, constraint);
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
return [...this.diagnostics];
|
|
915
|
+
}
|
|
916
|
+
// -------------------------------------------------------------------------
|
|
917
|
+
// Pass 1: Reference resolution
|
|
918
|
+
// -------------------------------------------------------------------------
|
|
919
|
+
resolveReferences() {
|
|
920
|
+
for (const [entityId, decl] of this.declarations) {
|
|
921
|
+
for (const constraint of decl.constraints) {
|
|
922
|
+
const targetId = this.getTargetId(constraint);
|
|
923
|
+
if (!this.declarations.has(targetId)) {
|
|
924
|
+
const typeMatches = Array.from(this.declarations.values()).filter(
|
|
925
|
+
(d) => d.entityType === targetId
|
|
926
|
+
);
|
|
927
|
+
if (typeMatches.length === 0) {
|
|
928
|
+
this.addDiagnostic(
|
|
929
|
+
"error",
|
|
930
|
+
"HSP033",
|
|
931
|
+
`Spatial constraint on '${entityId}' references target '${targetId}' which is not declared. Ensure the target entity exists in the composition.`,
|
|
932
|
+
decl.line ?? 0,
|
|
933
|
+
decl.column ?? 0,
|
|
934
|
+
constraint.kind,
|
|
935
|
+
entityId,
|
|
936
|
+
targetId,
|
|
937
|
+
[`Declare entity '${targetId}' or fix the target reference.`]
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// -------------------------------------------------------------------------
|
|
945
|
+
// Pass 2: Circular reference detection
|
|
946
|
+
// -------------------------------------------------------------------------
|
|
947
|
+
detectCircularReferences() {
|
|
948
|
+
const visited = /* @__PURE__ */ new Set();
|
|
949
|
+
const stack = /* @__PURE__ */ new Set();
|
|
950
|
+
const visit = (entityId, path) => {
|
|
951
|
+
if (stack.has(entityId)) {
|
|
952
|
+
const cycle = [...path, entityId].join(" -> ");
|
|
953
|
+
const decl2 = this.declarations.get(entityId);
|
|
954
|
+
this.addDiagnostic(
|
|
955
|
+
"error",
|
|
956
|
+
"HSP034",
|
|
957
|
+
`Circular spatial constraint reference detected: ${cycle}. Spatial constraints must form a directed acyclic graph.`,
|
|
958
|
+
decl2?.line ?? 0,
|
|
959
|
+
decl2?.column ?? 0,
|
|
960
|
+
"spatial_contains",
|
|
961
|
+
// containment is the most common source of cycles
|
|
962
|
+
entityId,
|
|
963
|
+
path[path.length - 1] || entityId,
|
|
964
|
+
["Remove one of the constraints in the cycle to break the circular dependency."]
|
|
965
|
+
);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (visited.has(entityId)) return;
|
|
969
|
+
visited.add(entityId);
|
|
970
|
+
stack.add(entityId);
|
|
971
|
+
const decl = this.declarations.get(entityId);
|
|
972
|
+
if (decl) {
|
|
973
|
+
for (const constraint of decl.constraints) {
|
|
974
|
+
if (constraint.kind === "spatial_contains") {
|
|
975
|
+
visit(constraint.containedId, [...path, entityId]);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
stack.delete(entityId);
|
|
980
|
+
};
|
|
981
|
+
for (const entityId of this.declarations.keys()) {
|
|
982
|
+
visit(entityId, []);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// -------------------------------------------------------------------------
|
|
986
|
+
// Pass 3a: Adjacency validation
|
|
987
|
+
// -------------------------------------------------------------------------
|
|
988
|
+
validateAdjacent(source, constraint) {
|
|
989
|
+
const target = this.resolveTarget(constraint.targetId);
|
|
990
|
+
if (!target) return;
|
|
991
|
+
if (!source.position || !target.position) {
|
|
992
|
+
this.addDiagnostic(
|
|
993
|
+
"info",
|
|
994
|
+
"HSP030",
|
|
995
|
+
`spatial_adjacent constraint between '${source.entityId}' and '${constraint.targetId}': positions not fully known at compile time, deferring to runtime verification.`,
|
|
996
|
+
source.line ?? 0,
|
|
997
|
+
source.column ?? 0,
|
|
998
|
+
"spatial_adjacent",
|
|
999
|
+
source.entityId,
|
|
1000
|
+
constraint.targetId
|
|
1001
|
+
);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
const dist = this.computeAxisDistance(
|
|
1005
|
+
source.position,
|
|
1006
|
+
target.position,
|
|
1007
|
+
constraint.axis ?? "xyz"
|
|
1008
|
+
);
|
|
1009
|
+
if (dist > constraint.maxDistance) {
|
|
1010
|
+
this.addDiagnostic(
|
|
1011
|
+
"error",
|
|
1012
|
+
"HSP030",
|
|
1013
|
+
`spatial_adjacent violation: '${source.entityId}' is ${dist.toFixed(2)}m from '${constraint.targetId}' but must be within ${constraint.maxDistance}m` + (constraint.axis && constraint.axis !== "xyz" ? ` (on ${constraint.axis} axis)` : "") + `.${constraint.label ? ` (${constraint.label})` : ""}`,
|
|
1014
|
+
source.line ?? 0,
|
|
1015
|
+
source.column ?? 0,
|
|
1016
|
+
"spatial_adjacent",
|
|
1017
|
+
source.entityId,
|
|
1018
|
+
constraint.targetId,
|
|
1019
|
+
[
|
|
1020
|
+
`Move '${source.entityId}' closer to '${constraint.targetId}' (currently ${dist.toFixed(2)}m, max ${constraint.maxDistance}m).`
|
|
1021
|
+
]
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
if (constraint.minDistance !== void 0 && dist < constraint.minDistance) {
|
|
1025
|
+
this.addDiagnostic(
|
|
1026
|
+
"error",
|
|
1027
|
+
"HSP030",
|
|
1028
|
+
`spatial_adjacent violation: '${source.entityId}' is ${dist.toFixed(2)}m from '${constraint.targetId}' but must be at least ${constraint.minDistance}m apart.${constraint.label ? ` (${constraint.label})` : ""}`,
|
|
1029
|
+
source.line ?? 0,
|
|
1030
|
+
source.column ?? 0,
|
|
1031
|
+
"spatial_adjacent",
|
|
1032
|
+
source.entityId,
|
|
1033
|
+
constraint.targetId,
|
|
1034
|
+
[
|
|
1035
|
+
`Move '${source.entityId}' further from '${constraint.targetId}' (currently ${dist.toFixed(2)}m, min ${constraint.minDistance}m).`
|
|
1036
|
+
]
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
// -------------------------------------------------------------------------
|
|
1041
|
+
// Pass 3b: Containment validation
|
|
1042
|
+
// -------------------------------------------------------------------------
|
|
1043
|
+
validateContains(source, constraint) {
|
|
1044
|
+
const contained = this.resolveTarget(constraint.containedId);
|
|
1045
|
+
if (!contained) return;
|
|
1046
|
+
if (!source.bounds) {
|
|
1047
|
+
this.addDiagnostic(
|
|
1048
|
+
"warning",
|
|
1049
|
+
"HSP031",
|
|
1050
|
+
`spatial_contains constraint on '${source.entityId}': container has no declared bounds. Containment cannot be verified at compile time.`,
|
|
1051
|
+
source.line ?? 0,
|
|
1052
|
+
source.column ?? 0,
|
|
1053
|
+
"spatial_contains",
|
|
1054
|
+
source.entityId,
|
|
1055
|
+
constraint.containedId,
|
|
1056
|
+
[`Add bounds to '${source.entityId}' for compile-time containment checking.`]
|
|
1057
|
+
);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (!contained.position) {
|
|
1061
|
+
this.addDiagnostic(
|
|
1062
|
+
"info",
|
|
1063
|
+
"HSP031",
|
|
1064
|
+
`spatial_contains constraint: '${constraint.containedId}' position not known at compile time, deferring to runtime.`,
|
|
1065
|
+
source.line ?? 0,
|
|
1066
|
+
source.column ?? 0,
|
|
1067
|
+
"spatial_contains",
|
|
1068
|
+
source.entityId,
|
|
1069
|
+
constraint.containedId
|
|
1070
|
+
);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const margin = constraint.margin ?? 0;
|
|
1074
|
+
if (constraint.strict && contained.bounds) {
|
|
1075
|
+
this.validateStrictContainment(source, constraint, contained, margin);
|
|
1076
|
+
} else {
|
|
1077
|
+
this.validatePointContainment(source, constraint, contained.position, margin);
|
|
1078
|
+
}
|
|
1079
|
+
if (constraint.recursive) {
|
|
1080
|
+
this.validateRecursiveContainment(source, constraint);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Check that a point is inside the container bounds (with margin).
|
|
1085
|
+
*/
|
|
1086
|
+
validatePointContainment(container, constraint, point, margin) {
|
|
1087
|
+
const bounds = container.bounds;
|
|
1088
|
+
let isInside;
|
|
1089
|
+
if ("radius" in bounds) {
|
|
1090
|
+
const sphere = bounds;
|
|
1091
|
+
const dist = distance(point, sphere.center);
|
|
1092
|
+
isInside = dist <= sphere.radius - margin;
|
|
1093
|
+
} else {
|
|
1094
|
+
const box = bounds;
|
|
1095
|
+
const shrunk = {
|
|
1096
|
+
min: {
|
|
1097
|
+
x: box.min.x + margin,
|
|
1098
|
+
y: box.min.y + margin,
|
|
1099
|
+
z: box.min.z + margin
|
|
1100
|
+
},
|
|
1101
|
+
max: {
|
|
1102
|
+
x: box.max.x - margin,
|
|
1103
|
+
y: box.max.y - margin,
|
|
1104
|
+
z: box.max.z - margin
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
isInside = isPointInBox(point, shrunk);
|
|
1108
|
+
}
|
|
1109
|
+
if (!isInside) {
|
|
1110
|
+
this.addDiagnostic(
|
|
1111
|
+
"error",
|
|
1112
|
+
"HSP031",
|
|
1113
|
+
`spatial_contains violation: '${constraint.containedId}' (at [${point.x}, ${point.y}, ${point.z}]) is outside container '${container.entityId}'` + (margin > 0 ? ` (with margin ${margin}m)` : "") + `.${constraint.label ? ` (${constraint.label})` : ""}`,
|
|
1114
|
+
container.line ?? 0,
|
|
1115
|
+
container.column ?? 0,
|
|
1116
|
+
"spatial_contains",
|
|
1117
|
+
container.entityId,
|
|
1118
|
+
constraint.containedId,
|
|
1119
|
+
[
|
|
1120
|
+
`Move '${constraint.containedId}' inside '${container.entityId}' bounds.`,
|
|
1121
|
+
`Alternatively, increase the container's bounds to enclose the target.`
|
|
1122
|
+
]
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Strict containment: contained entity's full bounds must be inside container.
|
|
1128
|
+
*/
|
|
1129
|
+
validateStrictContainment(container, constraint, contained, margin) {
|
|
1130
|
+
if (!contained.bounds || !container.bounds) return;
|
|
1131
|
+
const containerBox = this.toBoundingBox(container.bounds);
|
|
1132
|
+
const containedBox = this.toBoundingBox(contained.bounds, contained.position);
|
|
1133
|
+
const shrunk = {
|
|
1134
|
+
min: {
|
|
1135
|
+
x: containerBox.min.x + margin,
|
|
1136
|
+
y: containerBox.min.y + margin,
|
|
1137
|
+
z: containerBox.min.z + margin
|
|
1138
|
+
},
|
|
1139
|
+
max: {
|
|
1140
|
+
x: containerBox.max.x - margin,
|
|
1141
|
+
y: containerBox.max.y - margin,
|
|
1142
|
+
z: containerBox.max.z - margin
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
const fullyInside = containedBox.min.x >= shrunk.min.x && containedBox.min.y >= shrunk.min.y && containedBox.min.z >= shrunk.min.z && containedBox.max.x <= shrunk.max.x && containedBox.max.y <= shrunk.max.y && containedBox.max.z <= shrunk.max.z;
|
|
1146
|
+
if (!fullyInside) {
|
|
1147
|
+
this.addDiagnostic(
|
|
1148
|
+
"error",
|
|
1149
|
+
"HSP031",
|
|
1150
|
+
`spatial_contains (strict) violation: '${constraint.containedId}' bounds extend outside container '${container.entityId}'` + (margin > 0 ? ` (with margin ${margin}m)` : "") + `.${constraint.label ? ` (${constraint.label})` : ""}`,
|
|
1151
|
+
container.line ?? 0,
|
|
1152
|
+
container.column ?? 0,
|
|
1153
|
+
"spatial_contains",
|
|
1154
|
+
container.entityId,
|
|
1155
|
+
constraint.containedId,
|
|
1156
|
+
[
|
|
1157
|
+
`Resize '${constraint.containedId}' to fit within '${container.entityId}'.`,
|
|
1158
|
+
`Alternatively, enlarge '${container.entityId}' bounds.`
|
|
1159
|
+
]
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Recursively check containment for child entities.
|
|
1165
|
+
*/
|
|
1166
|
+
validateRecursiveContainment(container, constraint) {
|
|
1167
|
+
const contained = this.declarations.get(constraint.containedId);
|
|
1168
|
+
if (!contained) return;
|
|
1169
|
+
for (const [, decl] of this.declarations) {
|
|
1170
|
+
if (decl.parentId === constraint.containedId && decl.position && container.bounds) {
|
|
1171
|
+
this.validatePointContainment(
|
|
1172
|
+
container,
|
|
1173
|
+
{
|
|
1174
|
+
...constraint,
|
|
1175
|
+
containedId: decl.entityId,
|
|
1176
|
+
label: `recursive child of '${constraint.containedId}'`
|
|
1177
|
+
},
|
|
1178
|
+
decl.position,
|
|
1179
|
+
constraint.margin ?? 0
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// -------------------------------------------------------------------------
|
|
1185
|
+
// Pass 3c: Reachability validation
|
|
1186
|
+
// -------------------------------------------------------------------------
|
|
1187
|
+
validateReachable(source, constraint) {
|
|
1188
|
+
const target = this.resolveTarget(constraint.targetId);
|
|
1189
|
+
if (!target) return;
|
|
1190
|
+
if (!source.position || !target.position) {
|
|
1191
|
+
this.addDiagnostic(
|
|
1192
|
+
"info",
|
|
1193
|
+
"HSP032",
|
|
1194
|
+
`spatial_reachable constraint between '${source.entityId}' and '${constraint.targetId}': positions not known at compile time, deferring to runtime.`,
|
|
1195
|
+
source.line ?? 0,
|
|
1196
|
+
source.column ?? 0,
|
|
1197
|
+
"spatial_reachable",
|
|
1198
|
+
source.entityId,
|
|
1199
|
+
constraint.targetId
|
|
1200
|
+
);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const straightDist = distance(source.position, target.position);
|
|
1204
|
+
if (constraint.maxPathLength !== void 0 && straightDist > constraint.maxPathLength) {
|
|
1205
|
+
this.addDiagnostic(
|
|
1206
|
+
"error",
|
|
1207
|
+
"HSP032",
|
|
1208
|
+
`spatial_reachable violation: straight-line distance from '${source.entityId}' to '${constraint.targetId}' is ${straightDist.toFixed(2)}m, exceeding maxPathLength of ${constraint.maxPathLength}m. No valid path can exist.`,
|
|
1209
|
+
source.line ?? 0,
|
|
1210
|
+
source.column ?? 0,
|
|
1211
|
+
"spatial_reachable",
|
|
1212
|
+
source.entityId,
|
|
1213
|
+
constraint.targetId,
|
|
1214
|
+
[
|
|
1215
|
+
`Move '${source.entityId}' closer to '${constraint.targetId}' or increase maxPathLength.`
|
|
1216
|
+
]
|
|
1217
|
+
);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (constraint.obstacleTypes && constraint.obstacleTypes.length > 0) {
|
|
1221
|
+
const blockingObstacle = this.checkLineOfSight(
|
|
1222
|
+
source.position,
|
|
1223
|
+
target.position,
|
|
1224
|
+
constraint.obstacleTypes,
|
|
1225
|
+
source.entityId,
|
|
1226
|
+
constraint.targetId
|
|
1227
|
+
);
|
|
1228
|
+
if (blockingObstacle) {
|
|
1229
|
+
const severity = constraint.algorithm === "line_of_sight" ? "error" : "warning";
|
|
1230
|
+
this.addDiagnostic(
|
|
1231
|
+
severity,
|
|
1232
|
+
"HSP032",
|
|
1233
|
+
`spatial_reachable: line-of-sight from '${source.entityId}' to '${constraint.targetId}' is blocked by '${blockingObstacle}'` + (constraint.algorithm && constraint.algorithm !== "line_of_sight" ? `. A ${constraint.algorithm} path may still exist (runtime check required).` : ".") + (constraint.label ? ` (${constraint.label})` : ""),
|
|
1234
|
+
source.line ?? 0,
|
|
1235
|
+
source.column ?? 0,
|
|
1236
|
+
"spatial_reachable",
|
|
1237
|
+
source.entityId,
|
|
1238
|
+
constraint.targetId,
|
|
1239
|
+
[
|
|
1240
|
+
`Remove or reposition obstacle '${blockingObstacle}' to clear the path.`,
|
|
1241
|
+
`Alternatively, use algorithm: "navmesh" or "astar" for path-around-obstacles.`
|
|
1242
|
+
]
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Check line-of-sight between two points through known obstacles.
|
|
1249
|
+
* Returns the ID of the first blocking obstacle, or null if clear.
|
|
1250
|
+
*/
|
|
1251
|
+
checkLineOfSight(from, to, obstacleTypes, excludeA, excludeB) {
|
|
1252
|
+
const dir = subtract(to, from);
|
|
1253
|
+
const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
|
|
1254
|
+
if (len === 0) return null;
|
|
1255
|
+
const normalizedDir = normalize(dir);
|
|
1256
|
+
for (const [entityId, decl] of this.declarations) {
|
|
1257
|
+
if (entityId === excludeA || entityId === excludeB) continue;
|
|
1258
|
+
if (!obstacleTypes.includes(decl.entityType)) continue;
|
|
1259
|
+
if (!decl.bounds) continue;
|
|
1260
|
+
const box = this.toBoundingBox(decl.bounds, decl.position);
|
|
1261
|
+
if (this.rayIntersectsBox(from, normalizedDir, box, len)) {
|
|
1262
|
+
return entityId;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
// -------------------------------------------------------------------------
|
|
1268
|
+
// Pass 3d: Temporal adjacency validation
|
|
1269
|
+
// -------------------------------------------------------------------------
|
|
1270
|
+
validateTemporalAdjacent(source, constraint) {
|
|
1271
|
+
const target = this.resolveTarget(constraint.targetId);
|
|
1272
|
+
if (!target) return;
|
|
1273
|
+
if (constraint.minDuration < 0) {
|
|
1274
|
+
this.addDiagnostic(
|
|
1275
|
+
"error",
|
|
1276
|
+
"HSP036",
|
|
1277
|
+
`spatial_temporal_adjacent on '${source.entityId}': minDuration must be >= 0 (got ${constraint.minDuration}s).`,
|
|
1278
|
+
source.line ?? 0,
|
|
1279
|
+
source.column ?? 0,
|
|
1280
|
+
"spatial_temporal_adjacent",
|
|
1281
|
+
source.entityId,
|
|
1282
|
+
constraint.targetId,
|
|
1283
|
+
["Set minDuration to a non-negative value."]
|
|
1284
|
+
);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (constraint.gracePeriod !== void 0 && constraint.gracePeriod < 0) {
|
|
1288
|
+
this.addDiagnostic(
|
|
1289
|
+
"error",
|
|
1290
|
+
"HSP036",
|
|
1291
|
+
`spatial_temporal_adjacent on '${source.entityId}': gracePeriod must be >= 0 (got ${constraint.gracePeriod}s).`,
|
|
1292
|
+
source.line ?? 0,
|
|
1293
|
+
source.column ?? 0,
|
|
1294
|
+
"spatial_temporal_adjacent",
|
|
1295
|
+
source.entityId,
|
|
1296
|
+
constraint.targetId,
|
|
1297
|
+
["Set gracePeriod to a non-negative value."]
|
|
1298
|
+
);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (constraint.gracePeriod !== void 0 && constraint.gracePeriod > constraint.minDuration && constraint.minDuration > 0) {
|
|
1302
|
+
this.addDiagnostic(
|
|
1303
|
+
"warning",
|
|
1304
|
+
"HSP036",
|
|
1305
|
+
`spatial_temporal_adjacent on '${source.entityId}': gracePeriod (${constraint.gracePeriod}s) exceeds minDuration (${constraint.minDuration}s). The grace period allows the entity to remain out of range longer than the required adjacency duration.`,
|
|
1306
|
+
source.line ?? 0,
|
|
1307
|
+
source.column ?? 0,
|
|
1308
|
+
"spatial_temporal_adjacent",
|
|
1309
|
+
source.entityId,
|
|
1310
|
+
constraint.targetId,
|
|
1311
|
+
["Consider reducing gracePeriod or increasing minDuration."]
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
if (source.position && target.position) {
|
|
1315
|
+
const dist = this.computeAxisDistance(
|
|
1316
|
+
source.position,
|
|
1317
|
+
target.position,
|
|
1318
|
+
constraint.axis ?? "xyz"
|
|
1319
|
+
);
|
|
1320
|
+
if (dist > constraint.maxDistance) {
|
|
1321
|
+
this.addDiagnostic(
|
|
1322
|
+
"error",
|
|
1323
|
+
"HSP036",
|
|
1324
|
+
`spatial_temporal_adjacent violation: '${source.entityId}' is ${dist.toFixed(2)}m from '${constraint.targetId}' (max: ${constraint.maxDistance}m). The duration constraint of ${constraint.minDuration}s cannot be satisfied if entities start out of range` + (constraint.axis && constraint.axis !== "xyz" ? ` (on ${constraint.axis} axis)` : "") + `.${constraint.label ? ` (${constraint.label})` : ""}`,
|
|
1325
|
+
source.line ?? 0,
|
|
1326
|
+
source.column ?? 0,
|
|
1327
|
+
"spatial_temporal_adjacent",
|
|
1328
|
+
source.entityId,
|
|
1329
|
+
constraint.targetId,
|
|
1330
|
+
[
|
|
1331
|
+
`Move '${source.entityId}' within ${constraint.maxDistance}m of '${constraint.targetId}'.`,
|
|
1332
|
+
`Or ensure runtime movement brings entities into range.`
|
|
1333
|
+
]
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
} else {
|
|
1337
|
+
this.addDiagnostic(
|
|
1338
|
+
"info",
|
|
1339
|
+
"HSP036",
|
|
1340
|
+
`spatial_temporal_adjacent between '${source.entityId}' and '${constraint.targetId}': positions not fully known at compile time. Duration constraint (${constraint.minDuration}s hold, ${constraint.gracePeriod ?? 0}s grace) will be verified at runtime.`,
|
|
1341
|
+
source.line ?? 0,
|
|
1342
|
+
source.column ?? 0,
|
|
1343
|
+
"spatial_temporal_adjacent",
|
|
1344
|
+
source.entityId,
|
|
1345
|
+
constraint.targetId
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
// -------------------------------------------------------------------------
|
|
1350
|
+
// Pass 3e: Temporal reachability validation
|
|
1351
|
+
// -------------------------------------------------------------------------
|
|
1352
|
+
validateTemporalReachable(source, constraint) {
|
|
1353
|
+
const target = this.resolveTarget(constraint.targetId);
|
|
1354
|
+
if (!target) return;
|
|
1355
|
+
if (constraint.predictionHorizon <= 0) {
|
|
1356
|
+
this.addDiagnostic(
|
|
1357
|
+
"error",
|
|
1358
|
+
"HSP037",
|
|
1359
|
+
`spatial_temporal_reachable on '${source.entityId}': predictionHorizon must be > 0 (got ${constraint.predictionHorizon}s).`,
|
|
1360
|
+
source.line ?? 0,
|
|
1361
|
+
source.column ?? 0,
|
|
1362
|
+
"spatial_temporal_reachable",
|
|
1363
|
+
source.entityId,
|
|
1364
|
+
constraint.targetId,
|
|
1365
|
+
["Set predictionHorizon to a positive value."]
|
|
1366
|
+
);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (constraint.safetyMargin !== void 0 && constraint.safetyMargin < 0) {
|
|
1370
|
+
this.addDiagnostic(
|
|
1371
|
+
"error",
|
|
1372
|
+
"HSP037",
|
|
1373
|
+
`spatial_temporal_reachable on '${source.entityId}': safetyMargin must be >= 0 (got ${constraint.safetyMargin}m).`,
|
|
1374
|
+
source.line ?? 0,
|
|
1375
|
+
source.column ?? 0,
|
|
1376
|
+
"spatial_temporal_reachable",
|
|
1377
|
+
source.entityId,
|
|
1378
|
+
constraint.targetId,
|
|
1379
|
+
["Set safetyMargin to a non-negative value."]
|
|
1380
|
+
);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if ((!constraint.movingObstacles || constraint.movingObstacles.length === 0) && (!constraint.staticObstacles || constraint.staticObstacles.length === 0)) {
|
|
1384
|
+
this.addDiagnostic(
|
|
1385
|
+
"warning",
|
|
1386
|
+
"HSP037",
|
|
1387
|
+
`spatial_temporal_reachable on '${source.entityId}': no moving or static obstacles specified. Consider using spatial_reachable instead if velocity prediction is not needed.`,
|
|
1388
|
+
source.line ?? 0,
|
|
1389
|
+
source.column ?? 0,
|
|
1390
|
+
"spatial_temporal_reachable",
|
|
1391
|
+
source.entityId,
|
|
1392
|
+
constraint.targetId,
|
|
1393
|
+
[
|
|
1394
|
+
"Add movingObstacles or staticObstacles to the constraint.",
|
|
1395
|
+
"Or use @spatial_reachable if velocity prediction is not needed."
|
|
1396
|
+
]
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
if (source.position && target.position) {
|
|
1400
|
+
const straightDist = distance(source.position, target.position);
|
|
1401
|
+
if (constraint.maxPathLength !== void 0 && straightDist > constraint.maxPathLength) {
|
|
1402
|
+
this.addDiagnostic(
|
|
1403
|
+
"error",
|
|
1404
|
+
"HSP037",
|
|
1405
|
+
`spatial_temporal_reachable violation: straight-line distance from '${source.entityId}' to '${constraint.targetId}' is ${straightDist.toFixed(2)}m, exceeding maxPathLength of ${constraint.maxPathLength}m. No valid path can exist (prediction horizon: ${constraint.predictionHorizon}s).`,
|
|
1406
|
+
source.line ?? 0,
|
|
1407
|
+
source.column ?? 0,
|
|
1408
|
+
"spatial_temporal_reachable",
|
|
1409
|
+
source.entityId,
|
|
1410
|
+
constraint.targetId,
|
|
1411
|
+
[
|
|
1412
|
+
`Move '${source.entityId}' closer to '${constraint.targetId}' or increase maxPathLength.`
|
|
1413
|
+
]
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
} else {
|
|
1417
|
+
this.addDiagnostic(
|
|
1418
|
+
"info",
|
|
1419
|
+
"HSP037",
|
|
1420
|
+
`spatial_temporal_reachable between '${source.entityId}' and '${constraint.targetId}': positions not known at compile time. Velocity-predicted reachability (horizon: ${constraint.predictionHorizon}s) will be verified at runtime.`,
|
|
1421
|
+
source.line ?? 0,
|
|
1422
|
+
source.column ?? 0,
|
|
1423
|
+
"spatial_temporal_reachable",
|
|
1424
|
+
source.entityId,
|
|
1425
|
+
constraint.targetId
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// -------------------------------------------------------------------------
|
|
1430
|
+
// Pass 3f: Trajectory validation
|
|
1431
|
+
// -------------------------------------------------------------------------
|
|
1432
|
+
validateTrajectory(source, constraint) {
|
|
1433
|
+
if (constraint.horizon <= 0) {
|
|
1434
|
+
this.addDiagnostic(
|
|
1435
|
+
"error",
|
|
1436
|
+
"HSP038",
|
|
1437
|
+
`spatial_trajectory on '${source.entityId}': horizon must be > 0 (got ${constraint.horizon}s).`,
|
|
1438
|
+
source.line ?? 0,
|
|
1439
|
+
source.column ?? 0,
|
|
1440
|
+
"spatial_trajectory",
|
|
1441
|
+
source.entityId,
|
|
1442
|
+
constraint.regionId ?? source.entityId,
|
|
1443
|
+
["Set horizon to a positive value."]
|
|
1444
|
+
);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (constraint.sampleCount !== void 0 && constraint.sampleCount < 1) {
|
|
1448
|
+
this.addDiagnostic(
|
|
1449
|
+
"error",
|
|
1450
|
+
"HSP038",
|
|
1451
|
+
`spatial_trajectory on '${source.entityId}': sampleCount must be >= 1 (got ${constraint.sampleCount}).`,
|
|
1452
|
+
source.line ?? 0,
|
|
1453
|
+
source.column ?? 0,
|
|
1454
|
+
"spatial_trajectory",
|
|
1455
|
+
source.entityId,
|
|
1456
|
+
constraint.regionId ?? source.entityId,
|
|
1457
|
+
["Set sampleCount to at least 1."]
|
|
1458
|
+
);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
switch (constraint.mode) {
|
|
1462
|
+
case "keep_in":
|
|
1463
|
+
case "keep_out": {
|
|
1464
|
+
if (!constraint.regionId) {
|
|
1465
|
+
this.addDiagnostic(
|
|
1466
|
+
"error",
|
|
1467
|
+
"HSP038",
|
|
1468
|
+
`spatial_trajectory (${constraint.mode}) on '${source.entityId}': regionId is required for '${constraint.mode}' mode.`,
|
|
1469
|
+
source.line ?? 0,
|
|
1470
|
+
source.column ?? 0,
|
|
1471
|
+
"spatial_trajectory",
|
|
1472
|
+
source.entityId,
|
|
1473
|
+
source.entityId,
|
|
1474
|
+
[`Add regionId to the @spatial_trajectory constraint.`]
|
|
1475
|
+
);
|
|
1476
|
+
} else {
|
|
1477
|
+
const region = this.resolveTarget(constraint.regionId);
|
|
1478
|
+
if (region && !region.bounds) {
|
|
1479
|
+
this.addDiagnostic(
|
|
1480
|
+
"warning",
|
|
1481
|
+
"HSP038",
|
|
1482
|
+
`spatial_trajectory (${constraint.mode}) on '${source.entityId}': region '${constraint.regionId}' has no declared bounds. Trajectory containment cannot be verified at compile time.`,
|
|
1483
|
+
source.line ?? 0,
|
|
1484
|
+
source.column ?? 0,
|
|
1485
|
+
"spatial_trajectory",
|
|
1486
|
+
source.entityId,
|
|
1487
|
+
constraint.regionId,
|
|
1488
|
+
[`Add bounds to '${constraint.regionId}' for compile-time trajectory checking.`]
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
case "follow": {
|
|
1495
|
+
if (!constraint.referencePath || constraint.referencePath.length < 2) {
|
|
1496
|
+
this.addDiagnostic(
|
|
1497
|
+
"error",
|
|
1498
|
+
"HSP038",
|
|
1499
|
+
`spatial_trajectory (follow) on '${source.entityId}': referencePath must contain at least 2 points for 'follow' mode (got ${constraint.referencePath?.length ?? 0}).`,
|
|
1500
|
+
source.line ?? 0,
|
|
1501
|
+
source.column ?? 0,
|
|
1502
|
+
"spatial_trajectory",
|
|
1503
|
+
source.entityId,
|
|
1504
|
+
constraint.regionId ?? source.entityId,
|
|
1505
|
+
["Provide a referencePath with at least 2 waypoints."]
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
if (constraint.maxDeviation !== void 0 && constraint.maxDeviation <= 0) {
|
|
1509
|
+
this.addDiagnostic(
|
|
1510
|
+
"error",
|
|
1511
|
+
"HSP038",
|
|
1512
|
+
`spatial_trajectory (follow) on '${source.entityId}': maxDeviation must be > 0 (got ${constraint.maxDeviation}m).`,
|
|
1513
|
+
source.line ?? 0,
|
|
1514
|
+
source.column ?? 0,
|
|
1515
|
+
"spatial_trajectory",
|
|
1516
|
+
source.entityId,
|
|
1517
|
+
constraint.regionId ?? source.entityId,
|
|
1518
|
+
["Set maxDeviation to a positive value."]
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
if (source.position && constraint.referencePath && constraint.referencePath.length >= 2) {
|
|
1522
|
+
const distToStart = distance(source.position, constraint.referencePath[0]);
|
|
1523
|
+
const maxDev = constraint.maxDeviation ?? 1;
|
|
1524
|
+
if (distToStart > maxDev * 3) {
|
|
1525
|
+
this.addDiagnostic(
|
|
1526
|
+
"warning",
|
|
1527
|
+
"HSP038",
|
|
1528
|
+
`spatial_trajectory (follow) on '${source.entityId}': entity starts ${distToStart.toFixed(2)}m from the reference path origin (maxDeviation: ${maxDev}m). The entity may immediately violate the constraint.`,
|
|
1529
|
+
source.line ?? 0,
|
|
1530
|
+
source.column ?? 0,
|
|
1531
|
+
"spatial_trajectory",
|
|
1532
|
+
source.entityId,
|
|
1533
|
+
constraint.regionId ?? source.entityId,
|
|
1534
|
+
[`Move '${source.entityId}' closer to the reference path start point.`]
|
|
1535
|
+
);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
case "waypoint": {
|
|
1541
|
+
if (!constraint.waypoints || constraint.waypoints.length === 0) {
|
|
1542
|
+
this.addDiagnostic(
|
|
1543
|
+
"error",
|
|
1544
|
+
"HSP038",
|
|
1545
|
+
`spatial_trajectory (waypoint) on '${source.entityId}': at least one waypoint is required for 'waypoint' mode.`,
|
|
1546
|
+
source.line ?? 0,
|
|
1547
|
+
source.column ?? 0,
|
|
1548
|
+
"spatial_trajectory",
|
|
1549
|
+
source.entityId,
|
|
1550
|
+
constraint.regionId ?? source.entityId,
|
|
1551
|
+
["Add waypoints with position and radius to the constraint."]
|
|
1552
|
+
);
|
|
1553
|
+
} else {
|
|
1554
|
+
for (let i = 0; i < constraint.waypoints.length; i++) {
|
|
1555
|
+
const wp = constraint.waypoints[i];
|
|
1556
|
+
if (wp.radius <= 0) {
|
|
1557
|
+
this.addDiagnostic(
|
|
1558
|
+
"error",
|
|
1559
|
+
"HSP038",
|
|
1560
|
+
`spatial_trajectory (waypoint) on '${source.entityId}': waypoint ${i} (${wp.label ?? "unnamed"}) has invalid radius ${wp.radius}m. Radius must be > 0.`,
|
|
1561
|
+
source.line ?? 0,
|
|
1562
|
+
source.column ?? 0,
|
|
1563
|
+
"spatial_trajectory",
|
|
1564
|
+
source.entityId,
|
|
1565
|
+
constraint.regionId ?? source.entityId,
|
|
1566
|
+
[`Set waypoint ${i} radius to a positive value.`]
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
// -------------------------------------------------------------------------
|
|
1576
|
+
// Pass 4: Cross-constraint consistency
|
|
1577
|
+
// -------------------------------------------------------------------------
|
|
1578
|
+
validateCrossConstraintConsistency() {
|
|
1579
|
+
for (const [entityId, decl] of this.declarations) {
|
|
1580
|
+
const adjacentTargets = decl.constraints.filter((c) => c.kind === "spatial_adjacent").map((c) => c.targetId);
|
|
1581
|
+
const containedIn = this.findContainers(entityId);
|
|
1582
|
+
for (const adjacentTarget of adjacentTargets) {
|
|
1583
|
+
for (const containerId of containedIn) {
|
|
1584
|
+
const container = this.declarations.get(containerId);
|
|
1585
|
+
const target = this.resolveTarget(adjacentTarget);
|
|
1586
|
+
if (container?.bounds && target?.position) {
|
|
1587
|
+
const isTargetReachable = this.isPointNearBounds(
|
|
1588
|
+
target.position,
|
|
1589
|
+
container.bounds,
|
|
1590
|
+
// Use the max distance from the adjacency constraint
|
|
1591
|
+
decl.constraints.filter(
|
|
1592
|
+
(c) => c.kind === "spatial_adjacent" && c.targetId === adjacentTarget
|
|
1593
|
+
).map((c) => c.maxDistance)[0] ?? Infinity
|
|
1594
|
+
);
|
|
1595
|
+
if (!isTargetReachable) {
|
|
1596
|
+
this.addDiagnostic(
|
|
1597
|
+
"warning",
|
|
1598
|
+
"HSP035",
|
|
1599
|
+
`Potential inconsistency: '${entityId}' must be adjacent to '${adjacentTarget}' but is contained in '${containerId}'. '${adjacentTarget}' appears to be outside the container's reachable range.`,
|
|
1600
|
+
decl.line ?? 0,
|
|
1601
|
+
decl.column ?? 0,
|
|
1602
|
+
"spatial_adjacent",
|
|
1603
|
+
entityId,
|
|
1604
|
+
adjacentTarget,
|
|
1605
|
+
[
|
|
1606
|
+
`Ensure '${adjacentTarget}' is placed near '${containerId}' bounds.`,
|
|
1607
|
+
`Or adjust the adjacency maxDistance to account for container size.`
|
|
1608
|
+
]
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
// -------------------------------------------------------------------------
|
|
1617
|
+
// Utility methods
|
|
1618
|
+
// -------------------------------------------------------------------------
|
|
1619
|
+
/**
|
|
1620
|
+
* Reset all internal state for a new validation run.
|
|
1621
|
+
*/
|
|
1622
|
+
reset() {
|
|
1623
|
+
this.declarations.clear();
|
|
1624
|
+
this.diagnostics = [];
|
|
1625
|
+
this.constraintMap.clear();
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Add a diagnostic to the list.
|
|
1629
|
+
*/
|
|
1630
|
+
addDiagnostic(severity, code, message, line, column, constraintKind, sourceId, targetId, suggestions) {
|
|
1631
|
+
this.diagnostics.push({
|
|
1632
|
+
severity,
|
|
1633
|
+
code,
|
|
1634
|
+
message,
|
|
1635
|
+
line,
|
|
1636
|
+
column,
|
|
1637
|
+
constraintKind,
|
|
1638
|
+
sourceId,
|
|
1639
|
+
targetId,
|
|
1640
|
+
suggestions
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Resolve a target ID to a declaration, including type-based matching.
|
|
1645
|
+
*/
|
|
1646
|
+
resolveTarget(targetId) {
|
|
1647
|
+
const direct = this.declarations.get(targetId);
|
|
1648
|
+
if (direct) return direct;
|
|
1649
|
+
for (const [, decl] of this.declarations) {
|
|
1650
|
+
if (decl.entityType === targetId) return decl;
|
|
1651
|
+
}
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Get the target ID from any spatial constraint.
|
|
1656
|
+
*/
|
|
1657
|
+
getTargetId(constraint) {
|
|
1658
|
+
switch (constraint.kind) {
|
|
1659
|
+
case "spatial_adjacent":
|
|
1660
|
+
return constraint.targetId;
|
|
1661
|
+
case "spatial_contains":
|
|
1662
|
+
return constraint.containedId;
|
|
1663
|
+
case "spatial_reachable":
|
|
1664
|
+
return constraint.targetId;
|
|
1665
|
+
case "spatial_temporal_adjacent":
|
|
1666
|
+
return constraint.targetId;
|
|
1667
|
+
case "spatial_temporal_reachable":
|
|
1668
|
+
return constraint.targetId;
|
|
1669
|
+
case "spatial_trajectory":
|
|
1670
|
+
return constraint.regionId ?? constraint.sourceId;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Compute distance along a specific axis filter.
|
|
1675
|
+
*/
|
|
1676
|
+
computeAxisDistance(a, b, axis) {
|
|
1677
|
+
switch (axis) {
|
|
1678
|
+
case "x":
|
|
1679
|
+
return Math.abs(b.x - a.x);
|
|
1680
|
+
case "y":
|
|
1681
|
+
return Math.abs(b.y - a.y);
|
|
1682
|
+
case "z":
|
|
1683
|
+
return Math.abs(b.z - a.z);
|
|
1684
|
+
case "xy": {
|
|
1685
|
+
const dx = b.x - a.x;
|
|
1686
|
+
const dy = b.y - a.y;
|
|
1687
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1688
|
+
}
|
|
1689
|
+
case "xz": {
|
|
1690
|
+
const dx = b.x - a.x;
|
|
1691
|
+
const dz = b.z - a.z;
|
|
1692
|
+
return Math.sqrt(dx * dx + dz * dz);
|
|
1693
|
+
}
|
|
1694
|
+
case "xyz":
|
|
1695
|
+
default:
|
|
1696
|
+
return distance(a, b);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Convert any bounds type to a BoundingBox, optionally offset by position.
|
|
1701
|
+
*/
|
|
1702
|
+
toBoundingBox(bounds, offset) {
|
|
1703
|
+
const ox = offset?.x ?? 0;
|
|
1704
|
+
const oy = offset?.y ?? 0;
|
|
1705
|
+
const oz = offset?.z ?? 0;
|
|
1706
|
+
if ("radius" in bounds) {
|
|
1707
|
+
const sphere = bounds;
|
|
1708
|
+
return {
|
|
1709
|
+
min: {
|
|
1710
|
+
x: sphere.center.x + ox - sphere.radius,
|
|
1711
|
+
y: sphere.center.y + oy - sphere.radius,
|
|
1712
|
+
z: sphere.center.z + oz - sphere.radius
|
|
1713
|
+
},
|
|
1714
|
+
max: {
|
|
1715
|
+
x: sphere.center.x + ox + sphere.radius,
|
|
1716
|
+
y: sphere.center.y + oy + sphere.radius,
|
|
1717
|
+
z: sphere.center.z + oz + sphere.radius
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
const box = bounds;
|
|
1722
|
+
return {
|
|
1723
|
+
min: { x: box.min.x + ox, y: box.min.y + oy, z: box.min.z + oz },
|
|
1724
|
+
max: { x: box.max.x + ox, y: box.max.y + oy, z: box.max.z + oz }
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Ray-AABB intersection test.
|
|
1729
|
+
*/
|
|
1730
|
+
rayIntersectsBox(origin, direction, box, maxDist) {
|
|
1731
|
+
let tmin = -Infinity;
|
|
1732
|
+
let tmax = Infinity;
|
|
1733
|
+
const axes = ["x", "y", "z"];
|
|
1734
|
+
for (const axis of axes) {
|
|
1735
|
+
const d = direction[axis];
|
|
1736
|
+
const o = origin[axis];
|
|
1737
|
+
const bmin = box.min[axis];
|
|
1738
|
+
const bmax = box.max[axis];
|
|
1739
|
+
if (Math.abs(d) < 1e-10) {
|
|
1740
|
+
if (o < bmin || o > bmax) return false;
|
|
1741
|
+
} else {
|
|
1742
|
+
let t1 = (bmin - o) / d;
|
|
1743
|
+
let t2 = (bmax - o) / d;
|
|
1744
|
+
if (t1 > t2) {
|
|
1745
|
+
const tmp = t1;
|
|
1746
|
+
t1 = t2;
|
|
1747
|
+
t2 = tmp;
|
|
1748
|
+
}
|
|
1749
|
+
tmin = Math.max(tmin, t1);
|
|
1750
|
+
tmax = Math.min(tmax, t2);
|
|
1751
|
+
if (tmin > tmax) return false;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return tmin >= 0 && tmin <= maxDist;
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Check if a point is within `extraRadius` of a bounding volume.
|
|
1758
|
+
*/
|
|
1759
|
+
isPointNearBounds(point, bounds, extraRadius) {
|
|
1760
|
+
if ("radius" in bounds) {
|
|
1761
|
+
const sphere = bounds;
|
|
1762
|
+
return distance(point, sphere.center) <= sphere.radius + extraRadius;
|
|
1763
|
+
}
|
|
1764
|
+
const box = bounds;
|
|
1765
|
+
const expanded = {
|
|
1766
|
+
min: {
|
|
1767
|
+
x: box.min.x - extraRadius,
|
|
1768
|
+
y: box.min.y - extraRadius,
|
|
1769
|
+
z: box.min.z - extraRadius
|
|
1770
|
+
},
|
|
1771
|
+
max: {
|
|
1772
|
+
x: box.max.x + extraRadius,
|
|
1773
|
+
y: box.max.y + extraRadius,
|
|
1774
|
+
z: box.max.z + extraRadius
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
return isPointInBox(point, expanded);
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Find all containers that declare spatial_contains with a given entity.
|
|
1781
|
+
*/
|
|
1782
|
+
findContainers(entityId) {
|
|
1783
|
+
const containers = [];
|
|
1784
|
+
for (const [containerId, decl] of this.declarations) {
|
|
1785
|
+
for (const constraint of decl.constraints) {
|
|
1786
|
+
if (constraint.kind === "spatial_contains" && constraint.containedId === entityId) {
|
|
1787
|
+
containers.push(containerId);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return containers;
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
// src/spatial/OctreeSystem.ts
|
|
1796
|
+
var OctreeSystem = class {
|
|
1797
|
+
root;
|
|
1798
|
+
maxEntriesPerNode = 8;
|
|
1799
|
+
maxDepth = 8;
|
|
1800
|
+
entryCount = 0;
|
|
1801
|
+
constructor(centerX, centerY, centerZ, halfSize) {
|
|
1802
|
+
this.root = {
|
|
1803
|
+
cx: centerX,
|
|
1804
|
+
cy: centerY,
|
|
1805
|
+
cz: centerZ,
|
|
1806
|
+
halfSize,
|
|
1807
|
+
entries: [],
|
|
1808
|
+
children: null,
|
|
1809
|
+
depth: 0
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
// ---------------------------------------------------------------------------
|
|
1813
|
+
// Insert / Remove
|
|
1814
|
+
// ---------------------------------------------------------------------------
|
|
1815
|
+
insert(entry) {
|
|
1816
|
+
const inserted = this.insertIntoNode(this.root, entry);
|
|
1817
|
+
if (inserted) this.entryCount++;
|
|
1818
|
+
return inserted;
|
|
1819
|
+
}
|
|
1820
|
+
insertIntoNode(node, entry) {
|
|
1821
|
+
if (!this.containsPoint(node, entry.x, entry.y, entry.z)) return false;
|
|
1822
|
+
if (node.children === null) {
|
|
1823
|
+
node.entries.push(entry);
|
|
1824
|
+
if (node.entries.length > this.maxEntriesPerNode && node.depth < this.maxDepth) {
|
|
1825
|
+
this.subdivide(node);
|
|
1826
|
+
}
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1829
|
+
for (const child of node.children) {
|
|
1830
|
+
if (this.insertIntoNode(child, entry)) return true;
|
|
1831
|
+
}
|
|
1832
|
+
node.entries.push(entry);
|
|
1833
|
+
return true;
|
|
1834
|
+
}
|
|
1835
|
+
remove(id) {
|
|
1836
|
+
const removed = this.removeFromNode(this.root, id);
|
|
1837
|
+
if (removed) this.entryCount--;
|
|
1838
|
+
return removed;
|
|
1839
|
+
}
|
|
1840
|
+
removeFromNode(node, id) {
|
|
1841
|
+
const idx = node.entries.findIndex((e) => e.id === id);
|
|
1842
|
+
if (idx >= 0) {
|
|
1843
|
+
node.entries.splice(idx, 1);
|
|
1844
|
+
return true;
|
|
1845
|
+
}
|
|
1846
|
+
if (node.children) {
|
|
1847
|
+
for (const child of node.children) {
|
|
1848
|
+
if (this.removeFromNode(child, id)) return true;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return false;
|
|
1852
|
+
}
|
|
1853
|
+
// ---------------------------------------------------------------------------
|
|
1854
|
+
// Queries
|
|
1855
|
+
// ---------------------------------------------------------------------------
|
|
1856
|
+
queryRadius(x, y, z, radius) {
|
|
1857
|
+
const results = [];
|
|
1858
|
+
this.queryRadiusNode(this.root, x, y, z, radius, results);
|
|
1859
|
+
return results;
|
|
1860
|
+
}
|
|
1861
|
+
queryRadiusNode(node, x, y, z, radius, results) {
|
|
1862
|
+
if (!this.sphereOverlapsNode(node, x, y, z, radius)) return;
|
|
1863
|
+
for (const entry of node.entries) {
|
|
1864
|
+
const dx = entry.x - x, dy = entry.y - y, dz = entry.z - z;
|
|
1865
|
+
if (Math.sqrt(dx * dx + dy * dy + dz * dz) <= radius + entry.radius) {
|
|
1866
|
+
results.push(entry);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
if (node.children) {
|
|
1870
|
+
for (const child of node.children) {
|
|
1871
|
+
this.queryRadiusNode(child, x, y, z, radius, results);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
sphereOverlapsNode(node, x, y, z, radius) {
|
|
1876
|
+
const dx = Math.max(0, Math.abs(x - node.cx) - node.halfSize);
|
|
1877
|
+
const dy = Math.max(0, Math.abs(y - node.cy) - node.halfSize);
|
|
1878
|
+
const dz = Math.max(0, Math.abs(z - node.cz) - node.halfSize);
|
|
1879
|
+
return dx * dx + dy * dy + dz * dz <= radius * radius;
|
|
1880
|
+
}
|
|
1881
|
+
// ---------------------------------------------------------------------------
|
|
1882
|
+
// Subdivision
|
|
1883
|
+
// ---------------------------------------------------------------------------
|
|
1884
|
+
subdivide(node) {
|
|
1885
|
+
const hs = node.halfSize / 2;
|
|
1886
|
+
node.children = [];
|
|
1887
|
+
for (let x = -1; x <= 1; x += 2) {
|
|
1888
|
+
for (let y = -1; y <= 1; y += 2) {
|
|
1889
|
+
for (let z = -1; z <= 1; z += 2) {
|
|
1890
|
+
node.children.push({
|
|
1891
|
+
cx: node.cx + x * hs,
|
|
1892
|
+
cy: node.cy + y * hs,
|
|
1893
|
+
cz: node.cz + z * hs,
|
|
1894
|
+
halfSize: hs,
|
|
1895
|
+
entries: [],
|
|
1896
|
+
children: null,
|
|
1897
|
+
depth: node.depth + 1
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const entries = [...node.entries];
|
|
1903
|
+
node.entries = [];
|
|
1904
|
+
for (const entry of entries) {
|
|
1905
|
+
let placed = false;
|
|
1906
|
+
for (const child of node.children) {
|
|
1907
|
+
if (this.containsPoint(child, entry.x, entry.y, entry.z)) {
|
|
1908
|
+
child.entries.push(entry);
|
|
1909
|
+
placed = true;
|
|
1910
|
+
break;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
if (!placed) node.entries.push(entry);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
// ---------------------------------------------------------------------------
|
|
1917
|
+
// Helpers
|
|
1918
|
+
// ---------------------------------------------------------------------------
|
|
1919
|
+
containsPoint(node, x, y, z) {
|
|
1920
|
+
return Math.abs(x - node.cx) <= node.halfSize && Math.abs(y - node.cy) <= node.halfSize && Math.abs(z - node.cz) <= node.halfSize;
|
|
1921
|
+
}
|
|
1922
|
+
getEntryCount() {
|
|
1923
|
+
return this.entryCount;
|
|
1924
|
+
}
|
|
1925
|
+
clear() {
|
|
1926
|
+
this.root.entries = [];
|
|
1927
|
+
this.root.children = null;
|
|
1928
|
+
this.entryCount = 0;
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
// src/spatial/OctreeLODSystem.ts
|
|
1933
|
+
var DEFAULT_CONFIG = {
|
|
1934
|
+
maxDepth: 6,
|
|
1935
|
+
powerLawExponent: 1.5,
|
|
1936
|
+
baseDistance: 2,
|
|
1937
|
+
maxDistance: 200,
|
|
1938
|
+
vrMode: false,
|
|
1939
|
+
gaussianBudget: 0,
|
|
1940
|
+
perAvatarReservation: 0,
|
|
1941
|
+
maxAvatars: 3,
|
|
1942
|
+
maxAnchorsPerNode: 16
|
|
1943
|
+
};
|
|
1944
|
+
var OctreeLODSystem = class {
|
|
1945
|
+
root = null;
|
|
1946
|
+
config;
|
|
1947
|
+
thresholds = [];
|
|
1948
|
+
anchorCount = 0;
|
|
1949
|
+
totalGaussianCount = 0;
|
|
1950
|
+
activeAvatars = 0;
|
|
1951
|
+
nodeCount = 0;
|
|
1952
|
+
/** Scene center for distance calculations */
|
|
1953
|
+
sceneCX = 0;
|
|
1954
|
+
sceneCY = 0;
|
|
1955
|
+
sceneCZ = 0;
|
|
1956
|
+
constructor(config) {
|
|
1957
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
1958
|
+
this.computeThresholds();
|
|
1959
|
+
}
|
|
1960
|
+
// ---------------------------------------------------------------------------
|
|
1961
|
+
// Initialization
|
|
1962
|
+
// ---------------------------------------------------------------------------
|
|
1963
|
+
/**
|
|
1964
|
+
* Initialize the octree with scene bounds.
|
|
1965
|
+
* Must be called before inserting anchors.
|
|
1966
|
+
*/
|
|
1967
|
+
initialize(centerX, centerY, centerZ, halfSize) {
|
|
1968
|
+
this.sceneCX = centerX;
|
|
1969
|
+
this.sceneCY = centerY;
|
|
1970
|
+
this.sceneCZ = centerZ;
|
|
1971
|
+
this.root = this.createNode(centerX, centerY, centerZ, halfSize, 0);
|
|
1972
|
+
this.anchorCount = 0;
|
|
1973
|
+
this.totalGaussianCount = 0;
|
|
1974
|
+
this.nodeCount = 1;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Initialize from a bounding box (min/max corners).
|
|
1978
|
+
*/
|
|
1979
|
+
initializeFromBounds(minX, minY, minZ, maxX, maxY, maxZ) {
|
|
1980
|
+
const cx = (minX + maxX) / 2;
|
|
1981
|
+
const cy = (minY + maxY) / 2;
|
|
1982
|
+
const cz = (minZ + maxZ) / 2;
|
|
1983
|
+
const halfSize = Math.max(maxX - cx, maxY - cy, maxZ - cz);
|
|
1984
|
+
this.initialize(cx, cy, cz, halfSize);
|
|
1985
|
+
}
|
|
1986
|
+
// ---------------------------------------------------------------------------
|
|
1987
|
+
// Threshold Computation
|
|
1988
|
+
// ---------------------------------------------------------------------------
|
|
1989
|
+
/**
|
|
1990
|
+
* Compute power-law transition thresholds for LOD level selection.
|
|
1991
|
+
*
|
|
1992
|
+
* Power-law spacing (Levy flight-inspired, W.030):
|
|
1993
|
+
* threshold[i] = baseDistance * ((i + 1) / maxDepth) ^ exponent * (maxDistance / baseDistance)
|
|
1994
|
+
*
|
|
1995
|
+
* This produces thresholds that are tightly spaced near the camera
|
|
1996
|
+
* (where detail matters most) and widely spaced at far distances
|
|
1997
|
+
* (where coarse LOD suffices).
|
|
1998
|
+
*/
|
|
1999
|
+
computeThresholds() {
|
|
2000
|
+
const { maxDepth, powerLawExponent, baseDistance, maxDistance } = this.config;
|
|
2001
|
+
this.thresholds = [];
|
|
2002
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
2003
|
+
const t = (i + 1) / maxDepth;
|
|
2004
|
+
const threshold = baseDistance + (maxDistance - baseDistance) * Math.pow(t, powerLawExponent);
|
|
2005
|
+
this.thresholds.push(threshold);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Get the computed transition thresholds (read-only).
|
|
2010
|
+
*/
|
|
2011
|
+
getThresholds() {
|
|
2012
|
+
return this.thresholds;
|
|
2013
|
+
}
|
|
2014
|
+
// ---------------------------------------------------------------------------
|
|
2015
|
+
// Anchor Insertion / Removal
|
|
2016
|
+
// ---------------------------------------------------------------------------
|
|
2017
|
+
/**
|
|
2018
|
+
* Insert an anchor Gaussian into the octree at its designated LOD level.
|
|
2019
|
+
* Returns true if successfully inserted.
|
|
2020
|
+
*/
|
|
2021
|
+
insertAnchor(anchor) {
|
|
2022
|
+
if (!this.root) return false;
|
|
2023
|
+
if (anchor.lodLevel < 0 || anchor.lodLevel >= this.config.maxDepth) return false;
|
|
2024
|
+
const inserted = this.insertIntoNode(this.root, anchor);
|
|
2025
|
+
if (inserted) {
|
|
2026
|
+
this.anchorCount++;
|
|
2027
|
+
this.totalGaussianCount += anchor.gaussianCount;
|
|
2028
|
+
}
|
|
2029
|
+
return inserted;
|
|
2030
|
+
}
|
|
2031
|
+
insertIntoNode(node, anchor) {
|
|
2032
|
+
if (!this.containsPoint(node, anchor.x, anchor.y, anchor.z)) return false;
|
|
2033
|
+
if (node.depth === anchor.lodLevel) {
|
|
2034
|
+
node.anchors.push(anchor);
|
|
2035
|
+
node.gaussianCount += anchor.gaussianCount;
|
|
2036
|
+
if (node.anchors.length > this.config.maxAnchorsPerNode && node.depth < this.config.maxDepth - 1 && node.children === null) {
|
|
2037
|
+
this.subdivideNode(node);
|
|
2038
|
+
}
|
|
2039
|
+
return true;
|
|
2040
|
+
}
|
|
2041
|
+
if (node.children === null && node.depth < this.config.maxDepth - 1) {
|
|
2042
|
+
this.subdivideNode(node);
|
|
2043
|
+
}
|
|
2044
|
+
if (node.children) {
|
|
2045
|
+
for (const child of node.children) {
|
|
2046
|
+
if (this.insertIntoNode(child, anchor)) return true;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
if (node.depth < anchor.lodLevel) {
|
|
2050
|
+
node.anchors.push(anchor);
|
|
2051
|
+
node.gaussianCount += anchor.gaussianCount;
|
|
2052
|
+
return true;
|
|
2053
|
+
}
|
|
2054
|
+
return false;
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Remove an anchor by ID.
|
|
2058
|
+
*/
|
|
2059
|
+
removeAnchor(id) {
|
|
2060
|
+
if (!this.root) return false;
|
|
2061
|
+
const result = this.removeFromNode(this.root, id);
|
|
2062
|
+
if (result.removed) {
|
|
2063
|
+
this.anchorCount--;
|
|
2064
|
+
this.totalGaussianCount -= result.gaussianCount;
|
|
2065
|
+
}
|
|
2066
|
+
return result.removed;
|
|
2067
|
+
}
|
|
2068
|
+
removeFromNode(node, id) {
|
|
2069
|
+
const idx = node.anchors.findIndex((a) => a.id === id);
|
|
2070
|
+
if (idx >= 0) {
|
|
2071
|
+
const count = node.anchors[idx].gaussianCount;
|
|
2072
|
+
node.anchors.splice(idx, 1);
|
|
2073
|
+
node.gaussianCount -= count;
|
|
2074
|
+
return { removed: true, gaussianCount: count };
|
|
2075
|
+
}
|
|
2076
|
+
if (node.children) {
|
|
2077
|
+
for (const child of node.children) {
|
|
2078
|
+
const result = this.removeFromNode(child, id);
|
|
2079
|
+
if (result.removed) return result;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return { removed: false, gaussianCount: 0 };
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Bulk-insert anchors (more efficient than individual inserts for large scenes).
|
|
2086
|
+
* Anchors are sorted by LOD level for efficient tree traversal.
|
|
2087
|
+
*/
|
|
2088
|
+
bulkInsert(anchors) {
|
|
2089
|
+
if (!this.root) return 0;
|
|
2090
|
+
const sorted = [...anchors].sort((a, b) => a.lodLevel - b.lodLevel);
|
|
2091
|
+
let inserted = 0;
|
|
2092
|
+
for (const anchor of sorted) {
|
|
2093
|
+
if (this.insertAnchor(anchor)) {
|
|
2094
|
+
inserted++;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return inserted;
|
|
2098
|
+
}
|
|
2099
|
+
// ---------------------------------------------------------------------------
|
|
2100
|
+
// LOD Selection (Core Algorithm)
|
|
2101
|
+
// ---------------------------------------------------------------------------
|
|
2102
|
+
/**
|
|
2103
|
+
* Select LOD levels and anchors to render based on camera position.
|
|
2104
|
+
*
|
|
2105
|
+
* Algorithm:
|
|
2106
|
+
* 1. Compute camera distance to scene center
|
|
2107
|
+
* 2. Walk thresholds to find the deepest (finest) LOD level visible
|
|
2108
|
+
* 3. Select all levels from 0 (coarsest) through the deepest visible level
|
|
2109
|
+
* 4. Collect anchors from selected levels
|
|
2110
|
+
* 5. If budget mode, drop deepest levels until under budget
|
|
2111
|
+
* 6. In VR mode, subtract avatar reservations from available budget
|
|
2112
|
+
*/
|
|
2113
|
+
selectLOD(cameraX, cameraY, cameraZ, avatarCount) {
|
|
2114
|
+
if (!this.root) {
|
|
2115
|
+
return {
|
|
2116
|
+
selectedLevels: [],
|
|
2117
|
+
totalGaussians: 0,
|
|
2118
|
+
budgetCapped: false,
|
|
2119
|
+
levelsDropped: 0,
|
|
2120
|
+
anchors: [],
|
|
2121
|
+
cameraDistance: 0,
|
|
2122
|
+
thresholds: [...this.thresholds],
|
|
2123
|
+
availableBudget: this.config.gaussianBudget
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
const dx = cameraX - this.sceneCX;
|
|
2127
|
+
const dy = cameraY - this.sceneCY;
|
|
2128
|
+
const dz = cameraZ - this.sceneCZ;
|
|
2129
|
+
const cameraDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
2130
|
+
let deepestVisibleLevel = 0;
|
|
2131
|
+
for (let i = 0; i < this.thresholds.length; i++) {
|
|
2132
|
+
if (cameraDistance < this.thresholds[i]) {
|
|
2133
|
+
deepestVisibleLevel = i + 1;
|
|
2134
|
+
} else {
|
|
2135
|
+
break;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
deepestVisibleLevel = Math.min(deepestVisibleLevel, this.config.maxDepth - 1);
|
|
2139
|
+
const selectedLevels = [];
|
|
2140
|
+
for (let l = 0; l <= deepestVisibleLevel; l++) {
|
|
2141
|
+
selectedLevels.push(l);
|
|
2142
|
+
}
|
|
2143
|
+
const anchorsByLevel = /* @__PURE__ */ new Map();
|
|
2144
|
+
const gaussiansByLevel = /* @__PURE__ */ new Map();
|
|
2145
|
+
for (const level of selectedLevels) {
|
|
2146
|
+
anchorsByLevel.set(level, []);
|
|
2147
|
+
gaussiansByLevel.set(level, 0);
|
|
2148
|
+
}
|
|
2149
|
+
this.collectAnchors(this.root, selectedLevels, anchorsByLevel, gaussiansByLevel);
|
|
2150
|
+
let availableBudget = this.config.gaussianBudget;
|
|
2151
|
+
const actualAvatars = avatarCount ?? this.activeAvatars;
|
|
2152
|
+
if (this.config.vrMode && this.config.perAvatarReservation > 0 && availableBudget > 0) {
|
|
2153
|
+
const clampedAvatars = Math.min(actualAvatars, this.config.maxAvatars);
|
|
2154
|
+
const reserved = clampedAvatars * this.config.perAvatarReservation;
|
|
2155
|
+
availableBudget = Math.max(0, availableBudget - reserved);
|
|
2156
|
+
}
|
|
2157
|
+
let budgetCapped = false;
|
|
2158
|
+
let levelsDropped = 0;
|
|
2159
|
+
let totalGaussians = 0;
|
|
2160
|
+
for (const level of selectedLevels) {
|
|
2161
|
+
totalGaussians += gaussiansByLevel.get(level) ?? 0;
|
|
2162
|
+
}
|
|
2163
|
+
if (availableBudget > 0 && totalGaussians > availableBudget) {
|
|
2164
|
+
budgetCapped = true;
|
|
2165
|
+
const levelUtility = (level) => {
|
|
2166
|
+
const anchorsAtLevel = anchorsByLevel.get(level) || [];
|
|
2167
|
+
const gaussians = gaussiansByLevel.get(level) || 1;
|
|
2168
|
+
const baseUtility = Math.pow(0.7, level);
|
|
2169
|
+
const avgImportance = anchorsAtLevel.length > 0 ? anchorsAtLevel.reduce((sum, a) => sum + (a.importance ?? 0.5), 0) / anchorsAtLevel.length : 0.5;
|
|
2170
|
+
return baseUtility * avgImportance / gaussians;
|
|
2171
|
+
};
|
|
2172
|
+
while (selectedLevels.length > 1 && totalGaussians > availableBudget) {
|
|
2173
|
+
let worstIdx = selectedLevels.length - 1;
|
|
2174
|
+
let worstRatio = Infinity;
|
|
2175
|
+
for (let i = 1; i < selectedLevels.length; i++) {
|
|
2176
|
+
const ratio = levelUtility(selectedLevels[i]);
|
|
2177
|
+
if (ratio < worstRatio) {
|
|
2178
|
+
worstRatio = ratio;
|
|
2179
|
+
worstIdx = i;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
const droppedLevel = selectedLevels.splice(worstIdx, 1)[0];
|
|
2183
|
+
totalGaussians -= gaussiansByLevel.get(droppedLevel) ?? 0;
|
|
2184
|
+
anchorsByLevel.delete(droppedLevel);
|
|
2185
|
+
levelsDropped++;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const anchors = [];
|
|
2189
|
+
for (const level of selectedLevels) {
|
|
2190
|
+
const levelAnchors = anchorsByLevel.get(level);
|
|
2191
|
+
if (levelAnchors) {
|
|
2192
|
+
anchors.push(...levelAnchors);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return {
|
|
2196
|
+
selectedLevels,
|
|
2197
|
+
totalGaussians,
|
|
2198
|
+
budgetCapped,
|
|
2199
|
+
levelsDropped,
|
|
2200
|
+
anchors,
|
|
2201
|
+
cameraDistance,
|
|
2202
|
+
thresholds: [...this.thresholds],
|
|
2203
|
+
availableBudget
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Recursively collect anchors from the octree that belong to the selected levels.
|
|
2208
|
+
*/
|
|
2209
|
+
collectAnchors(node, levels, anchorsByLevel, gaussiansByLevel) {
|
|
2210
|
+
if (anchorsByLevel.has(node.depth)) {
|
|
2211
|
+
for (const anchor of node.anchors) {
|
|
2212
|
+
if (levels.includes(anchor.lodLevel)) {
|
|
2213
|
+
anchorsByLevel.get(anchor.lodLevel).push(anchor);
|
|
2214
|
+
gaussiansByLevel.set(
|
|
2215
|
+
anchor.lodLevel,
|
|
2216
|
+
(gaussiansByLevel.get(anchor.lodLevel) ?? 0) + anchor.gaussianCount
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (node.children) {
|
|
2222
|
+
for (const child of node.children) {
|
|
2223
|
+
this.collectAnchors(child, levels, anchorsByLevel, gaussiansByLevel);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
// ---------------------------------------------------------------------------
|
|
2228
|
+
// Avatar Reservation Management
|
|
2229
|
+
// ---------------------------------------------------------------------------
|
|
2230
|
+
/**
|
|
2231
|
+
* Set the number of active avatars (for VR budget reservation).
|
|
2232
|
+
*/
|
|
2233
|
+
setActiveAvatars(count) {
|
|
2234
|
+
this.activeAvatars = Math.min(Math.max(0, count), this.config.maxAvatars);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Get the number of active avatar reservations.
|
|
2238
|
+
*/
|
|
2239
|
+
getActiveAvatars() {
|
|
2240
|
+
return this.activeAvatars;
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Get the scene-available Gaussian budget after avatar reservations.
|
|
2244
|
+
*/
|
|
2245
|
+
getAvailableSceneBudget() {
|
|
2246
|
+
if (this.config.gaussianBudget <= 0) return Infinity;
|
|
2247
|
+
const reserved = this.activeAvatars * this.config.perAvatarReservation;
|
|
2248
|
+
return Math.max(0, this.config.gaussianBudget - reserved);
|
|
2249
|
+
}
|
|
2250
|
+
// ---------------------------------------------------------------------------
|
|
2251
|
+
// Auto-Assignment: Assign LOD Level from Scale
|
|
2252
|
+
// ---------------------------------------------------------------------------
|
|
2253
|
+
/**
|
|
2254
|
+
* Compute the appropriate LOD level for a Gaussian based on its scale.
|
|
2255
|
+
*
|
|
2256
|
+
* Larger Gaussians (coarse detail) -> lower LOD levels (shallow octree nodes)
|
|
2257
|
+
* Smaller Gaussians (fine detail) -> higher LOD levels (deep octree nodes)
|
|
2258
|
+
*
|
|
2259
|
+
* Uses logarithmic mapping: level = floor(log2(maxScale / scale))
|
|
2260
|
+
* Clamped to [0, maxDepth-1].
|
|
2261
|
+
*/
|
|
2262
|
+
computeLODLevelFromScale(scale2, maxScaleInScene) {
|
|
2263
|
+
if (scale2 <= 0 || maxScaleInScene <= 0) return this.config.maxDepth - 1;
|
|
2264
|
+
if (scale2 >= maxScaleInScene) return 0;
|
|
2265
|
+
const ratio = maxScaleInScene / scale2;
|
|
2266
|
+
const level = Math.floor(Math.log2(ratio));
|
|
2267
|
+
return Math.min(Math.max(0, level), this.config.maxDepth - 1);
|
|
2268
|
+
}
|
|
2269
|
+
// ---------------------------------------------------------------------------
|
|
2270
|
+
// Octree Internals
|
|
2271
|
+
// ---------------------------------------------------------------------------
|
|
2272
|
+
createNode(cx, cy, cz, halfSize, depth) {
|
|
2273
|
+
return {
|
|
2274
|
+
cx,
|
|
2275
|
+
cy,
|
|
2276
|
+
cz,
|
|
2277
|
+
halfSize,
|
|
2278
|
+
depth,
|
|
2279
|
+
anchors: [],
|
|
2280
|
+
gaussianCount: 0,
|
|
2281
|
+
children: null
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
subdivideNode(node) {
|
|
2285
|
+
const hs = node.halfSize / 2;
|
|
2286
|
+
node.children = [];
|
|
2287
|
+
for (let x = -1; x <= 1; x += 2) {
|
|
2288
|
+
for (let y = -1; y <= 1; y += 2) {
|
|
2289
|
+
for (let z = -1; z <= 1; z += 2) {
|
|
2290
|
+
const child = this.createNode(
|
|
2291
|
+
node.cx + x * hs,
|
|
2292
|
+
node.cy + y * hs,
|
|
2293
|
+
node.cz + z * hs,
|
|
2294
|
+
hs,
|
|
2295
|
+
node.depth + 1
|
|
2296
|
+
);
|
|
2297
|
+
node.children.push(child);
|
|
2298
|
+
this.nodeCount++;
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
const remaining = [];
|
|
2303
|
+
for (const anchor of node.anchors) {
|
|
2304
|
+
let placed = false;
|
|
2305
|
+
if (anchor.lodLevel > node.depth) {
|
|
2306
|
+
for (const child of node.children) {
|
|
2307
|
+
if (this.containsPoint(child, anchor.x, anchor.y, anchor.z)) {
|
|
2308
|
+
if (anchor.lodLevel === child.depth) {
|
|
2309
|
+
child.anchors.push(anchor);
|
|
2310
|
+
child.gaussianCount += anchor.gaussianCount;
|
|
2311
|
+
} else {
|
|
2312
|
+
this.insertIntoNode(child, anchor);
|
|
2313
|
+
}
|
|
2314
|
+
placed = true;
|
|
2315
|
+
break;
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
if (!placed) {
|
|
2320
|
+
remaining.push(anchor);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
node.anchors = remaining;
|
|
2324
|
+
node.gaussianCount = remaining.reduce((sum, a) => sum + a.gaussianCount, 0);
|
|
2325
|
+
}
|
|
2326
|
+
containsPoint(node, x, y, z) {
|
|
2327
|
+
return Math.abs(x - node.cx) <= node.halfSize && Math.abs(y - node.cy) <= node.halfSize && Math.abs(z - node.cz) <= node.halfSize;
|
|
2328
|
+
}
|
|
2329
|
+
// ---------------------------------------------------------------------------
|
|
2330
|
+
// Metrics & Diagnostics
|
|
2331
|
+
// ---------------------------------------------------------------------------
|
|
2332
|
+
/**
|
|
2333
|
+
* Get comprehensive metrics about the octree state.
|
|
2334
|
+
*/
|
|
2335
|
+
getMetrics() {
|
|
2336
|
+
const levelStats = /* @__PURE__ */ new Map();
|
|
2337
|
+
for (let i = 0; i < this.config.maxDepth; i++) {
|
|
2338
|
+
levelStats.set(i, { level: i, anchorCount: 0, gaussianCount: 0, nodeCount: 0 });
|
|
2339
|
+
}
|
|
2340
|
+
if (this.root) {
|
|
2341
|
+
this.collectMetrics(this.root, levelStats);
|
|
2342
|
+
}
|
|
2343
|
+
let actualDepth = 0;
|
|
2344
|
+
for (const [level, stats] of levelStats) {
|
|
2345
|
+
if (stats.anchorCount > 0 && level > actualDepth) {
|
|
2346
|
+
actualDepth = level;
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
return {
|
|
2350
|
+
totalAnchors: this.anchorCount,
|
|
2351
|
+
totalGaussians: this.totalGaussianCount,
|
|
2352
|
+
levels: Array.from(levelStats.values()),
|
|
2353
|
+
actualDepth,
|
|
2354
|
+
activeAvatarReservations: this.activeAvatars,
|
|
2355
|
+
totalNodes: this.nodeCount
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
collectMetrics(node, stats) {
|
|
2359
|
+
const ls = stats.get(node.depth);
|
|
2360
|
+
if (ls) {
|
|
2361
|
+
ls.nodeCount++;
|
|
2362
|
+
for (const anchor of node.anchors) {
|
|
2363
|
+
const anchorStats = stats.get(anchor.lodLevel);
|
|
2364
|
+
if (anchorStats) {
|
|
2365
|
+
anchorStats.anchorCount++;
|
|
2366
|
+
anchorStats.gaussianCount += anchor.gaussianCount;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
if (node.children) {
|
|
2371
|
+
for (const child of node.children) {
|
|
2372
|
+
this.collectMetrics(child, stats);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Get the total number of anchors.
|
|
2378
|
+
*/
|
|
2379
|
+
getAnchorCount() {
|
|
2380
|
+
return this.anchorCount;
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Get the total Gaussian count across all anchors.
|
|
2384
|
+
*/
|
|
2385
|
+
getTotalGaussianCount() {
|
|
2386
|
+
return this.totalGaussianCount;
|
|
2387
|
+
}
|
|
2388
|
+
/**
|
|
2389
|
+
* Get the current configuration.
|
|
2390
|
+
*/
|
|
2391
|
+
getConfig() {
|
|
2392
|
+
return { ...this.config };
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Update configuration (recomputes thresholds).
|
|
2396
|
+
*/
|
|
2397
|
+
updateConfig(config) {
|
|
2398
|
+
this.config = { ...this.config, ...config };
|
|
2399
|
+
this.computeThresholds();
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Clear all anchors and reset the octree.
|
|
2403
|
+
* Preserves configuration.
|
|
2404
|
+
*/
|
|
2405
|
+
clear() {
|
|
2406
|
+
if (this.root) {
|
|
2407
|
+
this.root.anchors = [];
|
|
2408
|
+
this.root.gaussianCount = 0;
|
|
2409
|
+
this.root.children = null;
|
|
2410
|
+
}
|
|
2411
|
+
this.anchorCount = 0;
|
|
2412
|
+
this.totalGaussianCount = 0;
|
|
2413
|
+
this.nodeCount = this.root ? 1 : 0;
|
|
2414
|
+
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Check if the system has been initialized.
|
|
2417
|
+
*/
|
|
2418
|
+
isInitialized() {
|
|
2419
|
+
return this.root !== null;
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2423
|
+
0 && (module.exports = {
|
|
2424
|
+
DEFAULT_SPATIAL_CONFIG,
|
|
2425
|
+
OctreeLODSystem,
|
|
2426
|
+
OctreeSystem,
|
|
2427
|
+
SpatialConstraintValidator,
|
|
2428
|
+
SpatialContextProvider,
|
|
2429
|
+
SpatialQueryExecutor,
|
|
2430
|
+
add,
|
|
2431
|
+
boxesOverlap,
|
|
2432
|
+
cross,
|
|
2433
|
+
distance,
|
|
2434
|
+
distanceSquared,
|
|
2435
|
+
dot,
|
|
2436
|
+
getBoxCenter,
|
|
2437
|
+
isPointInBox,
|
|
2438
|
+
isPointInSphere,
|
|
2439
|
+
lerp,
|
|
2440
|
+
normalize,
|
|
2441
|
+
scale,
|
|
2442
|
+
subtract
|
|
2443
|
+
});
|