@rig-c/core 2.65.2 → 2.66.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/docs/AUTHORING.md CHANGED
@@ -12045,3 +12045,79 @@ for `declared` and the run says so on a line of its own.
12045
12045
  `@rig-c/play` or `rigc preview`.
12046
12046
  - **Not a second poser.** Everything it draws is a pose `render` would have drawn at
12047
12047
  the same frame; where it disagrees with `render --geometry`, it is wrong (`IN04`).
12048
+
12049
+ ## 14. Read a named mesh at setup
12050
+
12051
+ `rig-c/core` promises `readSetupMesh(view, target)` and the types
12052
+ `SetupMeshTarget` and `SetupMesh` (issue #1538). It reads any named mesh or linked
12053
+ mesh, including one the setup pose does not select, without a player, animation
12054
+ step or change to the document's setup selection.
12055
+
12056
+ ```ts
12057
+ import { readModel, underNoSkin, readSetupMesh } from 'rig-c/core';
12058
+ import type { SetupMeshTarget, SetupMesh } from 'rig-c/core';
12059
+
12060
+ const view = underNoSkin(readModel(modelText));
12061
+ const target: SetupMeshTarget = { slot: 'flag', attachment: 'alternate' };
12062
+ const mesh: SetupMesh = readSetupMesh(view, target);
12063
+ // mesh.vertices: full-double world x,y pairs, y up; no crop/camera conversion.
12064
+ ```
12065
+
12066
+ | field | contract |
12067
+ | --- | --- |
12068
+ | `target.slot`, `target.attachment` | exact slot name and attachment **table key**; a record's display name, path or atlas region is not an alias |
12069
+ | `target.skin` omitted | retain the supplied view, including `readModel`'s `all`; use `underNoSkin` for a fresh skeleton's view |
12070
+ | `target.skin: null` | no skin set: default-table fallback, no skin-required bones or constraints activated |
12071
+ | `target.skin: name` | the named view, with default-table fallback; only the named skin's activation lists apply |
12072
+ | `target.skin: 'all'` | all activation lists; a uniquely filled key resolves, identical definitions keep the existing resolver's first supplying skin, differing definitions are refused because the model does not carry the Spine file's merge order |
12073
+ | `mesh.slot`, `mesh.attachment`, `mesh.skin` | requested slot and table key, and the table's supplying skin (which can be the default fallback); a linked mesh retains its own identity |
12074
+ | `mesh.vertices` | fresh mutable `number[]`, flat x,y in world units; stored coordinates and weights use the poser's float32 reading and influence order, without normalization |
12075
+ | `mesh.triangles`, `mesh.hull` | fresh mutable triangle indices; hull is the number of leading hull **vertices**, or `null` when absent |
12076
+
12077
+ Definition equality ignores object property insertion order at every depth,
12078
+ including weighted binding records. Array positions, object key presence and
12079
+ leaf values remain significant; leaves use `Object.is`, so positive and negative
12080
+ zero are distinct when retained in a model. Reordering object keys alone is not
12081
+ a different definition. A genuine value, shape or array-order difference still
12082
+ produces the same named all-skin refusal; no merge order is inferred.
12083
+
12084
+ The setup matrices come from the raw setup poser's existing constraint path,
12085
+ including setup sliders and reset physics. A selected mesh is bit-identical to
12086
+ `poseRawSetup(view)`'s geometry, including any setup-slider deform. An unselected
12087
+ mesh uses its stored vertices through those same matrices: the API never switches
12088
+ an attachment to ask what a slider or path constraint would do after a switch.
12089
+ A linked mesh borrows its source vertices, triangles and hull; unweighted
12090
+ vertices use the queried slot's bone, weighted vertices their named bindings.
12091
+ Its `timelines` flag controls source-deform inheritance only when selected, as
12092
+ in the existing setup poser. An inactive bone keeps the existing zero world
12093
+ matrix; an attachment read does not activate it.
12094
+
12095
+ Every result is detached: changing its object or arrays changes no document,
12096
+ pose, cached topology or subsequent read. Missing slots, attachments and skins,
12097
+ and region, clipping, path or bounding-box targets, throw `CoreInputError`
12098
+ naming the requested identity and the problem. Examples include
12099
+ `setup mesh slot "s" attachment "missing" under skin null: no such attachment`
12100
+ and `…: region is not a mesh`. Ambiguous all-skin definitions name their skins
12101
+ and say `choose a named skin`; unknown skin retains `underSkin`'s existing
12102
+ `--skin "missing": no such skin` refusal. Existing setup/non-finite refusals
12103
+ propagate; the read does not manufacture a partial result when setup is absent.
12104
+
12105
+ `S1538` controls exercise literal coordinates, detached data, linked meshes,
12106
+ skin lookup and activation, setup-slider deform, and the official runtime's
12107
+ `computeWorldVertices` using ordinary JS output arrays at tolerance 0. The
12108
+ runtime stores local mesh coordinates and weights in float32, but an output
12109
+ `Float32Array` would add a second rounding that this API does not perform.
12110
+
12111
+ **Measured nonselected slider distinction.** In spine-core 4.3.13,
12112
+ `computeWorldVertices(skeleton, slot, …)` over an unselected mesh still reads
12113
+ the **selected slot's deform array**, even for a linked mesh with
12114
+ `timelines: false`. `S1538_09` measures a slider at time 0.5 interpolating
12115
+ local offsets `(1,2)` and `(3,4)` to `(2,3)`, with a selected source mesh,
12116
+ a linked mesh inheriting its timeline, and an independent linked mesh.
12117
+ All three selected reads are exact. Four of the six nonselected calls to the
12118
+ official method reuse the selected deform; their maximum absolute difference
12119
+ from this API is `2.9999999999999982` world units. The other two nonselected
12120
+ calls, with the independent linked mesh selected, agree exactly. The API's
12121
+ nonselected output is the stored source geometry through the unchanged setup
12122
+ matrices; it does not borrow a different selection's deform. The control
12123
+ checks both results and the source of the difference, without an epsilon.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.65.2",
3
+ "version": "2.66.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -180,9 +180,9 @@
180
180
  */
181
181
  import type { ModelSlot, ModelVertices } from '../model.ts';
182
182
  import { bezierPolyline, keyIndexAt, type CoreAnimationTimelines, type CoreCurve } from './animation.ts';
183
- import { activeBones, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
183
+ import { activeBones, shownAttachment, shownRow, type CompiledDocument, type CoreShown, type CoreSkin, type ShownResolution } from './index.ts';
184
184
  import { fillingSkins, slotTimelinesApply, type SlotTimelineGate } from './skins.ts';
185
- import type { CoreGeometry, ShownGeometry } from './vertices.ts';
185
+ import type { ShownGeometry } from './vertices.ts';
186
186
 
187
187
  /** The far end the deform curve's recurrence runs to — the header's measurement. */
188
188
  export const DEFORM_CURVE_END = 0.99999999;
@@ -515,6 +515,111 @@ export function timelineIdentity(slot: string, shown: CoreShown): string | null
515
515
 
516
516
  const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.attachment}`;
517
517
 
518
+ /** The part of an attachment state that depends only on the document view and placeholder. */
519
+ interface AttachmentIdentity {
520
+ readonly entry: ShownGeometry;
521
+ readonly identity: string | null;
522
+ readonly vertices: ModelVertices | null;
523
+ readonly count: number | undefined;
524
+ readonly fillers: readonly string[];
525
+ }
526
+
527
+ /** Like shownAttachment's cache, scoped to the view; a new skin view resolves its own records. */
528
+ const identitiesOfView = new WeakMap<CompiledDocument, Map<string, Map<string, AttachmentIdentity | null>>>();
529
+
530
+ function readIdentity(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string): AttachmentIdentity | null {
531
+ const s = resolve(doc, { ...slot, setup: placeholder });
532
+ if (s === null || 'conflict' in s || s.record.geometry === undefined) return null;
533
+ const g = s.record.geometry;
534
+ const source = g.kind === 'linkedmesh' ? doc.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source] : s.record;
535
+ const sg = source?.geometry;
536
+ return Object.freeze({
537
+ entry: Object.freeze({ slot: slot.name, bone: slot.bone, name: shownRow(s).name, placeholder: s.placeholder, skin: s.skin, geometry: g }),
538
+ identity: timelineIdentity(slot.name, s),
539
+ vertices: sg !== undefined && sg.kind !== 'region' && sg.kind !== 'linkedmesh' ? sg.vertices : null,
540
+ count: source?.sequenceCount,
541
+ fillers: Object.freeze(fillingSkins(doc, slot.name, s.placeholder).map((k) => `"${k}"`)),
542
+ });
543
+ }
544
+
545
+ function identityOf(doc: CompiledDocument, resolve: (doc: CompiledDocument, slot: ModelSlot) => ShownResolution, slot: ModelSlot, placeholder: string | null): AttachmentIdentity | null {
546
+ if (placeholder === null) return null;
547
+ // A planted resolver may read changing state outside the document. Only the document's own reader is immutable.
548
+ if (resolve !== shownAttachment) return readIdentity(doc, resolve, slot, placeholder);
549
+ let slots = identitiesOfView.get(doc);
550
+ if (slots === undefined) identitiesOfView.set(doc, slots = new Map());
551
+ let placeholders = slots.get(slot.name);
552
+ if (placeholders === undefined) slots.set(slot.name, placeholders = new Map());
553
+ if (placeholders.has(placeholder)) return placeholders.get(placeholder) as AttachmentIdentity | null;
554
+ const identity = readIdentity(doc, resolve, slot, placeholder);
555
+ placeholders.set(placeholder, identity);
556
+ return identity;
557
+ }
558
+
559
+ /** Index only immutable timeline identity. Times, mixes and the values they compute are never cached. */
560
+ const attachmentsByIdentity = new WeakMap<readonly CoreAttachmentTimeline[], ReadonlyMap<string, readonly CoreAttachmentTimeline[]>>();
561
+ type AttachmentSlots = AttachmentApplication['timelines']['slots'];
562
+ type AttachmentSwitch = NonNullable<AttachmentSlots[number]['timelines'][number]['keys']>;
563
+ const switchesBySlot = new WeakMap<AttachmentSlots, ReadonlyMap<string, readonly AttachmentSwitch[]>>();
564
+
565
+ function matchingAttachments(timelines: readonly CoreAttachmentTimeline[], identity: string): readonly CoreAttachmentTimeline[] | undefined {
566
+ let index = attachmentsByIdentity.get(timelines);
567
+ if (index === undefined) {
568
+ const rows = new Map<string, CoreAttachmentTimeline[]>();
569
+ for (const a of timelines) {
570
+ const name = nameOf(a);
571
+ const found = rows.get(name);
572
+ if (found === undefined) rows.set(name, [a]);
573
+ else found.push(a);
574
+ }
575
+ attachmentsByIdentity.set(timelines, index = rows);
576
+ }
577
+ return index.get(identity);
578
+ }
579
+
580
+ function slotSwitches(slots: AttachmentSlots, slot: string): readonly AttachmentSwitch[] | undefined {
581
+ let index = switchesBySlot.get(slots);
582
+ if (index === undefined) {
583
+ const rows = new Map<string, AttachmentSwitch[]>();
584
+ for (const target of slots) {
585
+ for (const tl of target.timelines) {
586
+ if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
587
+ const found = rows.get(target.name);
588
+ if (found === undefined) rows.set(target.name, [tl.keys]);
589
+ else found.push(tl.keys);
590
+ }
591
+ }
592
+ switchesBySlot.set(slots, index = rows);
593
+ }
594
+ return index.get(slot);
595
+ }
596
+
597
+ /** Allocated only when a matching timeline or a mixed deform actually has per-pose state. */
598
+ interface AttachmentValues {
599
+ deform: number[] | null;
600
+ frame: number | null;
601
+ seen: string[];
602
+ }
603
+
604
+ function applyAttachment(now: AttachmentIdentity | null, values: AttachmentValues | null, timelines: readonly CoreAttachmentTimeline[], t: number, blend: AttachmentApplication | null, evalDeform: DeformEvaluator, blendOf: DeformBlender, evalFrame: SequenceEvaluator): AttachmentValues | null {
605
+ if (now === null || now.identity === null) return values;
606
+ const matching = matchingAttachments(timelines, now.identity);
607
+ if (matching === undefined) return values;
608
+ values ??= { deform: null, frame: null, seen: [] };
609
+ if (!values.seen.includes(now.identity)) values.seen.push(now.identity);
610
+ for (const a of matching) {
611
+ if (a.deform !== null && now.vertices !== null) {
612
+ const target = evalDeform(now.vertices, a.deform, t);
613
+ if (target !== null) values.deform = blend === null ? target : blendOf(now.vertices, values.deform, target, blend.alpha, blend.additive);
614
+ }
615
+ if (a.sequence !== null && now.count !== undefined) {
616
+ const f = evalFrame(a.sequence, now.count, t);
617
+ if (f !== null) values.frame = f;
618
+ }
619
+ }
620
+ return values;
621
+ }
622
+
518
623
  /**
519
624
  * Every slot's shown geometry at one pose — the setup's (`sample` null) or a
520
625
  * sample's — with the deform array and the series' frame its attachment
@@ -528,6 +633,11 @@ const nameOf = (a: CoreAttachmentTimeline): string => `${a.skin}/${a.slot}/${a.a
528
633
  * differently is left to the slots' own absence. `why` names what the core
529
634
  * does not pose: a timeline moving a record whose placeholder several skins
530
635
  * fill (the header's ⚠️).
636
+ *
637
+ * Identity is kept per document view, slot and placeholder, and timelines
638
+ * indexed by their record identity. A slot with no per-pose values reuses its
639
+ * frozen entry; deform arrays and sequence frames are evaluated afresh.
640
+ * Sliders select identities in application order, never a cached posed value.
531
641
  */
532
642
  export function attachmentStates(
533
643
  doc: CompiledDocument,
@@ -557,66 +667,39 @@ export function attachmentStates(
557
667
  const live = gate(doc, slot, active);
558
668
  let placeholder = placeholders.get(slot.name) ?? null;
559
669
  const base = live ? mixed?.get(slot.name) : undefined;
560
- let deform: number[] | null = base?.deform ?? null;
561
- let frame: number | null = null;
562
- const seen: string[] = base === undefined ? [] : [...base.seen];
563
- // What the slot shows now, the record its timelines are keyed as, and what a deform or a frame is computed on.
564
- const state = (): { s: CoreShown; identity: string | null; vertices: ModelVertices | null; count: number | undefined } | null => {
565
- if (placeholder === null) return null;
566
- const s = resolve(doc, { ...slot, setup: placeholder });
567
- if (s === null || 'conflict' in s || s.record.geometry === undefined) return null;
568
- const g = s.record.geometry;
569
- const source = g.kind === 'linkedmesh' ? doc.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source] : s.record;
570
- const sg = source?.geometry;
571
- const vertices = sg !== undefined && sg.kind !== 'region' && sg.kind !== 'linkedmesh' ? sg.vertices : null;
572
- return { s, identity: timelineIdentity(slot.name, s), vertices, count: source?.sequenceCount };
573
- };
574
- const apply = (timelines: readonly CoreAttachmentTimeline[], t: number, blend: { alpha: number; additive: boolean } | null): void => {
575
- const now = state();
576
- if (now === null || now.identity === null) return;
577
- for (const a of timelines) {
578
- if (nameOf(a) !== now.identity) continue;
579
- if (!seen.includes(now.identity)) seen.push(now.identity);
580
- if (a.deform !== null && now.vertices !== null) {
581
- const target = evalDeform(now.vertices, a.deform, t);
582
- if (target !== null) deform = blend === null ? target : blendOf(now.vertices, deform, target, blend.alpha, blend.additive);
583
- }
584
- if (a.sequence !== null && now.count !== undefined) {
585
- const f = evalFrame(a.sequence, now.count, t);
586
- if (f !== null) frame = f;
587
- }
588
- }
589
- };
590
- if (sample !== null && live) apply(sample.timelines.attachments, sample.t, null);
591
- for (const app of live ? sliders : []) {
592
- // The slider's attachment key first, as the slot timelines are (`applySliderSlots` in `./constraints_slider.ts`); a switch to another placeholder clears what the timelines set.
593
- for (const target of app.timelines.slots) {
594
- if (target.name !== slot.name) continue;
595
- for (const tl of target.timelines) {
596
- if (tl.kind !== 'attachment' || tl.keys === undefined) continue;
597
- const i = keyIndexAt(tl.keys, app.at);
598
- if (i < 0) continue;
599
- const next = tl.keys[i].name ?? null;
600
- if (next !== placeholder) {
601
- deform = null;
602
- frame = null;
670
+ let values: AttachmentValues | null = base === undefined || (base.deform === null && base.seen.length === 0) ? null : { deform: base.deform, frame: null, seen: [...base.seen] };
671
+ let now = identityOf(doc, resolve, slot, placeholder);
672
+ if (sample !== null && live) values = applyAttachment(now, values, sample.timelines.attachments, sample.t, null, evalDeform, blendOf, evalFrame);
673
+ if (live) for (const app of sliders) {
674
+ // Each slider switches first, clearing only the per-pose values; then its attachment timelines apply to the new identity.
675
+ const switches = slotSwitches(app.timelines.slots, slot.name);
676
+ if (switches !== undefined) for (const keys of switches) {
677
+ const i = keyIndexAt(keys, app.at);
678
+ if (i < 0) continue;
679
+ const next = keys[i].name ?? null;
680
+ if (next !== placeholder) {
681
+ if (values !== null) {
682
+ values.deform = null;
683
+ values.frame = null;
603
684
  }
604
685
  placeholder = next;
686
+ now = identityOf(doc, resolve, slot, placeholder);
605
687
  }
606
688
  }
607
- apply(app.timelines.attachments, app.at, { alpha: app.alpha, additive: app.additive });
689
+ values = applyAttachment(now, values, app.timelines.attachments, app.at, app, evalDeform, blendOf, evalFrame);
608
690
  }
609
- const now = state();
610
691
  if (now === null) continue;
611
- const entry: ShownGeometry = { slot: slot.name, bone: slot.bone, name: shownRow(now.s).name, placeholder: now.s.placeholder, skin: now.s.skin, geometry: now.s.record.geometry as CoreGeometry };
692
+ // The cached entry is frozen. A pose with a deform/frame owns its wrapper and values; later poses cannot write into it.
693
+ const entry = values === null || (values.deform === null && values.frame === null) ? now.entry : { ...now.entry };
612
694
  shown.push(entry);
613
- const fillers = fillingSkins(doc, slot.name, now.s.placeholder).map((k) => `"${k}"`);
614
- if (seen.length > 0 && fillers.length > 1) {
615
- why.push(`slot "${slot.name}" shows placeholder "${now.s.placeholder}", which skins ${fillers.join(', ')} fill, and ${seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
695
+ if (values !== null && values.seen.length > 0 && now.fillers.length > 1) {
696
+ why.push(`slot "${slot.name}" shows placeholder "${now.entry.placeholder}", which skins ${now.fillers.join(', ')} fill, and ${values.seen.map((x) => `"${x}"`).join(', ')} key(s) it — which record --skin all shows, and so whether the timeline moves it, is the Spine file's skin order, not the model's`);
616
697
  continue;
617
698
  }
618
- if (deform !== null) entry.deform = deform;
619
- if (frame !== null) entry.frame = frame;
699
+ if (values !== null) {
700
+ if (values.deform !== null) entry.deform = values.deform;
701
+ if (values.frame !== null) entry.frame = values.frame;
702
+ }
620
703
  }
621
704
  return { shown, why };
622
705
  }
package/src/core/entry.ts CHANGED
@@ -24,14 +24,15 @@
24
24
  * a Bézier segment as, so that a fitter holds its tolerance to the poser's
25
25
  * own evaluation rather than to a mirror of it, issue #1417 — re-exported,
26
26
  * never copied, because a copy is the drift the request exists to remove;
27
- * `CA12` holds the identity).
27
+ * `CA12` holds the identity; `readSetupMesh`, issue #1538, the named setup
28
+ * geometry read requested by the player, with `SetupMesh` and `SetupMeshTarget`).
28
29
  * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
29
30
  * `./core`, and a symbol added here and not there is not promised.
30
31
  */
31
32
  export { readModel, underSkin, underNoSkin, CoreInputError, CORE_DOCUMENT_SPEC, CORE_DOCUMENT_SPECS, CORE_BLEND_MODES, activeBones } from './index.ts';
32
33
  export type { CompiledDocument, CoreSlotRow, CoreBlendMode } from './index.ts';
33
- export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
34
- export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
34
+ export { readSetupMesh, poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setupBounds, physicsClockStep, PHYSICS_CLOCK_LEAD } from './raw.ts';
35
+ export type { SetupMesh, SetupMeshTarget, RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
35
36
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
36
37
  export { openTrack } from './track.ts';
37
38
  export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, PhysicsEnvironment } from './track.ts';
package/src/core/index.ts CHANGED
@@ -1196,10 +1196,11 @@ const shownOfView = new WeakMap<CompiledDocument, Map<string, Map<string, ShownR
1196
1196
  * The answer reads the view's skins and the slot's name and placeholder only,
1197
1197
  * so it is resolved once per view, slot and placeholder and kept (issue
1198
1198
  * #1134: every sample of a walk resolved every slot again). Read-only to every
1199
- * caller; a view built anew resolves its own.
1199
+ * caller; a view built anew resolves its own. An explicit placeholder reads
1200
+ * another table key without changing the slot (issue #1538).
1200
1201
  */
1201
- export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownResolution {
1202
- if (slot.setup === null) return null;
1202
+ export function shownAttachment(doc: CompiledDocument, slot: ModelSlot, placeholder: string | null = slot.setup): ShownResolution {
1203
+ if (placeholder === null) return null;
1203
1204
  let bySlot = shownOfView.get(doc);
1204
1205
  if (bySlot === undefined) {
1205
1206
  bySlot = new Map();
@@ -1210,9 +1211,9 @@ export function shownAttachment(doc: CompiledDocument, slot: ModelSlot): ShownRe
1210
1211
  byPlaceholder = new Map();
1211
1212
  bySlot.set(slot.name, byPlaceholder);
1212
1213
  }
1213
- if (byPlaceholder.has(slot.setup)) return byPlaceholder.get(slot.setup) as ShownResolution;
1214
- const resolved = resolveShown(doc, slot.name, slot.setup);
1215
- byPlaceholder.set(slot.setup, resolved);
1214
+ if (byPlaceholder.has(placeholder)) return byPlaceholder.get(placeholder) as ShownResolution;
1215
+ const resolved = resolveShown(doc, slot.name, placeholder);
1216
+ byPlaceholder.set(placeholder, resolved);
1216
1217
  return resolved;
1217
1218
  }
1218
1219
 
package/src/core/raw.ts CHANGED
@@ -113,7 +113,7 @@
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, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
116
+ import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
117
  import { posedBoneWorld, 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';
@@ -121,8 +121,9 @@ import { attachmentStates } from './deform.ts';
121
121
  import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
122
122
  import { eventsFired, type CoreEventRow } from './events.ts';
123
123
  import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
124
- import { meshArraysOf, poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
124
+ import { meshArraysOf, poseGeometry, worldVertices, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
125
125
  import { RUNTIME_DEG, type CoreWorld } from './world.ts';
126
+ import { fillingSkins } from './skins.ts';
126
127
  import type { SliderApplication } from './constraints_slider.ts';
127
128
 
128
129
  /** One bone, posed: the world transform and the runtime getters' four readings (the header's `bones`). */
@@ -465,6 +466,81 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
465
466
  return setupPoseIn(doc, plant, RAW_MODE);
466
467
  }
467
468
 
469
+ /** A table key to read, with an optional skin-view override; omitted skin keeps the supplied view. */
470
+ export interface SetupMeshTarget {
471
+ readonly slot: string;
472
+ readonly attachment: string;
473
+ /** A named skin (with default fallback), `all`, or `null` for no skin set. */
474
+ readonly skin?: string | null;
475
+ }
476
+
477
+ /** A detached named mesh in setup world units, y up; hull counts vertices, not coordinates. */
478
+ export interface SetupMesh {
479
+ slot: string;
480
+ /** The requested attachment table key, even when the record states another display name. */
481
+ attachment: string;
482
+ /** The skin whose table supplied the requested record, including a fallback. */
483
+ skin: string;
484
+ vertices: number[];
485
+ triangles: number[];
486
+ hull: number | null;
487
+ }
488
+
489
+ /** Model definitions by value: object key order is irrelevant, array positions and signed zero are not. */
490
+ function sameSetupMeshDefinition(a: unknown, b: unknown): boolean {
491
+ if (Object.is(a, b)) return true;
492
+ if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false;
493
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
494
+ if (Array.isArray(a) && Array.isArray(b) && a.length !== b.length) return false;
495
+ const left = a as Record<string, unknown>;
496
+ const right = b as Record<string, unknown>;
497
+ const keys = Object.keys(left);
498
+ return keys.length === Object.keys(right).length && keys.every((key) =>
499
+ Object.prototype.hasOwnProperty.call(right, key) && sameSetupMeshDefinition(left[key], right[key]));
500
+ }
501
+
502
+ /**
503
+ * Read a named mesh without changing the setup selection or playing anything
504
+ * (issue #1538). Setup constraints and reset physics use the raw poser's own
505
+ * path. A selected mesh includes its setup-slider deform; an unselected mesh
506
+ * reads its stored geometry through those same matrices, with no hypothetical
507
+ * attachment switch or deform. Linked meshes use their source topology and
508
+ * bindings, and the queried slot's bone for unweighted vertices; `timelines`
509
+ * affects only a selected mesh's setup-slider deform, as in `poseRawSetup`.
510
+ *
511
+ * Skin resolution is the supplied view's unless explicitly overridden. Under
512
+ * `all`, differing definitions of the requested mesh are refused: the model
513
+ * does not carry the file order that would choose one. Object property order
514
+ * does not distinguish definitions; array positions and Object.is leaf values
515
+ * do. Identical definitions keep the resolver's first supplying skin. All arrays are fresh.
516
+ */
517
+ export function readSetupMesh(doc: CompiledDocument, target: SetupMeshTarget): SetupMesh {
518
+ const view = target.skin === undefined ? doc : target.skin === null ? underNoSkin(doc) : underSkin(doc, target.skin);
519
+ const where = `setup mesh slot ${JSON.stringify(target.slot)} attachment ${JSON.stringify(target.attachment)} under skin ${JSON.stringify(view.skin)}`;
520
+ const slot = view.slots.find((s) => s.name === target.slot);
521
+ if (slot === undefined) throw new CoreInputError(`${where}: no such slot`);
522
+ const resolved = shownAttachment(view, slot, target.attachment);
523
+ if (resolved === null) throw new CoreInputError(`${where}: no such attachment`);
524
+ const fills = fillingSkins(view, slot.name, target.attachment);
525
+ const definitions = fills.map((name) => view.skins.find((k) => k.name === name)?.attachments[slot.name]?.[target.attachment]);
526
+ if ('conflict' in resolved || definitions.some((r) => !sameSetupMeshDefinition(r, definitions[0]))) {
527
+ throw new CoreInputError(`${where}: skins [${fills.join(', ')}] fill this attachment differently; choose a named skin`);
528
+ }
529
+ const geometry = resolved.record.geometry;
530
+ const mesh = geometry?.kind === 'linkedmesh'
531
+ ? view.skins.find((s) => s.name === geometry.skin)?.attachments[geometry.slot]?.[geometry.source]?.geometry
532
+ : geometry;
533
+ if (mesh?.kind !== 'mesh') throw new CoreInputError(`${where}: ${resolved.record.kind} is not a mesh`);
534
+ const { world, shown, drawOrder } = setupPosed(view, {}, RAW_MODE);
535
+ const selected = shown.find((s) => s.slot === slot.name && s.placeholder === target.attachment && s.skin === resolved.skin);
536
+ const bone = world.get(slot.bone);
537
+ if (bone === undefined) throw new CoreInputError(`${where}: no setup transform for bone ${JSON.stringify(slot.bone)}`);
538
+ const vertices = selected === undefined
539
+ ? numbersOf(worldVertices(mesh.vertices, bone, world).map(rawNumber), `${where} vertex`, RAW_MODE)
540
+ : rawDrawn(view, [selected], world, drawOrder, {}).drawn[0].vertices;
541
+ return { slot: slot.name, attachment: target.attachment, skin: resolved.skin, vertices: [...vertices], triangles: [...mesh.triangles], hull: mesh.hull ?? null };
542
+ }
543
+
468
544
  /** `poseRawSetup` in `mode` — under `keep`, a value that is not finite stays in the pose (`./walk.ts`). */
469
545
  export function setupPoseIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): RawPose {
470
546
  const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);