@ignifx/physics-2d 0.9.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
  *
@@ -1054,18 +1081,29 @@ function degenerate(entity, component, requirement) {
1054
1081
  //#endregion
1055
1082
  //#region src/components/tilemap-collider.ts
1056
1083
  /**
1057
- * Read tile collision data through the optional 2D toolkit's type and rebuild when its version changes.
1058
- * Closed polylines preserve concave tile outlines that convex hulls would fill in. They are thin
1059
- * surfaces, so a body starting inside the map is not pushed out.
1084
+ * Read tile collision data through the optional 2D toolkit's type and rebuild when it changes.
1085
+ * The sibling `Tilemap` is found by its registered type id, because `@ignifx/2d` is an optional
1086
+ * peer and must not be imported as a value. Closed polylines preserve concave tile outlines that
1087
+ * convex hulls would fill in. They are thin surfaces, so a body starting inside the map is not
1088
+ * pushed out.
1060
1089
  */
1090
+ /** The type id `@ignifx/2d` registers `Tilemap` under. */
1091
+ const TILEMAP_TYPE_ID = "ignifx/Tilemap";
1061
1092
  /**
1062
1093
  * The collision surface of a tilemap, as one static body's worth of Rapier shapes.
1063
1094
  *
1095
+ * @remarks
1096
+ * The collider reads the `Tilemap` on its own entity, so a scene file that holds both components
1097
+ * has tile collision as soon as it loads. It follows that tilemap: a tile edit, a new map, or a
1098
+ * tilemap added later all reach physics at the next fixed step. Assign
1099
+ * {@link TilemapCollider2D.collisionData} only to use other geometry, such as data you built in
1100
+ * code or a tilemap on another entity.
1101
+ *
1064
1102
  * @example
1065
1103
  * ```ts
1066
- * const map = world.createEntity("Map");
1067
- * const collider = map.addComponent(TilemapCollider2D);
1068
- * collider.collisionData = tilemap.collisionData;
1104
+ * const level = world.createEntity("Level");
1105
+ * level.addComponent(Tilemap).map = app.assets.load<TilemapAsset>("level.tilemap.json");
1106
+ * level.addComponent(TilemapCollider2D); // reads the Tilemap beside it
1069
1107
  * ```
1070
1108
  *
1071
1109
  * @public
@@ -1075,9 +1113,11 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1075
1113
  static typeId = "ignifx/TilemapCollider2D";
1076
1114
  /** The serialized field declarations; the geometry itself comes from the tilemap asset. */
1077
1115
  static schema = tilemapSchema();
1078
- /** The merged chunk geometry, or `null` until a tilemap supplies it. */
1079
- #data = null;
1080
- /** The `version` of the data the current shapes were built from. */
1116
+ /** Geometry that code assigned, which takes the place of the sibling tilemap's. */
1117
+ #override = null;
1118
+ /** The data the current shapes were built from. */
1119
+ #builtData = null;
1120
+ /** The `version` of that data when the shapes were built. */
1081
1121
  #builtVersion = -1;
1082
1122
  /** Applies the shared defaults. */
1083
1123
  constructor() {
@@ -1085,20 +1125,28 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1085
1125
  Object.assign(this, createDefaults(TilemapCollider2D.schema));
1086
1126
  }
1087
1127
  /**
1088
- * The merged chunk geometry this collider builds shapes from.
1128
+ * The merged chunk geometry this collider builds shapes from: the assigned data, or else the
1129
+ * `Tilemap` on the same entity.
1130
+ *
1131
+ * @remarks
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.
1089
1135
  *
1090
- * @returns The data, or `null` when none has been supplied.
1136
+ * @returns The data, or `null` when nothing was assigned and the entity has no `Tilemap`.
1091
1137
  */
1092
1138
  get collisionData() {
1093
- return this.#data;
1139
+ return this.#override ?? siblingCollisionData(this);
1094
1140
  }
1095
1141
  /**
1096
- * Replaces the tilemap geometry and schedules a rebuild.
1142
+ * Uses other geometry instead of the sibling tilemap's, and schedules a rebuild.
1097
1143
  *
1098
- * @param value - The merged chunk geometry, or `null` to drop the shapes.
1144
+ * @param value - The merged chunk geometry, or `null` to go back to the `Tilemap` on the same
1145
+ * entity (no shapes when there is none).
1099
1146
  */
1100
1147
  set collisionData(value) {
1101
- this.#data = value;
1148
+ this.#override = value;
1149
+ this.#builtData = null;
1102
1150
  this.#builtVersion = -1;
1103
1151
  this.rebuild();
1104
1152
  }
@@ -1106,12 +1154,19 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1106
1154
  * Whether the supplied data has changed since the shapes were built. The runtime polls this once
1107
1155
  * per fixed step, which is how a tile edit reaches physics without a cross-package subscription.
1108
1156
  *
1157
+ * @remarks
1158
+ * A disabled collider, or one on an inactive entity, builds no shapes, so it answers `false`
1159
+ * rather than asking for the same empty rebuild every step. Enabling it again finds the data
1160
+ * changed and rebuilds.
1161
+ *
1109
1162
  * @returns `true` when a rebuild is due.
1110
1163
  *
1111
1164
  * @internal
1112
1165
  */
1113
1166
  needsRebuild() {
1114
- return (this.#data?.version ?? -1) !== this.#builtVersion;
1167
+ if (!this.isEnabledInHierarchy) return false;
1168
+ const data = this.collisionData;
1169
+ return data !== this.#builtData || (data?.version ?? -1) !== this.#builtVersion;
1115
1170
  }
1116
1171
  /**
1117
1172
  * Appends one shape per merged outline and one per one-way edge.
@@ -1122,7 +1177,8 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1122
1177
  * @internal
1123
1178
  */
1124
1179
  collectShapes(scale, out) {
1125
- const data = this.#data;
1180
+ const data = this.collisionData;
1181
+ this.#builtData = data;
1126
1182
  this.#builtVersion = data?.version ?? -1;
1127
1183
  if (data === null) return;
1128
1184
  const offset = {
@@ -1155,6 +1211,27 @@ var TilemapCollider2D = class TilemapCollider2D extends Collider2D {
1155
1211
  }
1156
1212
  };
1157
1213
  /**
1214
+ * Reads the collision data of the `Tilemap` on a collider's entity.
1215
+ *
1216
+ * @param collider - The collider.
1217
+ * @returns The tilemap's merged geometry, or `null` when the entity has no `Tilemap` or the 2D
1218
+ * toolkit is not registered.
1219
+ */
1220
+ function siblingCollisionData(collider) {
1221
+ const type = collider.world.registry.get(TILEMAP_TYPE_ID);
1222
+ const tilemap = type === null ? null : collider.entity.getComponent(type);
1223
+ return tilemap !== null && isTilemap(tilemap) ? tilemap.collisionData : null;
1224
+ }
1225
+ /**
1226
+ * Whether a component registered as `ignifx/Tilemap` has the shape this package reads.
1227
+ *
1228
+ * @param component - The component.
1229
+ * @returns `true` when it carries `collisionData`.
1230
+ */
1231
+ function isTilemap(component) {
1232
+ return "collisionData" in component;
1233
+ }
1234
+ /**
1158
1235
  * Builds the `TilemapCollider2D` schema.
1159
1236
  *
1160
1237
  * @returns The schema.
@@ -1839,6 +1916,21 @@ function toRadians(degrees) {
1839
1916
  return degrees * TAU / 360;
1840
1917
  }
1841
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
+ /**
1842
1934
  * Turns Rapier's radians into the degrees `Transform.rotation2D` uses.
1843
1935
  *
1844
1936
  * @param radians - The angle in radians.
@@ -2114,6 +2206,12 @@ var Physics2DRuntime = class {
2114
2206
  #filterFeet = 0;
2115
2207
  #colliderSetChanged = true;
2116
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();
2117
2215
  #disposed = false;
2118
2216
  #stepsThisFrame = 0;
2119
2217
  #stepMs = 0;
@@ -2131,6 +2229,7 @@ var Physics2DRuntime = class {
2131
2229
  this.#settings = options.settings;
2132
2230
  this.#world = options.world;
2133
2231
  this.#counters = options.counters;
2232
+ this.#isEditMode = options.ctx.app.isEditMode;
2134
2233
  this.#queue = createEventQueue();
2135
2234
  this.#contacts.push(this.#contactPoint);
2136
2235
  setGravity(this.#world, this.#settings.gravity);
@@ -2155,6 +2254,15 @@ var Physics2DRuntime = class {
2155
2254
  return this.#stepped;
2156
2255
  }
2157
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
+ /**
2158
2266
  * Every body, in creation order.
2159
2267
  *
2160
2268
  * @returns The records.
@@ -2378,6 +2486,31 @@ var Physics2DRuntime = class {
2378
2486
  this.#dispatchEvents();
2379
2487
  }
2380
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
+ /**
2381
2514
  * Writes the interpolated display pose of every interpolated body.
2382
2515
  *
2383
2516
  * @param alpha - `time.fixedStepAlpha`, in `[0, 1)`.
@@ -2473,6 +2606,34 @@ var Physics2DRuntime = class {
2473
2606
  if (record !== void 0 && record.entity.layer !== record.layer) this.markDirty(record.entity);
2474
2607
  }
2475
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
+ }
2476
2637
  /** Marks every entity whose tilemap collision data changed since its shapes were built. */
2477
2638
  #pollTilemaps() {
2478
2639
  const colliders = this.#ctx.app.world.components(TilemapCollider2D);
@@ -2486,10 +2647,43 @@ var Physics2DRuntime = class {
2486
2647
  if (this.#dirty.size === 0) return;
2487
2648
  const pending = [...this.#dirty];
2488
2649
  this.#dirty.clear();
2489
- for (const entity of pending) this.#rebuildEntity(entity);
2650
+ for (const entity of pending) this.#guarded(entity, () => {
2651
+ this.#rebuildEntity(entity);
2652
+ });
2490
2653
  this.#colliderSetChanged = true;
2491
2654
  }
2492
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
+ /**
2493
2687
  * Tears an entity's body down and builds a new one from its current components.
2494
2688
  *
2495
2689
  * @param entity - The entity to rebuild.
@@ -2510,7 +2704,10 @@ var Physics2DRuntime = class {
2510
2704
  const controller = entity.getComponent(CharacterController2D);
2511
2705
  const colliders = collidersOf(entity);
2512
2706
  if (colliders.length === 0 && controller === null) return;
2513
- 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
+ }
2514
2711
  const record = this.#build(entity, entity.getComponent(Rigidbody2D), controller, colliders);
2515
2712
  if (record === null) return;
2516
2713
  this.#records.push(record);
@@ -2568,11 +2765,19 @@ var Physics2DRuntime = class {
2568
2765
  preStepVelocity: new Vec2(),
2569
2766
  staticVersion: transform.worldMatrixVersion,
2570
2767
  reportedMove: false,
2768
+ placedVersion: transform.worldMatrixVersion,
2769
+ placedScale: transform.lossyScale,
2571
2770
  disconnects: []
2572
2771
  };
2573
2772
  this.#byEntity.set(entity, record);
2574
- this.#attachColliders(record, colliders, layer);
2575
- 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
+ }
2576
2781
  if (record.colliders.length === 0) {
2577
2782
  this.#byEntity.delete(entity);
2578
2783
  destroyBody(this.#world, body);
@@ -3075,11 +3280,25 @@ var Physics2DRuntime = class {
3075
3280
  * @returns `true` when interpolation applies.
3076
3281
  */
3077
3282
  #shouldInterpolate(rigidbody, controller, motion) {
3078
- if (!this.#settings.interpolation || motion === BodyMotion2D.static) return false;
3283
+ if (this.#isEditMode || !this.#settings.interpolation || motion === BodyMotion2D.static) return false;
3079
3284
  if (controller !== null) return controller.interpolation === "interpolate";
3080
3285
  return rigidbody !== null && rigidbody.interpolation === "interpolate";
3081
3286
  }
3082
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() {}
3083
3302
  /** How far below a one-way platform's top the character's feet may be and still land on it. */
3084
3303
  const ONE_WAY_TOLERANCE = .02;
3085
3304
  /**
@@ -3276,6 +3495,35 @@ var Physics2DInterpolationSystem = class {
3276
3495
  this.#host.runtime?.interpolate(ctx.time.fixedStepAlpha);
3277
3496
  }
3278
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
+ };
3279
3527
  //#endregion
3280
3528
  //#region src/service.ts
3281
3529
  /**
@@ -3365,7 +3613,7 @@ var Physics2DService = class {
3365
3613
  * @param maxDistance - How far to travel; defaults to 10 km.
3366
3614
  * @param options - Layer mask and trigger behaviour.
3367
3615
  * @returns The hit, or `null` when the ray clears everything.
3368
- * @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.
3369
3617
  */
3370
3618
  raycast(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3371
3619
  this.#requireStep("raycast");
@@ -3382,7 +3630,7 @@ var Physics2DService = class {
3382
3630
  * @param maxDistance - How far to travel; defaults to 10 km.
3383
3631
  * @param options - Layer mask and trigger behaviour.
3384
3632
  * @returns The hits, in increasing distance. The array is reused between calls.
3385
- * @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.
3386
3634
  */
3387
3635
  raycastAll(origin, direction, maxDistance = DEFAULT_RAY_DISTANCE, options) {
3388
3636
  this.#requireStep("raycastAll");
@@ -3411,7 +3659,7 @@ var Physics2DService = class {
3411
3659
  * @param radius - Its radius in metres.
3412
3660
  * @param options - Layer mask and trigger behaviour.
3413
3661
  * @returns The overlapping entities, in Rapier's order. The array is reused between calls.
3414
- * @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.
3415
3663
  */
3416
3664
  overlapCircle(centre, radius, options) {
3417
3665
  return this.#overlap({
@@ -3427,7 +3675,7 @@ var Physics2DService = class {
3427
3675
  * @param rotation - Its rotation in degrees counter-clockwise; defaults to `0`.
3428
3676
  * @param options - Layer mask and trigger behaviour.
3429
3677
  * @returns The overlapping entities. The array is reused between calls.
3430
- * @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.
3431
3679
  */
3432
3680
  overlapBox(centre, size, rotation = 0, options) {
3433
3681
  return this.#overlap({
@@ -3445,7 +3693,7 @@ var Physics2DService = class {
3445
3693
  * @param maxDistance - How far to sweep.
3446
3694
  * @param options - Layer mask and trigger behaviour.
3447
3695
  * @returns The hit, or `null`.
3448
- * @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.
3449
3697
  */
3450
3698
  shapeCast(centre, radius, direction, maxDistance, options) {
3451
3699
  this.#requireStep("shapeCast");
@@ -3518,13 +3766,68 @@ var Physics2DService = class {
3518
3766
  return runtime;
3519
3767
  }
3520
3768
  /**
3521
- * 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).
3522
3820
  *
3523
3821
  * @param query - The query name, for the diagnostic.
3524
- * @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.
3525
3823
  */
3526
3824
  #requireStep(query) {
3527
- 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;
3528
3831
  throw physics2DError(Physics2DErrorCode.queryBeforeStep, `${query}() ran before the first fixed step.`, {
3529
3832
  context: { query },
3530
3833
  hint: "Step the app at least once (app.step(1 / 60)) before querying; Rapier builds its broadphase in the first step."
@@ -3672,15 +3975,22 @@ const physics2d = defineExtension((options = {}) => {
3672
3975
  ctx.defineAppProperty("physics2d", () => service);
3673
3976
  ctx.registerSystem(new Physics2DRestoreSystem(host), {
3674
3977
  phase: Phase.FixedUpdate,
3675
- order: PHYSICS_2D_RESTORE_ORDER
3978
+ order: PHYSICS_2D_RESTORE_ORDER,
3979
+ editMode: "skip"
3676
3980
  });
3677
3981
  ctx.registerSystem(new Physics2DStepSystem(host), {
3678
3982
  phase: Phase.FixedUpdate,
3679
- order: 100
3983
+ order: 100,
3984
+ editMode: "skip"
3680
3985
  });
3681
3986
  ctx.registerSystem(new Physics2DInterpolationSystem(host), {
3682
3987
  phase: Phase.Update,
3683
- order: PHYSICS_2D_INTERPOLATE_ORDER
3988
+ order: PHYSICS_2D_INTERPOLATE_ORDER,
3989
+ editMode: "skip"
3990
+ });
3991
+ if (ctx.app.isEditMode) ctx.registerSystem(new Physics2DEditSyncSystem(host), {
3992
+ phase: Phase.PreRender,
3993
+ order: PHYSICS_2D_EDIT_SYNC_ORDER
3684
3994
  });
3685
3995
  },
3686
3996
  async onStart(app) {