@rig-c/review 0.8.0 → 0.8.1

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/README.md CHANGED
@@ -115,7 +115,7 @@ bun run smoke
115
115
 
116
116
  `smoke` packs this tree, installs the tarball into an empty project, and runs the installed bin: `--help`, `open --help`, `check` on the bundled demo, and a served page whose browser bundle is built from the installed files. A file missing from `files` in `package.json` fails there, not after publishing.
117
117
 
118
- The server bundles `web/main.ts` at startup using the pinned installed runtime, with no CDN. `build` verifies that browser bundle in memory. CI checks types, focused path/state/API tests, bundling and the demo package. Follow AGENTS.md for contributor verification policy. [DESIGN.md](docs/DESIGN.md) distinguishes the runnable foundation from future modeling touch-up and preserves the illustrative screen concept.
118
+ The server bundles `web/main.ts` at startup using the pinned installed runtime, with no CDN. `build` verifies that browser bundle in memory. CI checks types, focused path/state/API tests, bundling and the demo package. Follow AGENTS.md for contributor verification policy. For a live run against the installed producers, [docs/LIVE_FIXTURE.md](docs/LIVE_FIXTURE.md) gives the public package, the producer bench and the open line that reaches `baseline: reproduced`. [DESIGN.md](docs/DESIGN.md) distinguishes the runnable foundation from future modeling touch-up and preserves the illustrative screen concept.
119
119
 
120
120
  ### Releasing
121
121
 
@@ -294,7 +294,10 @@ rig-review control /absolute/output --reference /absolute/original.mp4
294
294
  ```
295
295
 
296
296
  The second command reads the connected page's actual inspection state and its
297
- `pageId`/`revision`; it does not open a browser or start another worker. To change
297
+ `pageId`/`revision`; it does not open a browser or start another worker. A page
298
+ learns from `GET /api/access` (`control: true|false`) whether its instance serves
299
+ control; an instance opened without `--control` is never asked for
300
+ `/api/control/config`. To change
298
301
  that page, use the observed values and a closed inspection command:
299
302
 
300
303
  ```sh
@@ -695,7 +698,7 @@ In **Animate**, the **Retarget** block (#114) puts a pose sequence the package d
695
698
  - **Clean** — clean first (default; a sequence that already carries clean's record starts with it off), `--loop`, `--legs`, `--rest`, the stillness hold and `--hips-inferred`. With clean off the sequence goes to retarget as the package holds it, and an uncleaned one is refused by the producer (`POSE_NOT_CLEANED`).
696
699
  - **Retarget** — the new animation's name (`--animation`), `--flip`, `--short`, `--min-score`, `--bends refuse|clamp`, `--bend-tolerance`, `--leg-scale min,max`, `--plant-feet`, `--stance`, `--raised-by-head`, `--carry-idle`, and the person when the sequence holds more than one.
697
700
 
698
- **Apply · Retarget** sends `POST /api/rebuild {retarget: {sequence, map, flags, configHash}}` — a reference id, the map by content and typed flags; the server maps the flags to argv. Into a scratch directory under the review state directory it writes the sequence (`pose.json`) and the map (`map.json`), runs `rig-pose clean --pose pose.json --out pose.clean.json …` and `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json …`, copies the handoff rig's files beside the new `motion.json` as `rig/`, and finishes with the shared tail: the per-file diff against the inspected rig and `rigc build` with Apply's default pack flags, using the `rigc` installed beside `@rig-c/pose`. The package's `motion.json` is never written; review writes no keys. Every `FAIL` line of a refusal is shown verbatim — `POSE_NOT_CLEANED`, `POSE_BEND_ONE_WAY`, `RAISED_HEAD`, `MOTION_NAME_FREE`, `MAP_RESOLVES` / `MAP_BONE_EXISTS` — and the stage keeps its model. A built job loads in place, the new animation is chosen, the sequence is shown as the reference beside the rig, and the run's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines are listed; each line seeks to the first frame it names, at the motion's own key time for that frame (the run's report `frameTimes`). `context.applied` becomes `rig-review-applied/4`, with the `rig-review-retarget/1` record (sequence by reference, path and hash; map by content and hash; flags and their argv), the producer's line, what the scratch motion holds against the inspected rig's, the per-file diff and the listed lines. **Reset** returns to the inspected build. `rig-pose compare --sheet` is not run: it needs a detector pass over the render, which review does not run.
701
+ **Apply · Retarget** sends `POST /api/rebuild {retarget: {sequence, map, flags, configHash}}` — a reference id, the map by content and typed flags; the server maps the flags to argv. Into a scratch directory under the review state directory it writes the sequence (`pose.json`) and the map (`map.json`), runs `rig-pose clean --pose pose.json --out pose.clean.json …` and `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json …`, copies the handoff rig's files beside the new `motion.json` as `rig/`, and finishes with the shared tail: the per-file diff against the inspected rig and `rigc build` with Apply's default pack flags, using the `rigc` installed beside `@rig-c/pose`. The package's `motion.json` is never written; review writes no keys. Every `FAIL` line of a refusal is shown verbatim — `POSE_NOT_CLEANED`, `POSE_BEND_ONE_WAY`, `RAISED_HEAD`, `MOTION_NAME_FREE`, `MAP_RESOLVES` / `MAP_BONE_EXISTS` — and the stage keeps its model. A built job loads in place, the new animation is chosen, the sequence is shown as the reference beside the rig, and the run's FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines are listed; each line seeks to the first frame it names, at the motion's own key time for that frame (the run's report `frameTimes`). While that build is on the stage, its report `frameTimes` are the new animation's clock: the transport counter reads `F<n> / <last>` (`frameTimes · … · this build's report`) instead of `rate unknown`, and the rig pane's header names the animation, frame and time the drawn pose is at (`Generated rig · walk · F14 · 0.583 s`) — the same clock a seek sets. Reset or another rebuild drops that clock. `context.applied` becomes `rig-review-applied/4`, with the `rig-review-retarget/1` record (sequence by reference, path and hash; map by content and hash; flags and their argv), the producer's line, what the scratch motion holds against the inspected rig's, the per-file diff and the listed lines. **Reset** returns to the inspected build. `rig-pose compare --sheet` is not run: it needs a detector pass over the render, which review does not run.
699
702
 
700
703
  To reproduce an Apply outside review, run the same producer with the record's flags in an empty directory holding the sequence as `pose.json` and the record's map as `map.json`: `rig-pose clean --pose pose.json --out pose.clean.json <op.clean…>` then `rig-pose retarget --rig <handoff rig> --map map.json --sequence pose.clean.json --out motion.json <op.args…>`. Its `motion.json` is byte-equal to the scratch build's `rig/motion.json`.
701
704
 
@@ -295,7 +295,7 @@ The uniform example is an explicitly authored test clock; it is not a timing cla
295
295
 
296
296
  The current frame is the greatest sample index whose timestamp is at or before the inspection time, only within the closed interval from the first sample to the last. Time before or after that domain is unknown; the last frame has no invented trailing duration. A one-frame clock is known only at its single timestamp, and an empty clock is unknown. Frame seeking accepts only a nonnegative integer through the declared last index and returns that sample's exact time. Broad epsilon snapping does not advance a genuinely earlier time to the next frame.
297
297
 
298
- With a model player, the transport uses this clock only while the active animation exactly matches the report's named `animation`; other animations or a missing binding keep frame lookup unknown. A source-only inspection can use its explicit report clock without inventing an animation identity. Unbounded or absent timing keeps frame lookup unknown. The seconds transport remains available independently. Bounded clocks show `F<n> / <last>`; outside their domain the frame is unknown. The unchanged v19 report displays `rate unknown`. An explicit 24fps, zero-offset test fixture shows F100 at `100/24`, displayed as 4.1667s; offset and irregular fixtures exercise the different declared times.
298
+ With a model player, the transport uses this clock only while the active animation exactly matches the report's named `animation`; other animations or a missing binding keep frame lookup unknown. The one other clock is a scratch build's own: after a Retarget Apply, the run's report `frameTimes` bind to the new animation and to that build's model hash, and the transport uses them for that animation when the package report has no clock for it. A source-only inspection can use its explicit report clock without inventing an animation identity. Unbounded or absent timing keeps frame lookup unknown. The seconds transport remains available independently. Bounded clocks show `F<n> / <last>`; outside their domain the frame is unknown. The unchanged v19 report displays `rate unknown`. An explicit 24fps, zero-offset test fixture shows F100 at `100/24`, displayed as 4.1667s; offset and irregular fixtures exercise the different declared times.
299
299
 
300
300
  When feedback is added, `context.frame` is recalculated from the active clock and current inspection time, or recorded as null. Marker frame labels do not override that calculation. Existing inclusive feedback intervals remain producer annotations and are not inferred from the transport. No clock operation retimes or writes motion, source report or models.
301
301
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@ export async function startCompareServer(p:ComparePackage,options:{port?:number;
36
36
  const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try{
37
37
  const url=new URL(req.url),origin=`http://127.0.0.1:${port}`;
38
38
  if(url.origin!==origin)return json({error:'Loopback host required'},403);
39
- const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
39
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
40
40
  const controlled=await control?.(req,origin);if(controlled)return controlled;
41
41
  options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await assertCompareUnchanged(p);}catch{return staleOpenStatus(options.open!);}}return gate;}
42
42
  if(req.method==='PUT'&&url.pathname==='/api/compare/session'){
@@ -34,3 +34,33 @@ export function frameAtTime(clock:FrameClock|null|undefined,time:number):number|
34
34
  while(low<=high){const middle=low+Math.floor((high-low)/2);const sample=timeAtFrame(clock,middle);if(sample===null)return null;if(sample<=time)low=middle+1;else high=middle-1;}
35
35
  return high>=0?high:null;
36
36
  }
37
+
38
+ /** The most frame times a scratch build's report may state (a bound for the served job, not a timing claim). */
39
+ export const MAX_STAGE_FRAME_TIMES=100_000;
40
+
41
+ /** A producer report's `frameTimes`, kept only when it is a bounded, finite, strictly increasing list; else null. */
42
+ export function samplesClock(times:unknown):FrameClock|null {
43
+ if(!Array.isArray(times)||!times.length||times.length>MAX_STAGE_FRAME_TIMES)return null;
44
+ for(let i=0;i<times.length;i++){const t=times[i];if(typeof t!=='number'||!Number.isFinite(t)||(i>0&&t<=times[i-1]))return null;}
45
+ return {kind:'samples',frameTimes:[...times as number[]]};
46
+ }
47
+
48
+ /** The clock a scratch build states for one of its animations (a retarget's `motion.report.json`), bound to that build. */
49
+ export interface StageClock {animation:string;modelHash:string;clock:FrameClock}
50
+
51
+ /**
52
+ * The transport's clock. The package report's clock when it names the active animation; else, with a player, the
53
+ * clock the build on the stage states for the active animation (#164: a retarget's own report frameTimes). The
54
+ * stage clock binds by animation and by the drawn model's hash, so a reset or another rebuild drops it.
55
+ */
56
+ export function transportClock(report:{clock:FrameClock|null|undefined;animation:string|undefined},active:string,hasPlayer:boolean,stage:StageClock|null,stageModelHash:string|null):{clock:FrameClock;source:'report'|'stage'}|null {
57
+ const own=clockForAnimation(report.clock,report.animation,active,hasPlayer);if(own)return {clock:own,source:'report'};
58
+ if(hasPlayer&&stage&&active&&stage.animation===active&&stage.modelHash===stageModelHash)return {clock:stage.clock,source:'stage'};
59
+ return null;
60
+ }
61
+
62
+ /** The rig pane's header: the animation, frame and time the drawn pose is at — the transport's clock, the one a seek sets. */
63
+ export function rigPaneLabel(state:{hasPlayer:boolean;animation:string;time:number;frame:number|null}):string {
64
+ if(!state.hasPlayer)return 'Generated rig · read only';
65
+ return ['Generated rig',state.animation||'no animation',...(state.frame===null?[]:[`F${state.frame}`]),`${state.time.toFixed(3)} s`].join(' · ');
66
+ }
@@ -18,10 +18,11 @@ export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Op
18
18
  export const capabilityPattern=/^[a-f0-9]{64}$/;
19
19
  export function sameSecret(actual:string|null,expected:string):boolean{const a=Buffer.from(actual??''),b=Buffer.from(expected);return a.length===b.length&&timingSafeEqual(a,b);}
20
20
  /** Every non-GET request needs this instance's page capability, compared in constant time before any
21
- * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. */
22
- export function accessGate(req:Request,capability:string|undefined):Response|null{
21
+ * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. It also says
22
+ * whether this instance serves agent control, so a page without it never probes `/api/control/config` (#164). */
23
+ export function accessGate(req:Request,capability:string|undefined,control=false):Response|null{
23
24
  const url=new URL(req.url),headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'};
24
- if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
+ if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability,control},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
26
  if(!capability||req.method==='GET'||req.method==='HEAD')return null;
26
27
  return sameSecret(req.headers.get(capabilityHeader),capability)?null:Response.json({error:'REVIEW_CAPABILITY_REQUIRED: reopen with rig-review open to edit'},{status:403,headers});
27
28
  }
package/src/rebuild.ts CHANGED
@@ -29,6 +29,7 @@ import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, MAX_BU
29
29
  import { jsonDiff } from './config-patch.ts';
30
30
  import { CLOTH_KEYPOINTS_UNDECLARED, CLOTH_PATCH_MIXED, CLOTH_SOURCE_UNAVAILABLE, CLOTH_WRITE_SINCE, clothLinks, clothPatchKind, readClothTargets, writtenDiff, type ClothTagView, type WrittenDiff } from './cloth-recipe.ts';
31
31
  import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
32
+ import { samplesClock } from './frame-clock.ts';
32
33
  import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
33
34
 
34
35
  export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
@@ -302,6 +303,8 @@ export interface RetargetJob {
302
303
  lines: string[]; producerLine: string | null; refusals: string[]; exitCode: number | null;
303
304
  wallMs: { clean: number | null; retarget: number | null; compile: number | null };
304
305
  files: FilesDiff | null; motion: MotionSummary | null; readings: RetargetLine[];
306
+ /** The run's report `frameTimes` (`motion.report.json`): the new animation's own clock, null when absent or malformed (#164). */
307
+ frameTimes: number[] | null;
305
308
  build: RebuildBuild | null;
306
309
  }
307
310
  /** Deterministic: the package, the config bytes, the record's bytes and both tool versions. No clock. */
@@ -592,7 +595,14 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
592
595
  if (stored.schema !== REBUILD_SCHEMA || stored.id !== id || stored.status !== 'built' || !stored.build) return null;
593
596
  const now = await hashBuild(join(dir, 'build'), id);
594
597
  if (JSON.stringify(now.files) !== JSON.stringify(stored.build.files)) return null;
595
- return { ...stored, reused: true };
598
+ const job = { ...stored, reused: true };
599
+ // A retarget built before the job carried its report's frameTimes (#164): read them from the build's own report.
600
+ if ('kind' in job && job.kind === 'retarget' && (job as RetargetJob).frameTimes === undefined) {
601
+ let times: unknown = null;
602
+ try { times = (JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }).frameTimes; } catch { /* no report: no clock */ }
603
+ const clock = samplesClock(times); (job as RetargetJob).frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
604
+ }
605
+ return job;
596
606
  } catch { return null; }
597
607
  }
598
608
 
@@ -991,6 +1001,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
991
1001
  await writeFile(join(out, 'motion.json'), text, { mode: 0o600 });
992
1002
  let frameTimes: number[] | null = null;
993
1003
  try { const report = JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }; if (Array.isArray(report.frameTimes) && report.frameTimes.every(t => typeof t === 'number')) frameTimes = report.frameTimes as number[]; } catch { /* no report: the lines' own t= */ }
1004
+ const clock = samplesClock(frameTimes); job.frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
994
1005
  job.readings = retargetLines(retargetRun?.lines ?? [], frameTimes).slice(0, MAX_RETARGET_LINES);
995
1006
  let inspected: unknown = null;
996
1007
  try { inspected = JSON.parse(await readFile(join(inputs.rig, 'motion.json'), 'utf8')); } catch { /* a rig without motion.json: nothing to keep */ }
@@ -1031,7 +1042,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1031
1042
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1032
1043
  const reused = await reuse<RetargetJob>(dir, id);
1033
1044
  if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1034
- const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], build: null };
1045
+ const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], frameTimes: null, build: null };
1035
1046
  jobs.set(id, job); running = id;
1036
1047
  void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
1037
1048
  return { status: 202, body: snapshot(job) };
package/src/server.ts CHANGED
@@ -45,7 +45,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
45
45
  const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
46
46
  const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
47
47
  if(url.origin!==origin)return json({error:'Loopback host required'},403);
48
- const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
48
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
49
49
  const controlled=await control?.(req,origin);if(controlled)return controlled;
50
50
  options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
51
51
  if(req.method==='PUT'&&url.pathname==='/api/session'){
package/web/compare.ts CHANGED
@@ -49,6 +49,8 @@ let stripBusy = false;
49
49
  let persistence:PersistenceConnection|null=null,submittedBaseline:CompareSession|null=null,draftConflict=false;
50
50
  // First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
51
51
  const access=pageInstanceAccess();let readOnly:string|null=null;
52
+ /** Whether this instance serves agent control (`/api/access`); a plain open is never probed for it (#164). */
53
+ let controlServed=true;
52
54
  let referenceFrames:VideoInfo|null=null;
53
55
  const presentation=new ComparePresentation();
54
56
  const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
@@ -393,9 +395,9 @@ function bind(): void {
393
395
 
394
396
  async function start(): Promise<void> {
395
397
  try {
396
- let writes: { capabilityRequired: boolean };
397
- [data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
398
- readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true);
398
+ let writes: { capabilityRequired: boolean; control?: boolean };
399
+ [data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
400
+ readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true); controlServed = writes.control !== false;
399
401
  submittedBaseline=structuredClone(session);
400
402
  if (data.id !== session.id || data.sourceHash !== session.sourceHash || JSON.stringify(data.binding) !== JSON.stringify(session.binding)) throw new Error('Comparison and submission session refer to different source identities. Reopen the review.');
401
403
  text('title', data.title); text('animation', `${data.animation} · A/B order and reference video hash are fixed`); document.title = data.title + ' · rig review compare'; select('status').value = session.status; provenance(); bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlays(true);const part=parts?.value();if(part){activeCamera=part.side==='b'?'b':'a';workspace?.revealInspector();document.querySelector<HTMLButtonElement>('[data-inspector=rig]')?.click();renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera());persistence?.save();},part=>{select('side').value=part.side;select('target-kind').value='slot';renderFeedbackSelection();input('target-name').value=part.slot;document.querySelector<HTMLButtonElement>('[data-inspector=feedback]')?.click();}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackSelection(); renderFeedback(); savedState();
@@ -410,7 +412,7 @@ async function start(): Promise<void> {
410
412
  input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
411
413
  input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
412
414
  await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
413
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
415
+ if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
414
416
  } catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
415
417
  }
416
418
  function sharedBox():PoseBounds|null {const a=assets.a?.stage??players.a?.bounds(),b=assets.b?.stage??players.b?.bounds();return a&&b?unionBounds(a,b):a??b??null;}
@@ -431,7 +433,7 @@ function showSurvivors():void {
431
433
  updateFeedbackAvailability();
432
434
  const state=renderPairLayout();activeCamera=state.effective==='b'?'b':state.effective==='reference'?'reference':'a';fit();
433
435
  message(`Comparison unavailable · ${state.reasons.compare??''}${state.effective&&state.effective!=='compare'?` · showing ${state.effective==='reference'?'the reference':state.effective.toUpperCase()} alone`:''}`,true);
434
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
436
+ if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
435
437
  }
436
438
  function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
437
439
  const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;
package/web/index.html CHANGED
@@ -6,6 +6,6 @@
6
6
  <header class="topbar"><div class="brand"><span class="brand-mark">r</span>rig review</div><div><strong id="package-title">Review package</strong><span id="revision" class="subtle"></span></div><div class="mode-switch" role="group" aria-label="Mode"><button type="button" data-mode="rig" data-inspector="rig" aria-controls="inspector-rig" aria-pressed="true">Setup</button><button type="button" data-mode="playback" data-inspector="playback" aria-controls="inspector-playback" aria-pressed="false">Animate</button></div><div class="top-actions"><span id="dirty" class="subtle">Loading…</span><button id="write-review" type="button" class="primary" aria-haspopup="dialog" aria-controls="review-dialog">Write review</button></div></header>
7
7
  <div class="layout">
8
8
  <aside class="left-panel"><div class="left-header parts-header"><h2>PARTS</h2><input id="part-search" data-part-search type="search" autocomplete="off" aria-label="Find slot" placeholder="Find slot"></div><details id="package-context" class="package-context"><summary><span class="eyebrow">CHECKPOINTS</span><span id="checkpoint-title">Select a checkpoint</span></summary><nav id="checkpoints" aria-label="Checkpoints"></nav><section><label for="reference-select" class="label">Reference</label><select id="reference-select"></select><div id="reference-info" class="hint"></div><div id="person-control" hidden><label for="person-select" class="label space-top">Person in input pose</label><select id="person-select"></select></div></section></details><aside id="parts-explorer" class="parts-explorer" aria-label="Parts explorer"><button type="button" data-part-whole aria-pressed="true">Whole rig</button><label class="part-solo"><input id="part-solo" data-part-solo type="checkbox" disabled>Solo selected slot</label><p data-part-status class="hint" role="status">Loading actual slots…</p><ul data-part-list aria-label="Actual rig slots"></ul><p class="hint">Select in context or enable Solo. Thumbnails are each slot's declared page region; a type icon stands in where the model declares none. Atlas pages are not part thumbnails.</p></aside></aside>
9
- <main><div class="viewbar"><fieldset id="layer-controls" class="layer-toggles"><legend class="sr-only">Rig layers</legend><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="I" id="layer-images" type="checkbox" checked disabled><span>Images</span><kbd aria-hidden="true">I</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="M" id="layer-mesh" type="checkbox" disabled><span>Mesh edges</span><kbd aria-hidden="true">M</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="B" id="layer-bones" type="checkbox" disabled><span>Bone points</span><kbd aria-hidden="true">B</kbd></label></fieldset><div class="view-tools"><button id="chest-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="chest-editor-bar" title="Place the two chest regions on the rig: drag a centre, the core edge or the band" hidden>Chest regions</button><button id="mesh-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="mesh-editor-bar" title="Edit the selected slot's actual mesh as a typed delta: preview only, the producer retriangulates" hidden>Edit mesh</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div><details id="view-menu" class="view-menu"><summary aria-label="View layout">View: <span id="view-menu-label">Side by side</span></summary><div class="view-menu-list" role="group" aria-label="View"><button type="button" data-view="compare" aria-pressed="true">Side by side</button><button type="button" data-view="reference" aria-pressed="false">Reference</button><button type="button" data-view="rig" aria-pressed="false">Rig</button></div></details></div><div id="chest-editor-bar" class="chest-editor-bar" role="region" aria-label="Chest region editor" hidden><div class="bust-group" role="group" aria-label="Chest editor mode"><input id="bust-mode" type="hidden" value="circles"><button id="bust-mode-circles" type="button" data-bust-mode="circles" aria-pressed="true" title="Two circles, each centre, core and band set by hand (#99)">Circles</button><button id="bust-mode-landmarks" type="button" data-bust-mode="landmarks" aria-pressed="false" title="Three clicks (right apex, left apex, cleavage); the producer derives both circles (contour.bust)">Landmarks</button></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-r">● R</span><label>X<input id="bust-r-x" type="number" step="1" inputmode="decimal" aria-label="R region centre x"></label><label>Y<input id="bust-r-y" type="number" step="1" inputmode="decimal" aria-label="R region centre y"></label><label>R<input id="bust-r-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="R region core radius"></label><label>Band<input id="bust-r-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="R region band"></label></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-l">● L</span><label>X<input id="bust-l-x" type="number" step="1" inputmode="decimal" aria-label="L region centre x"></label><label>Y<input id="bust-l-y" type="number" step="1" inputmode="decimal" aria-label="L region centre y"></label><label>R<input id="bust-l-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="L region core radius"></label><label>Band<input id="bust-l-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="L region band"></label></div><div id="bust-landmarks" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Chest landmarks (rig px)" hidden><button id="lm-pick-apex_r" type="button" data-landmark="apex_r" aria-pressed="false" title="Click the part to place the apex r; drag its marker to move it">Apex R</button><label>X<input id="lm-apex_r-x" type="number" step="1" inputmode="decimal" aria-label="Apex R x (rig px)"></label><label>Y<input id="lm-apex_r-y" type="number" step="1" inputmode="decimal" aria-label="Apex R y (rig px)"></label><button id="lm-pick-apex_l" type="button" data-landmark="apex_l" aria-pressed="false" title="Click the part to place the apex l; drag its marker to move it">Apex L</button><label>X<input id="lm-apex_l-x" type="number" step="1" inputmode="decimal" aria-label="Apex L x (rig px)"></label><label>Y<input id="lm-apex_l-y" type="number" step="1" inputmode="decimal" aria-label="Apex L y (rig px)"></label><button id="lm-pick-cleavage" type="button" data-landmark="cleavage" aria-pressed="false" title="Click the part to place the cleavage; drag its marker to move it">Cleavage</button><label>X<input id="lm-cleavage-x" type="number" step="1" inputmode="decimal" aria-label="Cleavage x (rig px)"></label><label>Y<input id="lm-cleavage-y" type="number" step="1" inputmode="decimal" aria-label="Cleavage y (rig px)"></label></div><div id="bust-landmark-params" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Landmark constants" hidden><label title="Core ratio r / (r + band); the producer's default is 0.22">α<input id="lm-alpha" type="range" min="0.05" max="0.95" step="0.01" value="0.22" aria-label="Alpha, core ratio"><output id="lm-alpha-value" for="lm-alpha">0.22</output></label><label title="Cleavage clearance in rig px; the producer's default is 3.8">Clearance<input id="lm-clearance" type="range" min="0" max="40" step="0.1" value="3.8" aria-label="Clearance, rig px"><output id="lm-clearance-value" for="lm-clearance">3.8</output></label><label title="Control bone for bust_r; empty is the producer's default boob_r">Bone R<input id="lm-bone_r" type="text" list="lm-bones" placeholder="boob_r" spellcheck="false" autocomplete="off"></label><label title="Control bone for bust_l; empty is the producer's default boob_l">Bone L<input id="lm-bone_l" type="text" list="lm-bones" placeholder="boob_l" spellcheck="false" autocomplete="off"></label><datalist id="lm-bones"></datalist></div><span id="lm-derived" class="hint" data-chest-mode="landmarks" aria-live="polite" hidden></span><div class="bust-group" data-chest-mode="circles"><button id="bust-mirror" type="button" title="Copy R's y, core radius and band to L">Mirror R → L</button><button id="bust-download" type="button" title="Download chest-regions.json and its binding; evidence, not an approval">Download JSON</button></div><div class="bust-group"><button id="bust-apply" type="button" title="Run the installed producer (rig-parts remesh) on these regions or landmarks into a scratch build and load it here; the package is unchanged">Apply · Rebuild</button><button id="bust-reset" type="button" title="Return the stage to the inspected build" disabled>Reset</button></div><span id="handoff-line" class="hint handoff-line" aria-live="polite" title="Producer handoff: the config, parts and inspected rig an Apply rebuilds from"></span><span id="chest-editor-status" class="hint" aria-live="polite"></span><pre id="chest-editor-log" class="rebuild-log" aria-label="Producer output" hidden></pre><textarea id="rebuild-applied" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></div><div id="mesh-editor-bar" class="chest-editor-bar mesh-editor-bar" role="region" aria-label="Mesh edit" hidden><div class="bust-group" role="group" aria-label="Mesh edit sub-mode"><button id="mesh-mode-modify" type="button" data-mesh-mode="modify" aria-pressed="false" aria-keyshortcuts="V" title="Modify · key V">Modify<kbd aria-hidden="true">V</kbd></button><button id="mesh-mode-create" type="button" data-mesh-mode="create" aria-pressed="false" aria-keyshortcuts="C" title="Create · key C">Create<kbd aria-hidden="true">C</kbd></button><button id="mesh-mode-delete" type="button" data-mesh-mode="delete" aria-pressed="false" aria-keyshortcuts="X" title="Delete · key X">Delete<kbd aria-hidden="true">X</kbd></button><button id="mesh-mode-edges" type="button" data-mesh-mode="edges" aria-pressed="false" aria-keyshortcuts="E" title="Edges · key E">Edges<kbd aria-hidden="true">E</kbd></button></div><div class="bust-group"><button id="mesh-undo" type="button" aria-keyshortcuts="Control+Z Meta+Z" title="Undo the last action · Cmd/Ctrl-Z" disabled>Undo</button><button id="mesh-redo" type="button" aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z" title="Redo · Shift-Cmd/Ctrl-Z" disabled>Redo</button><button id="mesh-reset" type="button" title="Drop the whole delta for this attachment (asks first)" disabled>Reset</button></div><div class="bust-group"><button id="mesh-download" type="button" title="Download mesh-edit.json and its binding; evidence, not an approval">Download JSON</button><button id="mesh-apply" type="button" title="Send the delta to the producer (rig-parts remesh --delta) and load the scratch revision in place; nothing is written into the package">Apply · Rebuild</button></div><textarea id="mesh-edit-draft" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea><span id="mesh-editor-status" class="hint" aria-live="polite"></span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="idle-handles" class="idle-handles" hidden aria-label="Chain amplitude handles"></div><div id="rig-fields-stage" class="rig-fields-stage" hidden aria-label="Rig field handles"></div><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading sticky-header"><strong>INSPECTOR</strong><span class="hint">Local view controls · source files stay unchanged</span></div><div class="inspector-body"><section id="inspector-rig" aria-label="Setup" data-inspector-panel="rig"><nav class="block-row" aria-label="Setup blocks"><button type="button" data-block-target="selected-part">Part</button><button type="button" data-block-target="modeling-tools">Modeling</button><button type="button" data-block-target="chest-regions">Chest</button><button type="button" data-block-target="mesh-panel">Mesh</button><button type="button" data-block-target="weights">Weights</button><button type="button" data-block-target="bones-panel">Bones</button><button type="button" data-block-target="rig-fields">Fields</button><button type="button" data-block-target="cuts-panel">Cuts</button><button type="button" data-block-target="cloth-panel">Cloth</button></nav><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="chest-regions" class="chest-regions" aria-labelledby="chest-regions-heading"><div class="eyebrow" id="chest-regions-heading">CHEST REGIONS · DETAIL</div><p class="hint">Crop pixels, x screen-right and y down (<code>family-chest-regions/1</code>). Place the circles with <strong>Chest regions</strong> in the view bar; this block holds names, bones, semi-axes, clip and the contour. Equal semi-axes export as a circle, unequal ones (or a clip) as the generator's 32-sample polygon. Exporting writes nothing into the package and is not an approval.</p><p id="chest-mapping" class="hint"></p><div class="chest-grid"><label>Crop width<input id="chest-crop-w" type="number" step="any" inputmode="decimal"></label><label>Crop height<input id="chest-crop-h" type="number" step="any" inputmode="decimal"></label></div><fieldset class="chest-row"><legend>Region 1</legend><label>Name<input id="chest-0-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-0-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-0-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-0-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-0-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-0-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-0-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-0-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-0-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Region 2</legend><label>Name<input id="chest-1-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-1-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-1-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-1-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-1-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-1-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-1-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-1-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-1-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Contour</legend><label>Tolerance<input id="chest-tolerance" type="number" step="any" inputmode="decimal"></label><label>Margin<input id="chest-margin" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-spacing" type="number" step="any" inputmode="decimal"></label><label>Max edge<input id="chest-max-edge" type="number" step="any" inputmode="decimal"></label><label>Stray<input id="chest-stray" type="number" step="any" inputmode="decimal"></label><label class="chest-check"><input id="chest-bridges" type="checkbox">Bridges</label></fieldset><details class="chest-more"><summary>Generator and mesh fields</summary><label for="chest-generator" class="label space-top">Public generator</label><input id="chest-generator" type="text" autocomplete="off" spellcheck="false" placeholder="Generator that will read this file"><label for="chest-r" class="label space-top">Mesh r (optional)</label><input id="chest-r" type="number" step="any" inputmode="decimal"><label for="chest-segments" class="label space-top">Mesh segments JSON (optional)</label><textarea id="chest-segments" rows="3" spellcheck="false"></textarea><label for="chest-source-rule" class="label space-top">Source rule (optional)</label><input id="chest-source-rule" type="text" autocomplete="off"></details><p id="chest-status" class="hint" aria-live="polite"></p><button id="chest-export" type="button" class="outline block">Download chest-regions.json</button></section><section id="mesh-panel" class="physics-panel mesh-panel" aria-labelledby="mesh-panel-heading"><div class="row"><span id="mesh-panel-heading" class="eyebrow">MESH · APPLY REMESHES</span></div><p id="mesh-panel-state" class="hint">Select a mesh part in the parts list.</p><p class="hint">Edits the selected part's <code>meshes.&lt;part&gt;</code> entry from the handoff config (the grid and contour modes). Apply sends the whole pending entry to the installed producer's <code>remesh --mesh</code> (one patch op at <code>/meshes/&lt;part&gt;</code>), compiles and checks the revision, and reloads it with the mesh edges on; Reset returns to the inspected build and the config's entry. Slider ranges are review's; the number field takes any value, and the producer judges it.</p><div id="mesh-params" class="physics-controls mesh-params"></div><div class="row mesh-tools"><span class="eyebrow">REGIONS</span><button id="mesh-add-circle" type="button" disabled>Add circle</button><button id="mesh-add-polygon" type="button" aria-pressed="false" disabled>Draw polygon</button><button id="mesh-regions-edit" type="button" aria-pressed="false" disabled>Drag on stage</button></div><div id="mesh-regions" class="mesh-regions"></div><div class="row mesh-tools"><span class="eyebrow">SEGMENTS</span><select id="mesh-segment-bone" aria-label="Bone to add as a segment" disabled></select><button id="mesh-segment-add" type="button" disabled>Add</button></div><ol id="mesh-segments" class="physics-pending-list mesh-segments"></ol><div class="row"><span class="eyebrow">PENDING ENTRY · DIFF AGAINST THE CONFIG</span></div><ol id="mesh-diff" class="physics-pending-list"></ol><ul id="mesh-precheck" class="mesh-precheck" aria-label="Review pre-check in the config loader's names"></ul><div class="row"><button id="mesh-panel-apply" type="button" class="primary" disabled>Apply</button><button id="mesh-panel-reset" type="button" disabled>Reset</button><button id="mesh-folds" type="button" title="rigc render --folds of the drawn animation on the inspected build and on this revision (#108 readings)">Folds reading</button></div><output id="mesh-status" class="environment-readout"></output><div id="mesh-counts" class="physics-readout" aria-label="Vertex and triangle counts before and after"></div><div id="mesh-readout" class="physics-readout" aria-label="TEXTURE_STRETCH before and after Apply"></div><textarea id="mesh-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="weights" class="physics-panel weights-block" aria-labelledby="weights-heading"><div class="row"><span id="weights-heading" class="eyebrow">WEIGHTS · SETUP POSE · WATCH ONLY</span></div><p class="hint">The selected slot's mesh at the setup pose, each vertex coloured by the weight the compiled model document stores for the picked bone (0 → 1), or by its dominant bone under <strong>Max influence</strong>. An unweighted mesh is its slot's bone at 1.0. Hover a vertex for its stored values. Nothing here edits, applies or records a weight.</p><div class="row weights-controls"><button id="weights-show" type="button" aria-pressed="false">Show weights</button><label>Colour by<select id="weights-mode"><option value="bone">Bone weight</option><option value="max">Max influence</option></select></label><label>Bone<select id="weights-bone" disabled></select></label></div><p id="weights-state" class="hint" aria-live="polite"></p><div id="weights-legend" class="weights-legend" aria-label="Weights legend"></div><output id="weights-hover" class="environment-readout"></output></section><section id="bones-panel" class="physics-panel bones-panel" aria-labelledby="bones-panel-heading"><div class="row"><span id="bones-panel-heading" class="eyebrow">BONES · CONFIG ANCHORS · APPLY REBUILDS</span></div><p id="bones-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's bones at their <code>at</code>, chain <code>points</code> and <code>tip</code> (rig px, the setup pose; the stage may be posed by an animation). Drag a handle on the stage or edit the numbers: each move is a config patch op at <code>/bones/&lt;i&gt;/at</code>, <code>/bones/&lt;i&gt;/points/&lt;k&gt;</code> or <code>/bones/&lt;i&gt;/tip</code> naming its bone, moved in whole rig px. <strong>Check</strong> runs <code>propose --from-config</code> on a scratch copy of the patched config (LINT and coverage lines; LINT blocks nothing) and <code>compare</code> against the handoff config. <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; Reset returns to the inspected build and clears the pending ops. Adding, removing or re-parenting bones is out of scope.</p><div class="row bones-controls"><button id="bones-edit" type="button" aria-pressed="false" disabled>Edit bones on stage</button><label for="bones-select">Bone</label><select id="bones-select" disabled></select></div><div id="bones-anchor-editor" class="bones-anchor-editor"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="bones-pending-list" class="physics-pending-list"></ol><div class="row"><button id="bones-check" type="button" disabled>Check</button><button id="bones-apply" type="button" class="primary" disabled>Apply</button><button id="bones-reset-build" type="button" disabled>Reset</button></div><output id="bones-status" class="environment-readout"></output><div id="bones-compare" class="bones-lines" aria-label="compare: origin and tip distance per moved bone"></div><div id="bones-lint" class="bones-lines" aria-label="propose --from-config: LINT and coverage lines"></div><textarea id="bones-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="rig-fields" class="physics-panel rig-fields" aria-labelledby="rig-fields-heading"><div class="row"><span id="rig-fields-heading" class="eyebrow">RIG FIELDS · CONFIG · APPLY REBUILDS</span><span id="rig-fields-dial-label" class="idle-preview-label" hidden>preview — Apply decides</span></div><p id="rig-fields-state" class="hint">Reading the producer handoff…</p><p class="hint">Small producer config fields, each a config patch op: the bone a rigid part rides (<code>/regions/&lt;part&gt;</code>), a lash's blink still row (<code>/motion/blink/still/&lt;part&gt;</code>, dragged on the stage), a stop's inward / outward bounds (<code>/stops/&lt;i&gt;</code>, arcs dragged at the pivot), a bone's inherit mode (<code>/bones/&lt;i&gt;/inherit</code>; a chain's <code>link_inherit</code>) and <code>turn</code>'s pivot and per-part depth (<code>/turn/pivot</code>, <code>/turn/depths/&lt;part&gt;</code>). <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; the producer's refusal is shown as printed. The dial only previews the drawn build's <code>turn</code> bone and is never recorded. Reset returns to the inspected build and clears the pending ops.</p><div id="rig-fields-region" class="rig-fields-group"></div><div id="rig-fields-still" class="rig-fields-group"></div><div id="rig-fields-stops" class="rig-fields-group"></div><div id="rig-fields-inherit" class="rig-fields-group"></div><div id="rig-fields-turn" class="rig-fields-group"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="rig-fields-pending-list" class="physics-pending-list"></ol><div class="row"><button id="rig-fields-apply" type="button" class="primary" disabled>Apply</button><button id="rig-fields-reset" type="button" disabled>Reset</button><button id="rig-fields-reading" type="button" disabled>Angle range</button></div><output id="rig-fields-status" class="environment-readout"></output><div id="rig-fields-readout" class="physics-readout" aria-label="BLINK_NO_HOLE and FEET_STAY_LEVEL before and after"></div><textarea id="rig-fields-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cuts-panel" class="physics-panel bones-panel cuts-panel" aria-labelledby="cuts-panel-heading"><div class="row"><span id="cuts-panel-heading" class="eyebrow">CUTS · PATCHES · LIMBS · APPLY RUNS RIG-PARTS BUILD</span></div><p id="cuts-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>assemble.cuts</code> polygons, <code>assemble.patches</code> boxes and each limb joint with its <code>range</code> arc, on the stage in rig px of the setup pose. Drag a vertex, a box corner or a joint, or edit the numbers: each change is a config patch op (<code>/assemble/cuts/&lt;i&gt;/polygon/&lt;k&gt;</code>, <code>/assemble/patches/&lt;i&gt;/box</code>, <code>/limbs/&lt;limb&gt;/segments/&lt;k&gt;/range</code>; a joint is its bone's origin, <code>/bones/…</code>). A new cut or patch adds its entry and a <code>regions</code> bone. <strong>Apply</strong> runs <code>rig-parts build</code> (assemble → rig → check) from the declared painting and See-through runs into scratch and needs the reproduced baseline; the producer's refusals (<code>ASSEMBLE_CUT_*</code>, <code>ASSEMBLE_UNDER_*</code>, <code>CONFIG_LIMB_*</code>, <code>CONFIG_PART_UNIQUE</code>) are shown as printed, and the recomposite error map, the <code>seam pair</code> lines and the build's wall clock come back with the build. Review paints no <code>under</code> or <code>cap</code> image. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cuts-edit" type="button" aria-pressed="false" disabled>Edit cuts on stage</button><label for="cuts-select">Entry</label><select id="cuts-select" disabled></select></div><div id="cuts-editor" class="bones-anchor-editor"></div><div id="cuts-add" class="row cuts-add"><span class="eyebrow">ADD</span><select data-cuts-add="from" aria-label="Plan part the new cut takes pixels from"></select><input data-cuts-add="name" type="text" autocomplete="off" placeholder="new part" aria-label="New part name"><input data-cuts-add="bone" type="text" list="lm-bones" autocomplete="off" placeholder="bone" aria-label="Bone the new part's region rides"><select data-cuts-add="draw" aria-label="Where the new part is drawn"></select><input data-cuts-add="overlap" type="number" min="0" step="1" value="0" inputmode="numeric" aria-label="Cut overlap band in rig px"><select data-cuts-add="alpha" aria-label="New patch alpha"><option value="silhouette">silhouette</option><option value="box">box</option></select><button type="button" data-cuts-add="place-cut">Place cut</button><button type="button" data-cuts-add="place-patch">Place patch</button><button type="button" data-cuts-add="finish">Finish</button><button type="button" data-cuts-add="cancel">Cancel</button></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cuts-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cuts-apply" type="button" class="primary" disabled>Apply</button><button id="cuts-reset" type="button" disabled>Reset</button></div><output id="cuts-status" class="environment-readout"></output><div id="cuts-readout" class="physics-readout" aria-label="The build's refusals, cut lines, recomposite, seam pairs and wall clock"></div><textarea id="cuts-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cloth-panel" class="physics-panel bones-panel cloth-panel" aria-labelledby="cloth-panel-heading"><div class="row"><span id="cloth-panel-heading" class="eyebrow">CLOTH · RECIPE PER TAG · APPLY RUNS RIG-PARTS PROPOSE --CLOTH</span></div><p id="cloth-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>cloth</code> block: one recipe per tag the installed reader accepts, with its fields as written. Each change is a config patch op at <code>/cloth/&lt;tag&gt;/…</code>. <strong>Show links</strong> draws the chains the tag's parts are meshed on, from the config's <code>points</code> and <code>tip</code> in rig px of the setup pose, and after an Apply the chains of the config the producer wrote. <strong>Apply</strong> runs <code>rig-parts propose --cloth --write-config</code> on the declared parts, painting and keypoints into scratch, then builds the written config (<code>rig-parts build</code> when the config declares <code>limbs</code>, else <code>rig --config</code>) and reloads it under the Environment's wind and gravity; the producer's refusals (<code>CONFIG_CLOTH_*</code>, <code>PROPOSE_CLOTH_DRAFTED</code>, <code>PROPOSE_CLOTH_PART</code>, <code>PROPOSE_WRITE_CONFIG_*</code>) are shown as printed. Review edits the recipe, never the drafted constraints. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cloth-links" type="button" aria-pressed="false" disabled>Show links on stage</button><label for="cloth-tag">Tag</label><select id="cloth-tag" disabled></select><label class="cloth-declared"><input id="cloth-declared" type="checkbox" disabled> recipe declared</label></div><div id="cloth-fields" class="cloth-fields"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cloth-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cloth-apply" type="button" class="primary" disabled>Apply</button><button id="cloth-reset" type="button" disabled>Reset</button></div><output id="cloth-status" class="environment-readout"></output><div id="cloth-readout" class="physics-readout" aria-label="Propose's refusals, the written config's diff, the route and check"></div><textarea id="cloth-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section></section><section id="inspector-playback" aria-label="Animate" data-inspector-panel="playback" hidden><nav class="block-row" aria-label="Animate blocks"><button type="button" data-block-target="transport" data-block-includes="frame-controls motion-tools angle-panel">Transport</button><button type="button" data-block-target="sound-panel">Sound</button><button type="button" data-block-target="environment">Environment</button><button type="button" data-block-target="physics-panel">Physics</button><button type="button" data-block-target="idle-tracks">Idle tracks</button><button type="button" data-block-target="readings">Readings</button><button type="button" data-block-target="requirements">Requirements</button><button type="button" data-block-target="retarget">Retarget</button><button type="button" data-block-target="report-panel">Report</button></nav><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output><label class="idle-repeat"><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><div id="reading-ticks" class="reading-ticks" hidden aria-label="Reading ticks"></div><div id="idle-blink-strip" class="idle-blink-strip" hidden><button id="idle-blink-marker" type="button" class="idle-blink-marker" aria-label="Blink time">◆</button></div></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><div class="eyebrow">MOTION · EDITS BUILD THROUGH THE PRODUCER</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="environment" class="environment-panel" aria-labelledby="environment-heading"><div class="row"><span id="environment-heading" class="eyebrow">ENVIRONMENT · INSPECTION ONLY</span></div><p class="hint">Wind, gravity and impulses go to the player while you inspect. Nothing is written to the model or motion; feedback records the state as context.</p><input id="environment-applied" type="checkbox" hidden aria-hidden="true" tabindex="-1"><div class="row"><label for="wind-base">Wind</label><input id="wind-base" type="range" min="-100" max="100" step="1" value="0" disabled><output id="wind-base-label">0</output></div><div class="row"><label><input id="wind-breeze" type="checkbox" disabled>Breeze</label><button id="wind-gust" type="button" disabled>Gust</button><label><input id="wind-repeat" type="checkbox" disabled>Repeat</label></div><div class="row"><label for="wind-gust-peak">Gust peak</label><input id="wind-gust-peak" type="number" min="0" max="200" step="1" value="48" inputmode="decimal" disabled><label for="wind-gust-duration">Duration (s)</label><input id="wind-gust-duration" type="number" min="0.1" max="20" step="0.1" value="2.4" inputmode="decimal" disabled></div><output id="wind-readout" class="environment-readout">No compiled model drawn · environment unavailable</output><div class="row"><label for="gravity">Gravity</label><input id="gravity" type="range" min="0" max="4" step="0.05" value="1" disabled><output id="gravity-label">1.00</output></div><div class="row"><button id="jolt" type="button" disabled>Jolt</button><label title="Dragging the rig pane moves the rig's physics instead of the camera. Hold Space to pan."><input id="drag-trail" type="checkbox" disabled>Drag to trail</label><button id="physics-reset" type="button" disabled>Reset physics</button></div><p class="hint">Drag to trail: dragging the rig pane tells the physics the rig moved; the camera stays. Hold Space to pan. Reset returns the runtime start values and re-poses from the current time, clearing the physics history.</p><div class="row"><label title="Readings and Apply's check read this wind and gravity as a rigc-environment/1 schedule; the wind clock restarts with the clip so you watch the same world"><input id="environment-use" type="checkbox" disabled>Use for readings</label><button id="environment-export" type="button" disabled>Export schedule</button><button id="environment-check" type="button" disabled>Check under this world</button></div><output id="environment-schedule" class="environment-readout">No schedule · the controls are not applied (the player stands at the runtime start values)</output><div id="environment-check-lines" class="environment-check-lines" aria-live="polite"></div><p class="hint">The schedule is the controls as a <code>rigc-environment/1</code> file from the clip's first frame: one step when the wind is steady, else one step per frame at 12 fps (between frames rigc holds the frame's value). A one-off Gust is not in it; Repeat puts gusts in it. Check under this world runs <code>rig-parts check --environment</code> on the build on the stage; the default-world line is that build's own check.</p></section><section id="physics-panel" class="physics-panel" aria-labelledby="physics-panel-heading"><div class="row"><span id="physics-panel-heading" class="eyebrow">PHYSICS TUNING · APPLY REBUILDS</span></div><p id="physics-panel-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/constraints/&lt;i&gt;/&lt;field&gt;</code> naming its constraint. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and reloads it under the same wind, gust and jolt state; Reset returns to the inspected build and clears the pending ops. Live preview before Apply waits on play#147.</p><div class="row physics-chain-row"><label for="physics-chain">Chain</label><select id="physics-chain" disabled></select><button id="physics-chain-pick" type="button" aria-pressed="false" disabled>Pick on stage</button></div><div class="row physics-ramp-row"><label for="physics-ramp-field">Ramp</label><select id="physics-ramp-field" disabled></select><label for="physics-ramp-root">root</label><input id="physics-ramp-root" type="number" step="any" inputmode="decimal" disabled><label for="physics-ramp-tip">tip</label><input id="physics-ramp-tip" type="number" step="any" inputmode="decimal" disabled><button id="physics-ramp" type="button" disabled>Ramp</button></div><div class="physics-work"><div id="physics-rows" class="physics-table-wrap" role="region" aria-label="Physics constraints the config declares" tabindex="0"></div><div id="physics-row-editor" class="physics-row-editor"></div></div><div id="physics-locked" class="physics-table-wrap physics-locked" role="region" aria-label="Constraints that are not editable" tabindex="0" hidden></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="physics-pending-list" class="physics-pending-list"></ol><div class="row"><button id="physics-apply" type="button" class="primary" disabled>Apply</button><button id="physics-reset-build" type="button" disabled>Reset</button></div><output id="physics-apply-status" class="environment-readout"></output><div id="physics-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="physics-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="idle-tracks" class="physics-panel idle-panel" aria-labelledby="idle-tracks-heading"><div class="row"><span id="idle-tracks-heading" class="eyebrow">IDLE TRACKS · APPLY REBUILDS</span><span id="idle-preview-label" class="idle-preview-label" aria-live="polite" hidden>preview — Apply decides</span></div><p id="idle-tracks-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/motion/tracks/&lt;i&gt;/…</code> or <code>/motion/blink/…</code>. Period is offered only among the divisors of <code>motion.duration</code> (the producer refuses any other, <code>CONFIG_PERIOD_DIVIDES_DURATION</code>). While edits are pending the preview drives the edited bones through the player's <code>adjust</code> with the producer's sine, <code>v(t) = base + amp·sin(2π(t/period − phase))</code>, on the bone the idle keys (<code>&lt;bone&gt;_ctl</code> under ctl keys); it is never recorded or submitted. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and replaces the preview with the built idle; Reset returns to the inspected build and clears the pending ops.</p><p id="idle-keys-line" class="hint"></p><div class="physics-work"><div id="idle-track-list" class="physics-table-wrap" role="region" aria-label="Idle tracks the config states" tabindex="0"></div><div id="idle-track-editor" class="physics-row-editor"></div></div><datalist id="idle-bones"></datalist><div class="row idle-add-row"><span class="eyebrow">ADD</span><input data-idle-add-bone type="text" list="idle-bones" autocomplete="off" placeholder="bone" aria-label="Bone for a new track" disabled><select data-idle-add-prop aria-label="Property for a new track" disabled></select><button data-idle-add-single type="button" disabled>Add track</button><select data-idle-add-chain aria-label="Chain for a new chain track" disabled></select><button data-idle-add-chain-button type="button" disabled>Add chain track</button></div><div id="idle-blink" class="idle-blink"></div><div class="row"><label class="idle-preview-toggle"><input data-idle-preview type="checkbox" checked>Preview edits (not recorded)</label></div><div id="idle-precheck" class="idle-precheck" aria-label="Review pre-check"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="idle-pending-list" class="physics-pending-list"></ol><div class="row"><button id="idle-apply" type="button" class="primary" disabled>Apply</button><button id="idle-reset" type="button" disabled>Reset</button></div><output id="idle-apply-status" class="environment-readout"></output><div id="idle-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="idle-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="readings" class="readings-panel" aria-labelledby="readings-heading"><div class="row"><span id="readings-heading" class="eyebrow">READINGS · RIGC RENDER</span></div><p class="hint">Each reading is one <code>rigc render</code> of the drawn animation, on the inspected build and on the latest scratch build. Its frames become ticks under the timeline (<span class="tick-swatch inspected" aria-hidden="true"></span> inspected, <span class="tick-swatch scratch" aria-hidden="true"></span> scratch; click one to seek) and each pair gets its worst-frame line. A reading, not a gate: an unknown slot or bone is refused with rigc's own line.</p><datalist id="readings-slots"></datalist><datalist id="readings-bones"></datalist><div class="row"><label><input id="readings-folds" type="checkbox">Folds</label><label><input id="readings-overlaps" type="checkbox">Overlaps</label></div><div class="row readings-pair"><label><input id="readings-pass-on" type="checkbox">Pass-through</label><input id="readings-pass-cloth" type="text" list="readings-slots" autocomplete="off" placeholder="cloth slot" aria-label="Pass-through cloth slot"><span aria-hidden="true">:</span><input id="readings-pass-leg" type="text" list="readings-slots" autocomplete="off" placeholder="leg slot" aria-label="Pass-through leg slot"></div><div class="row readings-pair"><label><input id="readings-seam-on" type="checkbox">Seam</label><input id="readings-seam-a" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam first slot"><span aria-hidden="true">:</span><input id="readings-seam-b" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam second slot"></div><div class="row readings-pair"><label><input id="readings-angle-on" type="checkbox">Angle range</label><input id="readings-angle-bone" type="text" list="readings-bones" autocomplete="off" placeholder="bone" aria-label="Angle range bone"><span aria-hidden="true">vs</span><input id="readings-angle-relative" type="text" list="readings-bones" autocomplete="off" placeholder="relative bone" aria-label="Angle range relative-to bone"></div><div class="row"><button id="readings-run" type="button" disabled>Run readings</button></div><p id="readings-status" class="hint" aria-live="polite">No compiled model drawn.</p><ul id="readings-worst" class="readings-worst" aria-label="Worst frame per reading"></ul></section><section id="requirements" class="readings-panel requirements-panel" aria-labelledby="requirements-heading"><div class="row"><span id="requirements-heading" class="eyebrow">REQUIREMENTS · RIG-PARTS CHECK</span></div><p class="hint">Declare what the scene asks of the motion: a slot (picked on the stage), its mesh or a bone, an animation and a kind, with your own bar. Check writes a <code>spine-parts-requirements/1</code> file into scratch and runs the installed producer's <code>rig-parts check --requirements</code> on the inspected rig or the latest scratch build; each requirement gets one line with its worst frame (click to seek). A refusal is the producer's, by name. Waivers (<code>may_fold</code>), scene targets and the part exception lists are not written here. Submitted feedback carries the file by hash and content.</p><div id="requirements-body"></div></section><section id="retarget" class="readings-panel requirements-panel retarget-panel" aria-labelledby="retarget-heading"><div class="row"><span id="retarget-heading" class="eyebrow">RETARGET · RIG-POSE</span></div><p class="hint">Put a pose sequence the package declares onto this rig: edit the <code>rig-pose-map/1</code> (per chain, the bone that carries each segment), set the clean and retarget flags, and Apply. The installed <code>@rig-c/pose</code> runs <code>rig-pose clean</code> and <code>rig-pose retarget</code> onto the handoff rig, and its <code>rigc</code> compiles the result, all into scratch; the new animation plays here beside the reference (pick the sequence under Reference). Review writes no keys and never writes the package's <code>motion.json</code>. Refusals (<code>POSE_NOT_CLEANED</code>, <code>POSE_BEND_ONE_WAY</code>, <code>RAISED_HEAD</code>, <code>MOTION_NAME_FREE</code>, <code>MAP_*</code>) are the producer's, verbatim. FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines seek to their frame.</p><div id="retarget-body"></div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section></div></section></main></div><footer class="status-bar"><span id="message" class="status-message" role="status" aria-live="polite">Loading local review package…</span><div class="status-readouts" aria-label="Status"><span id="player-state" class="subtle">No rig supplied</span><span id="layout-state" class="subtle" role="status" aria-label="Effective layout">Loading inputs…</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span><span id="layer-state" class="hint">No rig supplied</span><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div></div><div class="status-source"><details class="view-region"><summary>View region</summary><div class="view-region-body"><span class="hint">Numeric source region · opening this link restores a paused inspection</span><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></div></details><span id="source-hash" class="subtle"></span></div></footer><dialog id="review-dialog" class="review-dialog" aria-labelledby="review-dialog-title"><div class="review-dialog-header"><h2 id="review-dialog-title">Write review</h2><span class="hint">Drafts save locally as you type · nothing is submitted until you press Submit</span><button id="review-dialog-close" type="button">Close</button></div><section id="inspector-feedback" class="review-dialog-body" aria-label="Feedback"><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><div class="source-files"><div class="eyebrow">SOURCE FILES · READ ONLY</div><div id="source-files"></div></div><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><p id="feedback-applied-check" class="hint error" role="alert" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section><div class="review-dialog-actions"><span id="review-message" class="review-message" role="status" aria-live="polite"></span><span id="review-draft-state" class="review-draft-state" role="status" aria-live="polite"></span><button id="save" class="primary" disabled>Submit review</button></div></dialog></div>
9
+ <main><div class="viewbar"><fieldset id="layer-controls" class="layer-toggles"><legend class="sr-only">Rig layers</legend><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="I" id="layer-images" type="checkbox" checked disabled><span>Images</span><kbd aria-hidden="true">I</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="M" id="layer-mesh" type="checkbox" disabled><span>Mesh edges</span><kbd aria-hidden="true">M</kbd></label><label class="layer-toggle" title="No rig supplied"><input aria-keyshortcuts="B" id="layer-bones" type="checkbox" disabled><span>Bone points</span><kbd aria-hidden="true">B</kbd></label></fieldset><div class="view-tools"><button id="chest-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="chest-editor-bar" title="Place the two chest regions on the rig: drag a centre, the core edge or the band" hidden>Chest regions</button><button id="mesh-edit" type="button" class="view-tool" aria-pressed="false" aria-controls="mesh-editor-bar" title="Edit the selected slot's actual mesh as a typed delta: preview only, the producer retriangulates" hidden>Edit mesh</button></div><div class="zoom-controls"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom out">−</button><output id="zoom-value">100%</output><button id="zoom-in" aria-label="Zoom in">+</button><button id="fit">Fit</button></div><details id="view-menu" class="view-menu"><summary aria-label="View layout">View: <span id="view-menu-label">Side by side</span></summary><div class="view-menu-list" role="group" aria-label="View"><button type="button" data-view="compare" aria-pressed="true">Side by side</button><button type="button" data-view="reference" aria-pressed="false">Reference</button><button type="button" data-view="rig" aria-pressed="false">Rig</button></div></details></div><div id="chest-editor-bar" class="chest-editor-bar" role="region" aria-label="Chest region editor" hidden><div class="bust-group" role="group" aria-label="Chest editor mode"><input id="bust-mode" type="hidden" value="circles"><button id="bust-mode-circles" type="button" data-bust-mode="circles" aria-pressed="true" title="Two circles, each centre, core and band set by hand (#99)">Circles</button><button id="bust-mode-landmarks" type="button" data-bust-mode="landmarks" aria-pressed="false" title="Three clicks (right apex, left apex, cleavage); the producer derives both circles (contour.bust)">Landmarks</button></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-r">● R</span><label>X<input id="bust-r-x" type="number" step="1" inputmode="decimal" aria-label="R region centre x"></label><label>Y<input id="bust-r-y" type="number" step="1" inputmode="decimal" aria-label="R region centre y"></label><label>R<input id="bust-r-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="R region core radius"></label><label>Band<input id="bust-r-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="R region band"></label></div><div class="bust-group" data-chest-mode="circles"><span class="bust-tag bust-tag-l">● L</span><label>X<input id="bust-l-x" type="number" step="1" inputmode="decimal" aria-label="L region centre x"></label><label>Y<input id="bust-l-y" type="number" step="1" inputmode="decimal" aria-label="L region centre y"></label><label>R<input id="bust-l-r" type="number" min="6" max="120" step="1" inputmode="decimal" aria-label="L region core radius"></label><label>Band<input id="bust-l-band" type="number" min="4" max="120" step="1" inputmode="decimal" aria-label="L region band"></label></div><div id="bust-landmarks" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Chest landmarks (rig px)" hidden><button id="lm-pick-apex_r" type="button" data-landmark="apex_r" aria-pressed="false" title="Click the part to place the apex r; drag its marker to move it">Apex R</button><label>X<input id="lm-apex_r-x" type="number" step="1" inputmode="decimal" aria-label="Apex R x (rig px)"></label><label>Y<input id="lm-apex_r-y" type="number" step="1" inputmode="decimal" aria-label="Apex R y (rig px)"></label><button id="lm-pick-apex_l" type="button" data-landmark="apex_l" aria-pressed="false" title="Click the part to place the apex l; drag its marker to move it">Apex L</button><label>X<input id="lm-apex_l-x" type="number" step="1" inputmode="decimal" aria-label="Apex L x (rig px)"></label><label>Y<input id="lm-apex_l-y" type="number" step="1" inputmode="decimal" aria-label="Apex L y (rig px)"></label><button id="lm-pick-cleavage" type="button" data-landmark="cleavage" aria-pressed="false" title="Click the part to place the cleavage; drag its marker to move it">Cleavage</button><label>X<input id="lm-cleavage-x" type="number" step="1" inputmode="decimal" aria-label="Cleavage x (rig px)"></label><label>Y<input id="lm-cleavage-y" type="number" step="1" inputmode="decimal" aria-label="Cleavage y (rig px)"></label></div><div id="bust-landmark-params" class="bust-group" data-chest-mode="landmarks" role="group" aria-label="Landmark constants" hidden><label title="Core ratio r / (r + band); the producer's default is 0.22">α<input id="lm-alpha" type="range" min="0.05" max="0.95" step="0.01" value="0.22" aria-label="Alpha, core ratio"><output id="lm-alpha-value" for="lm-alpha">0.22</output></label><label title="Cleavage clearance in rig px; the producer's default is 3.8">Clearance<input id="lm-clearance" type="range" min="0" max="40" step="0.1" value="3.8" aria-label="Clearance, rig px"><output id="lm-clearance-value" for="lm-clearance">3.8</output></label><label title="Control bone for bust_r; empty is the producer's default boob_r">Bone R<input id="lm-bone_r" type="text" list="lm-bones" placeholder="boob_r" spellcheck="false" autocomplete="off"></label><label title="Control bone for bust_l; empty is the producer's default boob_l">Bone L<input id="lm-bone_l" type="text" list="lm-bones" placeholder="boob_l" spellcheck="false" autocomplete="off"></label><datalist id="lm-bones"></datalist></div><span id="lm-derived" class="hint" data-chest-mode="landmarks" aria-live="polite" hidden></span><div class="bust-group" data-chest-mode="circles"><button id="bust-mirror" type="button" title="Copy R's y, core radius and band to L">Mirror R → L</button><button id="bust-download" type="button" title="Download chest-regions.json and its binding; evidence, not an approval">Download JSON</button></div><div class="bust-group"><button id="bust-apply" type="button" title="Run the installed producer (rig-parts remesh) on these regions or landmarks into a scratch build and load it here; the package is unchanged">Apply · Rebuild</button><button id="bust-reset" type="button" title="Return the stage to the inspected build" disabled>Reset</button></div><span id="handoff-line" class="hint handoff-line" aria-live="polite" title="Producer handoff: the config, parts and inspected rig an Apply rebuilds from"></span><span id="chest-editor-status" class="hint" aria-live="polite"></span><pre id="chest-editor-log" class="rebuild-log" aria-label="Producer output" hidden></pre><textarea id="rebuild-applied" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></div><div id="mesh-editor-bar" class="chest-editor-bar mesh-editor-bar" role="region" aria-label="Mesh edit" hidden><div class="bust-group" role="group" aria-label="Mesh edit sub-mode"><button id="mesh-mode-modify" type="button" data-mesh-mode="modify" aria-pressed="false" aria-keyshortcuts="V" title="Modify · key V">Modify<kbd aria-hidden="true">V</kbd></button><button id="mesh-mode-create" type="button" data-mesh-mode="create" aria-pressed="false" aria-keyshortcuts="C" title="Create · key C">Create<kbd aria-hidden="true">C</kbd></button><button id="mesh-mode-delete" type="button" data-mesh-mode="delete" aria-pressed="false" aria-keyshortcuts="X" title="Delete · key X">Delete<kbd aria-hidden="true">X</kbd></button><button id="mesh-mode-edges" type="button" data-mesh-mode="edges" aria-pressed="false" aria-keyshortcuts="E" title="Edges · key E">Edges<kbd aria-hidden="true">E</kbd></button></div><div class="bust-group"><button id="mesh-undo" type="button" aria-keyshortcuts="Control+Z Meta+Z" title="Undo the last action · Cmd/Ctrl-Z" disabled>Undo</button><button id="mesh-redo" type="button" aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z" title="Redo · Shift-Cmd/Ctrl-Z" disabled>Redo</button><button id="mesh-reset" type="button" title="Drop the whole delta for this attachment (asks first)" disabled>Reset</button></div><div class="bust-group"><button id="mesh-download" type="button" title="Download mesh-edit.json and its binding; evidence, not an approval">Download JSON</button><button id="mesh-apply" type="button" title="Send the delta to the producer (rig-parts remesh --delta) and load the scratch revision in place; nothing is written into the package">Apply · Rebuild</button></div><textarea id="mesh-edit-draft" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea><span id="mesh-editor-status" class="hint" aria-live="polite"></span></div><div id="stage-layout" class="stages"><section id="reference-pane" class="stage-pane" aria-label="Reference"><span id="reference-label" class="pane-label">Reference</span><div id="reference-viewport" class="viewport"><div id="reference-content" class="reference-content"></div><div id="reference-empty" class="empty">No reference supplied.</div></div></section><section id="rig-pane" class="stage-pane" aria-label="Rig"><span id="rig-label" class="pane-label">Generated rig · read only</span><div id="rig-viewport" class="viewport"><div id="rig-root"></div><canvas id="rig-overlay" class="rig-overlay" aria-label="Actual rig mesh and bone overlay"></canvas><div id="idle-handles" class="idle-handles" hidden aria-label="Chain amplitude handles"></div><div id="rig-fields-stage" class="rig-fields-stage" hidden aria-label="Rig field handles"></div><div id="rig-empty" class="empty">No compiled model supplied.</div></div></section></div><div class="stage-footer"><span id="coordinates">Drag to pan · scroll to zoom · cameras synchronized</span><span>Source files remain unchanged.</span></div><section class="inspector" aria-label="Inspection controls"><div class="inspector-heading sticky-header"><strong>INSPECTOR</strong><span class="hint">Local view controls · source files stay unchanged</span></div><div class="inspector-body"><section id="inspector-rig" aria-label="Setup" data-inspector-panel="rig"><nav class="block-row" aria-label="Setup blocks"><button type="button" data-block-target="selected-part">Part</button><button type="button" data-block-target="modeling-tools">Modeling</button><button type="button" data-block-target="chest-regions">Chest</button><button type="button" data-block-target="mesh-panel">Mesh</button><button type="button" data-block-target="weights">Weights</button><button type="button" data-block-target="bones-panel">Bones</button><button type="button" data-block-target="rig-fields">Fields</button><button type="button" data-block-target="cuts-panel">Cuts</button><button type="button" data-block-target="cloth-panel">Cloth</button></nav><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><section id="modeling-tools"><div class="eyebrow">MODELING REVIEW</div><button data-proposal="mesh" class="tool">Mesh density <small>Add feedback</small></button><button data-proposal="contour" class="tool">Contour curvature <small>Add feedback</small></button><button data-proposal="chest" class="tool">Chest position <small>Add feedback</small></button><label for="part-set" class="label space-top">Part presets</label><select id="part-set" disabled><option>No sets supplied</option></select><p class="hint">Preview only · does not create motion keys.</p></section><section id="chest-regions" class="chest-regions" aria-labelledby="chest-regions-heading"><div class="eyebrow" id="chest-regions-heading">CHEST REGIONS · DETAIL</div><p class="hint">Crop pixels, x screen-right and y down (<code>family-chest-regions/1</code>). Place the circles with <strong>Chest regions</strong> in the view bar; this block holds names, bones, semi-axes, clip and the contour. Equal semi-axes export as a circle, unequal ones (or a clip) as the generator's 32-sample polygon. Exporting writes nothing into the package and is not an approval.</p><p id="chest-mapping" class="hint"></p><div class="chest-grid"><label>Crop width<input id="chest-crop-w" type="number" step="any" inputmode="decimal"></label><label>Crop height<input id="chest-crop-h" type="number" step="any" inputmode="decimal"></label></div><fieldset class="chest-row"><legend>Region 1</legend><label>Name<input id="chest-0-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-0-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-0-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-0-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-0-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-0-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-0-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-0-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-0-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Region 2</legend><label>Name<input id="chest-1-name" type="text" autocomplete="off" spellcheck="false"></label><label>Bone<select id="chest-1-bone"><option value="">Select bone</option></select></label><label>Centre x<input id="chest-1-cx" type="number" step="any" inputmode="decimal"></label><label>Centre y<input id="chest-1-cy" type="number" step="any" inputmode="decimal"></label><label>Semi-axis x<input id="chest-1-ax" type="number" step="any" inputmode="decimal"></label><label>Semi-axis y<input id="chest-1-ay" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-1-spacing" type="number" step="any" inputmode="decimal"></label><label>Band<input id="chest-1-band" type="number" step="any" inputmode="decimal"></label><label>Medial clip x<input id="chest-1-clip" type="number" step="any" inputmode="decimal" placeholder="none"></label></fieldset><fieldset class="chest-row"><legend>Contour</legend><label>Tolerance<input id="chest-tolerance" type="number" step="any" inputmode="decimal"></label><label>Margin<input id="chest-margin" type="number" step="any" inputmode="decimal"></label><label>Spacing<input id="chest-spacing" type="number" step="any" inputmode="decimal"></label><label>Max edge<input id="chest-max-edge" type="number" step="any" inputmode="decimal"></label><label>Stray<input id="chest-stray" type="number" step="any" inputmode="decimal"></label><label class="chest-check"><input id="chest-bridges" type="checkbox">Bridges</label></fieldset><details class="chest-more"><summary>Generator and mesh fields</summary><label for="chest-generator" class="label space-top">Public generator</label><input id="chest-generator" type="text" autocomplete="off" spellcheck="false" placeholder="Generator that will read this file"><label for="chest-r" class="label space-top">Mesh r (optional)</label><input id="chest-r" type="number" step="any" inputmode="decimal"><label for="chest-segments" class="label space-top">Mesh segments JSON (optional)</label><textarea id="chest-segments" rows="3" spellcheck="false"></textarea><label for="chest-source-rule" class="label space-top">Source rule (optional)</label><input id="chest-source-rule" type="text" autocomplete="off"></details><p id="chest-status" class="hint" aria-live="polite"></p><button id="chest-export" type="button" class="outline block">Download chest-regions.json</button></section><section id="mesh-panel" class="physics-panel mesh-panel" aria-labelledby="mesh-panel-heading"><div class="row"><span id="mesh-panel-heading" class="eyebrow">MESH · APPLY REMESHES</span></div><p id="mesh-panel-state" class="hint">Select a mesh part in the parts list.</p><p class="hint">Edits the selected part's <code>meshes.&lt;part&gt;</code> entry from the handoff config (the grid and contour modes). Apply sends the whole pending entry to the installed producer's <code>remesh --mesh</code> (one patch op at <code>/meshes/&lt;part&gt;</code>), compiles and checks the revision, and reloads it with the mesh edges on; Reset returns to the inspected build and the config's entry. Slider ranges are review's; the number field takes any value, and the producer judges it.</p><div id="mesh-params" class="physics-controls mesh-params"></div><div class="row mesh-tools"><span class="eyebrow">REGIONS</span><button id="mesh-add-circle" type="button" disabled>Add circle</button><button id="mesh-add-polygon" type="button" aria-pressed="false" disabled>Draw polygon</button><button id="mesh-regions-edit" type="button" aria-pressed="false" disabled>Drag on stage</button></div><div id="mesh-regions" class="mesh-regions"></div><div class="row mesh-tools"><span class="eyebrow">SEGMENTS</span><select id="mesh-segment-bone" aria-label="Bone to add as a segment" disabled></select><button id="mesh-segment-add" type="button" disabled>Add</button></div><ol id="mesh-segments" class="physics-pending-list mesh-segments"></ol><div class="row"><span class="eyebrow">PENDING ENTRY · DIFF AGAINST THE CONFIG</span></div><ol id="mesh-diff" class="physics-pending-list"></ol><ul id="mesh-precheck" class="mesh-precheck" aria-label="Review pre-check in the config loader's names"></ul><div class="row"><button id="mesh-panel-apply" type="button" class="primary" disabled>Apply</button><button id="mesh-panel-reset" type="button" disabled>Reset</button><button id="mesh-folds" type="button" title="rigc render --folds of the drawn animation on the inspected build and on this revision (#108 readings)">Folds reading</button></div><output id="mesh-status" class="environment-readout"></output><div id="mesh-counts" class="physics-readout" aria-label="Vertex and triangle counts before and after"></div><div id="mesh-readout" class="physics-readout" aria-label="TEXTURE_STRETCH before and after Apply"></div><textarea id="mesh-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="weights" class="physics-panel weights-block" aria-labelledby="weights-heading"><div class="row"><span id="weights-heading" class="eyebrow">WEIGHTS · SETUP POSE · WATCH ONLY</span></div><p class="hint">The selected slot's mesh at the setup pose, each vertex coloured by the weight the compiled model document stores for the picked bone (0 → 1), or by its dominant bone under <strong>Max influence</strong>. An unweighted mesh is its slot's bone at 1.0. Hover a vertex for its stored values. Nothing here edits, applies or records a weight.</p><div class="row weights-controls"><button id="weights-show" type="button" aria-pressed="false">Show weights</button><label>Colour by<select id="weights-mode"><option value="bone">Bone weight</option><option value="max">Max influence</option></select></label><label>Bone<select id="weights-bone" disabled></select></label></div><p id="weights-state" class="hint" aria-live="polite"></p><div id="weights-legend" class="weights-legend" aria-label="Weights legend"></div><output id="weights-hover" class="environment-readout"></output></section><section id="bones-panel" class="physics-panel bones-panel" aria-labelledby="bones-panel-heading"><div class="row"><span id="bones-panel-heading" class="eyebrow">BONES · CONFIG ANCHORS · APPLY REBUILDS</span></div><p id="bones-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's bones at their <code>at</code>, chain <code>points</code> and <code>tip</code> (rig px, the setup pose; the stage may be posed by an animation). Drag a handle on the stage or edit the numbers: each move is a config patch op at <code>/bones/&lt;i&gt;/at</code>, <code>/bones/&lt;i&gt;/points/&lt;k&gt;</code> or <code>/bones/&lt;i&gt;/tip</code> naming its bone, moved in whole rig px. <strong>Check</strong> runs <code>propose --from-config</code> on a scratch copy of the patched config (LINT and coverage lines; LINT blocks nothing) and <code>compare</code> against the handoff config. <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; Reset returns to the inspected build and clears the pending ops. Adding, removing or re-parenting bones is out of scope.</p><div class="row bones-controls"><button id="bones-edit" type="button" aria-pressed="false" disabled>Edit bones on stage</button><label for="bones-select">Bone</label><select id="bones-select" disabled></select></div><div id="bones-anchor-editor" class="bones-anchor-editor"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="bones-pending-list" class="physics-pending-list"></ol><div class="row"><button id="bones-check" type="button" disabled>Check</button><button id="bones-apply" type="button" class="primary" disabled>Apply</button><button id="bones-reset-build" type="button" disabled>Reset</button></div><output id="bones-status" class="environment-readout"></output><div id="bones-compare" class="bones-lines" aria-label="compare: origin and tip distance per moved bone"></div><div id="bones-lint" class="bones-lines" aria-label="propose --from-config: LINT and coverage lines"></div><textarea id="bones-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="rig-fields" class="physics-panel rig-fields" aria-labelledby="rig-fields-heading"><div class="row"><span id="rig-fields-heading" class="eyebrow">RIG FIELDS · CONFIG · APPLY REBUILDS</span><span id="rig-fields-dial-label" class="idle-preview-label" hidden>preview — Apply decides</span></div><p id="rig-fields-state" class="hint">Reading the producer handoff…</p><p class="hint">Small producer config fields, each a config patch op: the bone a rigid part rides (<code>/regions/&lt;part&gt;</code>), a lash's blink still row (<code>/motion/blink/still/&lt;part&gt;</code>, dragged on the stage), a stop's inward / outward bounds (<code>/stops/&lt;i&gt;</code>, arcs dragged at the pivot), a bone's inherit mode (<code>/bones/&lt;i&gt;/inherit</code>; a chain's <code>link_inherit</code>) and <code>turn</code>'s pivot and per-part depth (<code>/turn/pivot</code>, <code>/turn/depths/&lt;part&gt;</code>). <strong>Apply</strong> rebuilds the whole rig (<code>rig --config</code>, then <code>check</code>) and needs the reproduced baseline; the producer's refusal is shown as printed. The dial only previews the drawn build's <code>turn</code> bone and is never recorded. Reset returns to the inspected build and clears the pending ops.</p><div id="rig-fields-region" class="rig-fields-group"></div><div id="rig-fields-still" class="rig-fields-group"></div><div id="rig-fields-stops" class="rig-fields-group"></div><div id="rig-fields-inherit" class="rig-fields-group"></div><div id="rig-fields-turn" class="rig-fields-group"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="rig-fields-pending-list" class="physics-pending-list"></ol><div class="row"><button id="rig-fields-apply" type="button" class="primary" disabled>Apply</button><button id="rig-fields-reset" type="button" disabled>Reset</button><button id="rig-fields-reading" type="button" disabled>Angle range</button></div><output id="rig-fields-status" class="environment-readout"></output><div id="rig-fields-readout" class="physics-readout" aria-label="BLINK_NO_HOLE and FEET_STAY_LEVEL before and after"></div><textarea id="rig-fields-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cuts-panel" class="physics-panel bones-panel cuts-panel" aria-labelledby="cuts-panel-heading"><div class="row"><span id="cuts-panel-heading" class="eyebrow">CUTS · PATCHES · LIMBS · APPLY RUNS RIG-PARTS BUILD</span></div><p id="cuts-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>assemble.cuts</code> polygons, <code>assemble.patches</code> boxes and each limb joint with its <code>range</code> arc, on the stage in rig px of the setup pose. Drag a vertex, a box corner or a joint, or edit the numbers: each change is a config patch op (<code>/assemble/cuts/&lt;i&gt;/polygon/&lt;k&gt;</code>, <code>/assemble/patches/&lt;i&gt;/box</code>, <code>/limbs/&lt;limb&gt;/segments/&lt;k&gt;/range</code>; a joint is its bone's origin, <code>/bones/…</code>). A new cut or patch adds its entry and a <code>regions</code> bone. <strong>Apply</strong> runs <code>rig-parts build</code> (assemble → rig → check) from the declared painting and See-through runs into scratch and needs the reproduced baseline; the producer's refusals (<code>ASSEMBLE_CUT_*</code>, <code>ASSEMBLE_UNDER_*</code>, <code>CONFIG_LIMB_*</code>, <code>CONFIG_PART_UNIQUE</code>) are shown as printed, and the recomposite error map, the <code>seam pair</code> lines and the build's wall clock come back with the build. Review paints no <code>under</code> or <code>cap</code> image. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cuts-edit" type="button" aria-pressed="false" disabled>Edit cuts on stage</button><label for="cuts-select">Entry</label><select id="cuts-select" disabled></select></div><div id="cuts-editor" class="bones-anchor-editor"></div><div id="cuts-add" class="row cuts-add"><span class="eyebrow">ADD</span><select data-cuts-add="from" aria-label="Plan part the new cut takes pixels from"></select><input data-cuts-add="name" type="text" autocomplete="off" placeholder="new part" aria-label="New part name"><input data-cuts-add="bone" type="text" list="lm-bones" autocomplete="off" placeholder="bone" aria-label="Bone the new part's region rides"><select data-cuts-add="draw" aria-label="Where the new part is drawn"></select><input data-cuts-add="overlap" type="number" min="0" step="1" value="0" inputmode="numeric" aria-label="Cut overlap band in rig px"><select data-cuts-add="alpha" aria-label="New patch alpha"><option value="silhouette">silhouette</option><option value="box">box</option></select><button type="button" data-cuts-add="place-cut">Place cut</button><button type="button" data-cuts-add="place-patch">Place patch</button><button type="button" data-cuts-add="finish">Finish</button><button type="button" data-cuts-add="cancel">Cancel</button></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cuts-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cuts-apply" type="button" class="primary" disabled>Apply</button><button id="cuts-reset" type="button" disabled>Reset</button></div><output id="cuts-status" class="environment-readout"></output><div id="cuts-readout" class="physics-readout" aria-label="The build's refusals, cut lines, recomposite, seam pairs and wall clock"></div><textarea id="cuts-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="cloth-panel" class="physics-panel bones-panel cloth-panel" aria-labelledby="cloth-panel-heading"><div class="row"><span id="cloth-panel-heading" class="eyebrow">CLOTH · RECIPE PER TAG · APPLY RUNS RIG-PARTS PROPOSE --CLOTH</span></div><p id="cloth-panel-state" class="hint">Reading the producer handoff…</p><p class="hint">The producer config's <code>cloth</code> block: one recipe per tag the installed reader accepts, with its fields as written. Each change is a config patch op at <code>/cloth/&lt;tag&gt;/…</code>. <strong>Show links</strong> draws the chains the tag's parts are meshed on, from the config's <code>points</code> and <code>tip</code> in rig px of the setup pose, and after an Apply the chains of the config the producer wrote. <strong>Apply</strong> runs <code>rig-parts propose --cloth --write-config</code> on the declared parts, painting and keypoints into scratch, then builds the written config (<code>rig-parts build</code> when the config declares <code>limbs</code>, else <code>rig --config</code>) and reloads it under the Environment's wind and gravity; the producer's refusals (<code>CONFIG_CLOTH_*</code>, <code>PROPOSE_CLOTH_DRAFTED</code>, <code>PROPOSE_CLOTH_PART</code>, <code>PROPOSE_WRITE_CONFIG_*</code>) are shown as printed. Review edits the recipe, never the drafted constraints. Reset returns to the inspected build and clears the pending ops.</p><div class="row bones-controls"><button id="cloth-links" type="button" aria-pressed="false" disabled>Show links on stage</button><label for="cloth-tag">Tag</label><select id="cloth-tag" disabled></select><label class="cloth-declared"><input id="cloth-declared" type="checkbox" disabled> recipe declared</label></div><div id="cloth-fields" class="cloth-fields"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="cloth-pending-list" class="physics-pending-list"></ol><div class="row"><button id="cloth-apply" type="button" class="primary" disabled>Apply</button><button id="cloth-reset" type="button" disabled>Reset</button></div><output id="cloth-status" class="environment-readout"></output><div id="cloth-readout" class="physics-readout" aria-label="Propose's refusals, the written config's diff, the route and check"></div><textarea id="cloth-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section></section><section id="inspector-playback" aria-label="Animate" data-inspector-panel="playback" hidden><nav class="block-row" aria-label="Animate blocks"><button type="button" data-block-target="transport" data-block-includes="frame-controls motion-tools angle-panel">Transport</button><button type="button" data-block-target="sound-panel">Sound</button><button type="button" data-block-target="environment">Environment</button><button type="button" data-block-target="physics-panel">Physics</button><button type="button" data-block-target="idle-tracks">Idle tracks</button><button type="button" data-block-target="readings">Readings</button><button type="button" data-block-target="requirements">Requirements</button><button type="button" data-block-target="retarget">Retarget</button><button type="button" data-block-target="report-panel">Report</button></nav><section id="transport" class="transport"><button id="play" aria-label="Play" disabled>▶</button><label for="animation-select" class="sr-only">Animation</label><select id="animation-select" disabled><option>No animations supplied</option></select><input id="scrub" type="range" min="0" max="1" step="0.001" value="0" aria-label="Playback time"><output id="time-label">0.0000 / 0.0000 s</output><output id="frame-label">rate unknown</output><label class="idle-repeat"><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><div id="reading-ticks" class="reading-ticks" hidden aria-label="Reading ticks"></div><div id="idle-blink-strip" class="idle-blink-strip" hidden><button id="idle-blink-marker" type="button" class="idle-blink-marker" aria-label="Blink time">◆</button></div></section><section id="frame-controls" class="frame-controls" hidden aria-label="Frame navigation using the explicit clock"><label for="frame-number">Frame</label><input id="frame-number" type="number" min="0" step="1" inputmode="numeric" aria-label="Frame number to inspect"><button id="frame-jump" type="button">Go</button><input id="frame-scrub" type="range" min="0" max="1" step="1" value="0" aria-label="Scrub by frame"><span id="frame-clock-label" class="hint"></span><p id="frame-outside" class="hint" hidden>The current time is outside the explicit frame range. Enter a frame number to go to its exact time.</p></section><section id="motion-tools"><div class="eyebrow">MOTION · EDITS BUILD THROUGH THE PRODUCER</div><button data-proposal="pose" class="tool">Inspect pose <small>Add feedback</small></button><button data-proposal="timing" class="tool">Inspect interval <small>Add feedback</small></button><button data-proposal="audio" class="tool">Audio links <small>Add feedback</small></button><button data-proposal="angle" class="tool">Angle inspection <small id="axis-state">No inspection axes supplied</small></button></section><section id="angle-panel" class="angle-panel" hidden><label for="axis-select">Rig inspection axis</label><select id="axis-select"></select><label id="axis-label" for="axis-value"></label><input id="axis-value" type="range" disabled><output id="axis-value-label"></output><p class="hint">Maps one inspection axis to its animation time. This does not rotate the camera.</p></section><section id="sound-panel" class="sound-panel"><div class="row"><span class="eyebrow">SOUND CUES</span><label><input id="sync-audio" type="checkbox">Play cue audio in sync</label></div><div id="cues"></div><div class="row"><label for="audio-volume">Audio volume</label><input id="audio-volume" type="range" min="0" max="1" step="0.05" value="0.8"><output id="audio-volume-label">80%</output></div><div id="event-log" class="hint" aria-live="polite">Runtime events: none</div></section><section id="environment" class="environment-panel" aria-labelledby="environment-heading"><div class="row"><span id="environment-heading" class="eyebrow">ENVIRONMENT · INSPECTION ONLY</span></div><p class="hint">Wind, gravity and impulses go to the player while you inspect. Nothing is written to the model or motion; feedback records the state as context.</p><input id="environment-applied" type="checkbox" hidden aria-hidden="true" tabindex="-1"><div class="row"><label for="wind-base">Wind</label><input id="wind-base" type="range" min="-100" max="100" step="1" value="0" disabled><output id="wind-base-label">0</output></div><div class="row"><label><input id="wind-breeze" type="checkbox" disabled>Breeze</label><button id="wind-gust" type="button" disabled>Gust</button><label><input id="wind-repeat" type="checkbox" disabled>Repeat</label></div><div class="row"><label for="wind-gust-peak">Gust peak</label><input id="wind-gust-peak" type="number" min="0" max="200" step="1" value="48" inputmode="decimal" disabled><label for="wind-gust-duration">Duration (s)</label><input id="wind-gust-duration" type="number" min="0.1" max="20" step="0.1" value="2.4" inputmode="decimal" disabled></div><output id="wind-readout" class="environment-readout">No compiled model drawn · environment unavailable</output><div class="row"><label for="gravity">Gravity</label><input id="gravity" type="range" min="0" max="4" step="0.05" value="1" disabled><output id="gravity-label">1.00</output></div><div class="row"><button id="jolt" type="button" disabled>Jolt</button><label title="Dragging the rig pane moves the rig's physics instead of the camera. Hold Space to pan."><input id="drag-trail" type="checkbox" disabled>Drag to trail</label><button id="physics-reset" type="button" disabled>Reset physics</button></div><p class="hint">Drag to trail: dragging the rig pane tells the physics the rig moved; the camera stays. Hold Space to pan. Reset returns the runtime start values and re-poses from the current time, clearing the physics history.</p><div class="row"><label title="Readings and Apply's check read this wind and gravity as a rigc-environment/1 schedule; the wind clock restarts with the clip so you watch the same world"><input id="environment-use" type="checkbox" disabled>Use for readings</label><button id="environment-export" type="button" disabled>Export schedule</button><button id="environment-check" type="button" disabled>Check under this world</button></div><output id="environment-schedule" class="environment-readout">No schedule · the controls are not applied (the player stands at the runtime start values)</output><div id="environment-check-lines" class="environment-check-lines" aria-live="polite"></div><p class="hint">The schedule is the controls as a <code>rigc-environment/1</code> file from the clip's first frame: one step when the wind is steady, else one step per frame at 12 fps (between frames rigc holds the frame's value). A one-off Gust is not in it; Repeat puts gusts in it. Check under this world runs <code>rig-parts check --environment</code> on the build on the stage; the default-world line is that build's own check.</p></section><section id="physics-panel" class="physics-panel" aria-labelledby="physics-panel-heading"><div class="row"><span id="physics-panel-heading" class="eyebrow">PHYSICS TUNING · APPLY REBUILDS</span></div><p id="physics-panel-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/constraints/&lt;i&gt;/&lt;field&gt;</code> naming its constraint. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and reloads it under the same wind, gust and jolt state; Reset returns to the inspected build and clears the pending ops. Live preview before Apply waits on play#147.</p><div class="row physics-chain-row"><label for="physics-chain">Chain</label><select id="physics-chain" disabled></select><button id="physics-chain-pick" type="button" aria-pressed="false" disabled>Pick on stage</button></div><div class="row physics-ramp-row"><label for="physics-ramp-field">Ramp</label><select id="physics-ramp-field" disabled></select><label for="physics-ramp-root">root</label><input id="physics-ramp-root" type="number" step="any" inputmode="decimal" disabled><label for="physics-ramp-tip">tip</label><input id="physics-ramp-tip" type="number" step="any" inputmode="decimal" disabled><button id="physics-ramp" type="button" disabled>Ramp</button></div><div class="physics-work"><div id="physics-rows" class="physics-table-wrap" role="region" aria-label="Physics constraints the config declares" tabindex="0"></div><div id="physics-row-editor" class="physics-row-editor"></div></div><div id="physics-locked" class="physics-table-wrap physics-locked" role="region" aria-label="Constraints that are not editable" tabindex="0" hidden></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="physics-pending-list" class="physics-pending-list"></ol><div class="row"><button id="physics-apply" type="button" class="primary" disabled>Apply</button><button id="physics-reset-build" type="button" disabled>Reset</button></div><output id="physics-apply-status" class="environment-readout"></output><div id="physics-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="physics-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="idle-tracks" class="physics-panel idle-panel" aria-labelledby="idle-tracks-heading"><div class="row"><span id="idle-tracks-heading" class="eyebrow">IDLE TRACKS · APPLY REBUILDS</span><span id="idle-preview-label" class="idle-preview-label" aria-live="polite" hidden>preview — Apply decides</span></div><p id="idle-tracks-state" class="hint">No compiled model drawn.</p><p class="hint">Each edit is a config patch op at <code>/motion/tracks/&lt;i&gt;/…</code> or <code>/motion/blink/…</code>. Period is offered only among the divisors of <code>motion.duration</code> (the producer refuses any other, <code>CONFIG_PERIOD_DIVIDES_DURATION</code>). While edits are pending the preview drives the edited bones through the player's <code>adjust</code> with the producer's sine, <code>v(t) = base + amp·sin(2π(t/period − phase))</code>, on the bone the idle keys (<code>&lt;bone&gt;_ctl</code> under ctl keys); it is never recorded or submitted. Apply runs the installed producer (<code>rig --config</code>, then <code>check</code>) into a scratch build and replaces the preview with the built idle; Reset returns to the inspected build and clears the pending ops.</p><p id="idle-keys-line" class="hint"></p><div class="physics-work"><div id="idle-track-list" class="physics-table-wrap" role="region" aria-label="Idle tracks the config states" tabindex="0"></div><div id="idle-track-editor" class="physics-row-editor"></div></div><datalist id="idle-bones"></datalist><div class="row idle-add-row"><span class="eyebrow">ADD</span><input data-idle-add-bone type="text" list="idle-bones" autocomplete="off" placeholder="bone" aria-label="Bone for a new track" disabled><select data-idle-add-prop aria-label="Property for a new track" disabled></select><button data-idle-add-single type="button" disabled>Add track</button><select data-idle-add-chain aria-label="Chain for a new chain track" disabled></select><button data-idle-add-chain-button type="button" disabled>Add chain track</button></div><div id="idle-blink" class="idle-blink"></div><div class="row"><label class="idle-preview-toggle"><input data-idle-preview type="checkbox" checked>Preview edits (not recorded)</label></div><div id="idle-precheck" class="idle-precheck" aria-label="Review pre-check"></div><div class="row"><span class="eyebrow">PENDING OPS</span></div><ol id="idle-pending-list" class="physics-pending-list"></ol><div class="row"><button id="idle-apply" type="button" class="primary" disabled>Apply</button><button id="idle-reset" type="button" disabled>Reset</button></div><output id="idle-apply-status" class="environment-readout"></output><div id="idle-readout" class="physics-readout" aria-label="Check readout before and after Apply"></div><textarea id="idle-pending" class="sr-only" hidden aria-hidden="true" tabindex="-1"></textarea></section><section id="readings" class="readings-panel" aria-labelledby="readings-heading"><div class="row"><span id="readings-heading" class="eyebrow">READINGS · RIGC RENDER</span></div><p class="hint">Each reading is one <code>rigc render</code> of the drawn animation, on the inspected build and on the latest scratch build. Its frames become ticks under the timeline (<span class="tick-swatch inspected" aria-hidden="true"></span> inspected, <span class="tick-swatch scratch" aria-hidden="true"></span> scratch; click one to seek) and each pair gets its worst-frame line. A reading, not a gate: an unknown slot or bone is refused with rigc's own line.</p><datalist id="readings-slots"></datalist><datalist id="readings-bones"></datalist><div class="row"><label><input id="readings-folds" type="checkbox">Folds</label><label><input id="readings-overlaps" type="checkbox">Overlaps</label></div><div class="row readings-pair"><label><input id="readings-pass-on" type="checkbox">Pass-through</label><input id="readings-pass-cloth" type="text" list="readings-slots" autocomplete="off" placeholder="cloth slot" aria-label="Pass-through cloth slot"><span aria-hidden="true">:</span><input id="readings-pass-leg" type="text" list="readings-slots" autocomplete="off" placeholder="leg slot" aria-label="Pass-through leg slot"></div><div class="row readings-pair"><label><input id="readings-seam-on" type="checkbox">Seam</label><input id="readings-seam-a" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam first slot"><span aria-hidden="true">:</span><input id="readings-seam-b" type="text" list="readings-slots" autocomplete="off" placeholder="slot" aria-label="Seam second slot"></div><div class="row readings-pair"><label><input id="readings-angle-on" type="checkbox">Angle range</label><input id="readings-angle-bone" type="text" list="readings-bones" autocomplete="off" placeholder="bone" aria-label="Angle range bone"><span aria-hidden="true">vs</span><input id="readings-angle-relative" type="text" list="readings-bones" autocomplete="off" placeholder="relative bone" aria-label="Angle range relative-to bone"></div><div class="row"><button id="readings-run" type="button" disabled>Run readings</button></div><p id="readings-status" class="hint" aria-live="polite">No compiled model drawn.</p><ul id="readings-worst" class="readings-worst" aria-label="Worst frame per reading"></ul></section><section id="requirements" class="readings-panel requirements-panel" aria-labelledby="requirements-heading"><div class="row"><span id="requirements-heading" class="eyebrow">REQUIREMENTS · RIG-PARTS CHECK</span></div><p class="hint">Declare what the scene asks of the motion: a slot (picked on the stage), its mesh or a bone, an animation and a kind, with your own bar. Check writes a <code>spine-parts-requirements/1</code> file into scratch and runs the installed producer's <code>rig-parts check --requirements</code> on the inspected rig or the latest scratch build; each requirement gets one line with its worst frame (click to seek). A refusal is the producer's, by name. Waivers (<code>may_fold</code>), scene targets and the part exception lists are not written here. Submitted feedback carries the file by hash and content.</p><div id="requirements-body"></div></section><section id="retarget" class="readings-panel requirements-panel retarget-panel" aria-labelledby="retarget-heading"><div class="row"><span id="retarget-heading" class="eyebrow">RETARGET · RIG-POSE</span></div><p class="hint">Put a pose sequence the package declares onto this rig: edit the <code>rig-pose-map/1</code> (per chain, the bone that carries each segment), set the clean and retarget flags, and Apply. The installed <code>@rig-c/pose</code> runs <code>rig-pose clean</code> and <code>rig-pose retarget</code> onto the handoff rig, and its <code>rigc</code> compiles the result, all into scratch; the new animation plays here beside the reference (pick the sequence under Reference). Review writes no keys and never writes the package's <code>motion.json</code>. Refusals (<code>POSE_NOT_CLEANED</code>, <code>POSE_BEND_ONE_WAY</code>, <code>RAISED_HEAD</code>, <code>MOTION_NAME_FREE</code>, <code>MAP_*</code>) are the producer's, verbatim. FEET, STANCE, FLIP, SHORT, END-ON, LEG-SCALE and REACH lines seek to their frame.</p><div id="retarget-body"></div></section><section id="report-panel" class="report-panel" aria-labelledby="report-heading"><div class="report-header"><h2 id="report-heading">Report events</h2><span id="report-summary" class="subtle"></span><a id="report-source-link" target="_blank" rel="noopener" hidden>Source report</a></div><p id="report-unavailable" class="hint" hidden>No rig-pose-report/1 events supplied.</p><div id="report-browser"><div class="report-controls"><label for="report-category" class="sr-only">Report event category</label><select id="report-category"><option value="all">All events</option></select><button id="report-previous" type="button" aria-label="Previous event page">Previous</button><output id="report-page"></output><button id="report-next" type="button" aria-label="Next event page">Next</button></div><div id="report-empty-categories" class="report-empty-categories"></div><p id="report-missing" class="hint" hidden></p><ol id="report-events" class="report-event-list" aria-label="Read-only report events"></ol><section id="report-selection" class="report-selection" hidden aria-labelledby="report-selection-heading"><h3 id="report-selection-heading"></h3><p id="report-timing" class="hint"></p><div class="report-controls"><label id="report-time-label" for="report-time" hidden>Select explicit time</label><select id="report-time" hidden></select><button id="report-seek" type="button" disabled>Go to explicit time</button></div><div id="report-target-control" hidden><label for="report-target" class="label space-top">Select a target named in the report</label><select id="report-target"></select></div><p id="report-prefill-note" class="hint"></p><p id="report-provenance" class="hint"></p><details><summary>Source event details</summary><pre id="report-details"></pre></details></section></div></section></section></div></section></main></div><footer class="status-bar"><span id="message" class="status-message" role="status" aria-live="polite">Loading local review package…</span><div class="status-readouts" aria-label="Status"><span id="player-state" class="subtle">No rig supplied</span><span id="layout-state" class="subtle" role="status" aria-label="Effective layout">Loading inputs…</span><span id="renderer-state" class="subtle" aria-label="Last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span><span id="layer-state" class="hint">No rig supplied</span><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div></div><div class="status-source"><details class="view-region"><summary>View region</summary><div class="view-region-body"><span class="hint">Numeric source region · opening this link restores a paused inspection</span><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off to inspect each pane independently. Toolbar zoom and Fit use the last active pane. Hold Space while dragging a video to pan without taking over its native controls.</p></div></details><span id="source-hash" class="subtle"></span></div></footer><dialog id="review-dialog" class="review-dialog" aria-labelledby="review-dialog-title"><div class="review-dialog-header"><h2 id="review-dialog-title">Write review</h2><span class="hint">Drafts save locally as you type · nothing is submitted until you press Submit</span><button id="review-dialog-close" type="button">Close</button></div><section id="inspector-feedback" class="review-dialog-body" aria-label="Feedback"><div class="review-summary"><div class="eyebrow">REVIEW</div><label for="review-status" class="label">Review status</label><select id="review-status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance" aria-label="Reviewed source"><div class="eyebrow">REVIEWED SOURCE</div><div class="provenance-row"><span>Package revision</span><strong id="provenance-version">—</strong></div><div class="provenance-row"><span>rig SHA-256</span><code id="provenance-rig">—</code></div><div class="provenance-row"><span>motion SHA-256</span><code id="provenance-motion">—</code></div><details><summary>Full provenance</summary><pre id="provenance-full"></pre></details><div class="source-files"><div class="eyebrow">SOURCE FILES · READ ONLY</div><div id="source-files"></div></div><p class="hint">Submits server-verified file hashes and tool versions. Your selected review status is preserved.</p></section><div class="divider"></div></div><div id="checkpoint-detail"><div class="eyebrow">SELECTED CHECKPOINT</div><h1 id="detail-title">Checkpoint</h1><p id="detail-note" class="hint"></p><fieldset class="outcome"><legend>Checkpoint outcome</legend><label><input type="radio" name="outcome" value="pending" checked>Pending</label><label><input type="radio" name="outcome" value="pass">Pass</label><label><input type="radio" name="outcome" value="fail">Needs changes</label></fieldset><label for="checkpoint-note" class="label space-top">Review notes</label><textarea id="checkpoint-note" rows="5" placeholder="Describe the part, conditions and what looks wrong."></textarea></div><div class="divider"></div><section id="proposal-panel"><div class="eyebrow">STRUCTURED FEEDBACK</div><div class="label">Frame range · inclusive</div><div class="frame-range"><label>Start<input id="feedback-start" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 18"></label><label>End<input id="feedback-end" type="number" min="0" step="1" inputmode="numeric" placeholder="e.g. 32"></label></div><p id="feedback-range-hint" class="hint">Checking frame bounds…</p><label for="feedback-target-kind" class="label space-top">Target type</label><select id="feedback-target-kind"><option value="">Select target</option><option value="rig">Entire rig</option><option value="bone">Bone</option><option value="slot">Slot</option></select><label for="feedback-target-name" class="label space-top">Target name</label><input id="feedback-target-name" type="text" list="feedback-target-names" autocomplete="off" placeholder="Actual bone or slot name"><datalist id="feedback-target-names"></datalist><p id="feedback-target-hint" class="hint"></p><label for="feedback-category" class="label space-top">Category</label><select id="feedback-category"><option value="">Select category</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="feedback-summary" class="label space-top">One-line summary</label><input id="feedback-summary" type="text" maxlength="1000" placeholder="e.g. The left wrist moves outside the sleeve"><button id="add-proposal" class="outline block">Add feedback</button><p id="proposal-boundary" class="hint">Records the current checkpoint, time, reference, camera and inspection axis. Motion is read only: inspect, mark ranges and export feedback. No keys or source files are edited.</p><p id="feedback-warnings" class="hint" role="status" hidden></p><p id="feedback-applied-check" class="hint error" role="alert" hidden></p><div id="proposal-list"></div></section><details id="strip-panel" class="report-panel"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Inspection time and source files remain unchanged.</p><label for="strip-scope" class="label">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-reference" class="label space-top">Reference video</label><select id="strip-reference"><option value="">Select reference video</option></select><label for="strip-format" class="label space-top">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">PNG requires frameTimes in the selected report. Without it, JSON preserves requested frames with time: null. Batch export requires every feedback item to match the selected animation. Unsubmitted feedback is included; exporting does not submit the review.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading package…</p></details></section><div class="review-dialog-actions"><span id="review-message" class="review-message" role="status" aria-live="polite"></span><span id="review-draft-state" class="review-draft-state" role="status" aria-live="polite"></span><button id="save" class="primary" disabled>Submit review</button></div></dialog></div>
10
10
  <script type="module" src="main.js"></script>
11
11
  </body></html>
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Collision-free label placement for point labels on the stage overlay (#147). Pure: text widths are measured by the
3
+ * caller (canvas `measureText`, or an estimate in tests) and passed in, so the same input always gives the same output.
4
+ *
5
+ * Greedy, in a stable order: pinned labels (the hovered / dragged joint) first, then the rest in input order. Each label
6
+ * tries its home box (right of the point, rising from it), then the box left of the point, then both sides shifted up
7
+ * and down by one row at a time; it takes the first box that intersects no box already placed nor an obstacle (the point
8
+ * glyphs) and stays inside the bounds. A label with no free box within `rows` is hidden; a pinned label is never hidden and falls back to its home box.
9
+ */
10
+ export interface LabelPoint {x:number;y:number}
11
+ export interface LabelInput {at:LabelPoint;width:number;pinned?:boolean}
12
+ export interface LabelBox {x:number;y:number;w:number;h:number}
13
+ export interface PlacedLabel {box:LabelBox;/** False: drawn only when pinned (hover / selection). */visible:boolean;/** The box left its home slot; draw a leader to the point. */moved:boolean}
14
+ export interface LabelPlacementOptions {
15
+ /** Box height in px. */height?:number;
16
+ /** Horizontal gap between the point and the box edge. */gap?:number;
17
+ /** Vertical rise of the home box's bottom above the point. */rise?:number;
18
+ /** Shift per ring when stepping up / down; defaults to height + 2. */step?:number;
19
+ /** Rings of up / down shifts tried after the two home sides. */rows?:number;
20
+ /** Canvas bounds a box must stay inside, when given. */bounds?:{width:number;height:number};
21
+ /** Boxes a label must not cover (the point glyphs themselves), besides the labels already placed. */obstacles?:readonly LabelBox[];
22
+ }
23
+
24
+ export function boxesIntersect(a:LabelBox,b:LabelBox):boolean{return a.x<b.x+b.w&&b.x<a.x+a.w&&a.y<b.y+b.h&&b.y<a.y+a.h;}
25
+
26
+ /** Candidate boxes for one label, in the order they are tried. The first one is the home box. */
27
+ export function labelCandidates(input:LabelInput,options:LabelPlacementOptions={}):LabelBox[]{
28
+ const h=options.height??15,gap=options.gap??9,rise=options.rise??2,step=options.step??h+2,rows=options.rows??4,w=input.width;
29
+ const right=input.at.x+gap,left=input.at.x-gap-w,home=input.at.y-rise-h,out:LabelBox[]=[{x:right,y:home,w,h},{x:left,y:home,w,h}];
30
+ for(let k=1;k<=rows;k++){const d=k*step;out.push({x:right,y:home-d,w,h},{x:right,y:home+d,w,h},{x:left,y:home-d,w,h},{x:left,y:home+d,w,h});}
31
+ return out;
32
+ }
33
+
34
+ export function placeLabels(inputs:readonly LabelInput[],options:LabelPlacementOptions={}):PlacedLabel[]{
35
+ const order=inputs.map((_,i)=>i).sort((a,b)=>Number(!!inputs[b]!.pinned)-Number(!!inputs[a]!.pinned)||a-b);
36
+ const inside=(b:LabelBox)=>!options.bounds||(b.x>=0&&b.y>=0&&b.x+b.w<=options.bounds.width&&b.y+b.h<=options.bounds.height);
37
+ const placed:LabelBox[]=[...(options.obstacles??[])],out:PlacedLabel[]=new Array(inputs.length);
38
+ for(const i of order){
39
+ const input=inputs[i]!,candidates=labelCandidates(input,options),home=candidates[0]!;
40
+ const index=candidates.findIndex(b=>inside(b)&&!placed.some(p=>boxesIntersect(b,p)));
41
+ if(index>=0){const box=candidates[index]!;placed.push(box);out[i]={box,visible:true,moved:index>0};}
42
+ else if(input.pinned){placed.push(home);out[i]={box:home,visible:true,moved:false};}
43
+ else out[i]={box:home,visible:false,moved:false};
44
+ }
45
+ return out;
46
+ }
package/web/main.ts CHANGED
@@ -35,7 +35,7 @@ import type {ReferenceBinding} from '../src/reference.ts';
35
35
  import type { Checkpoint, Json, Reference, ReviewedSource, ReviewManifest } from '../src/manifest.ts';
36
36
  import type { Feedback, ReviewSession } from '../src/session.ts';
37
37
  import type { ReportEvent, ReportSeeds } from '../src/report.ts';
38
- import { clockForAnimation, frameAtTime, lastFrame as lastProducerFrame, timeAtFrame } from '../src/frame-clock.ts';
38
+ import { frameAtTime, lastFrame as lastProducerFrame, rigPaneLabel, samplesClock, timeAtFrame, transportClock, type StageClock } from '../src/frame-clock.ts';
39
39
  import { feedbackLastFrame, feedbackTargetNames, validateFeedbackSelection, type FeedbackCatalog } from '../src/feedback-validation.ts';
40
40
  import { eventCoordinates, eventSeekTime, eventSelection, eventTimingLabel, reportCounts, reportDraftForMarker, targetLabel } from './report-view.ts';
41
41
  import type { StripInterval, StripRequest } from '../src/strip.ts';
@@ -85,6 +85,8 @@ let player: RigPlayer | null = null;
85
85
  let animation = '';
86
86
  let playing = false;
87
87
  let time = 0;
88
+ /** The clock a scratch build on the stage states for its own animation (a retarget's report frameTimes, #164). */
89
+ let stageClock: StageClock | null = null;
88
90
  let duration = 0;
89
91
  let lastFrame = 0;
90
92
  type CameraTarget='rig'|'reference';
@@ -434,13 +436,14 @@ function renderFeedbackTargets(preserve = false): void {
434
436
  }
435
437
 
436
438
  function renderFrameTransport(): void {
437
- const clock = activeFrameClock();
439
+ const active = activeTransportClock(), clock = active?.clock ?? null;
438
440
  const last = lastProducerFrame(clock), frame = frameAtTime(clock, time);
441
+ text('rig-label', rigPaneLabel({ hasPlayer: !!player, animation: player?.current ?? animation, time, frame }));
439
442
  el('frame-controls').hidden = last === null;
440
443
  if (!clock) { text('frame-label', data.report?.clock ? data.report.animation ? 'Frame unknown · report and current clip do not match' : 'Frame unknown · report animation not supplied' : 'rate unknown'); return; }
441
444
  if (last === null) { text('frame-label', 'F? / ? · frame range unknown'); return; }
442
445
  text('frame-label', `F${frame === null ? '?' : frame} / ${last}${frame === null ? ' · Out of range' : ''}`);
443
- text('frame-clock-label', clock.kind === 'uniform' ? `${clock.fps} fps · frame0 ${clock.frame0} s` : `frameTimes · ${clock.frameTimes.length} explicit times`);
446
+ text('frame-clock-label', (clock.kind === 'uniform' ? `${clock.fps} fps · frame0 ${clock.frame0} s` : `frameTimes · ${clock.frameTimes.length} explicit times`) + (active?.source === 'stage' ? " · this build's report" : ''));
444
447
  input('frame-number').max = String(last);
445
448
  if (document.activeElement !== input('frame-number')) input('frame-number').value = frame === null ? '' : String(frame);
446
449
  input('frame-scrub').max = String(last); input('frame-scrub').hidden = frame === null;
@@ -449,10 +452,11 @@ function renderFrameTransport(): void {
449
452
  el('frame-outside').hidden = frame !== null;
450
453
  }
451
454
 
452
- function activeFrameClock() {
455
+ function activeTransportClock() {
453
456
  const active = player && player.current !== animation ? '' : animation;
454
- return clockForAnimation(data.report?.clock, data.report?.animation, active, !!player);
457
+ return transportClock({ clock: data.report?.clock, animation: data.report?.animation }, active, !!player, stageClock, stageModelHash);
455
458
  }
459
+ function activeFrameClock() { return activeTransportClock()?.clock ?? null; }
456
460
 
457
461
  function seekFrame(frame: number): void {
458
462
  const exact = timeAtFrame(activeFrameClock(), frame);
@@ -926,8 +930,8 @@ function bind(): void {
926
930
 
927
931
  async function start(): Promise<void> {
928
932
  try {
929
- let writes: { capabilityRequired: boolean };
930
- [data, session, writes] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
933
+ let writes: { capabilityRequired: boolean; control?: boolean };
934
+ [data, session, writes] = await Promise.all([api<ReviewResponse>('/api/review'), api<ReviewSession>('/api/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
931
935
  readOnly = readOnlyReason(access, writes.capabilityRequired);
932
936
  submittedBaseline=structuredClone(session);
933
937
  text('package-title', data.manifest.title); text('revision', data.manifest.revision); document.title = `${data.manifest.title} · rig review`; text('source-hash', `source ${data.sourceHash.slice(0, 12)}`); select('review-status').value = session.status; select('feedback-target-kind').value = 'rig'; input('feedback-target-name').value = data.manifest.id;
@@ -952,7 +956,8 @@ async function start(): Promise<void> {
952
956
  else message(data.manifest.model ? 'Record review status and structured feedback while preserving sources. Motion is read only.' : 'No rig supplied. Review the available references and checkpoints.');
953
957
  requestAnimationFrame(tick);
954
958
  const restored=await setupPersistence();await loadRebuildStatus();void loadRequirements();renderChest();restoreEnvironment();physicsPanel?.restore();bonesPanel?.restore();cutsPanel?.restore();clothPanel?.restore();idlePanel?.restore();rigFieldsPanel?.restore();meshPanel?.restore();renderIdle();if(!restored&&canRepeatIdle()&&automaticIdlePreview(assets?.animations.map(item=>item.name)??[],animation,first,!!requestedId))void playPreview();
955
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
959
+ // Only an instance that serves agent control is asked for its config: a plain open has no control endpoint (#164).
960
+ if(!readOnly&&writes.control!==false)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'single',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
956
961
  } catch (error) { message(`Could not open review package: ${errorText(error)}`, true); text('dirty', 'Load failed'); }
957
962
  }
958
963
  /** `feedback` is the review dialog, not an Inspector section: a user action opens it; a restored older bookmark that saved it selects nothing. */
@@ -1377,6 +1382,8 @@ async function applyRetarget(body:{sequence:string;map:PoseMap;flags:RetargetFla
1377
1382
  retargetPanel?.result(result);retargetPanel?.status(outcome.text,outcome.error);
1378
1383
  if(next.id===null||!('id' in result)){message(outcome.text,true);return result;}
1379
1384
  await swapRig(new URL(access.route(next.url),location.href).href,access.route(`/rebuild/${next.id}/`),next.hash,world);
1385
+ // The new animation's own clock (the run's report frameTimes), bound to this build: the transport counter and the rig pane read it.
1386
+ const own=samplesClock(result.frameTimes);stageClock=own?{animation:result.request.record.flags.animation,modelHash:next.hash,clock:own}:null;
1380
1387
  appliedOnStage=retargetAppliedRecord(result);stageCheck=null;
1381
1388
  const draft=el<HTMLTextAreaElement>('rebuild-applied');draft.value=JSON.stringify(appliedOnStage);draft.dispatchEvent(new Event('input',{bubbles:true}));
1382
1389
  // The new animation plays against the reference: choose it, and show the sequence it was made from beside it.
@@ -126,7 +126,7 @@ export interface DeltaJobView {id:string;kind:'delta';op:'remesh';status:'runnin
126
126
  /** `{part, delta, configHash}`: the server owns every path and flag; the delta is the editor's export as it stands. */
127
127
  export interface DeltaRequestBody {part:string;delta:import('../src/mesh-edit.ts').MeshEditDocument;configHash:string}
128
128
  /** A retarget Apply's job (#114): clean, retarget, compile; every FAIL line of the step that refused, verbatim; the listed lines. */
129
- export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
129
+ export interface RetargetJobView {id:string;kind:'retarget';op:'retarget';status:'running'|'built'|'refused'|'failed';reused:boolean;lines:string[];producerLine:string|null;refusals:string[];exitCode:number|null;wallMs:{clean:number|null;retarget:number|null;compile:number|null};files:FilesDiff|null;motion:MotionSummary|null;readings:RetargetLine[];frameTimes?:number[]|null;build:{modelUrl:string;modelHash:string;files:Record<string,string>}|null;request:{record:RetargetRecord;recordSha256:string;configHash:string};producer:{package:string;version:string};compiler:{package:string;version:string}}
130
130
  /** `{retarget: {sequence, map, flags, configHash}}`: a declared reference id, the map by content and typed flags — no path, no flag string. */
131
131
  export interface RetargetRequestBody {retarget:{sequence:string;map:Record<string,unknown>;flags:RetargetFlags;configHash:string}}
132
132
  /** What the draft keeps and `context.applied` carries for a retarget: the record, the producer's line, the motion summary, the diff and the lines. */
@@ -1,5 +1,6 @@
1
1
  /** Inspection graphics from the last successfully drawn public player pose. */
2
2
  import {partBounds} from './part-selection.ts';
3
+ import {placeLabels} from './label-placement.ts';
3
4
  import type {BoneWorld,PosedFrame,RigPlayer} from '@rig-c/play';
4
5
 
5
6
  export interface OverlayPoint {x:number;y:number}
@@ -221,15 +222,29 @@ export class RigOverlays {
221
222
  ctx.fillStyle=circle.flash?c.flash:c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=circle.flash?c.ink:c.stroke;ctx.fillText(circle.label,lx+4,ly);
222
223
  }
223
224
  }
224
- if(this.playerAvailable)for(const marker of markers){
225
- const at=projected(marker.at);if(!at)continue;const c=marker.colors;
226
- ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
227
- for(const to of marker.guideTo??[]){const end=projected(to);if(!end)continue;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(end.x,end.y);ctx.stroke();}
228
- ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
229
- ctx.beginPath();ctx.moveTo(at.x,at.y-size);ctx.lineTo(at.x+size,at.y);ctx.lineTo(at.x,at.y+size);ctx.lineTo(at.x-size,at.y);ctx.closePath();
230
- ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
231
- ctx.font='600 11px ui-monospace, monospace';const width=ctx.measureText(marker.tag).width+8,lx=at.x+size+4,ly=at.y-size-2;
232
- ctx.fillStyle=c.labelBg;ctx.fillRect(lx,ly-11,width,15);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,lx+4,ly);
225
+ if(this.playerAvailable){
226
+ const drawn:{marker:OverlayMarker;at:OverlayPoint}[]=[];
227
+ for(const marker of markers){
228
+ const at=projected(marker.at);if(!at)continue;const c=marker.colors;drawn.push({marker,at});
229
+ ctx.globalAlpha=.9;ctx.setLineDash([3,3]);ctx.strokeStyle=c.stroke;ctx.lineWidth=1.2;
230
+ for(const to of marker.guideTo??[]){const end=projected(to);if(!end)continue;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(end.x,end.y);ctx.stroke();}
231
+ ctx.setLineDash([]);ctx.globalAlpha=1;const size=marker.active?7:5.5;
232
+ ctx.beginPath();ctx.moveTo(at.x,at.y-size);ctx.lineTo(at.x+size,at.y);ctx.lineTo(at.x,at.y+size);ctx.lineTo(at.x-size,at.y);ctx.closePath();
233
+ ctx.fillStyle=c.fill;ctx.fill();ctx.strokeStyle=c.ink;ctx.lineWidth=marker.active?2.4:1.6;ctx.stroke();
234
+ }
235
+ // Labels avoid each other (#147): a moved label keeps a leader to its point; one with no free slot shows on hover only.
236
+ ctx.font='600 11px ui-monospace, monospace';
237
+ const placed=placeLabels(drawn.map(({marker,at})=>({at,width:ctx.measureText(marker.tag).width+8,pinned:marker.active})),{gap:10,rise:3,bounds:{width,height},obstacles:drawn.map(({marker,at})=>{const r=marker.active?8:6.5;return {x:at.x-r,y:at.y-r,w:r*2,h:r*2};})});
238
+ // Leaders first, so no leader crosses another joint's label.
239
+ drawn.forEach(({marker,at},i)=>{
240
+ const {box,visible,moved}=placed[i]!;if(!visible||!moved)return;
241
+ const ex=Math.min(Math.max(at.x,box.x),box.x+box.w),ey=Math.min(Math.max(at.y,box.y),box.y+box.h);
242
+ ctx.globalAlpha=.85;ctx.strokeStyle=marker.colors.stroke;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(at.x,at.y);ctx.lineTo(ex,ey);ctx.stroke();ctx.globalAlpha=1;
243
+ });
244
+ drawn.forEach(({marker},i)=>{
245
+ const {box,visible}=placed[i]!,c=marker.colors;if(!visible)return;
246
+ ctx.fillStyle=c.labelBg;ctx.fillRect(box.x,box.y,box.w,box.h);ctx.fillStyle=c.stroke;ctx.fillText(marker.tag,box.x+4,box.y+11);
247
+ });
233
248
  }
234
249
  ctx.globalAlpha=1;
235
250
  if(this.playerAvailable&&meshEdit)this.drawMeshEdit(ctx,meshEdit,projected,width,height);