@rig-c/core 2.70.0 → 2.71.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/src/core/index.ts CHANGED
@@ -176,7 +176,7 @@ import type { ModelAtlasRect, ModelBone, ModelEditorOrder, ModelPage, ModelPageR
176
176
  import { worldTransforms, type CoreWorld } from './world.ts';
177
177
  import { readAnimationTimelines, type CoreAnimationTimelines } from './animation.ts';
178
178
  import { readEventDefs, type CoreEventDef } from './events.ts';
179
- import { poseGeometry, readGeometry, type CoreAttachmentRow, type CoreClipRow, type CoreGeometry, type DrawWalk, type MeshArraysOf, type RegionPoser, type ShownGeometry, type VertexPoser } from './vertices.ts';
179
+ import { poseGeometry, readGeometry, type CoreAttachmentRow, type CoreClipRow, type CoreGeometry, type DeformedVertexPoser, type DrawWalk, type MeshArraysOf, type RegionPoser, type ShownGeometry, type VertexPoser } from './vertices.ts';
180
180
  import type { CoreClippedRow, ShapeClipper, TriangleClipper } from './clipping.ts';
181
181
  import { applyConstraints, constraintsAbsentWhy, readConstraintRecord, readConstraintTimelines, solverRules, type ConstraintPlant, type CoreConstraintRecord, type CoreConstraintTimelines, type SolverRules } from './constraints.ts';
182
182
  import { readPathRecord } from './constraints_path.ts';
@@ -1062,6 +1062,23 @@ export type BlendReader = (slot: ModelSlot) => CoreBlendMode;
1062
1062
  /** What reads a stated light colour into its four channels: `readColour` unless a caller passes another. */
1063
1063
  export type ColourReader = (hex: string) => [number, number, number, number];
1064
1064
 
1065
+ /**
1066
+ * The five pose stages a step passes through when instrumented (`StageHooks`,
1067
+ * issue #1589):
1068
+ * - `timeline`: timeline evaluation, animated bone local copy, attachment/deform/sequence timeline states
1069
+ * - `world`: bone hierarchy world-transform composition (`worldTransforms`)
1070
+ * - `constraints`: constraint record posing, physics step, and constraint solver orchestration (`applyConstraints`)
1071
+ * - `geometry`: region corners, mesh/deformed world vertices, and triangle clipping (`drawnGeometry`)
1072
+ * - `readout`: slot rows, draw-order resolution/sorting, bone rows, drawn row packaging, and events
1073
+ */
1074
+ export type CoreStage = 'timeline' | 'world' | 'constraints' | 'geometry' | 'readout';
1075
+
1076
+ /** Zero-cost-when-absent per-stage instrumentation callbacks on `CorePlant` (issue #1589). */
1077
+ export interface StageHooks {
1078
+ begin(stage: CoreStage): void;
1079
+ end(stage: CoreStage): void;
1080
+ }
1081
+
1065
1082
  /**
1066
1083
  * What the core poses with, each part replaceable. The core suite's plants
1067
1084
  * pass a copy of one of them — a mode's sign flipped, the wrong skin, a
@@ -1077,6 +1094,8 @@ export interface CorePlant {
1077
1094
  region?: RegionPoser;
1078
1095
  /** A vertex array's world positions (`worldVertices` in `./vertices.ts`). */
1079
1096
  vertices?: VertexPoser;
1097
+ /** A deformed vertex array's world positions (`deformedWorldVertices` in `./vertices.ts`). */
1098
+ deformedVertices?: DeformedVertexPoser;
1080
1099
  /** A drawn mesh's read-only UVs and triangles (`meshArraysOf` in `./vertices.ts`) — the raw entry's `drawn`; nothing but a control passes one. */
1081
1100
  meshArrays?: MeshArraysOf;
1082
1101
  /** The ik, transform and path constraints as posed, rewritten before they are applied (`./constraints.ts`). */
@@ -1107,6 +1126,8 @@ export interface CorePlant {
1107
1126
  * `rawNumber`, the unrounded double.
1108
1127
  */
1109
1128
  round?: (v: number) => number | null;
1129
+ /** Per-stage instrumentation callbacks for `tools/pose_bench.ts` (issue #1589); absent on every uninstrumented walk. */
1130
+ stage?: StageHooks;
1110
1131
  }
1111
1132
 
1112
1133
  /** The document's ik, transform and path constraint records, in its order — what `applyConstraints` runs. */
@@ -1166,6 +1187,46 @@ export function readColour(hex: string): [number, number, number, number] {
1166
1187
  return [channel(0), channel(1), channel(2), hex.length === 8 ? channel(3) : 1];
1167
1188
  }
1168
1189
 
1190
+ /** A stated colour's channels, read once and shared (`viewColours`): read, or copied before a write — never handed out. */
1191
+ export type StatedColour = Readonly<[number, number, number, number]>;
1192
+
1193
+ /** Each view's stated colours, by spelling, read once (`viewColours`). */
1194
+ const coloursOfView = new WeakMap<CompiledDocument, Map<string, StatedColour>>();
1195
+
1196
+ /**
1197
+ * The view's reader of a stated colour: `readColour(hex)` read the first time
1198
+ * the view meets that spelling and kept, frozen, for every pose after it. The
1199
+ * slot rows read every slot's colours on every pose, and a drawn row its
1200
+ * record's (the parse was a measured share of a step). The reading is the
1201
+ * spelling's alone, so the cache is keyed on the spelling rather than on a
1202
+ * slot or a record — a colour respelled in place reads its new spelling, and
1203
+ * a view built anew starts its own. The tuple is shared by every pose, so no
1204
+ * caller hands it out or writes it (`StatedColour` is read-only): a pose's
1205
+ * channels are copied out of it. It is not frozen because a frozen array is
1206
+ * read several times slower under JavaScriptCore, measured on Bun 1.4.4, and
1207
+ * the read is the whole of what the cache is for. The core suite's `CO63`
1208
+ * holds a pose's colours to their spelling and to the pose.
1209
+ */
1210
+ export function viewColours(doc: CompiledDocument): (hex: string) => StatedColour {
1211
+ let kept = coloursOfView.get(doc);
1212
+ if (kept === undefined) {
1213
+ kept = new Map();
1214
+ coloursOfView.set(doc, kept);
1215
+ }
1216
+ const colours = kept;
1217
+ return (hex) => {
1218
+ let c = colours.get(hex);
1219
+ if (c === undefined) {
1220
+ c = readColour(hex);
1221
+ colours.set(hex, c);
1222
+ }
1223
+ return c;
1224
+ };
1225
+ }
1226
+
1227
+ /** A slot or record that states no light colour: white, opaque. */
1228
+ export const WHITE: StatedColour = [1, 1, 1, 1];
1229
+
1169
1230
  /**
1170
1231
  * A slot's blend mode: `Normal` when it states none, else the stated
1171
1232
  * spelling folded (the header's measurement). `readModel` has already refused
package/src/core/raw.ts CHANGED
@@ -113,8 +113,8 @@
113
113
  * naming why (`CoreInputError`): a renderer handed a pose with a block
114
114
  * missing would draw a different picture in silence.
115
115
  */
116
- import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
- import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
116
+ import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, shownAttachment, sourceOfDoc, underNoSkin, underSkin, viewColours, WHITE, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
+ import { posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
119
  import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
@@ -342,7 +342,7 @@ function scanBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>)
342
342
  */
343
343
  function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode): { rows: CoreAttachmentRow[]; clips: CoreClipRow[]; drawnClipped: CoreClippedRow[] } {
344
344
  const draw = drawWalkOf(doc, order, plant);
345
- const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && mode.live !== true && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
345
+ const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices, deformedVertices: plant.deformedVertices }, mode.loop && mode.live !== true && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
346
346
  if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
347
347
  // The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
348
348
  if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
@@ -423,12 +423,20 @@ export interface ScanPlant {
423
423
 
424
424
  /** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
425
425
  function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
426
+ const stage = plant.stage;
427
+ if (stage !== undefined) stage.begin('geometry');
426
428
  const geometry = drawnGeometry(doc, shown, world, order, plant, mode);
429
+ if (stage !== undefined) {
430
+ stage.end('geometry');
431
+ stage.begin('readout');
432
+ }
427
433
  // Under a concave or inverse clip the oracle's `clipped` block is absent (the runtime's own triangle list); what the core draws is `drawnClipped`,
428
434
  // its own decomposition, and the render samples each drawn triangle at its source triangle's affine UV, so the pixels are the decomposition's
429
435
  // coverage alone (issue #964, `Mesh.source` in src/render.ts).
430
436
  const rows = geometry.rows;
431
437
  const drawn: RawDrawn[] = [];
438
+ // Each record's stated colour, read once per view (`viewColours`) and copied into the pose's own tuple, as the pose's arrays are its own.
439
+ const stated = viewColours(doc);
432
440
  let k = 0;
433
441
  for (const s of shown) {
434
442
  const g = s.geometry;
@@ -436,7 +444,8 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
436
444
  const row = rows[k++];
437
445
  if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
438
446
  const record = doc.skins.find((x) => x.name === s.skin)?.attachments[s.slot]?.[s.placeholder];
439
- const colour = record?.color === undefined ? ([1, 1, 1, 1] as [number, number, number, number]) : readColour(record.color);
447
+ const read = record?.color === undefined ? WHITE : stated(record.color);
448
+ const colour: [number, number, number, number] = [read[0], read[1], read[2], read[3]];
440
449
  let uvs: readonly number[];
441
450
  let triangles: readonly number[];
442
451
  let hull: number | null = null;
@@ -454,6 +463,7 @@ function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world:
454
463
  }
455
464
  const clips = geometry.clips.map((c): RawClip => [c[0], c[1], c[2], numbersOf(c[3], `clip vertex of slot "${c[0]}"`, mode)]);
456
465
  const clipped = geometry.drawnClipped.map((c): RawClipped => [c[0], c[1], c[2], numbersOf(c[3], `clipped vertex of slot "${c[0]}"`, mode), numbersOf(c[4], `clipped uv of slot "${c[0]}"`, mode), c[5]]);
466
+ if (stage !== undefined) stage.end('readout');
457
467
  return { drawn, clips, clipped };
458
468
  }
459
469
 
@@ -785,12 +795,16 @@ interface WalkStep {
785
795
  * (`walkEach`) is one body: every number a pose carries is computed there and
786
796
  * in the two assemblers below, by the same calls, whatever the shape. A shape
787
797
  * chooses only what is assembled from what was posed — the scan drops the
788
- * readings, rows and copies nothing it reads is computed from — and keeps the
798
+ * readings and copies nothing it reads is computed from — and keeps the
789
799
  * order of every call that can refuse.
800
+ *
801
+ * No shape forms the oracle's bone rows: no walk pose carries them, and the
802
+ * rounding they are formed through refuses nothing, so every step poses the
803
+ * world alone (`posedBoneWorldAlone`). The rows are the sample's
804
+ * (`posedBoneRows`, `posedBoneWorld`), formed there as before. The core
805
+ * suite's `CO62` reads the walk's modules for a call that forms them.
790
806
  */
791
807
  interface PoseShape<P> {
792
- /** Whether a step forms the oracle's bone rows beside its world (`posedBoneWorld`), which no walk pose carries; `false` poses the world alone (`posedBoneWorldAlone`). */
793
- rows: boolean;
794
808
  /** The reset pose at the setup (`reset: 'setup'`). */
795
809
  setup: (doc: CompiledDocument, world: Map<string, CoreWorld>, slots: CoreSlotRow[], drawOrder: string[], shown: ShownGeometry[], plant: CorePlant, mode: WalkMode) => P;
796
810
  /** One step's pose. */
@@ -799,19 +813,21 @@ interface PoseShape<P> {
799
813
 
800
814
  /** The raw entry's pose, assembled as it always was: at the reset the bones, then the drawn rows; at a step the drawn rows, the events, then the bones. */
801
815
  const RAW_SHAPE: PoseShape<RawPose> = {
802
- rows: true,
803
816
  setup: (doc, world, slots, drawOrder, shown, plant, mode) => ({ trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...rawDrawn(doc, shown, world, drawOrder, plant, mode), events: [], shown: drawOrderOf(shown, drawOrder) }),
804
817
  step: (doc, p, plant, mode) => {
805
818
  const drawn = rawDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode);
819
+ const stage = plant.stage;
820
+ if (stage !== undefined) stage.begin('readout');
806
821
  const events = p.events();
807
- return { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
822
+ const pose: RawPose = { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
823
+ if (stage !== undefined) stage.end('readout');
824
+ return pose;
808
825
  },
809
826
  };
810
827
 
811
828
  /** The scan's pose (`ScanPose`), in the raw shape's order: the scan's walk is a looping one, which fires no event. */
812
829
  function scanShape(scanPlant: ScanPlant): PoseShape<ScanPose> {
813
830
  return {
814
- rows: false,
815
831
  setup: (doc, world, slots, drawOrder, shown, plant, mode) => {
816
832
  const bones = scanBones(doc, world);
817
833
  return { animationTime: 0, bones, slots, drawn: scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant) };
@@ -908,7 +924,7 @@ function stepWalk<P>(track: TrackState<P>, dt: number, first = false): P {
908
924
  const keys = anim.timelines.events;
909
925
  const adjustment = adjust === undefined ? undefined : { callback: adjust, context: { trackTime, animation: anim.name, globalTime: trackTime } };
910
926
  const pose = poseStepFrom(doc, plant, resolve, shape, mode, ctx, first, trackTime, t, {
911
- world: (sliders) => (shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment })),
927
+ world: (sliders) => posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before, adjustment }),
912
928
  slots: (sliders, placeholders) => posedSlots(doc, anim.timelines, t, raw, sliders, placeholders),
913
929
  drawOrder: anim.timelines.drawOrder,
914
930
  at: t,
@@ -948,20 +964,30 @@ export interface StepPosers {
948
964
  * and a live tracks' step share.
949
965
  */
950
966
  function poseStepFrom<P>(doc: CompiledDocument, plant: TimelinePlant, resolve: NonNullable<TimelinePlant['shown']>, shape: PoseShape<P>, mode: WalkMode, ctx: ReturnType<typeof freshStepContext>, first: boolean, trackTime: number, animationTime: number, posers: StepPosers): P {
967
+ const stage = plant.stage;
951
968
  const sliders: SliderApplication[] = [];
952
969
  const world = posers.world(sliders);
953
970
  if (first) ctx.phase = 'update';
971
+ if (stage !== undefined) stage.begin('readout');
954
972
  const placeholders = new Map<string, string | null>();
955
973
  const slots = posers.slots(sliders, placeholders);
956
974
  if (slots.conflicts.length > 0) throw new CoreInputError(`the raw walk leaves the slots out: ${slots.conflicts.join('; ')}`);
957
975
  const evalOrder = plant.drawOrder ?? drawOrderAt;
958
- let order = evalOrder(doc.slots.length, posers.drawOrder, posers.at) ?? doc.slots.map((_s, k) => k);
976
+ const baseOrder = evalOrder(doc.slots.length, posers.drawOrder, posers.at) ?? doc.slots.map((_s, k) => k);
977
+ let order = baseOrder;
959
978
  for (const app of sliders) order = evalOrder(doc.slots.length, app.timelines.drawOrder, app.at) ?? order;
960
979
  const drawOrder = order.map((k) => doc.slots[k].name);
980
+ if (stage !== undefined) {
981
+ stage.end('readout');
982
+ stage.begin('timeline');
983
+ }
961
984
  const states = attachmentStates(doc, resolve, placeholders, posers.sample, sliders, plant, posers.mixed);
985
+ if (stage !== undefined) stage.end('timeline');
962
986
  if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
987
+ if (stage !== undefined) stage.begin('readout');
963
988
  const rank = new Map(drawOrder.map((n, r) => [n, r]));
964
989
  const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
990
+ if (stage !== undefined) stage.end('readout');
965
991
  const events = (): RawEvent[] => posers.events().map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]);
966
992
  return shape.step(doc, { trackTime, animationTime, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
967
993
  }
@@ -160,7 +160,7 @@
160
160
  * every later call refuses naming the call it was refused at.
161
161
  */
162
162
  import type { ModelBone, ModelSlot, ModelVertices } from '../model.ts';
163
- import { activeBones, CoreInputError, rawNumber, readColour, shownAttachment, type CompiledDocument, type CoreShown } from './index.ts';
163
+ import { activeBones, CoreInputError, rawNumber, shownAttachment, viewColours, WHITE, type CompiledDocument, type CoreShown, type StatedColour } from './index.ts';
164
164
  import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
165
165
  import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
166
166
  import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
@@ -440,6 +440,9 @@ function trackComplete(e: Entry): number {
440
440
  const signum = (v: number): number => (v > 0 ? 1 : v < 0 ? -1 : 0);
441
441
  const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
442
442
 
443
+ /** A slot's setup dark colour where it states none: never read, since such a slot carries no dark channel and a two-colour timeline returns before it (`applyColour`). */
444
+ const NO_DARK: readonly number[] = [0, 0, 0];
445
+
443
446
  // ---------------------------------------------------------------------------
444
447
  // one step's skeleton — the pose the entries are applied to
445
448
  // ---------------------------------------------------------------------------
@@ -473,6 +476,8 @@ class Pose {
473
476
  private readonly active: ReadonlySet<string>;
474
477
  private readonly live: boolean[];
475
478
  private readonly resolved = new Map<string, Resolved>();
479
+ /** The view's stated colours (`viewColours`). */
480
+ readonly stated: (hex: string) => StatedColour;
476
481
 
477
482
  constructor(
478
483
  private readonly doc: CompiledDocument,
@@ -480,12 +485,16 @@ class Pose {
480
485
  ) {
481
486
  this.bones = [...doc.bones];
482
487
  this.active = activeBones(doc);
483
- const colour = plant.colour ?? readColour;
488
+ // The view's stated colours, read once (`viewColours`) and copied into the step's own channels; a planted reader of the light colour is called as it always was.
489
+ const stated = viewColours(doc);
490
+ this.stated = stated;
491
+ const colour = plant.colour ?? stated;
484
492
  const gate = plant.slotTimelines ?? slotTimelinesApply;
485
493
  this.live = doc.slots.map((s) => gate(doc, s, this.active));
486
494
  this.slots = doc.slots.map((slot): SlotState => {
487
- const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
488
- return { placeholder: slot.setup, light: slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color), dark: setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]], deform: null, seen: [], attachmentState: ATTACH_NONE };
495
+ const setupDark = slot.dark === undefined ? null : stated(slot.dark);
496
+ const setupLight = slot.color === undefined ? WHITE : colour(slot.color);
497
+ return { placeholder: slot.setup, light: [setupLight[0], setupLight[1], setupLight[2], setupLight[3]], dark: setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]], deform: null, seen: [], attachmentState: ATTACH_NONE };
489
498
  });
490
499
  }
491
500
 
@@ -1103,9 +1112,9 @@ class Mixer {
1103
1112
  const s = pose.slots[tl.slot];
1104
1113
  const l = s.light;
1105
1114
  const d = s.dark;
1106
- const sl = slot.color === undefined ? [1, 1, 1, 1] : (this.plant.colour ?? readColour)(slot.color);
1107
- const sdFull = slot.dark === undefined ? null : readColour(slot.dark);
1108
- const sd = sdFull === null ? [0, 0, 0] : [sdFull[0], sdFull[1], sdFull[2]];
1115
+ // The setup colours, read only: the view's (`viewColours`), or a planted reader's light colour.
1116
+ const sl = slot.color === undefined ? WHITE : (this.plant.colour ?? pose.stated)(slot.color);
1117
+ const sd = slot.dark === undefined ? NO_DARK : pose.stated(slot.dark);
1109
1118
  const clampLight = (): void => {
1110
1119
  for (let k = 0; k < 4; k++) l[k] = clamp01(l[k]);
1111
1120
  };
@@ -1466,7 +1475,10 @@ class Tracks implements LiveTracks {
1466
1475
  if (typeof dt !== 'number' || !Number.isFinite(dt) || dt < 0) throw new CoreInputError(`the live tracks' step ${n} is ${String(dt)}, not a finite time at or above 0`);
1467
1476
  return this.guard(`step ${n}`, () => {
1468
1477
  this.time += dt;
1478
+ const stage = this.plant.stage;
1479
+ if (stage !== undefined) stage.begin('timeline');
1469
1480
  this.mixer.update(dt);
1481
+ if (stage !== undefined) stage.end('timeline');
1470
1482
  const pose = this.pose(dt, false);
1471
1483
  this.steps = n;
1472
1484
  return pose;
@@ -1489,6 +1501,8 @@ class Tracks implements LiveTracks {
1489
1501
  const ctx = path.ctx;
1490
1502
  const before = ctx.time;
1491
1503
  ctx.time += this.clock === undefined ? dt : this.clock(dt);
1504
+ const stage = this.plant.stage;
1505
+ if (stage !== undefined) stage.begin('timeline');
1492
1506
  const pose = new Pose(doc, this.plant);
1493
1507
  const animationTime = this.mixer.apply(pose);
1494
1508
  const entry = this.mixer.tracks[0] ?? null;
@@ -1497,8 +1511,9 @@ class Tracks implements LiveTracks {
1497
1511
  context: { trackTime: entry?.trackTime ?? null, animation: entry?.anim.name ?? null, globalTime: this.time },
1498
1512
  };
1499
1513
  const mixed = new Map(doc.slots.map((s, k) => [s.name, { deform: pose.slots[k].deform, seen: pose.slots[k].seen }]));
1514
+ if (stage !== undefined) stage.end('timeline');
1500
1515
  return path.step(first, this.time, animationTime, {
1501
- world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjustment }, true),
1516
+ world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjustment }),
1502
1517
  slots: (sliders, placeholders) => slotRowsFrom(doc, path.raw, sliders, placeholders, (_slot, _live): SlotBase => {
1503
1518
  const s = pose.slots[doc.slots.indexOf(_slot)];
1504
1519
  return { placeholder: s.placeholder, light: s.light, dark: s.dark };
@@ -191,6 +191,8 @@ export type CoreClipRow = [string, string, string | null, Array<number | null>];
191
191
  export type RegionPoser = (region: CoreRegionGeometry & { atlas: ModelAtlasRect }, bone: CoreWorld) => number[];
192
192
  /** What computes a vertex array's world positions: `worldVertices` unless a caller passes another. */
193
193
  export type VertexPoser = (vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords?: (v: number) => number) => number[];
194
+ /** What computes a deformed vertex array's world positions in one pass: `deformedWorldVertices` unless a caller passes another. */
195
+ export type DeformedVertexPoser = (vertices: ModelVertices, deform: readonly number[], bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>) => number[];
194
196
 
195
197
  /** A deformed array's coordinates are read as they are (`./deform.ts`, `deformedVertices`): they are not the stored float32s. */
196
198
  export const EXACT_COORDS = (v: number): number => v;
@@ -222,31 +224,157 @@ export function regionCorners(region: CoreRegionGeometry & { atlas: ModelAtlasRe
222
224
  return out;
223
225
  }
224
226
 
227
+ /** A weighted vertex array as the model states it. */
228
+ export type WeightedVertices = Extract<ModelVertices, { weighted: true }>;
229
+
225
230
  /**
226
- * A vertex array's world positions — the header's mesh rule, every stored
227
- * number through `Math.fround`; with `coords`, the coordinates through it
228
- * instead (a deformed array's are read as they are, `EXACT_COORDS`), the
229
- * weights through `Math.fround` still.
231
+ * One influence of a compiled weighted vertex (issue #1580): its bone as an
232
+ * index into `CompiledWeightedVertices.bones`, its raw coordinates (`x`, `y`)
233
+ * for a caller passing a custom `coords`, and its `Math.fround`-rounded
234
+ * coordinates (`fx`, `fy`) and `weight` for the default posing path.
230
235
  */
231
- export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords: (v: number) => number = Math.fround): number[] {
236
+ export interface CompiledBinding {
237
+ readonly bone: number;
238
+ readonly x: number;
239
+ readonly y: number;
240
+ readonly fx: number;
241
+ readonly fy: number;
242
+ readonly weight: number;
243
+ }
244
+
245
+ /**
246
+ * A weighted vertex array's bone bindings compiled to integer indices into
247
+ * `bones` (issue #1580): `bones` lists the distinct bone names in exact
248
+ * first-occurrence order across `vertices.bindings`, and `bindings` keeps the
249
+ * outer vertex order and inner influence order untouched so the summation
250
+ * order and every posed coordinate stay bit-for-bit (`Object.is`) identical.
251
+ */
252
+ export interface CompiledWeightedVertices {
253
+ readonly bones: readonly string[];
254
+ readonly bindings: ReadonlyArray<readonly CompiledBinding[]>;
255
+ }
256
+
257
+ /** Each weighted vertex array's compiled index table, keyed on the `WeightedVertices` object itself. */
258
+ const compiledWeightedByVertices = new WeakMap<WeightedVertices, CompiledWeightedVertices>();
259
+
260
+ /**
261
+ * Compile `vertices`'s bone names to integer indices in first-occurrence order
262
+ * (issue #1580). With `mapIndex`, each binding's index is passed through it
263
+ * instead (the core suite's wrong-index plant).
264
+ */
265
+ export function compileWeightedVertices(
266
+ vertices: WeightedVertices,
267
+ mapIndex?: (boneIndex: number, boneCount: number) => number,
268
+ ): CompiledWeightedVertices {
269
+ const bones: string[] = [];
270
+ const indexByName = new Map<string, number>();
271
+ for (const influences of vertices.bindings) {
272
+ for (const binding of influences) {
273
+ if (!indexByName.has(binding.bone)) {
274
+ indexByName.set(binding.bone, bones.length);
275
+ bones.push(binding.bone);
276
+ }
277
+ }
278
+ }
279
+ const count = bones.length;
280
+ const bindings: CompiledBinding[][] = vertices.bindings.map((influences) =>
281
+ influences.map((binding) => {
282
+ const idx = indexByName.get(binding.bone) as number;
283
+ return {
284
+ bone: mapIndex === undefined ? idx : mapIndex(idx, count),
285
+ x: binding.x,
286
+ y: binding.y,
287
+ fx: Math.fround(binding.x),
288
+ fy: Math.fround(binding.y),
289
+ weight: Math.fround(binding.weight),
290
+ };
291
+ }),
292
+ );
293
+ return { bones, bindings };
294
+ }
295
+
296
+ /**
297
+ * The cached `CompiledWeightedVertices` for `vertices`, compiled once per
298
+ * `WeightedVertices` object and never shared across two different vertex
299
+ * objects.
300
+ */
301
+ export function compiledWeightedOf(vertices: WeightedVertices): CompiledWeightedVertices {
302
+ let kept = compiledWeightedByVertices.get(vertices);
303
+ if (kept === undefined) {
304
+ kept = compileWeightedVertices(vertices);
305
+ compiledWeightedByVertices.set(vertices, kept);
306
+ }
307
+ return kept;
308
+ }
309
+
310
+ /**
311
+ * Resolve each distinct bone of `bones` once per pose call into a dense array
312
+ * indexed by `CompiledBinding.bone`. Because `bones` is in first-occurrence
313
+ * order across `vertices.bindings`, a missing bone throws the same error on
314
+ * the same bone name as a per-influence lookup.
315
+ */
316
+ function resolveBindingBones(bones: readonly string[], world: ReadonlyMap<string, CoreWorld>): CoreWorld[] {
317
+ const transforms = new Array<CoreWorld>(bones.length);
318
+ for (let i = 0; i < bones.length; i++) {
319
+ const name = bones[i];
320
+ const t = world.get(name);
321
+ if (t === undefined) throw new Error(`a binding names bone "${name}", which has no world transform (readModel refuses it first)`);
322
+ transforms[i] = t;
323
+ }
324
+ return transforms;
325
+ }
326
+
327
+ /**
328
+ * Evaluate a compiled weighted vertex table against `world` (issue #1580),
329
+ * looking up each distinct bone once per call and summing influences in their
330
+ * original order.
331
+ */
332
+ export function compiledWorldVertices(
333
+ compiled: CompiledWeightedVertices,
334
+ world: ReadonlyMap<string, CoreWorld>,
335
+ coords: (v: number) => number = Math.fround,
336
+ ): number[] {
337
+ const transforms = resolveBindingBones(compiled.bones, world);
232
338
  const out: number[] = [];
233
- if (!vertices.weighted) {
234
- for (let i = 0; i + 1 < vertices.xy.length; i += 2) {
235
- const x = coords(vertices.xy[i]);
236
- const y = coords(vertices.xy[i + 1]);
237
- out.push(x * bone.a + y * bone.b + bone.worldX, x * bone.c + y * bone.d + bone.worldY);
339
+ const fround = coords === Math.fround;
340
+ for (const influences of compiled.bindings) {
341
+ let wx = 0;
342
+ let wy = 0;
343
+ for (const binding of influences) {
344
+ const t = transforms[binding.bone];
345
+ const x = fround ? binding.fx : coords(binding.x);
346
+ const y = fround ? binding.fy : coords(binding.y);
347
+ const weight = binding.weight;
348
+ wx += (x * t.a + y * t.b + t.worldX) * weight;
349
+ wy += (x * t.c + y * t.d + t.worldY) * weight;
238
350
  }
239
- return out;
351
+ out.push(wx, wy);
240
352
  }
241
- for (const influences of vertices.bindings) {
353
+ return out;
354
+ }
355
+
356
+ /**
357
+ * Evaluate a compiled weighted vertex table with `deform` in one pass
358
+ * (issues #1530, #1580), looking up each distinct bone once per call and
359
+ * summing influences in their original order.
360
+ */
361
+ export function compiledDeformedWorldVertices(
362
+ compiled: CompiledWeightedVertices,
363
+ deform: readonly number[],
364
+ world: ReadonlyMap<string, CoreWorld>,
365
+ ): number[] {
366
+ const transforms = resolveBindingBones(compiled.bones, world);
367
+ const out: number[] = [];
368
+ let k = 0;
369
+ for (const influences of compiled.bindings) {
242
370
  let wx = 0;
243
371
  let wy = 0;
244
372
  for (const binding of influences) {
245
- const t = world.get(binding.bone);
246
- if (t === undefined) throw new Error(`a binding names bone "${binding.bone}", which has no world transform (readModel refuses it first)`);
247
- const x = coords(binding.x);
248
- const y = coords(binding.y);
249
- const weight = Math.fround(binding.weight);
373
+ const t = transforms[binding.bone];
374
+ const x = binding.fx + deform[k];
375
+ const y = binding.fy + deform[k + 1];
376
+ k += 2;
377
+ const weight = binding.weight;
250
378
  wx += (x * t.a + y * t.b + t.worldX) * weight;
251
379
  wy += (x * t.c + y * t.d + t.worldY) * weight;
252
380
  }
@@ -255,20 +383,42 @@ export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: R
255
383
  return out;
256
384
  }
257
385
 
386
+ /**
387
+ * A vertex array's world positions — the header's mesh rule, every stored
388
+ * number through `Math.fround`; with `coords`, the coordinates through it
389
+ * instead (a deformed array's are read as they are, `EXACT_COORDS`), the
390
+ * weights through `Math.fround` still. Weighted bone bindings are compiled once
391
+ * per vertex object to integer indices (`compiledWeightedOf`, issue #1580) so
392
+ * each pose looks up each distinct bone once rather than per influence.
393
+ */
394
+ export function worldVertices(vertices: ModelVertices, bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>, coords: (v: number) => number = Math.fround): number[] {
395
+ if (!vertices.weighted) {
396
+ const out: number[] = [];
397
+ for (let i = 0; i + 1 < vertices.xy.length; i += 2) {
398
+ const x = coords(vertices.xy[i]);
399
+ const y = coords(vertices.xy[i + 1]);
400
+ out.push(x * bone.a + y * bone.b + bone.worldX, x * bone.c + y * bone.d + bone.worldY);
401
+ }
402
+ return out;
403
+ }
404
+ return compiledWorldVertices(compiledWeightedOf(vertices), world, coords);
405
+ }
406
+
258
407
  /**
259
408
  * `worldVertices(deformedVertices(vertices, deform), bone, world, EXACT_COORDS)`
260
- * in one pass (issue #1530): the same operations on the same numbers in the
409
+ * in one pass (issues #1530, #1580): the same operations on the same numbers in the
261
410
  * same order — an unweighted vertex's coordinates are the deform array's,
262
411
  * read as they are; a weighted binding's are its float32 coordinate plus the
263
- * deform's pair, its weight through `Math.fround` — without building the
264
- * deformed array (and, weighted, one binding record per influence) on every
265
- * pose only to map it again. A planted vertex poser still reads the deformed
266
- * array (`poseGeometry`). Posed output is held equal by the core gate's
267
- * sample attachments and by `tools/posed_frames.ts` across the change.
412
+ * deform's pair, its weight through `Math.fround`, its bone transform indexed
413
+ * through `compiledWeightedOf` — without building the deformed array (and,
414
+ * weighted, one binding record per influence) on every pose only to map it
415
+ * again. A planted vertex poser still reads the deformed array (`poseGeometry`).
416
+ * Posed output is held equal by the core gate's sample attachments and by
417
+ * `tools/posed_frames.ts` across the change.
268
418
  */
269
419
  export function deformedWorldVertices(vertices: ModelVertices, deform: readonly number[], bone: CoreWorld, world: ReadonlyMap<string, CoreWorld>): number[] {
270
- const out: number[] = [];
271
420
  if (!vertices.weighted) {
421
+ const out: number[] = [];
272
422
  for (let i = 0; i + 1 < deform.length; i += 2) {
273
423
  const x = deform[i];
274
424
  const y = deform[i + 1];
@@ -276,23 +426,7 @@ export function deformedWorldVertices(vertices: ModelVertices, deform: readonly
276
426
  }
277
427
  return out;
278
428
  }
279
- let k = 0;
280
- for (const influences of vertices.bindings) {
281
- let wx = 0;
282
- let wy = 0;
283
- for (const binding of influences) {
284
- const t = world.get(binding.bone);
285
- if (t === undefined) throw new Error(`a binding names bone "${binding.bone}", which has no world transform (readModel refuses it first)`);
286
- const x = Math.fround(binding.x) + deform[k];
287
- const y = Math.fround(binding.y) + deform[k + 1];
288
- k += 2;
289
- const weight = Math.fround(binding.weight);
290
- wx += (x * t.a + y * t.b + t.worldX) * weight;
291
- wy += (x * t.c + y * t.d + t.worldY) * weight;
292
- }
293
- out.push(wx, wy);
294
- }
295
- return out;
429
+ return compiledDeformedWorldVertices(compiledWeightedOf(vertices), deform, world);
296
430
  }
297
431
 
298
432
  const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -342,7 +476,10 @@ function readVertices(value: unknown, where: string, bones: ReadonlySet<string>,
342
476
  for (const key of ['x', 'y', 'weight'] as const) if (!finite(b[key])) problems.push(`${at}: ${key} is ${JSON.stringify(b[key]) ?? 'absent'}, not a finite number`);
343
477
  });
344
478
  });
345
- return problems.length === before ? { weighted: true, bindings: bindings as ModelBinding[][] } : undefined;
479
+ if (problems.length !== before) return undefined;
480
+ const weighted: WeightedVertices = { weighted: true, bindings: bindings as ModelBinding[][] };
481
+ compiledWeightedByVertices.set(weighted, compileWeightedVertices(weighted));
482
+ return weighted;
346
483
  }
347
484
 
348
485
  /**
@@ -529,11 +666,12 @@ export function poseGeometry(
529
666
  world: ReadonlyMap<string, CoreWorld>,
530
667
  sourceOf: SourceOf,
531
668
  round: (v: number) => number | null,
532
- plant: { region?: RegionPoser; vertices?: VertexPoser } = {},
669
+ plant: { region?: RegionPoser; vertices?: VertexPoser; deformedVertices?: DeformedVertexPoser } = {},
533
670
  draw?: DrawWalk,
534
671
  ): { attachments: CoreAttachmentRow[] | null; attachmentsWhy: string | null; clips: CoreClipRow[]; clipped: CoreClippedRow[] | null; clippedWhy: string | null; drawnClipped: CoreClippedRow[] | null; drawnClippedWhy: string | null } {
535
672
  const region = plant.region ?? regionCorners;
536
673
  const vertices = plant.vertices ?? worldVertices;
674
+ const deformPoser = plant.deformedVertices ?? deformedWorldVertices;
537
675
  const attachments: CoreAttachmentRow[] = [];
538
676
  const clips: CoreClipRow[] = [];
539
677
  const nulls: string[] = [];
@@ -548,7 +686,7 @@ export function poseGeometry(
548
686
  // A deform array replaces the stored vertices, its coordinates read as they are (`./deform.ts`).
549
687
  // Unplanted, the deformed positions are read in one pass (`deformedWorldVertices`, issue #1530) rather than through a deformed copy of the array.
550
688
  const deform = s.deform;
551
- const drawn = (v: ModelVertices): number[] => (deform === undefined ? vertices(v, bone, world) : plant.vertices === undefined ? deformedWorldVertices(v, deform, bone, world) : vertices(deformedVertices(v, deform), bone, world, EXACT_COORDS));
689
+ const drawn = (v: ModelVertices): number[] => (deform === undefined ? vertices(v, bone, world) : plant.vertices === undefined || plant.deformedVertices !== undefined ? deformPoser(v, deform, bone, world) : vertices(deformedVertices(v, deform), bone, world, EXACT_COORDS));
552
690
  if (g.kind === 'region') {
553
691
  const rect = s.frame === undefined ? g.region.atlas : (g.region.frames?.[s.frame] ?? g.region.atlas);
554
692
  if (rect === null) {