@forgeax/engine-physics 0.1.2

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.
@@ -0,0 +1,364 @@
1
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
2
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
3
+ //
4
+ // Source files (N=2):
5
+ // - packages/physics/__tests__/component-schema.test.ts
6
+ // - packages/physics/__tests__/enum-helpers.test.ts
7
+ //
8
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
9
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
10
+ //
11
+ // Note: merged from __tests__/ into src/__tests__/; import paths adjusted (../src/index → ../index).
12
+
13
+ import { World } from '@forgeax/engine-ecs';
14
+ import { componentDefinition, componentSchema } from '@forgeax/engine-ecs/internal';
15
+ import { describe, expect, it } from 'vitest';
16
+ import type { PhysicsErrorCode } from '../index';
17
+ import {
18
+ COLLIDER_SHAPE_CAPSULE,
19
+ COLLIDER_SHAPE_CUBOID,
20
+ COLLIDER_SHAPE_SPHERE,
21
+ Collider,
22
+ ColliderShapeValue,
23
+ CollidingEntities,
24
+ CollisionEvent,
25
+ colliderShapeFromF32,
26
+ PHYSICS_ERROR_HINTS,
27
+ PhysicsError,
28
+ RIGID_BODY_TYPE_DYNAMIC,
29
+ RIGID_BODY_TYPE_KINEMATIC,
30
+ RIGID_BODY_TYPE_STATIC,
31
+ RigidBody,
32
+ RigidBodyTypeValue,
33
+ rigidBodyTypeFromF32,
34
+ } from '../index';
35
+
36
+ {
37
+ // ─── from component-schema.test.ts ───
38
+
39
+ describe('component-schema.test.ts', () => {
40
+ describe('feat-20260528 M1 t7 physics component schema definitions', () => {
41
+ it('RigidBody is a valid Component token with expected fields', () => {
42
+ expect(RigidBody).toBeDefined();
43
+ expect(RigidBody.name).toBe('RigidBody');
44
+ const schema = componentSchema(RigidBody);
45
+ expect(schema).toHaveProperty('type');
46
+ expect(schema).toHaveProperty('mass');
47
+ expect(schema).toHaveProperty('linearDamping');
48
+ expect(schema).toHaveProperty('angularDamping');
49
+ expect(schema).toHaveProperty('gravityScale');
50
+ expect(schema).toHaveProperty('ccdEnabled');
51
+ });
52
+
53
+ it('Collider is a valid Component token with expected fields', () => {
54
+ expect(Collider).toBeDefined();
55
+ expect(Collider.name).toBe('Collider');
56
+ const schema = componentSchema(Collider);
57
+ expect(schema).toHaveProperty('shape');
58
+ // feat-20260709 M4: cuboid half-extents collapsed from 3 per-axis
59
+ // scalar columns into one inline array<f32,3> column (halfExtents).
60
+ // radius / halfHeight stay scalar (OOS-1: independent sphere/capsule
61
+ // params, not part of the cuboid vec).
62
+ expect(schema).toHaveProperty('halfExtents');
63
+ expect(schema.halfExtents).toBe('array<f32, 3>');
64
+ expect('halfExtentsX' in schema).toBe(false);
65
+ expect('halfExtentsY' in schema).toBe(false);
66
+ expect('halfExtentsZ' in schema).toBe(false);
67
+ expect(schema).toHaveProperty('radius');
68
+ expect(schema).toHaveProperty('halfHeight');
69
+ expect(schema).toHaveProperty('friction');
70
+ expect(schema).toHaveProperty('restitution');
71
+ expect(schema).toHaveProperty('density');
72
+ expect(schema).toHaveProperty('isSensor');
73
+ expect(schema).toHaveProperty('collisionGroups');
74
+ expect(schema).toHaveProperty('solverGroups');
75
+ });
76
+
77
+ it('CollidingEntities is a valid Component token with entities field', () => {
78
+ expect(CollidingEntities).toBeDefined();
79
+ expect(CollidingEntities.name).toBe('CollidingEntities');
80
+ const schema = componentSchema(CollidingEntities);
81
+ expect(schema).toHaveProperty('entities');
82
+ });
83
+
84
+ it('CollisionEvent is the expected constant value', () => {
85
+ expect(CollisionEvent).toBe('__CollisionEvent__');
86
+ });
87
+
88
+ it('PhysicsError is importable and constructable', () => {
89
+ const err = new PhysicsError({
90
+ code: 'backend-not-registered',
91
+ expected: 'PhysicsWorld resource to be registered',
92
+ hint: 'use createApp(canvas, { plugins: [physicsPlugin(...)] })',
93
+ detail: {
94
+ code: 'backend-not-registered',
95
+ attemptedBackend: 'rapier-3d',
96
+ },
97
+ });
98
+ expect(err.code).toBe('backend-not-registered');
99
+ expect(err.name).toBe('PhysicsError');
100
+ expect(err.hint).toContain('createApp');
101
+ });
102
+
103
+ it('PhysicsErrorCode literal union has all 8 members', () => {
104
+ const codes: Set<string> = new Set();
105
+ const all: PhysicsErrorCode[] = [
106
+ 'wasm-load-failed',
107
+ 'wasm-simd-unsupported',
108
+ 'step-failed',
109
+ 'invalid-body-config',
110
+ 'body-not-found',
111
+ 'collider-not-found',
112
+ 'backend-not-registered',
113
+ 'teleport-invalid-body-type',
114
+ ];
115
+ for (const c of all) {
116
+ codes.add(c);
117
+ }
118
+ expect(codes.size).toBe(8);
119
+ });
120
+
121
+ it('PHYSICS_ERROR_HINTS has all 8 code entries', () => {
122
+ const allCodes: PhysicsErrorCode[] = [
123
+ 'wasm-load-failed',
124
+ 'wasm-simd-unsupported',
125
+ 'step-failed',
126
+ 'invalid-body-config',
127
+ 'body-not-found',
128
+ 'collider-not-found',
129
+ 'backend-not-registered',
130
+ 'teleport-invalid-body-type',
131
+ ];
132
+ for (const code of allCodes) {
133
+ expect(PHYSICS_ERROR_HINTS[code]).toBeDefined();
134
+ expect(PHYSICS_ERROR_HINTS[code].length).toBeGreaterThan(0);
135
+ }
136
+ });
137
+
138
+ it('RigidBodyType literal union has 3 discriminants', () => {
139
+ const types = ['static', 'dynamic', 'kinematic'] as const;
140
+ expect(new Set(types).size).toBe(3);
141
+ });
142
+
143
+ it('ColliderShape literal union has 3 discriminants', () => {
144
+ const shapes = ['cuboid', 'sphere', 'capsule'] as const;
145
+ expect(new Set(shapes).size).toBe(3);
146
+ });
147
+
148
+ // solo round-24 (P7 residue): the enum fields project their label→value
149
+ // map through FieldReflection, sourced from the SAME `*Value` const map
150
+ // (Derive, don't Duplicate). This is what the editor's describeComponent
151
+ // surfaces so a docs-only AI learns static=0/dynamic=1/kinematic=2 without
152
+ // reading engine source. Revert-to-red: drop `labels:` from the descriptor.
153
+ it('RigidBody.type reflects labels === RigidBodyTypeValue', () => {
154
+ const reflection = RigidBody.fields.type as { readonly labels?: Record<string, number> };
155
+ expect(reflection.labels).toEqual({ ...RigidBodyTypeValue });
156
+ expect(reflection.labels).toEqual({ static: 0, dynamic: 1, kinematic: 2 });
157
+ });
158
+
159
+ it('Collider.shape reflects labels === ColliderShapeValue', () => {
160
+ const reflection = Collider.fields.shape as { readonly labels?: Record<string, number> };
161
+ expect(reflection.labels).toEqual({ ...ColliderShapeValue });
162
+ expect(reflection.labels).toEqual({ cuboid: 0, sphere: 1, capsule: 2 });
163
+ });
164
+
165
+ it('a non-enum field (RigidBody.mass) reflects no labels (control)', () => {
166
+ const reflection = RigidBody.fields.mass as { readonly labels?: Record<string, number> };
167
+ expect(reflection.labels).toBeUndefined();
168
+ });
169
+ });
170
+ });
171
+ }
172
+
173
+ {
174
+ // ─── from enum-helpers.test.ts ───
175
+
176
+ describe('enum-helpers.test.ts', () => {
177
+ describe('rigidBodyTypeFromF32', () => {
178
+ it('0 maps to static', () => {
179
+ expect(rigidBodyTypeFromF32(0)).toBe('static');
180
+ });
181
+
182
+ it('1 maps to dynamic', () => {
183
+ expect(rigidBodyTypeFromF32(1)).toBe('dynamic');
184
+ });
185
+
186
+ it('2 maps to kinematic', () => {
187
+ expect(rigidBodyTypeFromF32(2)).toBe('kinematic');
188
+ });
189
+
190
+ it('out-of-range values fall back to static', () => {
191
+ expect(rigidBodyTypeFromF32(-1)).toBe('static');
192
+ expect(rigidBodyTypeFromF32(3)).toBe('static');
193
+ expect(rigidBodyTypeFromF32(99)).toBe('static');
194
+ });
195
+
196
+ it('fractional values fall back to static', () => {
197
+ expect(rigidBodyTypeFromF32(0.5)).toBe('static');
198
+ expect(rigidBodyTypeFromF32(1.5)).toBe('static');
199
+ expect(rigidBodyTypeFromF32(2.9)).toBe('static');
200
+ });
201
+ });
202
+
203
+ describe('colliderShapeFromF32', () => {
204
+ it('0 maps to cuboid', () => {
205
+ expect(colliderShapeFromF32(0)).toBe('cuboid');
206
+ });
207
+
208
+ it('1 maps to sphere', () => {
209
+ expect(colliderShapeFromF32(1)).toBe('sphere');
210
+ });
211
+
212
+ it('2 maps to capsule', () => {
213
+ expect(colliderShapeFromF32(2)).toBe('capsule');
214
+ });
215
+
216
+ it('out-of-range values fall back to cuboid', () => {
217
+ expect(colliderShapeFromF32(-1)).toBe('cuboid');
218
+ expect(colliderShapeFromF32(3)).toBe('cuboid');
219
+ expect(colliderShapeFromF32(99)).toBe('cuboid');
220
+ });
221
+
222
+ it('fractional values fall back to cuboid', () => {
223
+ expect(colliderShapeFromF32(0.5)).toBe('cuboid');
224
+ expect(colliderShapeFromF32(1.5)).toBe('cuboid');
225
+ expect(colliderShapeFromF32(2.9)).toBe('cuboid');
226
+ });
227
+ });
228
+
229
+ describe('numeric enum constants', () => {
230
+ it('RigidBodyTypeValue has expected numeric values', () => {
231
+ expect(RigidBodyTypeValue.static).toBe(0);
232
+ expect(RigidBodyTypeValue.dynamic).toBe(1);
233
+ expect(RigidBodyTypeValue.kinematic).toBe(2);
234
+ });
235
+
236
+ it('ColliderShapeValue has expected numeric values', () => {
237
+ expect(ColliderShapeValue.cuboid).toBe(0);
238
+ expect(ColliderShapeValue.sphere).toBe(1);
239
+ expect(ColliderShapeValue.capsule).toBe(2);
240
+ });
241
+
242
+ it('individual RigidBody constants match RigidBodyTypeValue', () => {
243
+ expect(RIGID_BODY_TYPE_STATIC).toBe(0);
244
+ expect(RIGID_BODY_TYPE_DYNAMIC).toBe(1);
245
+ expect(RIGID_BODY_TYPE_KINEMATIC).toBe(2);
246
+ });
247
+
248
+ it('individual ColliderShape constants match ColliderShapeValue', () => {
249
+ expect(COLLIDER_SHAPE_CUBOID).toBe(0);
250
+ expect(COLLIDER_SHAPE_SPHERE).toBe(1);
251
+ expect(COLLIDER_SHAPE_CAPSULE).toBe(2);
252
+ });
253
+ });
254
+
255
+ describe('layer-2 defaults (m3-1e regression)', () => {
256
+ it('RigidBody defaults include gravityScale=1', () => {
257
+ expect(componentSchema(RigidBody)).toHaveProperty('gravityScale');
258
+ const defaults = componentDefinition(RigidBody).defaults;
259
+ expect(defaults).toBeDefined();
260
+ if (defaults) {
261
+ expect(defaults.gravityScale).toBe(1);
262
+ }
263
+ });
264
+
265
+ it('RigidBody defaults include mass=1', () => {
266
+ const defaults = componentDefinition(RigidBody).defaults;
267
+ expect(defaults).toBeDefined();
268
+ if (defaults) {
269
+ expect(defaults.mass).toBe(1);
270
+ }
271
+ });
272
+
273
+ it('RigidBody defaults include type=dynamic (1)', () => {
274
+ const defaults = componentDefinition(RigidBody).defaults;
275
+ expect(defaults).toBeDefined();
276
+ if (defaults) {
277
+ expect(defaults.type).toBe(1);
278
+ }
279
+ });
280
+
281
+ it('Collider defaults include density=1', () => {
282
+ expect(componentSchema(Collider)).toHaveProperty('density');
283
+ const defaults = componentDefinition(Collider).defaults;
284
+ expect(defaults).toBeDefined();
285
+ if (defaults) {
286
+ expect(defaults.density).toBe(1);
287
+ }
288
+ });
289
+
290
+ it('Collider defaults include friction=0.5', () => {
291
+ const defaults = componentDefinition(Collider).defaults;
292
+ expect(defaults).toBeDefined();
293
+ if (defaults) {
294
+ expect(defaults.friction).toBe(0.5);
295
+ }
296
+ });
297
+
298
+ it('Collider defaults include restitution=0', () => {
299
+ const defaults = componentDefinition(Collider).defaults;
300
+ expect(defaults).toBeDefined();
301
+ if (defaults) {
302
+ expect(defaults.restitution).toBe(0);
303
+ }
304
+ });
305
+ });
306
+ });
307
+ }
308
+
309
+ {
310
+ // ─── feat-20260709 M4 / w17: Collider halfExtents vec-collapse ───
311
+ //
312
+ // AC-01 / E1 / AC-04: halfExtents is array<f32,3> with explicit layer-2
313
+ // default [0.5,0.5,0.5] (the array layer-3 fallback is all-zero, so the
314
+ // non-zero default MUST be declared explicitly). Spawn-omit resolves to the
315
+ // same value the old per-axis scalar defaults produced. radius/halfHeight
316
+ // stay scalar (OOS-1). TDD red until w19 lands the schema collapse.
317
+
318
+ describe('collider-halfextents-vec.test.ts', () => {
319
+ it('Collider.halfExtents is array<f32,3> with explicit layer-2 default [0.5,0.5,0.5]', () => {
320
+ expect(componentSchema(Collider).halfExtents).toBe('array<f32, 3>');
321
+ expect(Array.from(Collider.fields.halfExtents.default as Float32Array)).toEqual([
322
+ 0.5, 0.5, 0.5,
323
+ ]);
324
+ });
325
+
326
+ it('Collider per-axis scalar keys are gone; radius/halfHeight stay scalar (OOS-1)', () => {
327
+ expect('halfExtentsX' in componentSchema(Collider)).toBe(false);
328
+ expect('halfExtentsY' in componentSchema(Collider)).toBe(false);
329
+ expect('halfExtentsZ' in componentSchema(Collider)).toBe(false);
330
+ expect(componentSchema(Collider).radius).toBe('f32');
331
+ expect(componentSchema(Collider).halfHeight).toBe('f32');
332
+ });
333
+
334
+ it('E1: Collider spawned with halfExtents omitted resolves to [0.5,0.5,0.5]', () => {
335
+ const world = new World();
336
+ const e = world
337
+ .spawn({ component: Collider, data: { shape: ColliderShapeValue.cuboid } })
338
+ .unwrap();
339
+ const row = world.get(e, Collider).unwrap();
340
+ expect(Array.from(row.halfExtents)).toEqual([0.5, 0.5, 0.5]);
341
+ });
342
+
343
+ it('E1: Collider spawned with explicit halfExtents carries the array through', () => {
344
+ const world = new World();
345
+ const e = world
346
+ .spawn({
347
+ component: Collider,
348
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 2, 3] },
349
+ })
350
+ .unwrap();
351
+ const row = world.get(e, Collider).unwrap();
352
+ expect(Array.from(row.halfExtents)).toEqual([1, 2, 3]);
353
+ });
354
+
355
+ it('AC-04: residual halfExtentsX at a spawn call-site is a compile error', () => {
356
+ const world = new World();
357
+ world.spawn({
358
+ component: Collider,
359
+ // @ts-expect-error halfExtentsX/Y/Z were collapsed into the halfExtents array.
360
+ data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1], halfExtentsX: 1 },
361
+ });
362
+ });
363
+ });
364
+ }
@@ -0,0 +1,35 @@
1
+ // @forgeax/engine-physics — CollisionEvent ECS Event token placeholder.
2
+ //
3
+ // Emitted by physics tick systems during the Writeback phase.
4
+ // Two states only: 'started' (new contact) and 'stopped' (separated).
5
+ // No 'continued' event — use CollidingEntities component for ongoing contacts
6
+ // (plan-strategy D-3).
7
+
8
+ import type { Vec3 } from '@forgeax/engine-math';
9
+
10
+ /**
11
+ * Collision event payload — per-contact-pair event emitted during Writeback.
12
+ *
13
+ * `type: 'started'` — two colliders just began touching.
14
+ * `type: 'stopped'` — two colliders just separated.
15
+ */
16
+ export interface CollisionEventPayload {
17
+ type: 'started' | 'stopped';
18
+ entityA: number;
19
+ entityB: number;
20
+ contactPoint: Vec3;
21
+ contactNormal: Vec3;
22
+ }
23
+
24
+ /**
25
+ * CollisionEvent constant — identifies the collision event type.
26
+ * Backend systems push CollisionEventPayload instances into the event queue
27
+ * during the Writeback phase; user systems drain via query.
28
+ *
29
+ * The backing ECS event infrastructure (Event<T> generic + world.drainEvent)
30
+ * is deferred to a future feat. For M1, this is a type-only contract.
31
+ */
32
+ export const CollisionEvent = '__CollisionEvent__' as const;
33
+
34
+ /** Type-level identifier for the CollisionEvent event channel. */
35
+ export type CollisionEvent = typeof CollisionEvent;
@@ -0,0 +1,225 @@
1
+ // @forgeax/engine-physics — ECS Component schemas.
2
+ //
3
+ // RigidBody and Collider are the two user-facing entry points; AI users
4
+ // spawn entities with these components to opt into physics simulation.
5
+ // CollidingEntities is the runtime set-query component for continuous
6
+ // collision status (started/stopped model, no 'continued' event).
7
+
8
+ import { type Component, defineComponent, type World } from '@forgeax/engine-ecs';
9
+
10
+ /**
11
+ * RigidBody motion type — 3-state discriminant mirroring Rapier's
12
+ * Dynamic / Fixed / KinematicPositionBased triplet.
13
+ *
14
+ * `'static'`: infinite mass, never moves (Rapier Fixed).
15
+ * `'dynamic'`: driven by forces, gravity, collisions (Rapier Dynamic).
16
+ * `'kinematic'`: user-controlled position, velocity derived by engine
17
+ * (Rapier KinematicPositionBased).
18
+ */
19
+ export type RigidBodyType = 'static' | 'dynamic' | 'kinematic';
20
+
21
+ /**
22
+ * Collider shape discriminant — 3 AI-friendly shape names.
23
+ *
24
+ * `'cuboid'`: box shape defined by half-extents (x, y, z).
25
+ * `'sphere'`: sphere defined by radius.
26
+ * `'capsule'`: capsule defined by half-height + radius.
27
+ *
28
+ * Named `'sphere'` not `'ball'` per plan-strategy D-5: AI users see
29
+ * the familiar geometric term; backend maps to Rapier `ColliderDesc.ball()`.
30
+ */
31
+ export type ColliderShape = 'cuboid' | 'sphere' | 'capsule';
32
+
33
+ // ─── D-3: numeric enum constants + narrowing helpers ─────────────────────
34
+ //
35
+ // Aligned with `packages/runtime/src/components/camera.ts:41-53`
36
+ // `cameraProjectionFromF32` pattern. The ECS `enum` field maps to `number`
37
+ // (Uint32Array column); these constants let AI users write
38
+ // `{ type: RigidBodyTypeValue.dynamic }` instead of bare magic numbers,
39
+ // and the narrowing helpers let backends switch cleanly on the string union.
40
+ //
41
+ // Declared BEFORE the RigidBody / Collider components so each component's enum
42
+ // field descriptor can reference the SAME `*Value` map as its `labels`
43
+ // (Derive, don't Duplicate — one object is both the AI-facing const AND the
44
+ // schema-projected label map that `describeComponent` surfaces).
45
+
46
+ /** Numeric value for static rigid body (Rapier Fixed). */
47
+ export const RIGID_BODY_TYPE_STATIC = 0;
48
+ /** Numeric value for dynamic rigid body (Rapier Dynamic). */
49
+ export const RIGID_BODY_TYPE_DYNAMIC = 1;
50
+ /** Numeric value for kinematic rigid body (Rapier KinematicPositionBased). */
51
+ export const RIGID_BODY_TYPE_KINEMATIC = 2;
52
+
53
+ export const RigidBodyTypeValue = {
54
+ static: RIGID_BODY_TYPE_STATIC,
55
+ dynamic: RIGID_BODY_TYPE_DYNAMIC,
56
+ kinematic: RIGID_BODY_TYPE_KINEMATIC,
57
+ } as const;
58
+
59
+ export function rigidBodyTypeFromF32(n: number): RigidBodyType {
60
+ if (n === RIGID_BODY_TYPE_DYNAMIC) return 'dynamic';
61
+ if (n === RIGID_BODY_TYPE_KINEMATIC) return 'kinematic';
62
+ return 'static';
63
+ }
64
+
65
+ /** Numeric value for cuboid collider shape. */
66
+ export const COLLIDER_SHAPE_CUBOID = 0;
67
+ /** Numeric value for sphere collider shape. */
68
+ export const COLLIDER_SHAPE_SPHERE = 1;
69
+ /** Numeric value for capsule collider shape. */
70
+ export const COLLIDER_SHAPE_CAPSULE = 2;
71
+
72
+ export const ColliderShapeValue = {
73
+ cuboid: COLLIDER_SHAPE_CUBOID,
74
+ sphere: COLLIDER_SHAPE_SPHERE,
75
+ capsule: COLLIDER_SHAPE_CAPSULE,
76
+ } as const;
77
+
78
+ export function colliderShapeFromF32(n: number): ColliderShape {
79
+ if (n === COLLIDER_SHAPE_SPHERE) return 'sphere';
80
+ if (n === COLLIDER_SHAPE_CAPSULE) return 'capsule';
81
+ return 'cuboid';
82
+ }
83
+
84
+ /**
85
+ * ECS Component: rigid body physics properties.
86
+ *
87
+ * AI user entry point — spawn with `world.spawn(RigidBody({ type: 'dynamic' }))`
88
+ * to opt an entity into physics simulation.
89
+ *
90
+ * | Field | Type | Default | Purpose |
91
+ * |:--|:--|:--|:--|
92
+ * | `type` | `RigidBodyType` | `'dynamic'` | Motion type discriminant |
93
+ * | `mass` | `number` | `1.0` | Linear mass (> 0 for dynamic) |
94
+ * | `linearDamping` | `number` | `0.0` | Velocity damping factor [0, 1] |
95
+ * | `angularDamping` | `number` | `0.0` | Angular velocity damping [0, 1] |
96
+ * | `gravityScale` | `number` | `1.0` | Per-body gravity multiplier |
97
+ * | `ccdEnabled` | `boolean` | `false` | Continuous collision detection |
98
+ *
99
+ * `type` declares `labels: RigidBodyTypeValue` so `describeComponent` projects
100
+ * the `static=0 / dynamic=1 / kinematic=2` map through the front door — an AI
101
+ * learns the legal variants from the schema, not from engine source.
102
+ */
103
+ export const RigidBody = defineComponent('RigidBody', {
104
+ type: { type: 'enum', default: RIGID_BODY_TYPE_DYNAMIC, labels: RigidBodyTypeValue },
105
+ mass: { type: 'f32', default: 1 },
106
+ linearDamping: { type: 'f32', default: 0 },
107
+ angularDamping: { type: 'f32', default: 0 },
108
+ gravityScale: { type: 'f32', default: 1 },
109
+ ccdEnabled: { type: 'bool', default: false },
110
+ });
111
+
112
+ /**
113
+ * ECS Component: collision geometry.
114
+ *
115
+ * Spawn alongside RigidBody to give an entity a collision shape. Entities
116
+ * with Collider but no RigidBody are treated as static colliders (Rapier
117
+ * native behavior — collider without parent body is fixed): `physicsSyncBackend`
118
+ * synthesizes an implicit static body for them, so a bare-Collider floor/wall is
119
+ * simulated as immovable level geometry — the natural way to author static
120
+ * scenery without a redundant `RigidBody{static}`.
121
+ *
122
+ * | Field | Type | Default | Purpose |
123
+ * |:--|:--|:--|:--|
124
+ * | `shape` | `ColliderShape` | — | Shape discriminant |
125
+ * | `halfExtents` | `[number, number, number]` | `[0.5, 0.5, 0.5]` | Cuboid half-width/height/depth |
126
+ * | `radius` | `number` | `0.5` | Sphere / capsule radius |
127
+ * | `halfHeight` | `number` | `0.5` | Capsule half-height |
128
+ * | `friction` | `number` | `0.5` | Coulomb friction coefficient |
129
+ * | `restitution` | `number` | `0.0` | Elasticity (1.0 = perfect bounce) |
130
+ * | `density` | `number` | `1.0` | Mass density (alternative to mass) |
131
+ * | `isSensor` | `bool` | `false` | Sensor mode (detect, no physical response) |
132
+ * | `collisionGroups` | `u32` | `0x0001_FFFF` | 32-bit packed membership/filter |
133
+ * | `solverGroups` | `u32` | `0xFFFF_FFFF` | 32-bit packed constraint groups |
134
+ */
135
+ export const Collider = defineComponent('Collider', {
136
+ shape: { type: 'enum', default: COLLIDER_SHAPE_CUBOID, labels: ColliderShapeValue },
137
+ // feat-20260709 M4: cuboid half-extents collapsed from 3 per-axis scalar
138
+ // columns into one inline array<f32,3> column. Explicit layer-2 default
139
+ // (the array layer-3 fallback is all-zero, which would give a degenerate
140
+ // zero-size box). radius/halfHeight stay scalar (OOS-1: independent
141
+ // sphere/capsule params, not part of the cuboid vec).
142
+ halfExtents: { type: 'array<f32, 3>', default: new Float32Array([0.5, 0.5, 0.5]) },
143
+ radius: { type: 'f32', default: 0.5 },
144
+ halfHeight: { type: 'f32', default: 0.5 },
145
+ friction: { type: 'f32', default: 0.5 },
146
+ restitution: { type: 'f32', default: 0 },
147
+ density: { type: 'f32', default: 1 },
148
+ isSensor: { type: 'bool', default: false },
149
+ collisionGroups: { type: 'u32', default: 0x0001_ffff },
150
+ solverGroups: { type: 'u32', default: 0xffff_ffff },
151
+ });
152
+
153
+ /**
154
+ * ECS Component: kinematic character controller tuning + output state.
155
+ *
156
+ * Spawn alongside a `RigidBody({ type: 'kinematic' })` + `Collider` to opt an
157
+ * entity into collision-aware movement via `PhysicsWorld.moveAndSlide`. The
158
+ * tuning fields are stable character properties; `grounded` is written back by
159
+ * the engine after each `moveAndSlide` (game code reads it, never writes it).
160
+ *
161
+ * All fields are flat scalars in engine units (degrees, world-space distance) —
162
+ * no Rapier types leak through. Slope angles use the `Deg` suffix to make the
163
+ * unit explicit; the backend translates degrees to radians. A single field
164
+ * carries both the on/off switch and the value: `autoStepMaxHeight === 0`
165
+ * disables auto-step, `snapToGroundDist === 0` disables snap-to-ground.
166
+ *
167
+ * 2D and 3D reuse this same component (plan-strategy D-9): every field is a
168
+ * dimension-agnostic scalar, so no separate 2D component is needed.
169
+ *
170
+ * | Field | Type | Default | Purpose |
171
+ * |:--|:--|:--|:--|
172
+ * | `offset` | `f32` | `0.01` | Skin thickness, prevents penetration |
173
+ * | `maxSlopeClimbDeg` | `f32` | `45` | Max climbable slope angle (degrees) |
174
+ * | `minSlopeSlideDeg` | `f32` | `30` | Slope angle past which sliding starts (degrees) |
175
+ * | `autoStepMaxHeight` | `f32` | `0.3` | Max auto-step height (0 = off) |
176
+ * | `autoStepMinWidth` | `f32` | `0.2` | Min step width to be steppable |
177
+ * | `snapToGroundDist` | `f32` | `0.2` | Downhill ground-snap distance (0 = off) |
178
+ * | `grounded` | `bool` | `false` | Engine-written: grounded after last move |
179
+ */
180
+ export const CharacterController = defineComponent('CharacterController', {
181
+ offset: { type: 'f32', default: 0.01 },
182
+ maxSlopeClimbDeg: { type: 'f32', default: 45 },
183
+ minSlopeSlideDeg: { type: 'f32', default: 30 },
184
+ autoStepMaxHeight: { type: 'f32', default: 0.3 },
185
+ autoStepMinWidth: { type: 'f32', default: 0.2 },
186
+ snapToGroundDist: { type: 'f32', default: 0.2 },
187
+ grounded: { type: 'bool', default: false, transient: true },
188
+ });
189
+
190
+ /**
191
+ * ECS Component: set of entities currently colliding with the holder entity.
192
+ *
193
+ * Maintained by the physics tick systems — entities are added on collision
194
+ * start (`CollisionEvent.started`) and removed on collision stop
195
+ * (`CollisionEvent.stopped`). AI users query this component to know whose
196
+ * colliders overlap right now without consuming per-frame events.
197
+ *
198
+ * This is the `'continued'` equivalent — no repeated per-frame events,
199
+ * one component query per frame exposes the full active contact set
200
+ * (plan-strategy D-3: CollidingEntities set-query mode).
201
+ */
202
+ export const CollidingEntities = defineComponent(
203
+ 'CollidingEntities',
204
+ {
205
+ entities: { type: 'array<entity>' },
206
+ },
207
+ { transient: true },
208
+ );
209
+
210
+ const PHYSICS_COMPONENTS: readonly Component[] = [
211
+ CharacterController,
212
+ Collider,
213
+ CollidingEntities,
214
+ RigidBody,
215
+ ];
216
+
217
+ /** Install the physics component vocabulary in a World and release its leases on teardown. */
218
+ export function registerPhysicsComponents(world: World): () => void {
219
+ const leases = PHYSICS_COMPONENTS.map((component) =>
220
+ world.components.register(component).unwrap(),
221
+ );
222
+ return () => {
223
+ for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
224
+ };
225
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @forgeax/engine-physics — PhysicsError wrapper + re-exports from engine-types.
2
+ //
3
+ // PhysicsError, PhysicsErrorCode, PhysicsErrorDetail, and PHYSICS_ERROR_HINTS
4
+ // are registered in @forgeax/engine-types (SSOT, parallel to AssetError /
5
+ // AudioError / GltfError). This module re-exports them under the physics
6
+ // package namespace so AI users import from `@forgeax/engine-physics` without
7
+ // tracing to engine-types.
8
+
9
+ export type { PhysicsErrorCode, PhysicsErrorDetail } from '@forgeax/engine-types';
10
+ export { PHYSICS_ERROR_HINTS, PhysicsError } from '@forgeax/engine-types';
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ // @forgeax/engine-physics — physics interface package barrel.
2
+ //
3
+ // ECS component schemas (RigidBody / Collider / CollisionEvent),
4
+ // PhysicsWorld Resource interface, PhysicsErrorCode union.
5
+ //
6
+ // Dependencies: @forgeax/engine-ecs (Component / event token),
7
+ // @forgeax/engine-math (Vec2 / Vec3 / Quat),
8
+ // @forgeax/engine-types (type utilities).
9
+
10
+ export type { CollisionEventPayload } from './collision-event';
11
+ export { CollisionEvent } from './collision-event';
12
+ export type { ColliderShape, RigidBodyType } from './components';
13
+ export {
14
+ CharacterController,
15
+ COLLIDER_SHAPE_CAPSULE,
16
+ COLLIDER_SHAPE_CUBOID,
17
+ COLLIDER_SHAPE_SPHERE,
18
+ Collider,
19
+ ColliderShapeValue,
20
+ CollidingEntities,
21
+ colliderShapeFromF32,
22
+ RIGID_BODY_TYPE_DYNAMIC,
23
+ RIGID_BODY_TYPE_KINEMATIC,
24
+ RIGID_BODY_TYPE_STATIC,
25
+ RigidBody,
26
+ RigidBodyTypeValue,
27
+ registerPhysicsComponents,
28
+ rigidBodyTypeFromF32,
29
+ } from './components';
30
+ export type { PhysicsErrorCode, PhysicsErrorDetail } from './errors';
31
+
32
+ export { PHYSICS_ERROR_HINTS, PhysicsError } from './errors';
33
+ export type { PhysicsWorld, PhysicsWorld2D, RaycastHit, RaycastHit2D } from './physics-world';
34
+ export type { PhysicsBackend } from './plugin-factory';
35
+ export { physicsPlugin } from './plugin-factory';
36
+ export { PhysicsSet } from './system-set';
@@ -0,0 +1,2 @@
1
+ export function loadRapier3DBackend(): Promise<unknown>;
2
+ export function loadRapier2DBackend(): Promise<unknown>;
@@ -0,0 +1,8 @@
1
+ // Keep literal specifiers visible to bundlers without pulling backend declarations into the physics TypeScript graph.
2
+ export function loadRapier3DBackend() {
3
+ return import('@forgeax/engine-physics-rapier3d');
4
+ }
5
+
6
+ export function loadRapier2DBackend() {
7
+ return import('@forgeax/engine-physics-rapier2d');
8
+ }