@relu-interactives/spatial-ecs 0.2.22 → 0.2.24

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.
@@ -14,18 +14,20 @@ import { ScriptObject, type ColorInput } from "@relu-interactives/spatial-ecs";
14
14
  import * as THREE from "three";
15
15
 
16
16
  export default class WavePlane extends ScriptObject {
17
- // Typed data → inspector fields (same envelopes as Behaviours).
18
- declare data: { color: ColorInput };
17
+ // Inspector fields: an INLINE `data = { … }` literal (same envelopes as
18
+ // Behaviours). NEVER `declare data` — that is a type-only annotation with no
19
+ // runtime object, so the editor can't discover your fields and the inspector
20
+ // shows nothing to edit.
21
+ data = { color: "#3aa0ff" as ColorInput };
19
22
 
20
23
  private mat!: THREE.ShaderMaterial;
21
24
 
22
25
  // REQUIRED — build and return the visual root.
23
26
  build(): THREE.Object3D {
24
- this.data.color ??= { type: "color", value: "#3aa0ff" };
25
27
  this.mat = new THREE.ShaderMaterial({
26
28
  uniforms: {
27
29
  uTime: { value: 0 },
28
- uColor: { value: new THREE.Color(this.data.color.value) },
30
+ uColor: { value: new THREE.Color(this.data.color) },
29
31
  },
30
32
  vertexShader: /* glsl */ `
31
33
  uniform float uTime;
@@ -66,22 +68,33 @@ export default class WavePlane extends ScriptObject {
66
68
  | --- | --- | --- |
67
69
  | `this.world` | `World` | The owning ECS world. |
68
70
  | `this.entityId` | `number` | The entity this script is bound to. |
69
- | `this.data` | `Record<string, unknown>` | Inspector-bound payload. Declare a shape with `declare data: MyShape`. |
71
+ | `this.data` | `Record<string, unknown>` | Inspector-bound payload. Declare it with an INLINE literal (`data = { … }`) — never `declare data` (a type-only annotation with no runtime object → no inspector fields). |
70
72
  | `this.root` | `THREE.Object3D \| null` | The object `build()` returned (set after build). |
71
73
 
72
74
  ## Data fields (inspector)
73
75
 
74
- Type `data` fields with the same input envelopes as Behaviours to get inspector controls:
76
+ Declare `data` as an **inline literal** (`data = { … }`) with the same input envelopes as Behaviours. **Never use `declare data`** — it is a type-only annotation that produces no runtime object, so the editor can't discover the fields and the inspector shows nothing to edit. (Likewise, don't set defaults with `this.data.x ??= …` inside `build()` — put the default in the literal.)
77
+
78
+ Simple scalars take a value plus an `as <Input>` cast and are read directly:
79
+
80
+ ```ts
81
+ data = {
82
+ color: "#3aa0ff" as ColorInput, // color swatch → this.data.color
83
+ speed: 1.0 as FloatInput, // number field → this.data.speed
84
+ enabled: true as ToggleInput, // toggle → this.data.enabled
85
+ };
86
+ ```
87
+
88
+ Rich inputs that carry metadata are authored as inline objects and read via `.value` (or the resolved view for entity/component pickers):
75
89
 
76
90
  ```ts
77
- declare data: {
78
- color: ColorInput; // color swatch
79
- amplitude: SliderInput; // slider
80
- target: EntityInput; // entity picker
91
+ data = {
92
+ amplitude: { type: "slider", min: 0, max: 1, value: 0.5 }, // → this.data.amplitude.value
93
+ target: { type: "entity", value: null }, // → this.data.target.value
81
94
  };
82
95
  ```
83
96
 
84
- Read a field's value as `this.data.color.value`. See [Data binding](./behaviour-data-binding/) for the full envelope catalog. Edits made in the inspector are pushed to the live instance's `this.data` between frames.
97
+ See [Data binding](./behaviour-data-binding/) for the full envelope catalog. Edits made in the inspector are pushed to the live instance's `this.data` between frames.
85
98
 
86
99
  ## Rules
87
100
 
@@ -45,7 +45,7 @@ Initializes and manages the Rapier physics world, creates rigid bodies and colli
45
45
  ## Behavior notes
46
46
 
47
47
  - **Async init** — the Rapier WASM module is loaded once on the first `update()` call. Physics simulation does not start until the module is ready; entities created before init are processed on the first frame after init completes.
48
- - **Trimesh** — `trimesh` colliders extract vertex/index data from the entity's `THREE.BufferGeometry`. The extracted data is cached per entity by a geometry signature.
48
+ - **Trimesh** — `trimesh` colliders merge the vertex/index data of **every mesh** under the entity (multi-primitive GLBs are fully covered) with the entity's **world scale baked into the vertices** — Rapier bodies carry no scale, so scaling the entity is how you resize a trimesh; the collider rebuilds automatically when the scale or geometry changes. The extracted data is cached per entity by a geometry+scale signature, and the heavy vertex merge runs only when that signature changes.
49
49
  - **Sensor colliders** — setting `values.collider.isSensor = true` creates a Rapier sensor collider. Sensor contacts do not generate forces but can be used for overlap/trigger queries.
50
50
  - **Gravity** — the global gravity vector is read from the `Environment` component (`gravity` field, default `{ x: 0, y: -9.81, z: 0 }`) and applied to the Rapier world gravity setting each frame.
51
51
  - **Preview only** — the system is typically constructed with `{ isEditor: false }` for preview. In the editor the physics world step is skipped so objects don't fall during authoring.
package/dist/index.cjs CHANGED
@@ -6789,82 +6789,108 @@ function createColliderDesc(RAPIER, values, triMeshData) {
6789
6789
  return desc;
6790
6790
  }
6791
6791
  __name(createColliderDesc, "createColliderDesc");
6792
- function findFirstMeshWithGeometry(object) {
6793
- if (object instanceof THREE36__namespace.Mesh && object.geometry) {
6794
- return object;
6792
+ function collectTriMeshParts(object) {
6793
+ if (!object) {
6794
+ return null;
6795
6795
  }
6796
- let found = null;
6796
+ object.updateWorldMatrix(true, true);
6797
+ const worldScale = new THREE36__namespace.Vector3();
6798
+ object.getWorldScale(worldScale);
6799
+ const scale = {
6800
+ x: Math.abs(worldScale.x) || 1,
6801
+ y: Math.abs(worldScale.y) || 1,
6802
+ z: Math.abs(worldScale.z) || 1
6803
+ };
6804
+ const worldToObject = new THREE36__namespace.Matrix4().copy(object.matrixWorld).invert();
6805
+ const parts = [];
6797
6806
  object.traverse((child) => {
6798
- if (found) {
6807
+ if (!(child instanceof THREE36__namespace.Mesh) || !child.geometry) {
6799
6808
  return;
6800
6809
  }
6801
- if (child instanceof THREE36__namespace.Mesh && child.geometry) {
6802
- found = child;
6810
+ const geometry = child.geometry;
6811
+ const positionAttribute = geometry.getAttribute("position");
6812
+ if (!positionAttribute || positionAttribute.itemSize < 3) {
6813
+ return;
6803
6814
  }
6815
+ const positionCount = positionAttribute.count;
6816
+ if (positionCount < 3) {
6817
+ return;
6818
+ }
6819
+ const indexAttribute = geometry.getIndex();
6820
+ const indexCount = indexAttribute ? indexAttribute.count : Math.floor(positionCount / 3) * 3;
6821
+ if (indexCount < 3) {
6822
+ return;
6823
+ }
6824
+ const relativeMatrix = new THREE36__namespace.Matrix4().multiplyMatrices(
6825
+ worldToObject,
6826
+ child.matrixWorld
6827
+ );
6828
+ const matrixSignature = relativeMatrix.elements.map((value) => value.toFixed(5)).join(",");
6829
+ parts.push({
6830
+ positionAttribute,
6831
+ indexAttribute,
6832
+ positionCount,
6833
+ indexCount,
6834
+ relativeMatrix,
6835
+ signature: `${child.uuid}:${geometry.uuid}:${positionCount}:${indexCount}:${matrixSignature}`
6836
+ });
6804
6837
  });
6805
- return found;
6806
- }
6807
- __name(findFirstMeshWithGeometry, "findFirstMeshWithGeometry");
6808
- function createTriMeshData(object) {
6809
- if (!object) {
6838
+ if (parts.length === 0) {
6810
6839
  return null;
6811
6840
  }
6812
- const mesh = findFirstMeshWithGeometry(object);
6813
- if (!mesh) {
6841
+ const scaleSignature = `${scale.x.toFixed(5)},${scale.y.toFixed(5)},${scale.z.toFixed(5)}`;
6842
+ return {
6843
+ parts,
6844
+ scale,
6845
+ signature: `${scaleSignature}|${parts.map((p) => p.signature).join("|")}`
6846
+ };
6847
+ }
6848
+ __name(collectTriMeshParts, "collectTriMeshParts");
6849
+ function createTriMeshData(collected) {
6850
+ if (!collected) {
6814
6851
  return null;
6815
6852
  }
6816
- const geometry = mesh.geometry;
6817
- const positionAttribute = geometry.getAttribute("position");
6818
- if (!positionAttribute || positionAttribute.itemSize < 3) {
6819
- return null;
6853
+ const { parts, scale } = collected;
6854
+ let vertexTotal = 0;
6855
+ let indexTotal = 0;
6856
+ for (const part of parts) {
6857
+ vertexTotal += part.positionCount;
6858
+ indexTotal += part.indexCount;
6820
6859
  }
6821
- const positionCount = positionAttribute.count;
6822
- if (positionCount < 3) {
6860
+ if (vertexTotal < 3 || indexTotal < 3) {
6823
6861
  return null;
6824
6862
  }
6825
- object.updateWorldMatrix(true, true);
6826
- mesh.updateWorldMatrix(true, true);
6827
- const worldToObject = new THREE36__namespace.Matrix4().copy(object.matrixWorld).invert();
6828
- const relativeMatrix = new THREE36__namespace.Matrix4().multiplyMatrices(
6829
- worldToObject,
6830
- mesh.matrixWorld
6831
- );
6832
- const vertices = new Float32Array(positionCount * 3);
6863
+ const vertices = new Float32Array(vertexTotal * 3);
6864
+ const indices = new Uint32Array(indexTotal);
6833
6865
  const point = new THREE36__namespace.Vector3();
6834
- for (let index = 0; index < positionCount; index += 1) {
6835
- point.set(
6836
- positionAttribute.getX(index),
6837
- positionAttribute.getY(index),
6838
- positionAttribute.getZ(index)
6839
- ).applyMatrix4(relativeMatrix);
6840
- const outputIndex = index * 3;
6841
- vertices[outputIndex] = point.x;
6842
- vertices[outputIndex + 1] = point.y;
6843
- vertices[outputIndex + 2] = point.z;
6844
- }
6845
- const indexAttribute = geometry.getIndex();
6846
- let indices;
6847
- if (indexAttribute) {
6848
- indices = new Uint32Array(indexAttribute.count);
6849
- for (let index = 0; index < indexAttribute.count; index += 1) {
6850
- indices[index] = indexAttribute.getX(index);
6851
- }
6852
- } else {
6853
- const triangleIndexCount = Math.floor(positionCount / 3) * 3;
6854
- if (triangleIndexCount < 3) {
6855
- return null;
6856
- }
6857
- indices = new Uint32Array(triangleIndexCount);
6858
- for (let index = 0; index < triangleIndexCount; index += 1) {
6859
- indices[index] = index;
6866
+ let vertexOffset = 0;
6867
+ let indexOffset = 0;
6868
+ for (const part of parts) {
6869
+ const { positionAttribute, indexAttribute, relativeMatrix } = part;
6870
+ for (let index = 0; index < part.positionCount; index += 1) {
6871
+ point.set(
6872
+ positionAttribute.getX(index),
6873
+ positionAttribute.getY(index),
6874
+ positionAttribute.getZ(index)
6875
+ ).applyMatrix4(relativeMatrix);
6876
+ const outputIndex = (vertexOffset + index) * 3;
6877
+ vertices[outputIndex] = point.x * scale.x;
6878
+ vertices[outputIndex + 1] = point.y * scale.y;
6879
+ vertices[outputIndex + 2] = point.z * scale.z;
6880
+ }
6881
+ if (indexAttribute) {
6882
+ for (let index = 0; index < part.indexCount; index += 1) {
6883
+ indices[indexOffset + index] = vertexOffset + indexAttribute.getX(index);
6884
+ }
6885
+ } else {
6886
+ for (let index = 0; index < part.indexCount; index += 1) {
6887
+ indices[indexOffset + index] = vertexOffset + index;
6888
+ }
6860
6889
  }
6890
+ vertexOffset += part.positionCount;
6891
+ indexOffset += part.indexCount;
6861
6892
  }
6862
- const matrixSignature = relativeMatrix.elements.map((value) => value.toFixed(5)).join(",");
6863
- return {
6864
- vertices,
6865
- indices,
6866
- signature: `${mesh.uuid}:${geometry.uuid}:${positionCount}:${indices.length}:${matrixSignature}`
6867
- };
6893
+ return { vertices, indices, signature: collected.signature };
6868
6894
  }
6869
6895
  __name(createTriMeshData, "createTriMeshData");
6870
6896
  function syncBodyFromTransform(body, transform) {
@@ -7041,7 +7067,7 @@ var _PhysicsSyncSystem = class _PhysicsSyncSystem {
7041
7067
  syncBodyFromTransform(body, transform);
7042
7068
  }
7043
7069
  const effectiveCollider = computeColliderState(physics.values.collider, object);
7044
- const triMeshData = effectiveCollider.shape === "trimesh" ? createTriMeshData(object) : null;
7070
+ const triMeshParts = effectiveCollider.shape === "trimesh" ? collectTriMeshParts(object) : null;
7045
7071
  if (!effectiveCollider || !effectiveCollider.enabled) {
7046
7072
  const colliderHandle = this.colliderHandles.get(entityId);
7047
7073
  if (typeof colliderHandle === "number") {
@@ -7055,7 +7081,7 @@ var _PhysicsSyncSystem = class _PhysicsSyncSystem {
7055
7081
  physics.collider = null;
7056
7082
  continue;
7057
7083
  }
7058
- const colliderSignature = `${JSON.stringify(effectiveCollider)}:${triMeshData?.signature ?? ""}`;
7084
+ const colliderSignature = `${JSON.stringify(effectiveCollider)}:${triMeshParts?.signature ?? ""}`;
7059
7085
  const previousColliderSignature = this.colliderSignatures.get(entityId);
7060
7086
  if (!this.colliderHandles.has(entityId) || previousColliderSignature !== colliderSignature) {
7061
7087
  const previousColliderHandle = this.colliderHandles.get(entityId);
@@ -7068,7 +7094,7 @@ var _PhysicsSyncSystem = class _PhysicsSyncSystem {
7068
7094
  const colliderDesc = createColliderDesc(
7069
7095
  this.rapier,
7070
7096
  effectiveCollider,
7071
- triMeshData
7097
+ createTriMeshData(triMeshParts)
7072
7098
  );
7073
7099
  const createdCollider = physicsWorld.createCollider(colliderDesc, body);
7074
7100
  this.colliderHandles.set(entityId, createdCollider.handle);
@@ -9419,9 +9445,9 @@ var _ScriptObjectSystem = class _ScriptObjectSystem {
9419
9445
  } catch {
9420
9446
  instance = new ctor(world, entityId, currentData);
9421
9447
  }
9422
- const defaults = normalizeScriptData(instance.data);
9423
- const merged = mergeDataByCurrentDefaults(defaults, currentData);
9424
- instance.data = resolveInputData(world, merged);
9448
+ const literalDefaults = normalizeScriptData(instance.data);
9449
+ const seed = Object.keys(literalDefaults).length > 0 ? mergeDataByCurrentDefaults(literalDefaults, currentData) : { ...currentData };
9450
+ instance.data = resolveInputData(world, seed);
9425
9451
  const parent = world.getComponent(entityId, Object3DRef)?.object;
9426
9452
  if (!parent) {
9427
9453
  throw new Error("Script object entity has no Object3D to attach to.");
@@ -9451,6 +9477,13 @@ var _ScriptObjectSystem = class _ScriptObjectSystem {
9451
9477
  built.userData[SCRIPT_OBJECT_ROOT_FLAG] = true;
9452
9478
  parent.add(built);
9453
9479
  instance.root = built;
9480
+ const postBuildData = normalizeScriptData(instance.data);
9481
+ const schemaDefaults = { ...literalDefaults };
9482
+ for (const [key2, value] of Object.entries(postBuildData)) {
9483
+ if (!(key2 in schemaDefaults)) schemaDefaults[key2] = value;
9484
+ }
9485
+ const merged = mergeDataByCurrentDefaults(schemaDefaults, currentData);
9486
+ instance.data = resolveInputData(world, merged);
9454
9487
  const stored = serializeDataForStorage(merged, merged);
9455
9488
  component.data = stored;
9456
9489
  component.runtimeState = "ready";