@rig-c/review 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ Compare feedback requires side `a` or `b` and carries both package source hashes
69
69
 
70
70
  ## Feedback ownership
71
71
 
72
- Opening leaves status **pending**. The person explicitly saves **approved**, **changes-requested**, or **pending**, checkpoint outcomes/notes, and feedback containing an inclusive frame interval, a bone/slot/rig target, defect/quality category and one-line summary. Frame numbers are entered explicitly; the app does not invent a source FPS. The primary **리뷰 제출** action saves that explicit status and feedback. JSON and issue-ready Markdown exports carry the original source binding and reproducible inspection context. Each submission records `reviewedSource`: package revision, actual viewer/runtime versions, and separate exact-byte SHA-256 plus path for the reviewed rig and motion; an absent artifact stays null. An active compiled model is the rig identity; raw skeleton JSON is evidence with playback unavailable.
72
+ Opening leaves status **pending**. The person explicitly saves **approved**, **changes-requested**, or **pending**, checkpoint outcomes/notes, and feedback containing an inclusive frame interval, a bone/slot/rig target, defect/quality category and one-line summary. Frame numbers are entered explicitly; the app does not invent a source FPS. **Write review**, at the top right, opens the review dialog: review status, reviewed source, selected checkpoint, structured feedback and the frame-strip export. Its **Submit review** saves that explicit status and feedback; closing the dialog keeps the unsubmitted draft. The page has no feedback export buttons: `rig-review feedback --format json` and `--format issue` (below) are the export path, and carry the original source binding and reproducible inspection context. Each submission records `reviewedSource`: package revision, actual viewer/runtime versions, and separate exact-byte SHA-256 plus path for the reviewed rig and motion; an absent artifact stays null. An active compiled model is the rig identity; raw skeleton JSON is evidence with playback unavailable.
73
73
 
74
74
  New submissions validate bone/slot names against the exact loaded model; the target field offers those names. A rig target names the package id. Source-only packages cannot validate a bone/slot target and say so explicitly. When a matching animation has a declared producer clock, the inclusive interval cannot end beyond the last declared sample at or before the actual clip end. Unknown clocks keep manual frame intervals without inventing a limit. `context.interval` records the chosen interval; inspection `time`/`frame` remain separate. Older invalid feedback stays readable with per-row warnings and exports its original evidence, but cannot be resubmitted until corrected or removed.
75
75
 
@@ -93,7 +93,7 @@ rig-review feedback /absolute/output --reference /absolute/original.mp4 --format
93
93
  rig-review feedback /absolute/a --compare /absolute/b --reference /absolute/original.mp4 --animation dance --format issue
94
94
  ```
95
95
 
96
- The header counts feedback records by defect/quality and by build; blocks group by category, then exact target kind/name. Overlapping intervals and A/B sides remain distinct items. Each block retains its id, inclusive interval, one-line summary and selected/full paired provenance. Historical tool identities and validation warnings remain visible; output never migrates approval, writes state or publishes an issue. Default output and `--format json` remain the existing session JSON. Only feedback accepts `--format`, with `json` or `issue` as its value.
96
+ The header counts feedback records by defect/quality and by build; blocks group by category, then exact target kind/name. Overlapping intervals and A/B sides remain distinct items. Each block retains its id, inclusive interval, one-line summary and selected/full paired provenance. Historical tool identities and validation warnings remain visible; output never migrates approval, writes state or publishes an issue. Default output and `--format json` remain the existing session JSON. Only feedback accepts `--format`, with `json` or `issue` as its value. These two formats are how submitted feedback leaves the tool; the browser page offers no JSON or Markdown download.
97
97
 
98
98
  ## Development
99
99
 
@@ -334,7 +334,9 @@ active pane, and each canonical camera center with visible bounds. Reloading a m
334
334
  it does not restart an idle loop or submit a review. Coordinates adapt to the
335
335
  current viewport instead of replaying another window's CSS pan offsets.
336
336
  Historical links without a sync mode restore with sync off to preserve their
337
- saved views; fresh inspections start with sync on. Legacy modeling/motion
337
+ saved views; fresh inspections start with sync on. The page keeps the current
338
+ fragment in the address bar as you inspect, so copying the address copies the
339
+ view; there is no separate copy button. Legacy modeling/motion
338
340
  workspace selections are accepted without restoring a top-level workspace switch. A link
339
341
  for a different source binding is refused.
340
342
 
@@ -354,25 +356,61 @@ or corrupt saved data remains available to download, with an explicit warning;
354
356
  recovering a conflicting draft requires choosing a pending review. Storage
355
357
  errors never count as a successful save. Nothing is written to the server or
356
358
  input package merely by restoring a page. Browser storage is convenient local
357
- recovery, not a durable backup; export feedback separately when it must be kept.
359
+ recovery, not a durable backup; submit the review (and retrieve it with
360
+ `rig-review feedback`) when it must be kept.
358
361
  Control capabilities are excluded from links and browser persistence.
359
362
 
360
363
  ## Workspace settings and panels
361
364
 
362
365
  The review page has a left column (Parts explorer, with checkpoints and the
363
366
  reference select folded above it), the stage, the Inspector below it with
364
- **Setup**, **Animate** and **Feedback** sections, and a status footer. The
365
- compare page keeps sources on the left and the Parts explorer on the right.
366
-
367
- The view bar keeps the layer toggles in reach in every section: **Images**
367
+ **Setup** and **Animate** sections, and a status footer. The compare page
368
+ keeps sources on the left and the Parts explorer on the right. The page
369
+ header carries the **Setup** / **Animate** mode switch next to the package
370
+ title; it is the only way to change the Inspector's section. Setup is the
371
+ mesh-review composition (selected part, part presets, modeling-review
372
+ proposals) and holds nothing that seeks or plays; the angle inspection, the
373
+ transport and frame navigation are in Animate. **Write review** at the top
374
+ right opens the review dialog, a modal holding the review status, reviewed
375
+ source, selected checkpoint, structured feedback and frame-strip export, with
376
+ its own **Submit review**. Escape or Close dismisses it and returns focus to
377
+ **Write review**; the draft keeps saving whether the dialog is open or not,
378
+ and the footer and the dialog both show its state.
379
+ The left column's header (Parts title and search) stays fixed at the top;
380
+ the checkpoint strip, the part list and Settings scroll beneath it as one
381
+ body. A drawer keeps its title and close button in place while its body
382
+ scrolls. The per-inspection **Repeat idle** toggle sits in the Animate
383
+ transport row.
384
+
385
+ Animate's **Environment** block drives the player's physics while you
386
+ inspect: a **Wind** base (−100…100), **Breeze**, a **Gust** pulse
387
+ (peak 0…200, duration 0.1…20 s, **Repeat** every max(6, duration + 0.6) s)
388
+ and **Gravity** (0…4), applied through `player.environment` in the same frame
389
+ step that draws. The gust clock is the stepped player time, so pausing freezes
390
+ it. **Jolt** sends a translate-and-return impulse through `player.moved`;
391
+ with **Drag to trail** on, dragging the rig pane moves the rig's physics
392
+ instead of the camera (Space still pans). **Reset physics** returns the
393
+ runtime start values and re-poses from the current time, clearing the physics
394
+ history. The physics constraints are listed read only as the compiled model
395
+ declares them; `@rig-c/play` exposes no per-constraint mix, so none is offered.
396
+ Until a control is touched the player stays at the runtime start values. The
397
+ state is kept in the local draft and recorded in feedback as
398
+ `context.environment`; nothing is written to the model or motion. The compare
399
+ page does not have this block.
400
+
401
+ The view bar is one row and keeps the layer toggles in reach in every section: **Images**
368
402
  (`I`), **Mesh edges** (`M`) and **Bone points** (`B`). The keys do nothing while
369
403
  a text field, textarea, select or editable text has focus, with Ctrl/Alt/Cmd
370
404
  held, or on key repeat; Space stays pan and Enter stays frame entry. A disabled
371
405
  toggle shows why as a tooltip, and the footer shows the same layer status. The
372
406
  footer also gathers the message, player, layout, renderer, idle, remote control
373
- and draft state; **View region** opens the numeric view.
407
+ and draft state; **View region** opens the numeric view. Side by side,
408
+ Reference and Rig are in the **View** menu at the right end of the view bar,
409
+ labelled with the current choice.
374
410
 
375
- The side and bottom panels are resizable. Drag a separator, or focus it
411
+ The side and bottom panels are resizable. The separators are thin (4px)
412
+ lines whose colour is never the scrollbar's; the grab area is wider than the
413
+ line. Drag a separator, or focus it
376
414
  and use the arrow keys (Shift for larger steps) and Home/End. Escape, a
377
415
  cancelled pointer or window blur during a drag returns the previous size. The
378
416
  stage keeps a usable minimum. When the window is too narrow, the side columns
@@ -380,9 +418,11 @@ stage keeps a usable minimum. When the window is too narrow, the side columns
380
418
  drawers opened from buttons above the stage instead of shrinking to nothing. Responsive clamping
381
419
  never overwrites the desired size, so a wider window restores it.
382
420
 
383
- Settings, at the bottom of the left column, holds browser defaults: Theme, Sync cameras by
421
+ Settings, at the end of the left column's scrolling body, holds browser defaults: Theme, Sync cameras by
384
422
  default, Repeat idle by default, and Reset layout. Defaults seed a new
385
- inspection only. A view link or source draft keeps its own appearance, camera
423
+ inspection only. Theme is the page's only appearance control: it shows the
424
+ current inspection's appearance, and changing it applies it and saves it as the
425
+ default. A view link or source draft keeps its own appearance, camera
386
426
  sync and idle choice, and restoring it neither changes the saved defaults nor
387
427
  starts playback. Reset layout restores panel sizes and nothing else.
388
428
 
@@ -413,6 +453,41 @@ Source-bound links/drafts and explicit feedback preserve that side/slot plus exa
413
453
 
414
454
  This explorer is inspection. Producer-backed optimization and candidate Apply/undo/export remain separate work; the explorer does not generate mesh, rig or motion bytes.
415
455
 
456
+ ## Chest regions (designation only)
457
+
458
+ In **Setup**, the view bar's **Chest regions** button (pressed state; hidden in Animate) opens the chest-region editor bar directly under the view bar and enables editing on the rig pane. The bar has an **R** and an **L** group, each with **X**, **Y**, **R** (core radius, 6–120) and **Band** (4–120) in crop pixels, x screen-right and y down; **Mirror R → L** copies R's y, core radius and band to L; **Download JSON**; and **Apply · Rebuild**.
459
+
460
+ On the rig pane each region is drawn as two circles: the core (full weight) and a dashed outer circle at core + band, R in one colour and L in another (two theme tokens per side), with a centre handle, a core handle on the right edge, a band handle on the top edge and a label `R (x,y) r:… b:…` under the outer circle. Dragging the centre moves the region, dragging the core edge sets r and dragging the band handle sets the band, all in crop pixels through the current camera scale. Each drag and each committed bar value is clamped live with the public example rebuild script's non-overlap rule: integer values, both cores plus 8 px must fit the centre distance minus a 2 px margin (both shrink by one factor) and the bands share what remains. A side the rule changed flashes its label and the footer says `CHEST_REGIONS_CLAMPED`. A handle drag never moves the camera; a drag anywhere else, or with Space held, still pans, and the wheel still zooms.
461
+
462
+ **Apply · Rebuild** refuses by name, `MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer`, in its tooltip, the editor bar and the footer. Review never runs a generator, a rebuild script or a manifest command; regenerating the rig from these regions is the producer's job.
463
+
464
+ The editor's values are the fields of Setup's **Chest regions · detail** block (R is region 1, L is region 2), which also holds what the bar does not: per region a name, a bone chosen from the loaded model's actual bones, the two semi-axes, a spacing and an optional medial clip x, plus the contour settings (tolerance, margin, spacing, max edge, stray, bridges). Setting R in the bar writes both semi-axes. A region with equal semi-axes and no clip exports as the examples' circle, `{"name", "shape": "circle", "cx", "cy", "r", "bone", "spacing", "band"}`; unequal semi-axes or a clip export as the generator's 32-sample polygon on its 1/256-pixel grid and are drawn filled, without handles.
465
+
466
+ The overlay needs the loaded model to fix the crop: its declared stage must be the crop frame with the root at its bottom centre (`x = -width/2`, `y = 0`) and the drawn root bone must sit at the world origin, so crop `(x, y)` is world `(x - width/2, height - y)`. Otherwise the editor bar and the detail block show `CHEST_REGION_MAPPING_UNAVAILABLE: <what is missing>`, nothing is drawn, and the values still edit numerically and export from a crop size you enter.
467
+
468
+ **Download JSON** (and the detail block's **Download chest-regions.json**) saves two files in the browser: `chest-regions.json`, formatted exactly as the public example files, and `chest-regions.binding.json`, which names the package source hash, the rig and motion SHA-256 values, the export time, the viewer and runtime versions and the mapping used. Export refuses `CHEST_REGION_NAME_DUPLICATE`, `CHEST_REGION_BONE_UNKNOWN <bone>`, `CHEST_REGION_OUT_OF_CROP <name>`, `CHEST_REGION_OFF_GRID` and `CHEST_REGIONS_OVERLAP <a> <b>`. Two circles overlap when their centre distance minus both cores is less than the sum of their bands; any other pair is tested through polygons. The generator refuses per mesh vertex, so this check can refuse a pair it would accept, never the reverse.
469
+
470
+ Field values stay in the inspection's local draft. They never enter the review session's feedback, nothing is written into the package, and an export is review evidence, not an approval (see `docs/DESIGN.md`).
471
+
472
+ ## Mesh edit (typed delta, preview only)
473
+
474
+ In **Setup**, the view bar's **Edit mesh** button (pressed state; hidden in Animate) edits the selected slot's actual mesh attachment. With **Whole rig** selected it refuses `MESH_EDIT_SELECT_SLOT`; a slot whose active attachment is not a mesh refuses `MESH_EDIT_NO_MESH <slot>`. Entering pauses playback, turns the chest-region editor off and opens the mesh editor bar under the view bar. The rig pane dims except the slot's own triangles; the mesh is drawn at full opacity with every vertex as a handle, hull edges, interior triangulation edges and added constraint edges each in their own theme token.
475
+
476
+ The bar has four sub-modes, one pressed at a time, with single keys that avoid the layer keys `I` / `M` / `B`:
477
+
478
+ | Key | Sub-mode | Pointer |
479
+ | --- | --- | --- |
480
+ | `V` | Modify | drag a vertex, or an edge to move both ends; Shift-click adds to the selection; drag empty space to box-select; arrow keys nudge the selection 1 crop px (Shift 10) |
481
+ | `C` | Create | click empty space to add a vertex; click an edge to split it at the click point |
482
+ | `X` | Delete | click a vertex to remove it and its edges; click an edge to remove it (hull edges are refused, `MESH_EDIT_HULL_EDGE`) |
483
+ | `E` | Edges | click vertex A then vertex B to add a constraint edge; click a constraint edge to remove it; a hull edge is refused and an existing edge records nothing |
484
+
485
+ Hit-testing is in screen pixels through the current camera: a vertex within 6 px (8 on touch) wins over an edge within 4 px. In a sub-mode a press never pans; Space-drag still pans and the wheel still zooms. Escape leaves the sub-mode, a second Escape leaves mesh edit. **Undo** / **Redo** (Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z) step one action at a time — an edge or multi-selection drag is one action of several `move` ops. **Reset** drops the attachment's whole delta after a confirmation.
486
+
487
+ Everything is a typed delta over the loaded mesh, in crop pixels (x right, y down, the chest regions' crop mapping): `{slot, skin, attachment, source:{vertexCount, triangleCount, hullLength, hash}, ops}` with ordered ops `move {v, from, to}`, `add {at, onEdge?}`, `remove {v}`, `edge {a, b}` and `unedge {a, b}`. Source vertex ids are the attachment's indices; an added vertex takes the next id; a removed id is never reused. `hullLength` is the model's `hull` (the first `hull` vertices, in order, are the hull) and `hash` is the SHA-256 of the attachment's entry in the hash-verified model. The vertices are the slot's last drawn posed vertices, so `basis` names the animation and time they were held at. The overlay previews moved, added and removed vertices and the constraint edges over the original triangulation; nothing is retriangulated in the browser (the status line says *preview only — the producer retriangulates*).
488
+
489
+ **Download JSON** saves `mesh-edit.json` and `mesh-edit.binding.json` (package source hash, rig and motion SHA-256, the attachment and its hash, export time, viewer and runtime versions and the mapping). **Apply · Rebuild** refuses `MODELING_INPUT_UNAVAILABLE` as the chest editor's does. The delta rides in the inspection's local draft bound to the attachment hash (at most 300 ops per attachment); it never enters the review session and nothing is written into the package.
490
+
416
491
  ## License
417
492
 
418
493
  MIT. See [LICENSE](LICENSE).
package/docs/DESIGN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  The delivery unit is an existing output folder or an optional curated review package handed from an agent to a person. This reusable app owns inspection, reference comparison, annotations and feedback export. Upstream compiler, splitter and runtime remain their respective tools. The existing producing agent needs no change to open its output here.
6
6
 
7
- The person uses one inspection workspace with **Setup**, **Animate** and **Feedback** sections (the compare page keeps **View**, **Rig**, **Playback** and **Feedback**). Setup covers setup geometry, source art, pivot/joint placement, meshes, weights, draw order and angle coverage; Animate covers frame intervals, reference video/poses, contact/deformation and audio inspection. Motion is strictly **view, annotate and export**: time/angle controls navigate inspection. This app does not author animation keys, retime motion or write `motion.json`. Future modeling touch-up remains in scope as a design goal; it is not implemented by this starter.
7
+ The person uses one inspection workspace with **Setup** and **Animate** sections and a review dialog for feedback (the compare page keeps **View**, **Rig**, **Playback** and **Feedback** sections). Setup covers setup geometry, source art, pivot/joint placement, meshes, weights and draw order; Animate covers angle coverage, frame intervals, reference video/poses, contact/deformation, audio inspection and the inspection environment (wind, gravity, impulses and a read-only physics constraint list through the published player's `environment`/`moved`; its state is feedback context, never a model or motion edit). Motion is strictly **view, annotate and export**: time/angle controls navigate inspection. This app does not author animation keys, retime motion or write `motion.json`. Future modeling touch-up remains in scope as a design goal; it is not implemented by this starter.
8
8
 
9
9
  ## Implemented foundation
10
10
 
@@ -38,12 +38,17 @@ Any future modeling edit needs provenance, undo/redo, explicit units and an expo
38
38
 
39
39
  The single review page is one inspection workspace: a left column, the stage, the Inspector under the stage and one status footer. There is no right panel.
40
40
 
41
- - **Left column.** The Parts explorer fills it. Checkpoints, the current checkpoint title and the reference select fold into a compact strip above the part list; the strip opens when the package supplies checkpoints or references and stays folded for a plain output folder. Browser Settings sit at the bottom.
42
- - **View bar.** Pane choice, the persistent image / mesh edges / bone points layer toggles (keys `I`, `M`, `B`), camera sync and zoom, then appearance, repeat idle and Copy view link at the right end. The toggles stay native checkboxes, so their checked state is the pressed state, the draft input and the `layers` control value. A disabled toggle names the footer's layer reason as its tooltip. Turning images off leaves the mesh/bone overlay alone.
43
- - **Inspector.** Three sections: **Setup** (selected part, part presets, modeling-review proposals, angle panel), **Animate** (transport, frame navigation, motion-review proposals, sound cues, report events) and **Feedback** (review status, reviewed source with the read-only source-file list, selected checkpoint, structured feedback, frame-strip export). Their element ids and saved section names (`rig`, `playback`, `feedback`) are unchanged; a saved `view` section from older links restores without selecting a section.
44
- - **Footer.** Message (truncated, full text as tooltip) on the left; player, effective layout, renderer, layers, idle, remote control and draft state in the middle; the numeric view region (behind **View region**) and the source hash on the right.
41
+ - **Left column.** A fixed header row (Parts title and slot search on one line; the search's name, *Find slot*, is its `aria-label`) and one scrolling body. Nothing sits above the header and it never scrolls. The body holds, in order, the checkpoints/reference strip (current checkpoint title, checkpoints and the reference select, folded into a compact strip that opens when the package supplies checkpoints or references), the part list and browser Settings; it is the column's only scroll container. Settings opens and closes from its own header (`#settings-toggle`, `aria-expanded`, controlling `#settings-body`); it starts collapsed and the state is a browser preference.
42
+ - **Header.** The **Setup** / **Animate** mode switch sits next to the package title and is the Inspector's only section control: its buttons carry `data-inspector` and `aria-pressed`, and every section change (click, `showInspector` from a part selection or a tool, a restored bookmark) goes through them. On the right, **Write review** (primary) opens the review dialog. The top bar has no feedback export buttons; `rig-review feedback --format json|issue` is the export path.
43
+ - **View bar.** One row: the persistent image / mesh edges / bone points layer toggles (keys `I`, `M`, `B`), camera sync and zoom, then the **View** menu (a `<details>` holding Side by side, Reference and Rig, labelled with the effective choice) at the right end. The toggles stay native checkboxes, so their checked state is the pressed state, the draft input and the `layers` control value. A disabled toggle names the footer's layer reason as its tooltip. Turning images off leaves the mesh/bone overlay alone. Appearance lives only in Settings (its Theme select shows the inspection's current appearance and drives it), the per-inspection Repeat idle toggle lives in the Animate transport row, and there is no Copy view link button: the persisted `#review=` fragment stays in the address bar and the numeric region stays behind **View region**.
44
+ - **Panel headers and scrolling.** The left column's header is a fixed row outside its scrolling body. The Inspector heading, the Settings title and each drawer's header (title and close) carry `.sticky-header` and are their panel's first child, so the body scrolls beneath them. Every scrolling region shares one thin scrollbar rule on `*` whose colours are the `--rv-scrollbar-*` tokens. Resize separators are drawn 4px with a 16px transparent hit area (22px on coarse pointers) and use `--rv-separator` / `--rv-separator-hover`, never a scrollbar colour.
45
+ - **Chest-region editor.** In Setup only, the view bar's **Chest regions** toggle (`#chest-edit`, `aria-pressed`) opens `#chest-editor-bar` under the view bar: R / L groups (X, Y, R, Band), Mirror R → L, Download JSON and **Apply · Rebuild**, which refuses `MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer` and runs nothing. The circles, their centre / core / band handles and labels are drawn on `#rig-overlay` through the crop → world mapping; handle drags (move, r, band) are clamped live with the example rebuild script's non-overlap rule (`CHEST_REGIONS_CLAMPED`) and never move the camera, while other drags and Space-drags pan. The values are the Setup **Chest regions · detail** block's fields, so its draft, export and binding apply unchanged; the block keeps names, bones, semi-axes, clip and the contour. This is designation evidence, not a mesh editor: nothing is regenerated here.
46
+ - **Mesh edit.** In Setup only, the view bar's **Edit mesh** toggle (`#mesh-edit`, `aria-pressed`) opens `#mesh-editor-bar`: sub-modes Modify / Create / Delete / Edges (keys `V` / `C` / `X` / `E`, one pressed), Undo, Redo, Reset, Download JSON and **Apply · Rebuild** refusing `MODELING_INPUT_UNAVAILABLE`. Whole rig refuses `MESH_EDIT_SELECT_SLOT`; a non-mesh slot `MESH_EDIT_NO_MESH <slot>`. It records a typed delta over the actual loaded attachment (`src/mesh-edit.ts`) and previews it over the original triangulation on the overlay; it is not a mesher and never retriangulates, writes the package or runs a producer. The chest editor and mesh edit are exclusive: turning one on turns the other off.
47
+ - **Inspector.** Two sections and no tab row: **Setup** is the mesh-review composition (selected part, part presets, the modeling-review proposals for mesh density, contour curvature and chest position) and holds no control that seeks or plays; **Animate** has the transport with the Repeat idle toggle, frame navigation, the motion-review proposals and angle inspection with its axis panel, sound cues and report events. Their element ids and saved section names (`rig`, `playback`) are unchanged. A saved `view` or `feedback` section from older links and drafts restores without selecting a section or opening the dialog.
48
+ - **Review dialog.** A native modal `<dialog id="review-dialog">` holds the former Feedback section unchanged (review status, reviewed source with the read-only source-file list, selected checkpoint, structured feedback, frame-strip export; every element id kept) and the **Submit review** button (`#save`). **Write review**, a part's or a proposal tool's Add feedback opens it with `showModal()` and pauses playback; Escape or Close dismisses it and focus returns to **Write review**. Its inputs stay in the document while it is closed, so the draft mechanism (autosave, recovery, conflict handling) is unchanged and closing loses nothing. The modal makes the rest of the page inert, so the dialog repeats the footer's message and draft state in its own status line; the footer keeps reporting both.
49
+ - **Footer.** Message (truncated, full text as tooltip) on the left; player, effective layout, renderer, layers, idle, remote control and draft state in the middle; the numeric view region (behind **View region**) and the source hash on the right. The View region body is a `position:fixed` popover above the 40px footer, right-aligned, its width and height clamped to the viewport and its content scrolling inside, so the footer's clipping never hides it and opening it never scrolls the page.
45
50
 
46
- The compare page keeps its own markup and stylesheet: sources left, Parts explorer right and its four Inspector sections.
51
+ The compare page keeps its own markup and stylesheet: sources left, Parts explorer right, its four Inspector sections, its export buttons and its separator colour. It shares the workspace coordinator, so its separators are drawn at the same 4px.
47
52
 
48
53
  ## Inspection strips
49
54
 
@@ -60,6 +65,10 @@ The Parts explorer (the review page's left column; the compare page's right one)
60
65
 
61
66
  Published Play1.12.0 supplies a frozen `slotIdentity()` snapshot coherent with `posedFrame().generation`. Review retains copied frame/identity together only after equal-generation and source-slot/order checks, uses the player's default public filter, and never seeks/reposes/changes physics merely to read identity. Exact owning skin/key, active set/skin and null reasons come from that snapshot, not a unique-declaration or texture match. A resolved identity can remain known without visible geometry or fit bounds. Clip declarations do not invent keys when the API reports no drawable attachment.
62
67
 
68
+ Selecting the selected row again (click, Space or Enter) clears the selection, the same as **Whole rig**; the control value is then `null`.
69
+
70
+ Each row shows a 24×24 CSS-pixel thumbnail (2× backing store) of the attachment the held identity snapshot says is active: the region the compiled model's `pages[].regions[]` declares under that attachment's path (or name), cut from the loaded page image by its page UVs. It is drawn when the row scrolls into view and cached per skin/attachment key. A row whose attachment has no such region (no active attachment, a non-image kind, no or several regions of that name, a rotated packing, a page that did not load) shows the type icon, and its tooltip names the reason. A whole atlas page is never a part's thumbnail. Row text and the slot count are unchanged; the compare page registers no thumbnail source and keeps its icons.
71
+
63
72
  Live selection follows a slot. A source-bound bookmark captures its exact identity tuple at that pose; restore validates it after applying time/animation/set and drawing, with transactional rollback on a named mismatch. Old slot-only bookmarks resolve through current public identity. Generation belongs to current readback/feedback, not a stable source target. Named weights and original part alpha remain unavailable without an exact original-source mapping. A conservative bounds box and counts are not source-faithfulness or physics validation.
64
73
 
65
74
  A future optimization operation sends explicit source-bound selection/bounds to the owning producer. That producer returns a new package revision/hash and provenance including producer version/request identity; Review never regenerates rig, motion or mesh bytes. Candidate Apply/undo/export requires its own implemented contract and acceptance, rather than being inferred from the explorer.
@@ -70,7 +79,7 @@ Side and bottom panels have pointer and keyboard separators with separator seman
70
79
 
71
80
  The allocator measures the layout rectangle minus stage chrome and keeps a 240×180 CSS-pixel stage minimum. It shrinks side panels proportionally toward their own minima; when the width still cannot hold them it turns the left, then the right column, and when the height cannot hold it the Inspector, into a native modal drawer opened from a trigger outside it. Effective sizes are computed from desired sizes and never written back; a separator inverts the allocator so a clamped pixel value is not saved as the desktop desire.
72
81
 
73
- Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat and the three desired panel sizes. The saved `rightWidth` is the Parts explorer's width wherever the page shows it, so the review page's left parts column keeps the width chosen before the move; `leftWidth` is the compare page's sources column. Each field carries a logical stamp, so stale whole-record writes from other tabs converge per field without wall-clock ordering; an equal-stamp disagreement quarantines only that field until an explicit edit repairs it. Denied, unavailable or corrupt storage falls back to in-page defaults. The legacy same-origin appearance key migrates once, only when no new record exists. Reset layout changes geometry only.
82
+ Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat, whether Settings is collapsed (`settingsCollapsed`, default `true`) and the three desired panel sizes. A record saved before `settingsCollapsed` existed carries neither the value nor its stamp and loads as collapsed at stamp revision 0; the schema name is unchanged. The saved `rightWidth` is the Parts explorer's width wherever the page shows it, so the review page's left parts column keeps the width chosen before the move; `leftWidth` is the compare page's sources column. Each field carries a logical stamp, so stale whole-record writes from other tabs converge per field without wall-clock ordering; an equal-stamp disagreement quarantines only that field until an explicit edit repairs it. Denied, unavailable or corrupt storage falls back to in-page defaults. The legacy same-origin appearance key migrates once, only when no new record exists. Reset layout changes geometry only.
74
83
 
75
84
  Content state stays source-bound. Each Inspector section's x/y scroll offset is captured before its panel hides and restored after it shows, after two frames of layout, and again when its content extent changes. Restoring clamps to the current extent without replacing the remembered offset, and intentional user scrolling supersedes a pending restore. Offsets are kept in the source draft's UI state, never in the global record or a copied view link.
76
85
 
@@ -86,8 +95,9 @@ Rules:
86
95
  - Every colour token is defined for both schemes: `:root` holds dark (the default before any script runs) and `:root[data-theme=light]` redefines the same set. The appearance control resolves `system` through `prefers-color-scheme` and always writes `data-theme=dark|light` on the root element, so the stylesheets carry no per-component light overrides.
87
96
  - State colours: pressed and current selections use the `--pass` family, primary actions and links `--link`, dirty and draft notices `--warn`, errors `--fail`.
88
97
  - The stage (pane gradient, grid dots, pane labels, empty-state text, video letterbox) stays dark in both schemes so source pixels and overlays read the same; its tokens carry equal values in both blocks.
98
+ - Resize separators and scrollbars never share a colour: `--rv-separator` and `--rv-separator-hover` resolve to values different from both `--rv-scrollbar-*` colours in each scheme (the review page; the compare stylesheet keeps its own separator colour).
89
99
 
90
- `tests/theme.test.ts` enforces the first two rules.
100
+ `tests/theme.test.ts` enforces the first two rules and the separator/scrollbar rule.
91
101
 
92
102
  ## Comparison eligibility
93
103
 
@@ -96,6 +96,8 @@ For a matching report/actual model animation with an explicit bounded clock, `fr
96
96
 
97
97
  The server derives `context.interval:{frameStart,frameEnd,inclusive:true}` from the chosen top-level annotation. Contradictory supplied interval data is refused; optional inspection `time` and `frame` remain separate and do not replace it. Compare uses the selected side's catalog and the CLI's exact animation; a row cannot borrow another side's names or bypass the animation binding.
98
98
 
99
+ A single-review note also records `context.environment`, the Animate environment it was written under: `{applied, wind:{base, breeze, gust:{peak, duration, repeat, active, value}, total}, gravity, standing:{wind:[x,y], gravity:[x,y]}}`, where `standing` is what the player reported (x right, y down) and `applied:false` means the runtime start values (`wind [1, 0]`, `gravity [0, 1]`). It is `null` when no compiled model was drawn. Records without it (older sessions, compare) stay valid; a present value must have exactly this shape within the control limits. The issue export adds an `Environment:` line for it. The environment is inspection context only: it is never written to the model, the motion or any producer file.
100
+
99
101
  Both session responses include server-derived `feedbackWarnings:[{id,error}]`. Reading an older session preserves invalid target rows and previously accepted integer indices outside JavaScript's safe range, together with source identity, inspection context and original state-file bytes; a missing chosen interval can be derived in memory. Contradictory existing interval evidence remains visible with its warning. The UI shows these warnings and prevents resubmission until the affected rows are corrected or removed. Strict saves independently validate every row and ignore a client's attempt to clear warnings; exports retain the warnings and original feedback evidence. Syntax and source-binding failures remain refusals rather than silently repaired data.
100
102
 
101
103
  The state filename is a digest of identity, revision, source hash and reviewed artifact/tool identity, so package names cannot select arbitrary filesystem paths. Writes are atomic and source files remain untouched. Review/session load and save stream-hash the declared inputs; changed bytes produce HTTP 409 and require restart. Asset reads do not rehash an entire video: single byte ranges return 206 and invalid ranges 416. Asset documents receive a sandbox CSP with no scripts or same-origin permission, so delivered authoring HTML cannot approve its own package. No network listener beyond loopback or cross-origin mutation is offered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Chest-region designation (`family-chest-regions/1`): pure geometry, validation and the exact JSON text the
3
+ * public generator's example files use. No DOM, no filesystem. Review designates and exports these values as
4
+ * evidence; regenerating a mesh from them is the producer path, never this module.
5
+ */
6
+
7
+ /** The generator's coordinate grid: every point is a multiple of 1/256 crop pixel. */
8
+ export const CHEST_REGION_GRID = 256;
9
+ export const CHEST_REGIONS_SPEC = 'family-chest-regions/1';
10
+
11
+ export type CropPoint = [number, number];
12
+ export type ChestSegment = string | [string, [number, number], [number, number]];
13
+ export interface ChestPolygonRegion { name: string; bone: string; shape: 'polygon'; points: CropPoint[]; spacing: number; band: number }
14
+ /** The examples' `config.json` circle (key order as there): core radius r at full weight, linear falloff over band. */
15
+ export interface ChestCircleRegion { name: string; shape: 'circle'; cx: number; cy: number; r: number; bone: string; spacing: number; band: number }
16
+ export type ChestRegion = ChestPolygonRegion | ChestCircleRegion;
17
+ export interface ChestContour { tolerance: number; margin: number; spacing: number; maxEdge: number; stray: number; bridges: boolean; regions: ChestRegion[] }
18
+ export interface ChestMesh { contour: ChestContour; r?: number; segments?: ChestSegment[] }
19
+ export interface ChestRegionsDocument {
20
+ spec: typeof CHEST_REGIONS_SPEC;
21
+ coordinates: string;
22
+ publicGenerator: string;
23
+ mesh: { name: string; value: ChestMesh };
24
+ sourceRule?: string;
25
+ }
26
+
27
+ export interface ChestRegionInput {
28
+ name: string;
29
+ bone: string;
30
+ center: [number, number];
31
+ semiAxes: [number, number];
32
+ spacing: number;
33
+ band: number;
34
+ /** Medial clip line x: the polygon side toward this x stops at it (x ≤ clip when clip > centre x, x ≥ clip when clip < centre x). */
35
+ clip?: number;
36
+ }
37
+ export interface ChestRegionsInput {
38
+ crop: { w: number; h: number };
39
+ generator: string;
40
+ mesh?: string;
41
+ contour: Omit<ChestContour, 'regions'>;
42
+ regions: ChestRegionInput[];
43
+ r?: number;
44
+ segments?: unknown;
45
+ sourceRule?: string;
46
+ /** The loaded model's bone names; when present every region bone must be one of them. */
47
+ bones?: readonly string[] | null;
48
+ }
49
+
50
+ export class ChestRegionError extends Error {
51
+ constructor(readonly code: string, detail: string) { super(`${code} ${detail}`.trim()); }
52
+ }
53
+
54
+ const quantize = (value: number): number => { const q = Math.round(value * CHEST_REGION_GRID) / CHEST_REGION_GRID; return q === 0 ? 0 : q; };
55
+ const onGrid = (value: number): boolean => Number.isInteger(value * CHEST_REGION_GRID);
56
+
57
+ /**
58
+ * The 32-sample ellipse the examples encode: sample i at angle θ = 2πi/samples, starting at +x (screen-right)
59
+ * and turning toward +y (down, crop pixels), each coordinate rounded to the nearest 1/256 pixel. A clip x
60
+ * (on the grid) then stops the side that faces it.
61
+ */
62
+ export function ellipsePolygon(center: [number, number], semiAxes: [number, number], samples = 32, clip?: number): CropPoint[] {
63
+ const [cx, cy] = center, [ax, ay] = semiAxes, points: CropPoint[] = [];
64
+ for (let i = 0; i < samples; i++) {
65
+ const angle = 2 * Math.PI * i / samples;
66
+ let x = quantize(cx + ax * Math.cos(angle));
67
+ const y = quantize(cy + ay * Math.sin(angle));
68
+ if (clip !== undefined) x = clip > cx ? Math.min(x, clip) : Math.max(x, clip);
69
+ points.push([x, y]);
70
+ }
71
+ return points;
72
+ }
73
+
74
+ function finite(value: unknown, region: string, field: string, min: number, inclusive = true): number {
75
+ if (typeof value !== 'number' || !Number.isFinite(value) || (inclusive ? value < min : value <= min)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${region} ${field}`);
76
+ return value;
77
+ }
78
+
79
+ function pointSegmentDistance(p: CropPoint, a: CropPoint, b: CropPoint): number {
80
+ const dx = b[0] - a[0], dy = b[1] - a[1], length = dx * dx + dy * dy;
81
+ const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length));
82
+ return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
83
+ }
84
+ const cross = (o: CropPoint, a: CropPoint, b: CropPoint) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
85
+ function segmentsCross(a: CropPoint, b: CropPoint, c: CropPoint, d: CropPoint): boolean {
86
+ const d1 = cross(c, d, a), d2 = cross(c, d, b), d3 = cross(a, b, c), d4 = cross(a, b, d);
87
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
88
+ }
89
+ function inside(p: CropPoint, polygon: CropPoint[]): boolean {
90
+ let hit = false;
91
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
92
+ const [xi, yi] = polygon[i]!, [xj, yj] = polygon[j]!;
93
+ if ((yi > p[1]) !== (yj > p[1]) && p[0] < (xj - xi) * (p[1] - yi) / (yj - yi) + xi) hit = !hit;
94
+ }
95
+ return hit;
96
+ }
97
+ /** Distance between two closed polygons; 0 when their edges cross or one contains the other. */
98
+ export function polygonDistance(a: CropPoint[], b: CropPoint[]): number {
99
+ if (inside(a[0]!, b) || inside(b[0]!, a)) return 0;
100
+ let best = Infinity;
101
+ for (let i = 0; i < a.length; i++) {
102
+ const a0 = a[i]!, a1 = a[(i + 1) % a.length]!;
103
+ for (let j = 0; j < b.length; j++) {
104
+ const b0 = b[j]!, b1 = b[(j + 1) % b.length]!;
105
+ if (segmentsCross(a0, a1, b0, b1)) return 0;
106
+ best = Math.min(best, pointSegmentDistance(a0, b0, b1), pointSegmentDistance(a1, b0, b1), pointSegmentDistance(b0, a0, a1), pointSegmentDistance(b1, a0, a1));
107
+ }
108
+ }
109
+ return best;
110
+ }
111
+
112
+ /**
113
+ * Review's pre-check for the generator's no-overlap rule: two regions are refused when their supports (polygon
114
+ * grown by its band) overlap, i.e. polygon distance < bandA + bandB. The generator itself refuses per mesh
115
+ * vertex (`RIG_CONTOUR_REGIONS_OVERLAP`, a vertex weighted by two falloffs); without a mesh this geometric test
116
+ * is the stricter side of that rule: it can refuse a pair the generator would accept, never the reverse.
117
+ */
118
+ export function regionsOverlap(a: { points: CropPoint[]; band: number }, b: { points: CropPoint[]; band: number }): boolean {
119
+ return polygonDistance(a.points, b.points) < a.band + b.band;
120
+ }
121
+ /** Two circles are tested exactly (centre distance minus both cores); any other pair through polygons. */
122
+ export function chestRegionsOverlap(a: ChestRegion, b: ChestRegion): boolean {
123
+ if (a.shape === 'circle' && b.shape === 'circle') return Math.hypot(b.cx - a.cx, b.cy - a.cy) - a.r - b.r < a.band + b.band;
124
+ return regionsOverlap({ points: regionPoints(a), band: a.band }, { points: regionPoints(b), band: b.band });
125
+ }
126
+ export const regionPoints = (region: ChestRegion): CropPoint[] => region.shape === 'circle' ? ellipsePolygon([region.cx, region.cy], [region.r, region.r]) : region.points;
127
+
128
+ /** Python's `round` (half to even), which the example's rebuild script applies before clamping. */
129
+ export function pythonRound(value: number): number {
130
+ const floor = Math.floor(value), fraction = value - floor;
131
+ if (fraction > 0.5) return floor + 1;
132
+ if (fraction < 0.5) return floor;
133
+ return floor % 2 === 0 ? floor : floor + 1;
134
+ }
135
+ export interface ChestCircle { cx: number; cy: number; r: number; band: number }
136
+ export const CHEST_CIRCLE_LIMITS = { r: [6, 120], band: [4, 120] } as const;
137
+ const clampInt = (value: number, [low, high]: readonly [number, number]) => Math.max(low, Math.min(high, value));
138
+ /**
139
+ * The example rebuild script's `clamp_non_overlapping`, reproduced: integer values, r 6–120 and band 4–120,
140
+ * both cores plus 8 px must fit the centre distance minus the margin (both shrink by one factor), and the band
141
+ * sum is limited to what remains. Pure; returns the clamped pair and whether the non-overlap rule changed it.
142
+ */
143
+ export function clampNonOverlapping(right: ChestCircle, left: ChestCircle, margin = 2): { right: ChestCircle; left: ChestCircle; clamped: boolean } {
144
+ const integer = (c: ChestCircle): ChestCircle => ({ cx: pythonRound(c.cx), cy: pythonRound(c.cy), r: pythonRound(c.r), band: pythonRound(c.band) });
145
+ const r = integer(right), l = integer(left);
146
+ r.r = clampInt(r.r, CHEST_CIRCLE_LIMITS.r); l.r = clampInt(l.r, CHEST_CIRCLE_LIMITS.r);
147
+ r.band = clampInt(r.band, CHEST_CIRCLE_LIMITS.band); l.band = clampInt(l.band, CHEST_CIRCLE_LIMITS.band);
148
+ const dist = Math.hypot(l.cx - r.cx, l.cy - r.cy), avail = Math.max(16, dist - margin);
149
+ let clamped = false;
150
+ if (r.r + l.r + 8 > avail) {
151
+ const scale = Math.max(0.25, (avail - 8) / Math.max(1, r.r + l.r));
152
+ r.r = Math.max(6, Math.floor(r.r * scale)); l.r = Math.max(6, Math.floor(l.r * scale));
153
+ clamped = true;
154
+ }
155
+ const maxBandSum = Math.max(8, Math.floor(avail - (r.r + l.r))), bandSum = r.band + l.band;
156
+ if (bandSum > maxBandSum) {
157
+ const ratio = maxBandSum / Math.max(1, bandSum);
158
+ r.band = Math.max(4, Math.floor(r.band * ratio)); l.band = Math.max(4, maxBandSum - r.band);
159
+ clamped = true;
160
+ }
161
+ return { right: r, left: l, clamped };
162
+ }
163
+
164
+ function coordinatesText(crop: { w: number; h: number }, regions: ChestRegionInput[]): string {
165
+ const [left, right] = [...regions].sort((a, b) => a.center[0] - b.center[0]);
166
+ return `${crop.w}x${crop.h} crop pixels, x screen-right and y down; ${left!.bone} screen-left, ${right!.bone} screen-right`;
167
+ }
168
+
169
+ function segmentList(value: unknown): ChestSegment[] {
170
+ if (!Array.isArray(value)) throw new ChestRegionError('CHEST_SEGMENTS_INVALID', 'expected an array');
171
+ const point = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
172
+ return value.map((item: unknown, i) => {
173
+ if (typeof item === 'string' && item) return item;
174
+ if (Array.isArray(item) && item.length === 3 && typeof item[0] === 'string' && item[0] && point(item[1]) && point(item[2])) return [item[0], [item[1][0], item[1][1]], [item[2][0], item[2][1]]] as ChestSegment;
175
+ throw new ChestRegionError('CHEST_SEGMENTS_INVALID', `segments[${i}]`);
176
+ });
177
+ }
178
+
179
+ export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocument {
180
+ const w = finite(input.crop?.w, 'crop', 'w', 0, false), h = finite(input.crop?.h, 'crop', 'h', 0, false);
181
+ if (typeof input.generator !== 'string' || !input.generator.trim()) throw new ChestRegionError('CHEST_REGIONS_GENERATOR_MISSING', '');
182
+ if (input.regions.length !== 2) throw new ChestRegionError('CHEST_REGIONS_COUNT', String(input.regions.length));
183
+ const c = input.contour;
184
+ for (const field of ['tolerance', 'margin', 'spacing', 'maxEdge', 'stray'] as const) finite(c[field], 'contour', field, 0);
185
+ if (typeof c.bridges !== 'boolean') throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'contour bridges');
186
+ const names = new Set<string>(), regions: ChestRegion[] = [];
187
+ for (const region of input.regions) {
188
+ const name = region.name;
189
+ if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(name)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${String(name)} name`);
190
+ if (names.has(name)) throw new ChestRegionError('CHEST_REGION_NAME_DUPLICATE', name);
191
+ names.add(name);
192
+ if (typeof region.bone !== 'string' || !region.bone) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${name} bone`);
193
+ if (input.bones && !input.bones.includes(region.bone)) throw new ChestRegionError('CHEST_REGION_BONE_UNKNOWN', region.bone);
194
+ const cx = finite(region.center?.[0], name, 'center x', -Infinity), cy = finite(region.center?.[1], name, 'center y', -Infinity);
195
+ const ax = finite(region.semiAxes?.[0], name, 'semi-axis x', 0, false), ay = finite(region.semiAxes?.[1], name, 'semi-axis y', 0, false);
196
+ const spacing = finite(region.spacing, name, 'spacing', 0, false), band = finite(region.band, name, 'band', 0);
197
+ if (region.clip !== undefined) finite(region.clip, name, 'clip', -Infinity);
198
+ for (const [field, value] of [['center x', cx], ['center y', cy], ['band', band], ...(region.clip === undefined ? [] : [['clip', region.clip]])] as [string, number][]) {
199
+ if (!onGrid(value)) throw new ChestRegionError('CHEST_REGION_OFF_GRID', `${name} ${field}`);
200
+ }
201
+ // Equal semi-axes without a clip are the examples' circle; anything else stays the 32-sample polygon.
202
+ if (ax === ay && region.clip === undefined) {
203
+ if (cx - ax < 0 || cy - ax < 0 || cx + ax > w || cy + ax > h) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
204
+ regions.push({ name, shape: 'circle', cx, cy, r: ax, bone: region.bone, spacing, band });
205
+ continue;
206
+ }
207
+ const points = ellipsePolygon([cx, cy], [ax, ay], 32, region.clip);
208
+ if (points.some(([x, y]) => x < 0 || y < 0 || x > w || y > h)) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
209
+ regions.push({ name, bone: region.bone, shape: 'polygon', points, spacing, band });
210
+ }
211
+ if (chestRegionsOverlap(regions[0]!, regions[1]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[0]!.name} ${regions[1]!.name}`);
212
+ const mesh: ChestMesh = { contour: { tolerance: c.tolerance, margin: c.margin, spacing: c.spacing, maxEdge: c.maxEdge, stray: c.stray, bridges: c.bridges, regions } };
213
+ if (input.r !== undefined) mesh.r = finite(input.r, 'mesh', 'r', 0);
214
+ if (input.segments !== undefined) mesh.segments = segmentList(input.segments);
215
+ const meshName = input.mesh ?? 'topwear';
216
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(meshName)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'mesh name');
217
+ return { spec: CHEST_REGIONS_SPEC, coordinates: coordinatesText({ w, h }, input.regions), publicGenerator: input.generator, mesh: { name: meshName, value: mesh }, ...(input.sourceRule ? { sourceRule: input.sourceRule } : {}) };
218
+ }
219
+
220
+ /** Python-style JSON text (`json.dumps(value, indent=2)` + newline), the examples' exact formatting; `{float}` marks a Python float. */
221
+ type Emit = { float: number } | number | string | boolean | Emit[] | { [key: string]: Emit };
222
+ const isFloat = (v: unknown): v is { float: number } => !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 1 && 'float' in v;
223
+ function numberText(value: number, float: boolean): string {
224
+ if (!Number.isFinite(value)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'non-finite number');
225
+ const v = value === 0 ? 0 : value, text = String(v);
226
+ if (/e/i.test(text)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `number ${text} needs exponent notation`);
227
+ return float && Number.isInteger(v) ? `${text}.0` : text;
228
+ }
229
+ const stringText = (value: string) => JSON.stringify(value).replace(/[\u007f-￿]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
230
+ function emit(value: Emit, depth: number): string {
231
+ const pad = ' '.repeat(depth + 1), close = ' '.repeat(depth);
232
+ if (isFloat(value)) return numberText(value.float, true);
233
+ if (typeof value === 'number') return numberText(value, false);
234
+ if (typeof value === 'string') return stringText(value);
235
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
236
+ if (Array.isArray(value)) return value.length ? `[\n${value.map(v => pad + emit(v, depth + 1)).join(',\n')}\n${close}]` : '[]';
237
+ const entries = Object.entries(value);
238
+ return entries.length ? `{\n${entries.map(([k, v]) => `${pad}${stringText(k)}: ${emit(v, depth + 1)}`).join(',\n')}\n${close}}` : '{}';
239
+ }
240
+
241
+ export function serializeChestRegions(doc: ChestRegionsDocument): string {
242
+ const mesh = doc.mesh.value, contour = mesh.contour;
243
+ const value: { [key: string]: Emit } = {
244
+ spec: doc.spec,
245
+ coordinates: doc.coordinates,
246
+ publicGenerator: doc.publicGenerator,
247
+ [`meshes.${doc.mesh.name}`]: {
248
+ contour: {
249
+ tolerance: contour.tolerance, margin: contour.margin, spacing: contour.spacing, maxEdge: contour.maxEdge, stray: contour.stray, bridges: contour.bridges,
250
+ regions: contour.regions.map((r): Emit => r.shape === 'circle'
251
+ ? { name: r.name, shape: r.shape, cx: r.cx, cy: r.cy, r: r.r, bone: r.bone, spacing: r.spacing, band: r.band }
252
+ : { name: r.name, bone: r.bone, shape: r.shape, points: r.points.map(([x, y]) => [{ float: x }, { float: y }]), spacing: r.spacing, band: r.band }),
253
+ },
254
+ ...(mesh.r === undefined ? {} : { r: mesh.r }),
255
+ ...(mesh.segments === undefined ? {} : { segments: mesh.segments as Emit[] }),
256
+ },
257
+ ...(doc.sourceRule === undefined ? {} : { sourceRule: doc.sourceRule }),
258
+ };
259
+ return emit(value, 0) + '\n';
260
+ }
261
+
262
+ /** Crop → rig world mapping, only when the loaded model fixes it. */
263
+ export type CropWorldMapping =
264
+ | { available: true; crop: { w: number; h: number }; toWorld: (x: number, y: number) => { x: number; y: number }; basis: string }
265
+ | { available: false; reason: string };
266
+
267
+ /**
268
+ * The rig-parts crop convention is world = (x − W/2, H − y): root at the crop's bottom centre, world y up. A
269
+ * model fixes W×H only when its declared `stage` is exactly that frame (x = −width/2, y = 0) and the root bone's
270
+ * drawn world position is the origin. Anything else is reported as missing; no frame is assumed.
271
+ */
272
+ export function cropWorldMapping(stage: { x: number; y: number; width: number; height: number } | null | undefined, root: { x: number; y: number } | null): CropWorldMapping {
273
+ const missing = (what: string): CropWorldMapping => ({ available: false, reason: `CHEST_REGION_MAPPING_UNAVAILABLE: ${what}` });
274
+ if (!stage) return missing('the model declares no stage');
275
+ if (![stage.x, stage.y, stage.width, stage.height].every(Number.isFinite) || stage.width <= 0 || stage.height <= 0) return missing('the model stage is not a positive finite box');
276
+ if (stage.x !== -stage.width / 2 || stage.y !== 0) return missing(`the model stage (${stage.x}, ${stage.y}, ${stage.width}×${stage.height}) is not a crop frame with the root at its bottom centre`);
277
+ if (!root) return missing('no drawn root bone position');
278
+ if (Math.abs(root.x) > 1e-6 || Math.abs(root.y) > 1e-6) return missing(`the drawn root bone is at (${root.x}, ${root.y}), not the world origin`);
279
+ const { x: ox, y: oy, width, height } = stage;
280
+ return { available: true, crop: { w: width, h: height }, toWorld: (x, y) => ({ x: ox + x, y: oy + height - y }), basis: `model stage ${width}×${height} at (${ox}, ${oy}), root at world origin: world = (x − ${width / 2}, ${height} − y)` };
281
+ }
282
+
283
+ export interface ChestRegionsBinding {
284
+ schema: 'rig-review-chest-regions-binding/1';
285
+ file: { name: 'chest-regions.json'; sha256: string };
286
+ package: string;
287
+ model: string | null;
288
+ motion: string | null;
289
+ exportedAt: string;
290
+ tools: { viewerVersion: string; runtimeVersion: string };
291
+ mapping: string;
292
+ evidence: string;
293
+ }
294
+ export function chestRegionsBinding(input: { fileSha256: string; sourceHash: string; reviewedSource: { viewerVersion: string; runtimeVersion: string; rig: { sha256: string } | null; motion: { sha256: string } | null }; exportedAt: string; mapping: string }): ChestRegionsBinding {
295
+ return {
296
+ schema: 'rig-review-chest-regions-binding/1',
297
+ file: { name: 'chest-regions.json', sha256: input.fileSha256 },
298
+ package: input.sourceHash,
299
+ model: input.reviewedSource.rig?.sha256 ?? null,
300
+ motion: input.reviewedSource.motion?.sha256 ?? null,
301
+ exportedAt: input.exportedAt,
302
+ tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
303
+ mapping: input.mapping,
304
+ evidence: 'Designation is review evidence, not an approval. Nothing was written into the package; regeneration is the producer path.',
305
+ };
306
+ }