@rig-c/core 2.70.0 → 2.72.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/compile.ts CHANGED
@@ -3011,10 +3011,50 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3011
3011
  const meshes: CompileResult['meshes'] = [];
3012
3012
  // `skin/slot/attachment` -> per-vertex z, for the attachments that named a
3013
3013
  // depth map. Read at deform-key time; never emitted. See `AttachmentContext`.
3014
- const attachmentDepths = new Map<string, number[]>();
3014
+ const attachmentDepths = new Map<string, number[] | { z: number[]; geometry: DeformGeometry }>();
3015
3015
  // The same for a `soft` mask's per-vertex level, which a deform envelope's
3016
3016
  // `mask` reads (issue #1413). Never emitted either.
3017
3017
  const attachmentSoftMasks = new Map<string, DeformSoftMask>();
3018
+ // `skin/slot/attachment` -> per-axis narrowing mode when a deform turn key on
3019
+ // that attachment declares `narrowing: false` (issue #1584), so `sampleMeshDepth`
3020
+ // evaluates `turnCeiling` with the `asin(|A₀ / A_axis|)` closed form that
3021
+ // matches `A39` on the keyed turn.
3022
+ const attachmentTurnNarrowing = new Map<string, { yaw?: boolean; pitch?: boolean }>();
3023
+ const attachmentTurnNarrowingBy = new Map<string, { yaw?: { mode: boolean; anim: string }; pitch?: { mode: boolean; anim: string } }>();
3024
+ withMotionSource(() => {
3025
+ for (const [turnAnimName, turnAnim] of Object.entries(motion.animations ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
3026
+ for (const turnTrack of turnAnim.deform ?? []) {
3027
+ const turnDeformSkin = turnTrack.skin ?? DEFAULT_SKIN;
3028
+ const turnTriple = `${turnDeformSkin}/${turnTrack.slot}/${turnTrack.attachment}`;
3029
+ for (const turnKey of turnTrack.keys ?? []) {
3030
+ const turnSpec = turnKey.transform as { kind?: unknown; narrowing?: unknown } | undefined;
3031
+ if (turnSpec && (turnSpec.kind === 'yaw' || turnSpec.kind === 'pitch')) {
3032
+ const turnAxis = turnSpec.kind;
3033
+ const turnNarrowMode = turnSpec.narrowing !== false;
3034
+ const turnPrev = attachmentTurnNarrowingBy.get(turnTriple)?.[turnAxis];
3035
+ if (turnPrev !== undefined && turnPrev.mode !== turnNarrowMode) {
3036
+ const turnFalseAnim = turnNarrowMode ? turnPrev.anim : turnAnimName;
3037
+ const turnTrueAnim = turnNarrowMode ? turnAnimName : turnPrev.anim;
3038
+ throw new CompileError(
3039
+ `deform ${turnTriple} keys "${turnAxis}" with narrowing=false in animation "${turnFalseAnim}" and with narrowing=true in animation "${turnTrueAnim}". ` +
3040
+ 'An attachment\'s turn ceiling on one axis is either asin(|A₀ / A_axis|) (without narrowing) or atan(|A₀ / A_axis|) (with narrowing) — ' +
3041
+ `mixing both on one attachment and axis would report one ceiling where two apply. Use one "narrowing" setting across every "${turnAxis}" key on ${turnTriple}.`,
3042
+ 'CE_COMPILE_DEFORM_TURN_NARROWING_MIXED',
3043
+ );
3044
+ }
3045
+ const curBy = attachmentTurnNarrowingBy.get(turnTriple) ?? {};
3046
+ if (curBy[turnAxis] === undefined) curBy[turnAxis] = { mode: turnNarrowMode, anim: turnAnimName };
3047
+ attachmentTurnNarrowingBy.set(turnTriple, curBy);
3048
+ if (!turnNarrowMode) {
3049
+ const cur = attachmentTurnNarrowing.get(turnTriple) ?? {};
3050
+ cur[turnAxis] = false;
3051
+ attachmentTurnNarrowing.set(turnTriple, cur);
3052
+ }
3053
+ }
3054
+ }
3055
+ }
3056
+ }
3057
+ });
3018
3058
 
3019
3059
  for (const rigSlot of rig.slots) {
3020
3060
  const part = partBySlot.get(rigSlot.name);
@@ -3183,6 +3223,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3183
3223
  imagesDir,
3184
3224
  depths: attachmentDepths,
3185
3225
  softMasks: attachmentSoftMasks,
3226
+ turnNarrowing: attachmentTurnNarrowing.get(`${skinName}/${rigSlot.name}/${placeholder}`),
3186
3227
  slotNames: new Set(rig.slots.map((s) => s.name)),
3187
3228
  atlasRegions: atlasIn === null ? null : atlasIn.byName,
3188
3229
  links: pendingLinks,
@@ -3688,6 +3729,7 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3688
3729
  if (deformTimelines.get(skinName)?.get(track.slot)?.has(track.attachment)) {
3689
3730
  throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`, 'CE_COMPILE_DEFORM_TIMELINE_DUPLICATE');
3690
3731
  }
3732
+ const sampledDepth = attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`);
3691
3733
  const keys = compileDeformTrack(
3692
3734
  track,
3693
3735
  motion,
@@ -3695,10 +3737,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3695
3737
  anim.duration,
3696
3738
  {
3697
3739
  ...deformGeometryOf(attachment, at, transforms),
3698
- depth: attachmentDepths.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3740
+ depth: (Array.isArray(sampledDepth) ? sampledDepth : sampledDepth?.z) ?? null,
3699
3741
  soft: attachmentSoftMasks.get(`${skinName}/${track.slot}/${track.attachment}`) ?? null,
3700
3742
  },
3701
3743
  deformTransforms,
3744
+ sampledDepth !== undefined && !Array.isArray(sampledDepth) ? sampledDepth.geometry : null,
3702
3745
  );
3703
3746
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time);
3704
3747
  attachmentTimelinesOf(skinName, track.slot).set(track.attachment, { deform: keys });
@@ -4326,13 +4369,19 @@ interface AttachmentContext {
4326
4369
  * round trip and then vanish — and the whole point of this table is to be
4327
4370
  * read by `evaluateDeformTransform` at key time and never emitted at all.
4328
4371
  */
4329
- depths: Map<string, number[]>;
4372
+ depths: Map<string, number[] | { z: number[]; geometry: DeformGeometry }>;
4330
4373
  /**
4331
4374
  * A `soft` mask's file and its sampled level per vertex, keyed like `depths`
4332
4375
  * — what a deform envelope's `mask` reads at key time (issue #1413). Never
4333
4376
  * emitted, for `depths`' reason.
4334
4377
  */
4335
4378
  softMasks: Map<string, DeformSoftMask>;
4379
+ /**
4380
+ * Per-axis `narrowing` mode when a deform turn key on this attachment states
4381
+ * `"narrowing": false` (issue #1584), so `sampleMeshDepth` computes the turn
4382
+ * ceiling with `asin(|A₀ / A_axis|)` on that axis.
4383
+ */
4384
+ turnNarrowing?: { yaw?: boolean; pitch?: boolean };
4336
4385
  /** Every slot the rig declares — a clipping attachment's `end` resolves here. */
4337
4386
  slotNames: Set<string>;
4338
4387
  /**
@@ -5708,7 +5757,12 @@ function buildRigMesh(
5708
5757
  if (authored && att.generator) {
5709
5758
  throw new CompileError(`${where}: a mesh is either authored geometry or a generator, never both`, 'CE_COMPILE_MESH_AUTHORED_AND_GENERATED');
5710
5759
  }
5711
- if (att.generator) return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
5760
+ if (att.generator) {
5761
+ if (att.depth !== undefined) {
5762
+ throw new CompileError(`${where}: attachment "depth" describes authored geometry; a generated mesh states its depth on the contour or grid generator instead`, 'CE_COMPILE_DEPTH_AUTHORED_REQUIRED');
5763
+ }
5764
+ return buildGeneratedMesh(att, att.generator, placeholder, where, ctx);
5765
+ }
5712
5766
  if (att.vertices && att.weights) {
5713
5767
  throw new CompileError(
5714
5768
  `${where}: a mesh gives geometry as "vertices" or as "weights", never both — "weights" is the by-name form of the same data`, 'CE_COMPILE_MESH_GEOMETRY_SOURCES_CONFLICT',
@@ -5786,6 +5840,35 @@ function buildRigMesh(
5786
5840
  height: f32(height),
5787
5841
  };
5788
5842
  if (att.sequence !== undefined) out.sequence = buildSequence(att.sequence);
5843
+ let depth: ReturnType<typeof sampleMeshDepth> | undefined;
5844
+ if (att.depth !== undefined) {
5845
+ if (img === undefined) {
5846
+ throw new CompileError(`${where}: an authored mesh with "depth" needs an "image" to locate the part's pixel grid`, 'CE_COMPILE_DEPTH_IMAGE_REQUIRED');
5847
+ }
5848
+ const anchor = ctx.transforms.get(ctx.anchorBone);
5849
+ if (!anchor) throw new CompileError(`${where}: slot bone "${ctx.anchorBone}" has no setup transform`, 'CE_COMPILE_SLOT_SETUP_TRANSFORM_MISSING');
5850
+ const geometry = deformGeometryOf(out, where, ctx.transforms, anchor);
5851
+ if (geometry.setup === null) {
5852
+ throw new CompileError(`${where}: depth needs resolved setup positions — ${geometry.setupWhy}`, 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED');
5853
+ }
5854
+ const world = geometry.setup;
5855
+ const plate = partPlate(img);
5856
+ const toArt = img.width / plate.width;
5857
+ // The generator's window is centred on the slot bone's setup WORLD origin.
5858
+ // UVs describe texture placement, not a vertex's position in that window.
5859
+ const points: Array<[number, number]> = [];
5860
+ for (let v = 0; v < world.length; v += 2) {
5861
+ points.push([(world[v] - anchor.worldX + img.width / 2) / toArt,
5862
+ (img.height / 2 - (world[v + 1] - anchor.worldY)) / toArt]);
5863
+ }
5864
+ depth = sampleMeshDepth(att.depth, 'authored', points, att.triangles,
5865
+ (px, py) => [anchor.worldX + px * toArt - img.width / 2, anchor.worldY + img.height / 2 - py * toArt],
5866
+ img.pageGrid === undefined ? plateAlpha(plate) : { unlocated: img.pageGrid.said },
5867
+ sheetGridOf(img, plate), where, ctx,
5868
+ Array.from({ length: world.length / 2 }, (_, v) => [world[2 * v], world[2 * v + 1]]));
5869
+ geometry.depth = depth.z;
5870
+ ctx.depths.set(`${ctx.skinName}/${ctx.slotName}/${placeholder}`, { z: depth.z, geometry });
5871
+ }
5789
5872
  // Register it as `authored`: geometry rigc did not build and whose topology it
5790
5873
  // therefore gets to assume nothing about. The generator-topology assertions
5791
5874
  // read this and skip rather than measuring a ring that was never a ring.
@@ -5796,6 +5879,7 @@ function buildRigMesh(
5796
5879
  ctx.meshes.push({
5797
5880
  slot: ctx.slotName,
5798
5881
  kind: 'authored',
5882
+ ...(depth === undefined ? {} : { depth: depth.summary }),
5799
5883
  attachments: [ctx.slotName],
5800
5884
  vertices: uvCount / 2,
5801
5885
  triangles: att.triangles.length / 3,
@@ -6157,8 +6241,8 @@ function buildGeneratedMesh(
6157
6241
  * not invent tolerances.
6158
6242
  */
6159
6243
  function sampleMeshDepth(
6160
- spec: NonNullable<Extract<NonNullable<RigMeshAttachment['generator']>, { kind: 'contour' }>['depth']>,
6161
- kind: 'contour' | 'grid',
6244
+ spec: RigDepthMap,
6245
+ kind: 'contour' | 'grid' | 'authored',
6162
6246
  points: ReadonlyArray<readonly [number, number]>,
6163
6247
  /** The mesh's own triangle list, for the turn ceiling. */
6164
6248
  triangles: ReadonlyArray<number>,
@@ -6197,6 +6281,8 @@ function sampleMeshDepth(
6197
6281
  grid: SheetGrid,
6198
6282
  where: string,
6199
6283
  ctx: AttachmentContext,
6284
+ /** Authored setup-world vertices, already in the depth turn's evaluation space. */
6285
+ turnPoints?: ReadonlyArray<readonly [number, number]>,
6200
6286
  ): {
6201
6287
  z: number[];
6202
6288
  /** The tone-curved 0..1 nearness per vertex, which `bind` thresholds. */
@@ -6280,7 +6366,9 @@ function sampleMeshDepth(
6280
6366
  kind === 'contour'
6281
6367
  ? 'A contour mesh puts every vertex ON the silhouette and pushes it out by the margin, so a sheet cut to ' +
6282
6368
  'the art stops short of every one of them. Dilate the sheet past the mesh margin, or lower the margin.'
6283
- : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6369
+ : kind === 'authored'
6370
+ ? 'The authored setup vertices reach outside the sheet coverage. Extend the sheet to cover their bilinear taps, or correct the setup geometry.'
6371
+ : 'A grid spans the whole part window, corners included, while a sheet is usually cut to the art — so the ' +
6284
6372
  'lattice reaches past it wherever the art does not fill the window. Dilate the sheet to the window, or ' +
6285
6373
  'state "us"/"vs" that keep the lattice inside the art.';
6286
6374
  throw new CompileError(
@@ -6321,9 +6409,10 @@ function sampleMeshDepth(
6321
6409
  range: [f32(lo), f32(hi)],
6322
6410
  ...(partAlpha instanceof Uint8Array ? { undrawn } : { undrawn: null, unlocated: partAlpha.unlocated }),
6323
6411
  ceiling: turnCeiling(
6324
- points.map(([px, py]) => toBind(px, py)),
6412
+ turnPoints ?? points.map(([px, py]) => toBind(px, py)),
6325
6413
  z,
6326
6414
  triangles,
6415
+ ctx.turnNarrowing === undefined ? undefined : { narrowing: ctx.turnNarrowing },
6327
6416
  ),
6328
6417
  },
6329
6418
  };
@@ -8689,7 +8778,7 @@ export interface DeformGeometry {
8689
8778
  /** Why `setup` is null, phrased for the refusal. Null when it is not. */
8690
8779
  setupWhy: string | null;
8691
8780
  /**
8692
- * Per-vertex `z`, when this attachment's generator named a depth map.
8781
+ * Per-vertex `z`, when this attachment or its generator named a depth map.
8693
8782
  *
8694
8783
  * Not measured from the attachment like everything else here — there
8695
8784
  * is nowhere in the format to measure it FROM, which is the whole reason the
@@ -8723,6 +8812,8 @@ export function deformGeometryOf(
8723
8812
  where: string,
8724
8813
  /** Every bone's setup world transform, by name. */
8725
8814
  transforms: Map<string, BoneTransform>,
8815
+ /** Force the world-offset path for an authored depth turn, including rigid meshes. */
8816
+ depthAnchor?: BoneTransform,
8726
8817
  ): DeformGeometry {
8727
8818
  const type = att.kind;
8728
8819
  let worldVerticesLength: number;
@@ -8768,6 +8859,15 @@ export function deformGeometryOf(
8768
8859
  const vertices = att.vertices;
8769
8860
  const weighted = vertices.weighted;
8770
8861
  if (!vertices.weighted) {
8862
+ if (depthAnchor !== undefined) {
8863
+ return {
8864
+ weighted, deformLength: worldVerticesLength, vertexCount: worldVerticesLength / 2,
8865
+ boneCounts: Array(worldVerticesLength / 2).fill(1),
8866
+ setup: setupWorldVertices(vertices, depthAnchor, transforms, where).flat(),
8867
+ influenceBones: Array(worldVerticesLength / 2).fill(depthAnchor),
8868
+ setupWhy: null, depth: null, soft: null,
8869
+ };
8870
+ }
8771
8871
  return {
8772
8872
  weighted,
8773
8873
  deformLength: worldVerticesLength,
@@ -8831,7 +8931,7 @@ export function deformGeometryOf(
8831
8931
  // the runtime's last bits (`b = −2.3e-8` on an unrotated bone) into its
8832
8932
  // offset, about 1e-6 on gallery/look's hair locks. Evaluating on the authored
8833
8933
  // positions instead would need them carried here from the binder.
8834
- if (boneCounts.every((n) => n === 1) && bones.size === 1) {
8934
+ if (depthAnchor === undefined && boneCounts.every((n) => n === 1) && bones.size === 1) {
8835
8935
  return { ...common, setup: bindSpace, influenceBones: null, setupWhy: null };
8836
8936
  }
8837
8937
  // -- several influences, evaluated in world (issue #389) -------------------
@@ -9007,6 +9107,7 @@ function compileDeformTrack(
9007
9107
  duration: number,
9008
9108
  geometry: DeformGeometry,
9009
9109
  generated: CompileResult['deformTransforms'],
9110
+ depthWorld: DeformGeometry | null = null,
9010
9111
  ): ModelKey[] {
9011
9112
  const skin = track.skin ?? 'default';
9012
9113
  const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
@@ -9032,6 +9133,7 @@ function compileDeformTrack(
9032
9133
  // definite, which the arithmetic does for any weighting that closes at 1 and
9033
9134
  // for no weighting that does not (issue #389).
9034
9135
  if (key.transform !== undefined) {
9136
+ const evaluated = ('depth' in key.transform && key.transform.depth === true ? depthWorld : null) ?? geometry;
9035
9137
  if (key.vertices !== undefined && key.vertices !== null) {
9036
9138
  throw new CompileError(
9037
9139
  `${where} (t=${key.t}): the key carries both a "transform" and a "vertices" run, and they are two answers to ` +
@@ -9042,33 +9144,33 @@ function compileDeformTrack(
9042
9144
  if (key.offset !== undefined || key.fromVertex !== undefined) {
9043
9145
  throw new CompileError(
9044
9146
  `${where} (t=${key.t}): the key states a "transform" and a start index. A transform is a model of the whole ` +
9045
- `attachment and is evaluated over all ${geometry.vertexCount} of its vertices, so it always starts at deform ` +
9147
+ `attachment and is evaluated over all ${evaluated.vertexCount} of its vertices, so it always starts at deform ` +
9046
9148
  'index 0. A model applied to part of a run leaves a step at the run\'s edge — write the partial run by hand ' +
9047
9149
  'if that is what you mean.', 'CE_COMPILE_DEFORM_TRANSFORM_START_INDEX',
9048
9150
  );
9049
9151
  }
9050
- if (geometry.setup === null) {
9152
+ if (evaluated.setup === null) {
9051
9153
  throw new CompileError(
9052
9154
  `${where} (t=${key.t}): a "transform" states where every vertex of this attachment goes, and this one has a ` +
9053
- `vertex the arithmetic cannot place — ${geometry.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9155
+ `vertex the arithmetic cannot place — ${evaluated.setupWhy}. Fix the weights, or write the bind-space pairs ` +
9054
9156
  'yourself and start the run with "offset".', 'CE_COMPILE_DEFORM_TRANSFORM_SETUP_UNRESOLVED',
9055
9157
  );
9056
9158
  }
9057
9159
  const report = evaluateDeformTransform(
9058
9160
  key.transform,
9059
- geometry.setup,
9161
+ evaluated.setup,
9060
9162
  onModelGrid,
9061
9163
  `${where} (t=${key.t})`,
9062
- geometry.depth,
9063
- geometry.soft,
9164
+ evaluated.depth,
9165
+ evaluated.soft,
9064
9166
  );
9065
- if (report.offsets.length !== geometry.vertexCount * 2) {
9167
+ if (report.offsets.length !== evaluated.vertexCount * 2) {
9066
9168
  // Unreachable while `setup` is one pair per vertex, which is the whole
9067
9169
  // reason both are derived from the same walk. Stated rather than
9068
9170
  // assumed: a silent mismatch here is the overrun A35 exists for.
9069
9171
  throw new CompileError(
9070
9172
  `${where} (t=${key.t}): the transform produced ${report.offsets.length} numbers for ` +
9071
- `${geometry.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9173
+ `${evaluated.vertexCount} vertices`, 'CE_COMPILE_DEFORM_TRANSFORM_OFFSET_COUNT',
9072
9174
  );
9073
9175
  }
9074
9176
  // -- one displacement per vertex -> one pair per influence (issue #389) -
@@ -9080,11 +9182,11 @@ function compileDeformTrack(
9080
9182
  // displacement it expands is the ROUNDED one the report prints, so a
9081
9183
  // reader can reproduce every emitted pair from the audit rather than from
9082
9184
  // a second evaluation.
9083
- const run = expandDeformToInfluences(report.offsets, geometry);
9084
- if (run.length !== geometry.deformLength) {
9185
+ const run = expandDeformToInfluences(report.offsets, evaluated);
9186
+ if (run.length !== evaluated.deformLength) {
9085
9187
  throw new CompileError(
9086
9188
  `${where} (t=${key.t}): the transform expanded to ${run.length} numbers and this attachment's deform ` +
9087
- `array is ${geometry.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9189
+ `array is ${evaluated.deformLength} long`, 'CE_COMPILE_DEFORM_TRANSFORM_RUN_LENGTH',
9088
9190
  );
9089
9191
  }
9090
9192
  generated.push({
@@ -9100,14 +9202,14 @@ function compileDeformTrack(
9100
9202
  // other way to know they were not evaluated alike. Said only on the path
9101
9203
  // where it is not the attachment's own space, so every existing report
9102
9204
  // prints exactly the lines it printed before.
9103
- ...(geometry.influenceBones === null
9205
+ ...(evaluated.influenceBones === null
9104
9206
  ? {}
9105
9207
  : {
9106
9208
  expanded: run,
9107
9209
  derived: [
9108
9210
  ...report.derived,
9109
- `evaluated at setup WORLD positions, because ${geometry.vertexCount} vertices carry ` +
9110
- `${geometry.deformLength / 2} influences; each displacement D is written into every influence as ` +
9211
+ `evaluated at setup WORLD positions, because ${evaluated.vertexCount} vertices carry ` +
9212
+ `${evaluated.deformLength / 2} influences; each displacement D is written into every influence as ` +
9111
9213
  'Mᵢ⁻¹·D, so the runtime composes Σwᵢ·Mᵢ·Mᵢ⁻¹·D = D',
9112
9214
  ],
9113
9215
  }),
@@ -189,11 +189,12 @@ import {
189
189
  foldInheritMode,
190
190
  gridRound,
191
191
  readBlend,
192
- readColour,
193
192
  shownAttachment,
194
193
  shownRow,
195
194
  slidersKeyingWhy,
196
195
  sourceOfDoc,
196
+ viewColours,
197
+ WHITE,
197
198
  type CompiledDocument,
198
199
  type CoreAnimation,
199
200
  type CoreBoneRow,
@@ -834,12 +835,15 @@ export function posedSlots(
834
835
  /** Filled with each slot's placeholder once the sample's own timelines have moved it, before the sliders — what the attachments are posed from (`attachmentStates`). */
835
836
  placeholders?: Map<string, string | null>,
836
837
  ): { rows: CoreSlotRow[]; conflicts: string[] } {
837
- const colour = plant.colour ?? readColour;
838
+ // The view's stated colours, read once (`viewColours`); a planted reader of the light colour is called as it always was.
839
+ const stated = viewColours(doc);
840
+ const colour = plant.colour ?? stated;
838
841
  const byName = new Map(timelines.slots.map((s) => [s.name, s]));
839
842
  return slotRowsFrom(doc, plant, sliders, placeholders, (slot, live) => {
840
843
  let placeholder = slot.setup;
841
- const light = slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color);
842
- const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
844
+ const setupLight = slot.color === undefined ? WHITE : colour(slot.color);
845
+ const light = [setupLight[0], setupLight[1], setupLight[2], setupLight[3]];
846
+ const setupDark = slot.dark === undefined ? null : stated(slot.dark);
843
847
  const dark = setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]];
844
848
  for (const tl of live ? (byName.get(slot.name)?.timelines ?? []) : []) {
845
849
  if (tl.kind === 'attachment') {
@@ -848,7 +852,6 @@ export function posedSlots(
848
852
  continue;
849
853
  }
850
854
  const v = valuesAt(tl.keys, t, plant);
851
- const setupLight = slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color);
852
855
  const channels = SLOT_COLOUR_TIMELINES[tl.kind].channels;
853
856
  channels.forEach((name, i) => {
854
857
  const at = { r: 0, g: 1, b: 2, a: 3, r2: 4, g2: 5, b2: 6 }[name];
@@ -955,15 +958,22 @@ export function posedBoneRows(doc: CompiledDocument, timelines: CoreAnimationTim
955
958
  /** `posedBoneRows`, with the world transforms the rows were read off — what the sample's attachments are posed through. */
956
959
  export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant = {}, constraints?: CoreConstraintTimelines, sliders?: SliderApplication[], step?: PoseStep): { rows: CoreBoneRow[]; world: Map<string, CoreWorld> } {
957
960
  const posed = posedBoneStep(doc, timelines, t, plant, constraints, sliders, step);
958
- return { rows: stepRows(posed), world: posed.world };
961
+ const stage = plant.stage;
962
+ if (stage !== undefined) stage.begin('readout');
963
+ const rows = stepRows(posed);
964
+ if (stage !== undefined) stage.end('readout');
965
+ return { rows, world: posed.world };
959
966
  }
960
967
 
961
968
  /**
962
969
  * `posedBoneWorld`'s world transforms without its rows (issue #1179, the
963
970
  * second part): the same pose by the same operations in the same order, since
964
971
  * `stepRows` only reads the world once it is posed. A walk reads `world` and
965
- * no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
966
- * this; the raw entry's walk still forms the rows it does not read, unchanged.
972
+ * no row, so every walk asks for this — the scan's (`scanWalkIn` in
973
+ * `./raw.ts`, A10's), the raw entry's batch walks and the live track's step.
974
+ * The rows were the only thing the other variant adds, and their rounding
975
+ * (`rawNumber`, `keptNumber`) refuses nothing, so leaving them out moves no
976
+ * number and no refusal of a walk.
967
977
  */
968
978
  export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): Map<string, CoreWorld> {
969
979
  return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
@@ -997,20 +1007,34 @@ function stepRows(posed: PosedStep): CoreBoneRow[] {
997
1007
 
998
1008
  /** `posedBoneWorld` up to its rows, refusing a bone the evaluator gave no transform (`PosedStep`). */
999
1009
  function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: PoseStep | undefined): PosedStep {
1010
+ const stage = plant.stage;
1011
+ if (stage !== undefined) stage.begin('timeline');
1000
1012
  const bones = posedBones(doc, timelines, t, plant);
1013
+ if (stage !== undefined) stage.end('timeline');
1001
1014
  // One step of the stepped phase (issue #956, `./constraints_physics.ts`): the physics records posed by their timelines and stepped under the walk's context; the deformed curve a path walks as above.
1002
- if (constraints !== undefined && step !== undefined) return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
1015
+ if (constraints !== undefined && step !== undefined) {
1016
+ return steppedLocals(doc, bones, plant, () => pathDeformed(doc, posedRecords(constraintRecords(doc), constraints, t), timelines, t, plant), constraints.physicsKeyed ?? [], t, sliders, step);
1017
+ }
1003
1018
  const active = activeBones(doc);
1004
1019
  // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
1005
- if (step?.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1006
- let world = (plant.evaluate ?? worldTransforms)(bones, active);
1020
+ if (step?.adjustment !== undefined) {
1021
+ if (stage !== undefined) stage.begin('timeline');
1022
+ adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1023
+ if (stage !== undefined) stage.end('timeline');
1024
+ }
1025
+ if (stage !== undefined) stage.begin('world');
1026
+ const initialWorld = (plant.evaluate ?? worldTransforms)(bones, active);
1027
+ if (stage !== undefined) stage.end('world');
1028
+ let world = initialWorld;
1007
1029
  if (constraints !== undefined) {
1030
+ if (stage !== undefined) stage.begin('constraints');
1008
1031
  const setupRecords = constraintRecords(doc);
1009
1032
  // A path walks the curve the sample's deform timelines left on its slot (`./deform.ts`); a slider's deform of a walked path leaves the bones out (`pathAnimationsWhy`).
1010
1033
  const records = pathDeformed(doc, posedRecords(setupRecords, constraints, t), timelines, t, plant);
1011
1034
  // A path constraint may read a slot bone the runtime has not yet brought up to date in this pass, as the previous pass left it (`./constraints_path.ts`, *Which slot bone*); `poseAnimations` holds that pass to the setup pose's reading.
1012
1035
  const previous = setupRecords.some((r) => r.kind === 'path') ? applyConstraints(doc.bones, (plant.evaluate ?? worldTransforms)(doc.bones, active), active, plant.constraints ? plant.constraints(setupRecords) : setupRecords, null, undefined, undefined, undefined, solverRules(plant.solver)) : null;
1013
- world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
1036
+ world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
1037
+ if (stage !== undefined) stage.end('constraints');
1014
1038
  }
1015
1039
  for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
1016
1040
  return { bones, world, active, round: plant.round ?? gridRound };
@@ -1027,27 +1051,35 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
1027
1051
  * past the timelines for both.
1028
1052
  */
1029
1053
  function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, records: () => CoreConstraintRecord[], physicsKeyed: NonNullable<CoreConstraintTimelines['physicsKeyed']>, t: number, sliders: SliderApplication[] | undefined, step: PoseStep): PosedStep {
1054
+ const stage = plant.stage;
1030
1055
  const active = activeBones(doc);
1031
1056
  // The caller's adjustment, after the timelines and before anything reads the locals (`LocalAdjust`).
1032
- if (step.adjustment !== undefined) adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1033
- let world = (plant.evaluate ?? worldTransforms)(bones, active);
1057
+ if (step.adjustment !== undefined) {
1058
+ if (stage !== undefined) stage.begin('timeline');
1059
+ adjustLocals(doc, bones, step.adjustment.callback, step.adjustment.context);
1060
+ if (stage !== undefined) stage.end('timeline');
1061
+ }
1062
+ if (stage !== undefined) stage.begin('world');
1063
+ const initialWorld = (plant.evaluate ?? worldTransforms)(bones, active);
1064
+ if (stage !== undefined) {
1065
+ stage.end('world');
1066
+ stage.begin('constraints');
1067
+ }
1034
1068
  const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
1035
- world = applyConstraints(bones, world, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
1069
+ const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
1070
+ if (stage !== undefined) stage.end('constraints');
1036
1071
  for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
1037
1072
  return { bones, world, active, round: plant.round ?? gridRound };
1038
1073
  }
1039
1074
 
1040
1075
  /**
1041
- * `steppedLocals`' world transforms, the oracle's rows formed beside them when
1042
- * `rows` is set (as `posedBoneWorld` forms them and `posedBoneWorldAlone` does
1043
- * not): the live tracks' entry to the stepped phase (`./tracks.ts`). The
1044
- * document's constraint records as they stand — the live tracks mix no
1045
- * constraint timeline — and no physics timeline keyed.
1076
+ * `steppedLocals`' world transforms, without the oracle's rows (as
1077
+ * `posedBoneWorldAlone` poses them): the live tracks' entry to the stepped
1078
+ * phase (`./tracks.ts`). The document's constraint records as they stand —
1079
+ * the live tracks mix no constraint timeline — and no physics timeline keyed.
1046
1080
  */
1047
- export function steppedLocalsWorld(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, sliders: SliderApplication[], step: PoseStep, rows: boolean): Map<string, CoreWorld> {
1048
- const posed = steppedLocals(doc, bones, plant, () => constraintRecords(doc), [], 0, sliders, step);
1049
- if (rows) stepRows(posed);
1050
- return posed.world;
1081
+ export function steppedLocalsWorld(doc: CompiledDocument, bones: ModelBone[], plant: TimelinePlant, sliders: SliderApplication[], step: PoseStep): Map<string, CoreWorld> {
1082
+ return steppedLocals(doc, bones, plant, () => constraintRecords(doc), [], 0, sliders, step).world;
1051
1083
  }
1052
1084
 
1053
1085
  /** Each path record with the curve its slot shows deformed by the sample's own deform timelines at `t` (`./deform.ts`), or as it was. */
@@ -33,6 +33,10 @@ import { gridRound } from './index.ts';
33
33
 
34
34
  /** An event definition, as the model states it (`ModelEvent`): the payload a key falls back to. */
35
35
  export interface CoreEventDef {
36
+ /** Audio metadata retained additively; the five-column oracle row is unchanged. */
37
+ audio?: string;
38
+ volume?: number;
39
+ balance?: number;
36
40
  name: string;
37
41
  int: number;
38
42
  float: number;
@@ -41,6 +45,9 @@ export interface CoreEventDef {
41
45
 
42
46
  /** One event key, as read: its time (float32) and the payload it fires with, each field resolved. */
43
47
  export interface CoreEventKey {
48
+ /** Audio delivery values, as observed from the public EventTimeline API. */
49
+ volume?: number;
50
+ balance?: number;
44
51
  time: number;
45
52
  name: string;
46
53
  int: number;
@@ -55,8 +62,8 @@ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'obj
55
62
  const EVENT_DEF_FIELDS = ['name', 'int', 'float', 'string', 'audio', 'volume', 'balance'] as const;
56
63
  const EVENT_KEY_FIELDS = ['time', 'name', 'int', 'float', 'string', 'volume', 'balance'] as const;
57
64
 
58
- function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string } {
59
- const out: { int?: number; float?: number; string?: string } = {};
65
+ function payload(raw: Record<string, unknown>, at: string, problems: string[]): { int?: number; float?: number; string?: string; volume?: number; balance?: number } {
66
+ const out: { int?: number; float?: number; string?: string; volume?: number; balance?: number } = {};
60
67
  if (raw.int !== undefined) {
61
68
  if (typeof raw.int !== 'number' || !Number.isInteger(raw.int)) problems.push(`${at}: int is ${JSON.stringify(raw.int)}, not a whole number`);
62
69
  else out.int = raw.int;
@@ -69,7 +76,10 @@ function payload(raw: Record<string, unknown>, at: string, problems: string[]):
69
76
  if (typeof raw.string !== 'string') problems.push(`${at}: string is ${JSON.stringify(raw.string)}, not a string`);
70
77
  else out.string = raw.string;
71
78
  }
72
- for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined && (typeof raw[key] !== 'number' || !Number.isFinite(raw[key]))) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
79
+ for (const key of ['volume', 'balance'] as const) if (raw[key] !== undefined) {
80
+ if (typeof raw[key] !== 'number' || !Number.isFinite(raw[key])) problems.push(`${at}: ${key} is ${JSON.stringify(raw[key])}, not a finite number`);
81
+ else out[key] = raw[key];
82
+ }
73
83
  return out;
74
84
  }
75
85
 
@@ -90,7 +100,7 @@ export function readEventDefs(value: unknown, problems: string[]): Map<string, C
90
100
  if (raw.audio !== undefined && typeof raw.audio !== 'string') problems.push(`${at} "${raw.name}": audio is ${JSON.stringify(raw.audio)}, not a string`);
91
101
  if (out.has(raw.name)) problems.push(`${at}: event "${raw.name}" is declared twice`);
92
102
  const p = payload(raw, `${at} "${raw.name}"`, problems);
93
- out.set(raw.name, { name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
103
+ out.set(raw.name, { audio: typeof raw.audio === 'string' ? raw.audio : '', volume: p.volume ?? 0, balance: p.balance ?? 0, name: raw.name, int: p.int ?? 0, float: p.float ?? 0, string: p.string ?? '' });
94
104
  });
95
105
  return out;
96
106
  }
@@ -121,7 +131,9 @@ export function readEventKeys(value: unknown, label: string, defs: ReadonlyMap<s
121
131
  return;
122
132
  }
123
133
  const p = payload(raw, at, problems);
124
- out.push({ time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
134
+ // Observed oracle behavior: a voiced key's omitted balance falls back to
135
+ // the definition's volume, not its balance. A silent event keeps both at 0.
136
+ out.push({ volume: def.audio ? p.volume ?? def.volume ?? 0 : 0, balance: def.audio ? p.balance ?? def.volume ?? 0 : 0, time: Math.fround(typeof t === 'number' && Number.isFinite(t) ? t : 0), name: def.name, int: p.int ?? def.int, float: p.float ?? def.float, string: p.string ?? def.string });
125
137
  });
126
138
  return out;
127
139
  }
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