@ignifx/physics-2d 0.10.0 → 0.11.0

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/index.js CHANGED
@@ -80,6 +80,15 @@ var Rigidbody2D = class Rigidbody2D extends Component {
80
80
  this.rebuild();
81
81
  }
82
82
  /**
83
+ * Rebuilds the body after a tool changed fields from outside: an editor's inspector, or a hot
84
+ * reload's new default (`ComponentHooks.onValidate`).
85
+ *
86
+ * @param fields - The fields that changed.
87
+ */
88
+ onValidate(fields) {
89
+ this.rebuild();
90
+ }
91
+ /**
83
92
  * Rebuilds the body at the start of the next fixed step. Call it after changing `bodyType`,
84
93
  * `mass`, `freezeRotation`, damping, or the entity's scale.
85
94
  */
@@ -276,6 +285,15 @@ var CharacterController2D = class CharacterController2D extends Component {
276
285
  onDetach() {
277
286
  this.#runtime()?.releaseController(this);
278
287
  }
288
+ /**
289
+ * Rebuilds the body after a tool changed fields from outside: an editor's inspector, or a hot
290
+ * reload's new default (`ComponentHooks.onValidate`).
291
+ *
292
+ * @param fields - The fields that changed.
293
+ */
294
+ onValidate(fields) {
295
+ this.rebuild();
296
+ }
279
297
  /** Rebuilds the capsule or box at the start of the next fixed step. */
280
298
  rebuild() {
281
299
  this.#runtime()?.markControllerDirty(this);
@@ -704,6 +722,15 @@ var Collider2D = class extends Component {
704
722
  this.rebuild();
705
723
  }
706
724
  /**
725
+ * Rebuilds the body after a tool changed fields from outside: an editor's inspector, or a hot
726
+ * reload's new default (`ComponentHooks.onValidate`).
727
+ *
728
+ * @param fields - The fields that changed.
729
+ */
730
+ onValidate(fields) {
731
+ this.rebuild();
732
+ }
733
+ /**
707
734
  * Rebuilds the entity's body and shapes at the start of the next fixed step. Call it after
708
735
  * changing a size, an offset, `isTrigger`, or the entity's scale.
709
736
  *
@@ -1102,8 +1129,9 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1102
1129
  * `Tilemap` on the same entity.
1103
1130
  *
1104
1131
  * @remarks
1105
- * Reading it works in an edit-mode app too, where no body is built (ADR-0028), so an editor can
1106
- * draw the outlines.
1132
+ * Reading it works in any app, before any body is built. An edit-mode app also builds the body
1133
+ * without stepping (ADR-0028, amendment of 2026-10-20), so `app.physics2d.colliderOutlines` draws
1134
+ * the same outlines from Rapier.
1107
1135
  *
1108
1136
  * @returns The data, or `null` when nothing was assigned and the entity has no `Tilemap`.
1109
1137
  */
@@ -1888,6 +1916,21 @@ function toRadians(degrees) {
1888
1916
  return degrees * TAU / 360;
1889
1917
  }
1890
1918
  /**
1919
+ * The outline Rapier's debug renderer draws for some colliders, as world-space line segments
1920
+ * (`pipeline/world.d.ts`, `debugRender`; `DebugRenderBuffers.vertices`: four numbers a segment, the
1921
+ * `x, y` of its two ends). It reads where the colliders were after the last step, or after the
1922
+ * zero-length step an edit-mode app runs instead.
1923
+ *
1924
+ * @param world - The world.
1925
+ * @param handles - The handles of the colliders to draw.
1926
+ * @returns The segments; a copy, because Rapier reuses its buffer.
1927
+ *
1928
+ * @internal
1929
+ */
1930
+ function colliderLines(world, handles) {
1931
+ return world.debugRender(void 0, (collider) => handles.has(collider.handle)).vertices.slice();
1932
+ }
1933
+ /**
1891
1934
  * Turns Rapier's radians into the degrees `Transform.rotation2D` uses.
1892
1935
  *
1893
1936
  * @param radians - The angle in radians.
@@ -2163,6 +2206,12 @@ var Physics2DRuntime = class {
2163
2206
  #filterFeet = 0;
2164
2207
  #colliderSetChanged = true;
2165
2208
  #stepped = false;
2209
+ /** Whether the app is an editor's scene view, where bodies exist and nothing steps (ADR-0028). */
2210
+ #isEditMode;
2211
+ /** The last failure reported for each entity whose body could not be built, in edit mode. */
2212
+ #failures = /* @__PURE__ */ new WeakMap();
2213
+ /** The entities `IGX-1157` (a body on a child entity) has been logged for, once each. */
2214
+ #warnedChildren = /* @__PURE__ */ new WeakSet();
2166
2215
  #disposed = false;
2167
2216
  #stepsThisFrame = 0;
2168
2217
  #stepMs = 0;
@@ -2180,6 +2229,7 @@ var Physics2DRuntime = class {
2180
2229
  this.#settings = options.settings;
2181
2230
  this.#world = options.world;
2182
2231
  this.#counters = options.counters;
2232
+ this.#isEditMode = options.ctx.app.isEditMode;
2183
2233
  this.#queue = createEventQueue();
2184
2234
  this.#contacts.push(this.#contactPoint);
2185
2235
  setGravity(this.#world, this.#settings.gravity);
@@ -2204,6 +2254,15 @@ var Physics2DRuntime = class {
2204
2254
  return this.#stepped;
2205
2255
  }
2206
2256
  /**
2257
+ * Whether this runtime belongs to an edit-mode app, where bodies are kept in sync with the world
2258
+ * by {@link Physics2DRuntime.syncEditMode} and never stepped (ADR-0028, amendment of 2026-10-20).
2259
+ *
2260
+ * @returns `true` in an editor's scene view.
2261
+ */
2262
+ get isEditMode() {
2263
+ return this.#isEditMode;
2264
+ }
2265
+ /**
2207
2266
  * Every body, in creation order.
2208
2267
  *
2209
2268
  * @returns The records.
@@ -2427,6 +2486,31 @@ var Physics2DRuntime = class {
2427
2486
  this.#dispatchEvents();
2428
2487
  }
2429
2488
  /**
2489
+ * Brings the bodies in line with the world without stepping, which is all an edit-mode app ever
2490
+ * does with them (ADR-0028, amendment of 2026-10-20): it builds, rebuilds, and removes bodies for
2491
+ * component, layer, and tile changes, and places every body whose transform moved where the
2492
+ * transform now is, so a query sees what the editor shows. The world is never stepped, so nothing
2493
+ * falls, no contact or trigger callback fires, and no pose is written back to a transform.
2494
+ *
2495
+ * @remarks
2496
+ * Rapier refreshes its broadphase only inside a step, so after a change this runs the same
2497
+ * zero-length step a game runs before its first real one (S6.2). With a zero timestep nothing
2498
+ * moves; the events it reports are dropped. A changed scale rebuilds the body's colliders.
2499
+ */
2500
+ syncEditMode() {
2501
+ if (this.#disposed) return;
2502
+ this.#syncLayers();
2503
+ this.#pollTilemaps();
2504
+ this.#followTransforms();
2505
+ this.#reconcile();
2506
+ if (this.#colliderSetChanged) {
2507
+ primeWorld(this.#world, this.#queue);
2508
+ this.#colliderSetChanged = false;
2509
+ drainCollisions(this.#queue, ignoreEvent);
2510
+ }
2511
+ this.#publishCounters();
2512
+ }
2513
+ /**
2430
2514
  * Writes the interpolated display pose of every interpolated body.
2431
2515
  *
2432
2516
  * @param alpha - `time.fixedStepAlpha`, in `[0, 1)`.
@@ -2522,6 +2606,34 @@ var Physics2DRuntime = class {
2522
2606
  if (record !== void 0 && record.entity.layer !== record.layer) this.markDirty(record.entity);
2523
2607
  }
2524
2608
  }
2609
+ /**
2610
+ * Places every body whose transform changed since it was placed where the transform now is, for
2611
+ * {@link Physics2DRuntime.syncEditMode}. A body whose lossy scale changed is rebuilt instead.
2612
+ */
2613
+ #followTransforms() {
2614
+ for (let index = 0; index < this.#records.length; index += 1) {
2615
+ const record = this.#records[index];
2616
+ if (record === void 0 || this.#dirty.has(record.entity)) continue;
2617
+ const transform = record.entity.transform;
2618
+ const version = transform.worldMatrixVersion;
2619
+ if (version === record.placedVersion) continue;
2620
+ const scale = transform.lossyScale;
2621
+ if (!sameVector(scale, record.placedScale)) {
2622
+ this.markDirty(record.entity);
2623
+ continue;
2624
+ }
2625
+ transform.positionToRef(this.#scratchPose);
2626
+ const radians = toRadians(transform.rotation2D);
2627
+ this.#scratchVector.set(this.#scratchPose.x, this.#scratchPose.y);
2628
+ setBodyPose(record.body, this.#scratchVector, radians);
2629
+ record.currentPosition.copyFrom(this.#scratchVector);
2630
+ record.previousPosition.copyFrom(this.#scratchVector);
2631
+ record.currentRotation = radians;
2632
+ record.previousRotation = radians;
2633
+ record.placedVersion = version;
2634
+ this.#colliderSetChanged = true;
2635
+ }
2636
+ }
2525
2637
  /** Marks every entity whose tilemap collision data changed since its shapes were built. */
2526
2638
  #pollTilemaps() {
2527
2639
  const colliders = this.#ctx.app.world.components(TilemapCollider2D);
@@ -2535,10 +2647,43 @@ var Physics2DRuntime = class {
2535
2647
  if (this.#dirty.size === 0) return;
2536
2648
  const pending = [...this.#dirty];
2537
2649
  this.#dirty.clear();
2538
- for (const entity of pending) this.#rebuildEntity(entity);
2650
+ for (const entity of pending) this.#guarded(entity, () => {
2651
+ this.#rebuildEntity(entity);
2652
+ });
2539
2653
  this.#colliderSetChanged = true;
2540
2654
  }
2541
2655
  /**
2656
+ * Runs one entity's rebuild. In an edit-mode app a failure (a collider naming an undeclared
2657
+ * layer, say) is reported through `app.onError` once per entity and message and the other
2658
+ * entities still build, so one broken entity never stops the editor's queries; the entity
2659
+ * builds again when its components change. A game rethrows, as before.
2660
+ *
2661
+ * @param entity - The entity being rebuilt.
2662
+ * @param rebuild - The work.
2663
+ */
2664
+ #guarded(entity, rebuild) {
2665
+ if (!this.#isEditMode) {
2666
+ rebuild();
2667
+ return;
2668
+ }
2669
+ try {
2670
+ rebuild();
2671
+ this.#failures.delete(entity);
2672
+ } catch (error) {
2673
+ const message = error instanceof Error ? error.message : String(error);
2674
+ if (this.#failures.get(entity) !== message) {
2675
+ this.#failures.set(entity, message);
2676
+ this.#ctx.app.onError.emit({
2677
+ error,
2678
+ source: "extension",
2679
+ phase: null,
2680
+ entity,
2681
+ component: null
2682
+ });
2683
+ }
2684
+ }
2685
+ }
2686
+ /**
2542
2687
  * Tears an entity's body down and builds a new one from its current components.
2543
2688
  *
2544
2689
  * @param entity - The entity to rebuild.
@@ -2559,7 +2704,10 @@ var Physics2DRuntime = class {
2559
2704
  const controller = entity.getComponent(CharacterController2D);
2560
2705
  const colliders = collidersOf(entity);
2561
2706
  if (colliders.length === 0 && controller === null) return;
2562
- if (entity.parent !== null) this.#ctx.log.warn(`${Physics2DErrorCode.bodyOnChildEntity}: {entity} carries a 2D physics body but is not a root entity; Rapier writes its world pose.`, entity.name);
2707
+ if (entity.parent !== null && !this.#warnedChildren.has(entity)) {
2708
+ this.#warnedChildren.add(entity);
2709
+ this.#ctx.log.warn(`${Physics2DErrorCode.bodyOnChildEntity}: {entity} carries a 2D physics body but is not a root entity; Rapier writes its world pose.`, entity.name);
2710
+ }
2563
2711
  const record = this.#build(entity, entity.getComponent(Rigidbody2D), controller, colliders);
2564
2712
  if (record === null) return;
2565
2713
  this.#records.push(record);
@@ -2617,11 +2765,19 @@ var Physics2DRuntime = class {
2617
2765
  preStepVelocity: new Vec2(),
2618
2766
  staticVersion: transform.worldMatrixVersion,
2619
2767
  reportedMove: false,
2768
+ placedVersion: transform.worldMatrixVersion,
2769
+ placedScale: transform.lossyScale,
2620
2770
  disconnects: []
2621
2771
  };
2622
2772
  this.#byEntity.set(entity, record);
2623
- this.#attachColliders(record, colliders, layer);
2624
- if (controller !== null) this.#attachController(record, controller, layer);
2773
+ try {
2774
+ this.#attachColliders(record, colliders, layer);
2775
+ if (controller !== null) this.#attachController(record, controller, layer);
2776
+ } catch (error) {
2777
+ this.#teardown(record);
2778
+ this.#byEntity.delete(entity);
2779
+ throw error;
2780
+ }
2625
2781
  if (record.colliders.length === 0) {
2626
2782
  this.#byEntity.delete(entity);
2627
2783
  destroyBody(this.#world, body);
@@ -3124,11 +3280,25 @@ var Physics2DRuntime = class {
3124
3280
  * @returns `true` when interpolation applies.
3125
3281
  */
3126
3282
  #shouldInterpolate(rigidbody, controller, motion) {
3127
- if (!this.#settings.interpolation || motion === BodyMotion2D.static) return false;
3283
+ if (this.#isEditMode || !this.#settings.interpolation || motion === BodyMotion2D.static) return false;
3128
3284
  if (controller !== null) return controller.interpolation === "interpolate";
3129
3285
  return rigidbody !== null && rigidbody.interpolation === "interpolate";
3130
3286
  }
3131
3287
  };
3288
+ /** How far apart two scales may be and still count as the same, for edit mode's follow pass. */
3289
+ const SCALE_EPSILON = 1e-6;
3290
+ /**
3291
+ * Whether two vectors match to within {@link SCALE_EPSILON}.
3292
+ *
3293
+ * @param first - One vector.
3294
+ * @param second - The other.
3295
+ * @returns `true` when every component matches.
3296
+ */
3297
+ function sameVector(first, second) {
3298
+ return Math.abs(first.x - second.x) < SCALE_EPSILON && Math.abs(first.y - second.y) < SCALE_EPSILON && Math.abs(first.z - second.z) < SCALE_EPSILON;
3299
+ }
3300
+ /** Drops an event an edit-mode priming step reported; nothing in edit mode delivers one. */
3301
+ function ignoreEvent() {}
3132
3302
  /** How far below a one-way platform's top the character's feet may be and still land on it. */
3133
3303
  const ONE_WAY_TOLERANCE = .02;
3134
3304
  /**
@@ -3325,6 +3495,35 @@ var Physics2DInterpolationSystem = class {
3325
3495
  this.#host.runtime?.interpolate(ctx.time.fixedStepAlpha);
3326
3496
  }
3327
3497
  };
3498
+ /**
3499
+ * Where the edit-mode sync system runs: in `PreRender`, after every script, tween and animation of
3500
+ * the frame has moved what it moves, and before the 2D render sync.
3501
+ */
3502
+ const PHYSICS_2D_EDIT_SYNC_ORDER = -800;
3503
+ /**
3504
+ * Keeps the bodies of an edit-mode app in line with the world, without stepping
3505
+ * (ADR-0028, amendment of 2026-10-20). It is registered only in an edit-mode app, so a game never
3506
+ * pays for it.
3507
+ *
3508
+ * @internal
3509
+ */
3510
+ var Physics2DEditSyncSystem = class {
3511
+ /** The name diagnostics and error reports use. */
3512
+ name = "physics2d-edit-sync";
3513
+ #host;
3514
+ /**
3515
+ * Binds the system to the runtime holder.
3516
+ *
3517
+ * @param host - The holder the runtime lands in.
3518
+ */
3519
+ constructor(host) {
3520
+ this.#host = host;
3521
+ }
3522
+ /** Builds, re-places, and removes bodies for this frame's changes. */
3523
+ update() {
3524
+ this.#host.runtime?.syncEditMode();
3525
+ }
3526
+ };
3328
3527
  //#endregion
3329
3528
  //#region src/service.ts
3330
3529
  /**
@@ -3414,7 +3613,7 @@ var Physics2DService = class {
3414
3613
  * @param maxDistance - How far to travel; defaults to 10 km.
3415
3614
  * @param options - Layer mask and trigger behaviour.
3416
3615
  * @returns The hit, or `null` when the ray clears everything.
3417
- * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3616
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet, outside edit mode.
3418
3617
  */
3419
3618
  raycast(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3420
3619
  this.#requireStep("raycast");
@@ -3431,7 +3630,7 @@ var Physics2DService = class {
3431
3630
  * @param maxDistance - How far to travel; defaults to 10 km.
3432
3631
  * @param options - Layer mask and trigger behaviour.
3433
3632
  * @returns The hits, in increasing distance. The array is reused between calls.
3434
- * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3633
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet, outside edit mode.
3435
3634
  */
3436
3635
  raycastAll(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3437
3636
  this.#requireStep("raycastAll");
@@ -3460,7 +3659,7 @@ var Physics2DService = class {
3460
3659
  * @param radius - Its radius in metres.
3461
3660
  * @param options - Layer mask and trigger behaviour.
3462
3661
  * @returns The overlapping entities, in Rapier's order. The array is reused between calls.
3463
- * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3662
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet, outside edit mode.
3464
3663
  */
3465
3664
  overlapCircle(centre, radius, options) {
3466
3665
  return this.#overlap({
@@ -3476,7 +3675,7 @@ var Physics2DService = class {
3476
3675
  * @param rotation - Its rotation in degrees counter-clockwise; defaults to `0`.
3477
3676
  * @param options - Layer mask and trigger behaviour.
3478
3677
  * @returns The overlapping entities. The array is reused between calls.
3479
- * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3678
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet, outside edit mode.
3480
3679
  */
3481
3680
  overlapBox(centre, size, rotation = 0, options) {
3482
3681
  return this.#overlap({
@@ -3494,7 +3693,7 @@ var Physics2DService = class {
3494
3693
  * @param maxDistance - How far to sweep.
3495
3694
  * @param options - Layer mask and trigger behaviour.
3496
3695
  * @returns The hit, or `null`.
3497
- * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet.
3696
+ * @throws IgnifxError with code `IGX-1153` when no fixed step has run yet, outside edit mode.
3498
3697
  */
3499
3698
  shapeCast(centre, radius, direction, maxDistance, options) {
3500
3699
  this.#requireStep("shapeCast");
@@ -3567,13 +3766,68 @@ var Physics2DService = class {
3567
3766
  return runtime;
3568
3767
  }
3569
3768
  /**
3570
- * Refuses a query that ran before Rapier built its broadphase (spike S6.2).
3769
+ * The outlines of an entity's 2D colliders, as world-space line segments: what an editor draws as
3770
+ * collider gizmos (ADR-0028, amendment of 2026-10-20).
3771
+ *
3772
+ * @remarks
3773
+ * The segments are Rapier's own debug drawing of the colliders the entity's body is built from,
3774
+ * so a polygon, an edge chain, a capsule, a one-way platform, and every piece of a
3775
+ * `TilemapCollider2D` are outlined exactly. A body exists from the first fixed step in a game,
3776
+ * and from the first frame or query in an edit-mode app, which keeps bodies on the transforms the
3777
+ * editor moves; an entity without one (no enabled collider yet, or before that point) has no
3778
+ * outline. A `CharacterController2D`'s own shape is not a collider and is not outlined.
3779
+ *
3780
+ * @param entity - The entity whose colliders to outline.
3781
+ * @returns One outline per collider that produced a shape, in component order; empty when none.
3782
+ *
3783
+ * @example
3784
+ * ```ts
3785
+ * for (const outline of app.physics2d.colliderOutlines(selected)) {
3786
+ * drawLines2D(outline.lines, outline.isTrigger ? "orange" : "green");
3787
+ * }
3788
+ * ```
3789
+ */
3790
+ colliderOutlines(entity) {
3791
+ const runtime = this.#host.runtime;
3792
+ const outlines = [];
3793
+ if (runtime === null || entity.isDestroyed) return outlines;
3794
+ if (runtime.isEditMode) runtime.syncEditMode();
3795
+ const record = runtime.recordOf(entity);
3796
+ if (record === null) return outlines;
3797
+ const byOwner = /* @__PURE__ */ new Map();
3798
+ for (let index = 0; index < record.colliders.length; index += 1) {
3799
+ const owner = record.owners[index] ?? null;
3800
+ const collider = record.colliders[index];
3801
+ if (owner === null || collider === void 0) continue;
3802
+ let handles = byOwner.get(owner);
3803
+ if (handles === void 0) {
3804
+ handles = /* @__PURE__ */ new Set();
3805
+ byOwner.set(owner, handles);
3806
+ }
3807
+ handles.add(colliderHandle(collider));
3808
+ }
3809
+ for (const [owner, handles] of byOwner) outlines.push({
3810
+ collider: owner,
3811
+ isTrigger: owner.isTrigger,
3812
+ lines: colliderLines(runtime.world, handles)
3813
+ });
3814
+ return outlines;
3815
+ }
3816
+ /**
3817
+ * Refuses a query that ran before Rapier built its broadphase (spike S6.2). In an edit-mode app,
3818
+ * which never steps, it brings the bodies in line with the world instead, so the query sees what
3819
+ * the editor shows (ADR-0028, amendment of 2026-10-20).
3571
3820
  *
3572
3821
  * @param query - The query name, for the diagnostic.
3573
- * @throws IgnifxError with code `IGX-1153` when no step has completed.
3822
+ * @throws IgnifxError with code `IGX-1153` when no step has completed, outside edit mode.
3574
3823
  */
3575
3824
  #requireStep(query) {
3576
- if (this.#rt().hasStepped) return;
3825
+ const runtime = this.#rt();
3826
+ if (runtime.isEditMode) {
3827
+ runtime.syncEditMode();
3828
+ return;
3829
+ }
3830
+ if (runtime.hasStepped) return;
3577
3831
  throw physics2DError(Physics2DErrorCode.queryBeforeStep, `${query}() ran before the first fixed step.`, {
3578
3832
  context: { query },
3579
3833
  hint: "Step the app at least once (app.step(1 / 60)) before querying; Rapier builds its broadphase in the first step."
@@ -3734,6 +3988,10 @@ const physics2d = defineExtension((options = {}) => {
3734
3988
  order: PHYSICS_2D_INTERPOLATE_ORDER,
3735
3989
  editMode: "skip"
3736
3990
  });
3991
+ if (ctx.app.isEditMode) ctx.registerSystem(new Physics2DEditSyncSystem(host), {
3992
+ phase: Phase.PreRender,
3993
+ order: PHYSICS_2D_EDIT_SYNC_ORDER
3994
+ });
3737
3995
  },
3738
3996
  async onStart(app) {
3739
3997
  const ctx = context;