@galfus/engine 0.0.0-stage → 0.31.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +190 -2
  2. package/package.json +59 -4
  3. package/src/anchors/cache.ts +101 -0
  4. package/src/anchors/component.ts +123 -0
  5. package/src/anchors/hierarchy.ts +192 -0
  6. package/src/anchors/index.ts +8 -0
  7. package/src/anchors/solver.ts +247 -0
  8. package/src/anchors/strategy.ts +142 -0
  9. package/src/animation/blending.ts +163 -0
  10. package/src/animation/clip.ts +132 -0
  11. package/src/animation/coordinator.ts +568 -0
  12. package/src/animation/index.ts +7 -0
  13. package/src/animation/native-targets.ts +37 -0
  14. package/src/animation/player.ts +287 -0
  15. package/src/audio/index.ts +4 -0
  16. package/src/audio/manager.ts +371 -0
  17. package/src/collision/components.ts +211 -0
  18. package/src/collision/index.ts +16 -0
  19. package/src/collision/query2d.ts +167 -0
  20. package/src/collision/query3d.ts +170 -0
  21. package/src/ecs/component.ts +130 -0
  22. package/src/ecs/entity.ts +75 -0
  23. package/src/ecs/index.ts +9 -0
  24. package/src/ecs/intent.ts +162 -0
  25. package/src/ecs/metadata.ts +44 -0
  26. package/src/ecs/system.ts +224 -0
  27. package/src/ecs/world.ts +291 -0
  28. package/src/index.ts +19 -0
  29. package/src/interaction/focus.ts +327 -0
  30. package/src/interaction/index.ts +9 -0
  31. package/src/interaction/listener.ts +58 -0
  32. package/src/interaction/snapshot2d.ts +210 -0
  33. package/src/interaction/snapshot3d.ts +214 -0
  34. package/src/interaction/system.ts +544 -0
  35. package/src/interaction/types.ts +27 -0
  36. package/src/math/common.ts +67 -0
  37. package/src/math/index.ts +17 -0
  38. package/src/math/mat2d.ts +320 -0
  39. package/src/math/mat4.ts +576 -0
  40. package/src/math/quat.ts +438 -0
  41. package/src/math/vec2.ts +161 -0
  42. package/src/math/vec3.ts +242 -0
  43. package/src/math/vec4.ts +171 -0
  44. package/src/multiverse/coordinator.ts +540 -0
  45. package/src/multiverse/index.ts +13 -0
  46. package/src/multiverse/presentation-graph.ts +306 -0
  47. package/src/multiverse/presentation-links.ts +134 -0
  48. package/src/multiverse/realm.ts +49 -0
  49. package/src/platform/index.ts +2 -0
  50. package/src/platform/platform.ts +149 -0
  51. package/src/platform/window.ts +181 -0
  52. package/src/realm2d/components.ts +254 -0
  53. package/src/realm2d/index.ts +12 -0
  54. package/src/realm2d/presets/index.ts +36 -0
  55. package/src/realm2d/realm.ts +522 -0
  56. package/src/realm2d/sync.ts +284 -0
  57. package/src/realm2d/utils/index.ts +32 -0
  58. package/src/realm3d/components.ts +341 -0
  59. package/src/realm3d/geometry.ts +224 -0
  60. package/src/realm3d/index.ts +12 -0
  61. package/src/realm3d/presets/index.ts +67 -0
  62. package/src/realm3d/realm.ts +588 -0
  63. package/src/realm3d/resource-ids.ts +62 -0
  64. package/src/realm3d/skinning.ts +128 -0
  65. package/src/realm3d/sync.ts +442 -0
  66. package/src/realm3d/utils/index.ts +26 -0
  67. package/src/resources/index.ts +4 -0
  68. package/src/resources/manager.ts +92 -0
  69. package/src/runtime.ts +588 -0
  70. package/src/spatial/dirty_registry.ts +217 -0
  71. package/src/spatial/flags.ts +14 -0
  72. package/src/spatial/index.ts +6 -0
  73. package/src/spatial/metrics.ts +124 -0
  74. package/src/text/editor.ts +224 -0
  75. package/src/text/encoding.ts +38 -0
  76. package/src/text/index.ts +13 -0
  77. package/src/text/input.ts +160 -0
  78. package/src/text/keyboard.ts +50 -0
  79. package/src/text/svg-decorations.ts +147 -0
  80. package/src/types.ts +85 -0
@@ -0,0 +1,211 @@
1
+ import { assertValidLogicalId } from '@galfus/transport-types';
2
+ import type { Component } from '../ecs/component';
3
+ import { mat2d, mat4, vec2, vec3 } from '../math';
4
+
5
+ export type ColliderShape2D =
6
+ | { type: 'rectangle'; halfExtents: [number, number] }
7
+ | { type: 'circle'; radius: number }
8
+ | { type: 'polygon'; vertices: [number, number][] };
9
+
10
+ export type ColliderShape3D =
11
+ | { type: 'box'; halfExtents: [number, number, number] }
12
+ | { type: 'sphere'; radius: number }
13
+ | { type: 'plane'; halfExtents: [number, number] };
14
+
15
+ export interface ColliderHit2D {
16
+ entityId: number;
17
+ colliderId: number;
18
+ point: [number, number];
19
+ normal: [number, number];
20
+ distance: number;
21
+ sensor: boolean;
22
+ }
23
+
24
+ export interface ColliderHit3D {
25
+ entityId: number;
26
+ colliderId: number;
27
+ point: [number, number, number];
28
+ normal: [number, number, number];
29
+ distance: number;
30
+ sensor: boolean;
31
+ }
32
+
33
+ export interface ColliderOptions {
34
+ /** Local affine matrix, column-major, relative to the entity's Model. */
35
+ matrix?: readonly number[];
36
+ active?: boolean;
37
+ sensor?: boolean;
38
+ layer?: number;
39
+ mask?: number;
40
+ }
41
+ export interface ColliderEntry<S> {
42
+ readonly colliderId: number;
43
+ readonly shape: S;
44
+ readonly matrix: readonly number[];
45
+ readonly boundingRadius?: number;
46
+ readonly active: boolean;
47
+ readonly sensor: boolean;
48
+ readonly layer: number;
49
+ readonly mask: number;
50
+ }
51
+ const IDENTITY_2D = Array.from(mat2d.create());
52
+ const IDENTITY_3D = Array.from(mat4.create());
53
+ function validPolygon(vertices: [number, number][]): boolean {
54
+ if (
55
+ vertices.length < 3 ||
56
+ vertices.some((p) => p.length !== 2 || p.some((v) => !Number.isFinite(v)))
57
+ )
58
+ return false;
59
+ let area = 0;
60
+ const cross = (a: [number, number], b: [number, number], c: [number, number]) =>
61
+ vec2.cross(vec2.subtract([0, 0], b, a), vec2.subtract([0, 0], c, a));
62
+ for (let i = 0; i < vertices.length; i++) {
63
+ const a = vertices[i]!;
64
+ const b = vertices[(i + 1) % vertices.length]!;
65
+ if (a[0] === b[0] && a[1] === b[1]) return false;
66
+ area += vec2.cross(a, b);
67
+ for (let j = i + 1; j < vertices.length; j++) {
68
+ if (j === i + 1 || (i === 0 && j === vertices.length - 1)) continue;
69
+ const c = vertices[j]!;
70
+ const d = vertices[(j + 1) % vertices.length]!;
71
+ const overlap =
72
+ Math.min(a[0], b[0]) <= Math.max(c[0], d[0]) &&
73
+ Math.min(c[0], d[0]) <= Math.max(a[0], b[0]) &&
74
+ Math.min(a[1], b[1]) <= Math.max(c[1], d[1]) &&
75
+ Math.min(c[1], d[1]) <= Math.max(a[1], b[1]);
76
+ if (overlap && cross(a, b, c) * cross(a, b, d) <= 0 && cross(c, d, a) * cross(c, d, b) <= 0)
77
+ return false;
78
+ }
79
+ }
80
+ return Math.abs(area) > 1e-9;
81
+ }
82
+ function validateShape(shape: ColliderShape2D | ColliderShape3D): void {
83
+ if (shape.type === 'circle' || shape.type === 'sphere') {
84
+ if (!Number.isFinite(shape.radius) || shape.radius <= 0)
85
+ throw new Error('Collider radius must be positive and finite.');
86
+ } else if (shape.type === 'polygon') {
87
+ if (!validPolygon(shape.vertices))
88
+ throw new Error('Collider polygon must be simple, non-degenerate, and finite.');
89
+ } else if (
90
+ shape.halfExtents.length !== (shape.type === 'box' ? 3 : 2) ||
91
+ shape.halfExtents.some((v) => !Number.isFinite(v) || v <= 0)
92
+ ) {
93
+ throw new Error('Collider half extents must be positive and finite.');
94
+ }
95
+ }
96
+ function computeBoundingRadius(shape: ColliderShape2D | ColliderShape3D): number {
97
+ if (shape.type === 'circle' || shape.type === 'sphere') return shape.radius;
98
+ if (shape.type === 'rectangle' || shape.type === 'plane')
99
+ return Math.hypot(shape.halfExtents[0], shape.halfExtents[1]);
100
+ if (shape.type === 'box')
101
+ return Math.hypot(shape.halfExtents[0], shape.halfExtents[1], shape.halfExtents[2]);
102
+ if (shape.type === 'polygon') {
103
+ let max = 0;
104
+ for (const [x, y] of shape.vertices) max = Math.max(max, Math.hypot(x, y));
105
+ return max;
106
+ }
107
+ return 0;
108
+ }
109
+ function validateMask(value: number): boolean {
110
+ return Number.isInteger(value) && value >= 0 && value <= 0xffffffff;
111
+ }
112
+
113
+ export function validateRay(
114
+ origin: readonly number[],
115
+ direction: readonly number[],
116
+ distance: number,
117
+ mask: number,
118
+ dimensions: 2 | 3
119
+ ): void {
120
+ if (
121
+ origin.length !== dimensions ||
122
+ direction.length !== dimensions ||
123
+ origin.some((v) => !Number.isFinite(v)) ||
124
+ direction.some((v) => !Number.isFinite(v)) ||
125
+ direction.every((v) => v === 0) ||
126
+ !Number.isFinite(
127
+ dimensions === 2
128
+ ? vec2.length([direction[0]!, direction[1]!])
129
+ : vec3.length([direction[0]!, direction[1]!, direction[2]!])
130
+ ) ||
131
+ !Number.isFinite(distance) ||
132
+ distance < 0 ||
133
+ !validateMask(mask)
134
+ ) {
135
+ throw new Error('Invalid collider ray query.');
136
+ }
137
+ }
138
+
139
+ export function validatePoint(point: readonly number[], mask: number): void {
140
+ if (point.length !== 2 || point.some((v) => !Number.isFinite(v)) || !validateMask(mask))
141
+ throw new Error('Invalid collider point query.');
142
+ }
143
+ const colliderEntries = Symbol('colliderEntries');
144
+ abstract class ColliderComponent<S extends ColliderShape2D | ColliderShape3D> implements Component {
145
+ abstract readonly type: string;
146
+ readonly [colliderEntries] = new Map<number, ColliderEntry<S>>();
147
+ protected constructor(private readonly identity: readonly number[]) {}
148
+ add(colliderId: number, shape: S, options: ColliderOptions = {}): this {
149
+ assertValidLogicalId(colliderId, 'ColliderId');
150
+ if (this[colliderEntries].has(colliderId))
151
+ throw new Error(`Collider ${colliderId} already exists.`);
152
+ return this.set(colliderId, shape, options);
153
+ }
154
+ set(colliderId: number, shape: S, options: ColliderOptions = {}): this {
155
+ assertValidLogicalId(colliderId, 'ColliderId');
156
+ validateShape(shape);
157
+ const matrix = options.matrix ?? this.identity;
158
+ if (matrix.length !== this.identity.length || matrix.some((v) => !Number.isFinite(v)))
159
+ throw new Error('Invalid collider local matrix.');
160
+ if (
161
+ matrix.length === 16 &&
162
+ (matrix[3] !== 0 || matrix[7] !== 0 || matrix[11] !== 0 || matrix[15] !== 1)
163
+ )
164
+ throw new Error('Collider 3D matrix must be affine.');
165
+ const layer = options.layer ?? 0xffffffff;
166
+ const mask = options.mask ?? 0xffffffff;
167
+ if (!validateMask(layer) || !validateMask(mask))
168
+ throw new Error('Collider layer and mask must be unsigned 32-bit integers.');
169
+ this[colliderEntries].set(colliderId, {
170
+ colliderId,
171
+ shape: structuredClone(shape),
172
+ matrix: [...matrix],
173
+ boundingRadius: computeBoundingRadius(shape),
174
+ active: options.active ?? true,
175
+ sensor: options.sensor ?? false,
176
+ layer,
177
+ mask
178
+ });
179
+ return this;
180
+ }
181
+ remove(colliderId: number): boolean {
182
+ return this[colliderEntries].delete(colliderId);
183
+ }
184
+ getShapes(): readonly ColliderEntry<S>[] {
185
+ return Array.from(this[colliderEntries].values(), (entry) => structuredClone(entry));
186
+ }
187
+ }
188
+ /** Internal query path; entries are immutable and may be retained by snapshots. */
189
+ export function getColliderEntries<S extends ColliderShape2D | ColliderShape3D>(
190
+ component: ColliderComponent<S>
191
+ ): IterableIterator<ColliderEntry<S>> {
192
+ return component[colliderEntries].values();
193
+ }
194
+ export class Collider2DComponent extends ColliderComponent<ColliderShape2D> {
195
+ readonly type = 'Collider2D';
196
+ constructor() {
197
+ super(IDENTITY_2D);
198
+ }
199
+ }
200
+ export class Collider3DComponent extends ColliderComponent<ColliderShape3D> {
201
+ readonly type = 'Collider3D';
202
+ constructor() {
203
+ super(IDENTITY_3D);
204
+ }
205
+ }
206
+ export function createCollider2D(): Collider2DComponent {
207
+ return new Collider2DComponent();
208
+ }
209
+ export function createCollider3D(): Collider3DComponent {
210
+ return new Collider3DComponent();
211
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ Collider2DComponent,
3
+ Collider3DComponent,
4
+ createCollider2D,
5
+ createCollider3D,
6
+ validatePoint,
7
+ validateRay,
8
+ type ColliderEntry,
9
+ type ColliderHit2D,
10
+ type ColliderHit3D,
11
+ type ColliderOptions,
12
+ type ColliderShape2D,
13
+ type ColliderShape3D
14
+ } from './components';
15
+ export * from './query2d';
16
+ export * from './query3d';
@@ -0,0 +1,167 @@
1
+ import type { EcsWorld } from '../ecs/world';
2
+ import { mat2d, type Mat2d, type ReadonlyMat2d, vec2 } from '../math';
3
+ import type { Model2DComponent } from '../realm2d/components';
4
+ import {
5
+ type Collider2DComponent,
6
+ type ColliderHit2D,
7
+ type ColliderShape2D,
8
+ getColliderEntries,
9
+ validatePoint,
10
+ validateRay
11
+ } from './components';
12
+
13
+ type Point = [number, number];
14
+ const EDGE: Point = [0, 0];
15
+ const POINT_A: Point = [0, 0];
16
+ const POINT_B: Point = [0, 0];
17
+ const RECT_VERTICES: Point[] = [
18
+ [0, 0],
19
+ [0, 0],
20
+ [0, 0],
21
+ [0, 0]
22
+ ];
23
+ function vertices(shape: Exclude<ColliderShape2D, { type: 'circle' }>): Point[] {
24
+ if (shape.type === 'polygon') return shape.vertices;
25
+ const [x, y] = shape.halfExtents;
26
+ vec2.set(RECT_VERTICES[0]!, -x, -y);
27
+ vec2.set(RECT_VERTICES[1]!, x, -y);
28
+ vec2.set(RECT_VERTICES[2]!, x, y);
29
+ vec2.set(RECT_VERTICES[3]!, -x, y);
30
+ return RECT_VERTICES;
31
+ }
32
+ /** @internal Tests a point already transformed to collider-local coordinates. */
33
+ export function containsColliderPoint2D(shape: ColliderShape2D, p: Point): boolean {
34
+ if (shape.type === 'circle') return vec2.dot(p, p) <= shape.radius ** 2;
35
+ if (shape.type === 'rectangle')
36
+ return Math.abs(p[0]) <= shape.halfExtents[0] && Math.abs(p[1]) <= shape.halfExtents[1];
37
+ const v = shape.vertices;
38
+ let result = false;
39
+ for (let i = 0; i < v.length; i++) {
40
+ const a = v[i]!;
41
+ const b = v[(i + 1) % v.length]!;
42
+ if (
43
+ Math.abs(vec2.cross(vec2.subtract(EDGE, b, a), vec2.subtract(POINT_A, p, a))) <= 1e-7 &&
44
+ vec2.dot(POINT_A, vec2.subtract(POINT_B, p, b)) <= 1e-7
45
+ )
46
+ return true;
47
+ if (
48
+ a[1] > p[1] !== b[1] > p[1] &&
49
+ p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]
50
+ )
51
+ result = !result;
52
+ }
53
+ return result;
54
+ }
55
+ function intersect(
56
+ shape: ColliderShape2D,
57
+ o: Point,
58
+ d: Point,
59
+ max: number
60
+ ): { distance: number; normal: Point } | undefined {
61
+ if (containsColliderPoint2D(shape, o)) return { distance: 0, normal: [0, 0] };
62
+ if (shape.type === 'circle') {
63
+ const a = vec2.dot(d, d);
64
+ const b = vec2.dot(o, d);
65
+ const disc = b * b - a * (vec2.dot(o, o) - shape.radius ** 2);
66
+ if (a <= 0 || disc < 0) return undefined;
67
+ const distance = (-b - Math.sqrt(disc)) / a;
68
+ if (distance < 0 || distance > max) return undefined;
69
+ const point: Point = [0, 0];
70
+ vec2.add(point, o, vec2.scale(point, d, distance));
71
+ return { distance, normal: vec2.normalize(point, point) };
72
+ }
73
+ const v = vertices(shape);
74
+ let area = 0;
75
+ let hit: { distance: number; normal: Point } | undefined;
76
+ for (let i = 0; i < v.length; i++) area += vec2.cross(v[i]!, v[(i + 1) % v.length]!);
77
+ for (let i = 0; i < v.length; i++) {
78
+ const a = v[i]!;
79
+ const edge = vec2.subtract(EDGE, v[(i + 1) % v.length]!, a);
80
+ const denominator = vec2.cross(d, edge);
81
+ if (Math.abs(denominator) < 1e-10) continue;
82
+ const delta = vec2.subtract(POINT_A, a, o);
83
+ const distance = vec2.cross(delta, edge) / denominator;
84
+ const along = vec2.cross(delta, d) / denominator;
85
+ if (
86
+ distance < 0 ||
87
+ distance > max ||
88
+ along < 0 ||
89
+ along > 1 ||
90
+ (hit && distance >= hit.distance)
91
+ )
92
+ continue;
93
+ const sign = area > 0 ? 1 : -1;
94
+ const normal: Point = [sign * edge[1], -sign * edge[0]];
95
+ hit = { distance, normal: vec2.normalize(normal, normal) };
96
+ }
97
+ return hit;
98
+ }
99
+ function eachCollider(
100
+ world: EcsWorld,
101
+ mask: number,
102
+ visit: (
103
+ entityId: number,
104
+ id: number,
105
+ shape: ColliderShape2D,
106
+ inverse: Mat2d,
107
+ sensor: boolean
108
+ ) => void
109
+ ): void {
110
+ const seen = new Set<number>();
111
+ const combined = mat2d.create();
112
+ const inverse = mat2d.create();
113
+ for (const [entityId, component] of world.components.entries<Collider2DComponent>('Collider2D')) {
114
+ const model = world.getComponent<Model2DComponent>(entityId, 'Model2D');
115
+ if (!model) continue;
116
+ for (const entry of getColliderEntries(component)) {
117
+ if (seen.has(entry.colliderId))
118
+ throw new Error(`Collider ID ${entry.colliderId} is used by more than one entity.`);
119
+ seen.add(entry.colliderId);
120
+ if (!entry.active || (entry.layer & mask) === 0) continue;
121
+ mat2d.multiply(combined, model.worldMatrix, entry.matrix as unknown as ReadonlyMat2d);
122
+ if (mat2d.invert(inverse, combined) && inverse.every(Number.isFinite))
123
+ visit(entityId, entry.colliderId, entry.shape, inverse, entry.sensor);
124
+ }
125
+ }
126
+ }
127
+ export function queryPoint2D(world: EcsWorld, point: Point, mask = 0xffffffff): ColliderHit2D[] {
128
+ validatePoint(point, mask);
129
+ const hits: ColliderHit2D[] = [];
130
+ const p: Point = [0, 0];
131
+ eachCollider(world, mask, (entityId, colliderId, shape, m, sensor) => {
132
+ vec2.transformMat2d(p, point, m);
133
+ if (containsColliderPoint2D(shape, p))
134
+ hits.push({ entityId, colliderId, point: [...point], normal: [0, 0], distance: 0, sensor });
135
+ });
136
+ return hits.sort((a, b) => a.colliderId - b.colliderId);
137
+ }
138
+ export function raycast2D(
139
+ world: EcsWorld,
140
+ origin: Point,
141
+ direction: Point,
142
+ maxDistance: number,
143
+ mask = 0xffffffff
144
+ ): ColliderHit2D[] {
145
+ validateRay(origin, direction, maxDistance, mask, 2);
146
+ const d = vec2.normalize([0, 0], direction);
147
+ const hits: ColliderHit2D[] = [];
148
+ const o: Point = [0, 0];
149
+ const local: Point = [0, 0];
150
+ eachCollider(world, mask, (entityId, colliderId, shape, m, sensor) => {
151
+ vec2.transformMat2d(o, origin, m);
152
+ vec2.transformDirectionMat2d(local, d, m);
153
+ const hit = intersect(shape, o, local, maxDistance);
154
+ if (!hit) return;
155
+ const normal = vec2.transformNormalMat2d([0, 0], hit.normal, m);
156
+ vec2.normalize(normal, normal);
157
+ hits.push({
158
+ entityId,
159
+ colliderId,
160
+ point: vec2.add([0, 0], origin, vec2.scale([0, 0], d, hit.distance)),
161
+ normal,
162
+ distance: hit.distance,
163
+ sensor
164
+ });
165
+ });
166
+ return hits.sort((a, b) => a.distance - b.distance || a.colliderId - b.colliderId);
167
+ }
@@ -0,0 +1,170 @@
1
+ import type { EcsWorld } from '../ecs/world';
2
+ import { mat4, type ReadonlyMat4, type ReadonlyVec3, vec3 } from '../math';
3
+ import type { Model3DComponent } from '../realm3d/components';
4
+ import {
5
+ type Collider3DComponent,
6
+ type ColliderHit3D,
7
+ type ColliderShape3D,
8
+ getColliderEntries,
9
+ validateRay
10
+ } from './components';
11
+
12
+ type Point = [number, number, number];
13
+ export interface LocalRayHit3D {
14
+ distance: number;
15
+ normal: Point;
16
+ uv?: [number, number];
17
+ }
18
+ /** @internal Tests a ray already transformed to collider-local coordinates. */
19
+ export function intersectCollider3D(
20
+ shape: ColliderShape3D,
21
+ o: ReadonlyVec3,
22
+ d: ReadonlyVec3,
23
+ max: number,
24
+ out: LocalRayHit3D = { distance: 0, normal: [0, 0, 0] }
25
+ ): LocalRayHit3D | undefined {
26
+ const normal = out.normal;
27
+ normal[0] = 0;
28
+ normal[1] = 0;
29
+ normal[2] = 0;
30
+ if (shape.type === 'sphere') {
31
+ const a = vec3.dot(d, d);
32
+ const b = vec3.dot(o, d);
33
+ const c = vec3.dot(o, o) - shape.radius ** 2;
34
+ if (c <= 0) {
35
+ out.distance = 0;
36
+ return out;
37
+ }
38
+ const discriminant = b * b - a * c;
39
+ if (a <= 0 || discriminant < 0) return undefined;
40
+ const distance = (-b - Math.sqrt(discriminant)) / a;
41
+ if (distance < 0 || distance > max) return undefined;
42
+ vec3.scaleAndAdd(normal, o, d, distance);
43
+ vec3.normalize(normal, normal);
44
+ out.distance = distance;
45
+ return out;
46
+ }
47
+ if (shape.type === 'plane') {
48
+ if (Math.abs(d[2]) < 1e-10) return undefined;
49
+ const distance = -o[2] / d[2];
50
+ vec3.scaleAndAdd(normal, o, d, distance);
51
+ if (
52
+ distance < 0 ||
53
+ distance > max ||
54
+ Math.abs(normal[0]) > shape.halfExtents[0] ||
55
+ Math.abs(normal[1]) > shape.halfExtents[1]
56
+ )
57
+ return undefined;
58
+ const u = (normal[0] + shape.halfExtents[0]) / (2 * shape.halfExtents[0]);
59
+ const v = (shape.halfExtents[1] - normal[1]) / (2 * shape.halfExtents[1]);
60
+ out.uv = [Math.max(0, Math.min(1, u)), Math.max(0, Math.min(1, v))];
61
+ normal[0] = 0;
62
+ normal[1] = 0;
63
+ normal[2] = d[2] < 0 ? 1 : -1;
64
+ out.distance = distance;
65
+ return out;
66
+ }
67
+ const h = shape.halfExtents;
68
+ if (Math.abs(o[0]) <= h[0] && Math.abs(o[1]) <= h[1] && Math.abs(o[2]) <= h[2]) {
69
+ out.distance = 0;
70
+ return out;
71
+ }
72
+ let near = 0;
73
+ let far = max;
74
+ for (let i = 0; i < 3; i++) {
75
+ const v = d[i]!;
76
+ const position = o[i]!;
77
+ const half = h[i]!;
78
+ if (Math.abs(v) < 1e-10) {
79
+ if (Math.abs(position) > half) return undefined;
80
+ continue;
81
+ }
82
+ let a = (-half - position) / v;
83
+ let b = (half - position) / v;
84
+ let sign = -1;
85
+ if (a > b) {
86
+ [a, b] = [b, a];
87
+ sign = 1;
88
+ }
89
+ if (a > near) {
90
+ near = a;
91
+ normal[0] = 0;
92
+ normal[1] = 0;
93
+ normal[2] = 0;
94
+ normal[i] = sign;
95
+ }
96
+ far = Math.min(far, b);
97
+ if (near > far) return undefined;
98
+ }
99
+ out.distance = near;
100
+ return out;
101
+ }
102
+ export function raycast3D(
103
+ world: EcsWorld,
104
+ origin: Point,
105
+ direction: Point,
106
+ maxDistance: number,
107
+ mask = 0xffffffff
108
+ ): ColliderHit3D[] {
109
+ validateRay(origin, direction, maxDistance, mask, 3);
110
+ const d = vec3.normalize([0, 0, 0], direction);
111
+ const hits: ColliderHit3D[] = [];
112
+ const seen = new Set<number>();
113
+ const combined = mat4.create();
114
+ const m = mat4.create();
115
+ const o: Point = [0, 0, 0];
116
+ const local: Point = [0, 0, 0];
117
+ const localCenter: Point = [0, 0, 0];
118
+ const worldCenter: Point = [0, 0, 0];
119
+ const intersection: LocalRayHit3D = { distance: 0, normal: [0, 0, 0] };
120
+ for (const [entityId, component] of world.components.entries<Collider3DComponent>('Collider3D')) {
121
+ const model = world.getComponent<Model3DComponent>(entityId, 'Model3D');
122
+ if (!model) continue;
123
+ for (const entry of getColliderEntries(component)) {
124
+ if (seen.has(entry.colliderId))
125
+ throw new Error(`Collider ID ${entry.colliderId} is used by more than one entity.`);
126
+ seen.add(entry.colliderId);
127
+ if (!entry.active || (entry.layer & mask) === 0) continue;
128
+
129
+ const rLocal = entry.boundingRadius;
130
+ if (rLocal !== undefined && rLocal > 0) {
131
+ localCenter[0] = entry.matrix[12]!;
132
+ localCenter[1] = entry.matrix[13]!;
133
+ localCenter[2] = entry.matrix[14]!;
134
+ vec3.transformMat4(worldCenter, localCenter, model.worldMatrix);
135
+
136
+ const wm = model.worldMatrix;
137
+ const sx = wm[0]! * wm[0]! + wm[1]! * wm[1]! + wm[2]! * wm[2]!;
138
+ const sy = wm[4]! * wm[4]! + wm[5]! * wm[5]! + wm[6]! * wm[6]!;
139
+ const sz = wm[8]! * wm[8]! + wm[9]! * wm[9]! + wm[10]! * wm[10]!;
140
+ const rWorld = rLocal * Math.sqrt(Math.max(sx, sy, sz));
141
+
142
+ const vx = worldCenter[0] - origin[0]!;
143
+ const vy = worldCenter[1] - origin[1]!;
144
+ const vz = worldCenter[2] - origin[2]!;
145
+ const proj = vx * d[0]! + vy * d[1]! + vz * d[2]!;
146
+ if (proj < -rWorld || proj > maxDistance + rWorld) continue;
147
+ const perpSq = vx * vx + vy * vy + vz * vz - proj * proj;
148
+ if (perpSq > rWorld * rWorld) continue;
149
+ }
150
+
151
+ mat4.multiply(combined, model.worldMatrix, entry.matrix as unknown as ReadonlyMat4);
152
+ if (!mat4.invert(m, combined) || !m.every(Number.isFinite)) continue;
153
+ vec3.transformMat4(o, origin, m);
154
+ vec3.transformDirectionMat4(local, d, m);
155
+ const hit = intersectCollider3D(entry.shape, o, local, maxDistance, intersection);
156
+ if (!hit) continue;
157
+ const normal = vec3.transformNormalMat4([0, 0, 0], hit.normal, m);
158
+ vec3.normalize(normal, normal);
159
+ hits.push({
160
+ entityId,
161
+ colliderId: entry.colliderId,
162
+ point: vec3.add([0, 0, 0], origin, vec3.scale([0, 0, 0], d, hit.distance)),
163
+ normal,
164
+ distance: hit.distance,
165
+ sensor: entry.sensor
166
+ });
167
+ }
168
+ }
169
+ return hits.sort((a, b) => a.distance - b.distance || a.colliderId - b.colliderId);
170
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Component contracts and storage registry.
3
+ */
4
+
5
+ import type { EntityId } from './entity';
6
+
7
+ /**
8
+ * Base contract for any component attached to an entity.
9
+ */
10
+ export interface Component {
11
+ readonly type: string;
12
+ }
13
+
14
+ /**
15
+ * High-performance storage managing component instances indexed by entity ID and type.
16
+ */
17
+ export class ComponentStore {
18
+ // Map of componentType -> Map of entityId -> Component
19
+ private readonly _byType = new Map<string, Map<EntityId, Component>>();
20
+ // Map of entityId -> Map of componentType -> Component
21
+ private readonly _byEntity = new Map<EntityId, Map<string, Component>>();
22
+
23
+ /**
24
+ * Attaches or replaces a component for the given entity.
25
+ */
26
+ set(entityId: EntityId, component: Component): void {
27
+ const type = component.type;
28
+
29
+ let typeMap = this._byType.get(type);
30
+ if (!typeMap) {
31
+ typeMap = new Map<EntityId, Component>();
32
+ this._byType.set(type, typeMap);
33
+ }
34
+ typeMap.set(entityId, component);
35
+
36
+ let entityMap = this._byEntity.get(entityId);
37
+ if (!entityMap) {
38
+ entityMap = new Map<string, Component>();
39
+ this._byEntity.set(entityId, entityMap);
40
+ }
41
+ entityMap.set(type, component);
42
+ }
43
+
44
+ /**
45
+ * Retrieves a component for the specified entity by type name.
46
+ */
47
+ get<T extends Component = Component>(entityId: EntityId, componentType: string): T | undefined {
48
+ return this._byType.get(componentType)?.get(entityId) as T | undefined;
49
+ }
50
+
51
+ /**
52
+ * Checks if an entity possesses a component of the specified type.
53
+ */
54
+ has(entityId: EntityId, componentType: string): boolean {
55
+ return this._byType.get(componentType)?.has(entityId) ?? false;
56
+ }
57
+
58
+ /** Iterates existing components without creating an array or copying the index. */
59
+ *entries<T extends Component = Component>(
60
+ componentType: string
61
+ ): IterableIterator<[EntityId, T]> {
62
+ const entries = this._byType.get(componentType);
63
+ if (entries) yield* entries as Map<EntityId, T>;
64
+ }
65
+
66
+ /** Iterates entity IDs without copying the component index. */
67
+ *entityIds(componentType: string): IterableIterator<EntityId> {
68
+ const entries = this._byType.get(componentType);
69
+ if (entries) yield* entries.keys();
70
+ }
71
+
72
+ /**
73
+ * Detaches a component of the specified type from an entity.
74
+ *
75
+ * @returns True if the component existed and was removed, false otherwise.
76
+ */
77
+ delete(entityId: EntityId, componentType: string): boolean {
78
+ const typeMap = this._byType.get(componentType);
79
+ const removedFromType = typeMap ? typeMap.delete(entityId) : false;
80
+
81
+ const entityMap = this._byEntity.get(entityId);
82
+ if (entityMap) {
83
+ entityMap.delete(componentType);
84
+ if (entityMap.size === 0) {
85
+ this._byEntity.delete(entityId);
86
+ }
87
+ }
88
+
89
+ return removedFromType;
90
+ }
91
+
92
+ /**
93
+ * Returns all components attached to the specified entity.
94
+ */
95
+ getAllForEntity(entityId: EntityId): Component[] {
96
+ const entityMap = this._byEntity.get(entityId);
97
+ if (!entityMap) return [];
98
+ return Array.from(entityMap.values());
99
+ }
100
+
101
+ /**
102
+ * Detaches and removes all components for a given entity (called on entity destruction).
103
+ */
104
+ removeAllForEntity(entityId: EntityId): void {
105
+ const entityMap = this._byEntity.get(entityId);
106
+ if (!entityMap) return;
107
+
108
+ for (const type of entityMap.keys()) {
109
+ this._byType.get(type)?.delete(entityId);
110
+ }
111
+ this._byEntity.delete(entityId);
112
+ }
113
+
114
+ /**
115
+ * Returns all EntityIds possessing a component of the specified type.
116
+ */
117
+ getEntitiesWith(componentType: string): Set<EntityId> {
118
+ const typeMap = this._byType.get(componentType);
119
+ if (!typeMap) return new Set();
120
+ return new Set(typeMap.keys());
121
+ }
122
+
123
+ /**
124
+ * Clears all stored components.
125
+ */
126
+ clear(): void {
127
+ this._byType.clear();
128
+ this._byEntity.clear();
129
+ }
130
+ }