@rig-c/review 0.3.0 → 0.5.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,22 +356,57 @@ 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
- The left, right and bottom panels are resizable. Drag a separator, or focus it
365
+ The review page has a left column (Parts explorer, with checkpoints and the
366
+ reference select folded above it), the stage, the Inspector below it with
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
+ The view bar is one row and keeps the layer toggles in reach in every section: **Images**
386
+ (`I`), **Mesh edges** (`M`) and **Bone points** (`B`). The keys do nothing while
387
+ a text field, textarea, select or editable text has focus, with Ctrl/Alt/Cmd
388
+ held, or on key repeat; Space stays pan and Enter stays frame entry. A disabled
389
+ toggle shows why as a tooltip, and the footer shows the same layer status. The
390
+ footer also gathers the message, player, layout, renderer, idle, remote control
391
+ and draft state; **View region** opens the numeric view. Side by side,
392
+ Reference and Rig are in the **View** menu at the right end of the view bar,
393
+ labelled with the current choice.
394
+
395
+ The side and bottom panels are resizable. The separators are thin (4px)
396
+ lines whose colour is never the scrollbar's; the grab area is wider than the
397
+ line. Drag a separator, or focus it
363
398
  and use the arrow keys (Shift for larger steps) and Home/End. Escape, a
364
399
  cancelled pointer or window blur during a drag returns the previous size. The
365
- stage keeps a usable minimum. When the window is too narrow, the left and then
366
- the right panel, and when it is too short, the Inspector, become explicit
400
+ stage keeps a usable minimum. When the window is too narrow, the side columns
401
+ (left first on the compare page), and when it is too short, the Inspector, become explicit
367
402
  drawers opened from buttons above the stage instead of shrinking to nothing. Responsive clamping
368
403
  never overwrites the desired size, so a wider window restores it.
369
404
 
370
- Settings, in the lower left, holds browser defaults: Theme, Sync cameras by
405
+ Settings, at the end of the left column's scrolling body, holds browser defaults: Theme, Sync cameras by
371
406
  default, Repeat idle by default, and Reset layout. Defaults seed a new
372
- inspection only. A view link or source draft keeps its own appearance, camera
407
+ inspection only. Theme is the page's only appearance control: it shows the
408
+ current inspection's appearance, and changing it applies it and saves it as the
409
+ default. A view link or source draft keeps its own appearance, camera
373
410
  sync and idle choice, and restoring it neither changes the saved defaults nor
374
411
  starts playback. Reset layout restores panel sizes and nothing else.
375
412
 
@@ -390,9 +427,9 @@ serving use per-port origins and keep them separate.
390
427
 
391
428
  ## Inspect a part
392
429
 
393
- The right Parts explorer selects an actual model slot on a specific single/A/B side. All rows follow the public held snapshot's exact draw-order z, including hidden/null slots. Search matches the exact source slot names: a row is shown when its name contains the typed text, case-sensitively, with no case folding or normalization. Arrow keys/Home/End move between result buttons, Enter/Space selects, and Escape returns to search. Whole rig restores full drawing. Selecting a drawn row fits its conservative world bounds within the existing camera zoom range; Sync cameras applies the same normalized camera action to every visible pane, while independent mode changes only that selected pane. Hidden/empty geometry reports `PART_FIT_UNAVAILABLE` and keeps the camera.
430
+ The Parts explorer (left column; right on the compare page) selects an actual model slot on a specific single/A/B side. All rows follow the public held snapshot's exact draw-order z, including hidden/null slots. Search matches the exact source slot names: a row is shown when its name contains the typed text, case-sensitively, with no case folding or normalization. Arrow keys/Home/End move between result buttons, Enter/Space selects, and Escape returns to search. Whole rig restores full drawing. Selecting a drawn row fits its conservative world bounds within the existing camera zoom range; Sync cameras applies the same normalized camera action to every visible pane, while independent mode changes only that selected pane. Hidden/empty geometry reports `PART_FIT_UNAVAILABLE` and keeps the camera.
394
431
 
395
- Solo preserves the full held pose's resolved clips and filters only drawing. In context, the whole rig remains visible with an amber dashed conservative selection box; this box is geometry/clip bounds, not exact source-alpha coverage. Selection changes no track, skin, set, motion, source asset or review decision. The selected slot's side/mode survives source-bound links and local drafts. The Rig inspector shows actual posed mesh counts or region corners, page/region, current skin/set context and declared attachment candidates. **Use slot for feedback** explicitly prefills the target; selecting a row alone submits nothing.
432
+ Solo preserves the full held pose's resolved clips and filters only drawing. In context, the whole rig remains visible with an amber dashed conservative selection box; this box is geometry/clip bounds, not exact source-alpha coverage. Selection changes no track, skin, set, motion, source asset or review decision. The selected slot's side/mode survives source-bound links and local drafts. The Setup section shows actual posed mesh counts or region corners, page/region, current skin/set context and declared attachment candidates. **Use slot for feedback** explicitly prefills the target; selecting a row alone submits nothing.
396
433
 
397
434
  The pinned published `@rig-c/play`1.12.0 supplies `slotIdentity()` and frame generation. The explorer atomically retains the public identity snapshot with the same-generation held frame: exact attachment key, owning skin, active set/skin, full z order and explicit hidden/no-attachment/timeline-null/set-hide reasons. Hidden/null slots keep null keys with their reported reason; declarations and texture/UV names never replace that public result. An identity-known attachment need not have a visible primitive or positive fit bounds. The current UI follows its selected slot as animation changes, updating the displayed identity to the actual held frame.
398
435
 
@@ -400,6 +437,16 @@ Source-bound links/drafts and explicit feedback preserve that side/slot plus exa
400
437
 
401
438
  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.
402
439
 
440
+ ## Chest regions (designation only)
441
+
442
+ Setup's **Chest regions** tool records two chest deformation regions in the public generator's `family-chest-regions/1` schema: per region a name, a bone chosen from the loaded model's actual bones, a core centre and semi-axes, a spacing, a linear band and an optional medial clip x, plus the contour settings (tolerance, margin, spacing, max edge, stray, bridges). Values are crop pixels, x screen-right and y down. The fields are numeric only; there are no drag handles.
443
+
444
+ Each core is drawn on the rig pane as the generator's 32-sample polygon on its 1/256-pixel grid. 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 tool shows `CHEST_REGION_MAPPING_UNAVAILABLE: <what is missing>`, draws nothing and still exports from a crop size you enter.
445
+
446
+ **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>`. The overlap test is geometric: two regions are refused when the distance between their polygons is less than the sum of their bands. The generator refuses per mesh vertex, so this check can refuse a pair it would accept, never the reverse.
447
+
448
+ 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. Regenerating a mesh from these regions is the producer's job (see `docs/DESIGN.md`).
449
+
403
450
  ## License
404
451
 
405
452
  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 **View**, **Rig**, **Playback** and **Feedback** sections. Rig covers setup geometry, source art, pivot/joint placement, meshes, weights, draw order and angle coverage; Playback 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 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.
8
8
 
9
9
  ## Implemented foundation
10
10
 
@@ -34,6 +34,20 @@ Any future modeling edit needs provenance, undo/redo, explicit units and an expo
34
34
 
35
35
  [design-prototype.html](design-prototype.html) preserves the interactive screen concept. It is **illustrative**, with sample controls/data, separate from the implemented app. Its older motion key/curve editing concept is superseded by the current view/annotate/export boundary. It does not demonstrate completed mesh, motion, angle or audio editing.
36
36
 
37
+ ## Workspace layout
38
+
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
+
41
+ - **Left column.** A fixed header row (Parts title and slot search) 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.
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
+ - **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.
46
+ - **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.
47
+ - **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.
48
+
49
+ 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.
50
+
37
51
  ## Inspection strips
38
52
 
39
53
  An inclusive annotation interval can also become a derived frame strip. The single view selects one declared source video; compare retains its explicit shared video and chooses a producer clock basis for each interval. A batch preserves separate feedback intervals and side provenance. Source rows run down the sheet and requested producer frames run across it. A vertical batch repeats this grid rather than merging overlapping annotations.
@@ -45,7 +59,7 @@ The export uses dedicated real canvas players and leaves the live playhead/sessi
45
59
 
46
60
  ## Implemented slot inspection and public identity snapshots
47
61
 
48
- The right Parts explorer provides source-hash-bound slot selection, held-pose clipped Solo, Whole/context, conservative bounds fit, a diagnostic selection box, actual mesh/region counts and reproducible selection context. This is inspection rather than geometry editing. All rows use the public held identity snapshot's exact z order, including hidden/null slots. Current source-bound links/drafts and control observations retain side/slot/mode.
62
+ The Parts explorer (the review page's left column; the compare page's right one) provides source-hash-bound slot selection, held-pose clipped Solo, Whole/context, conservative bounds fit, a diagnostic selection box, actual mesh/region counts and reproducible selection context. This is inspection rather than geometry editing. All rows use the public held identity snapshot's exact z order, including hidden/null slots. Current source-bound links/drafts and control observations retain side/slot/mode.
49
63
 
50
64
  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.
51
65
 
@@ -55,16 +69,30 @@ A future optimization operation sends explicit source-bound selection/bounds to
55
69
 
56
70
  ## Workspace geometry and browser preferences
57
71
 
58
- Left, right and bottom panels have pointer and keyboard separators with separator semantics: orientation, the controlled panel, an accessible name and the actual reachable minimum/maximum/current pixels. Arrow keys act only when the separator itself has focus, so text-entry arrows are never intercepted. A drag holds pointer capture; cancel, lost capture, blur, Escape and disposal restore the previous desired size and release it.
72
+ Side and bottom panels have pointer and keyboard separators with separator semantics: orientation, the controlled panel, an accessible name and the actual reachable minimum/maximum/current pixels. Arrow keys act only when the separator itself has focus, so text-entry arrows are never intercepted. A drag holds pointer capture; cancel, lost capture, blur, Escape and disposal restore the previous desired size and release it.
59
73
 
60
- 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 panel, 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.
74
+ 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.
61
75
 
62
- Browser defaults live in one closed, versioned record: theme, default camera sync, default idle repeat and the three desired panel sizes. 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.
76
+ 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.
63
77
 
64
78
  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.
65
79
 
66
80
  Browser storage is origin-scoped. Managed opens (the default) share one loopback front origin that routes `/instances/<key>/…` to each worker, so Settings, sizes and drafts carry across reopens; `--direct` keeps per-port origins. Because one origin cannot isolate its scripts, writes require a per-instance capability handed off only in the printed `#cap=` fragment and checked by both the front and the worker; a fragment-less copied link is read-only. Draft scopes carry the source hash, so revisions of one package never clobber each other's drafts.
67
81
 
82
+ ## Theme
83
+
84
+ The palette comes from the rig-c.org site stylesheet. Its dark `:root` values (`--bg`, `--fg`, `--fg-strong`, `--fg-soft`, `--fg-muted`, `--border`, `--border-strong`, `--hover-bg`, `--active-bg`, `--link`, `--link-hover`, `--btn-fg`, `--code-*`, `--footer-bg`, `--pass`, `--warn`, `--fail`, the font stacks and `--radius`) are copied into the `:root` block of `web/styles.css` and `web/compare.css`; nothing is fetched at runtime, so the viewer stays offline. The site loads Inter and JetBrains Mono from a font CDN; the review keeps only the family stacks and falls back to local fonts. The site has no light scheme, so the light values and every review-only surface token (`--rv-*`: bars, panels, controls, fields, pressed state, focus ring, backdrop, stage) are review-local derivations of the same palette.
85
+
86
+ Rules:
87
+
88
+ - No literal colour (`#hex`, `rgb()`, `hsl()`) outside the `:root` token blocks of either stylesheet. Rules use `var(--…)` only.
89
+ - 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.
90
+ - State colours: pressed and current selections use the `--pass` family, primary actions and links `--link`, dirty and draft notices `--warn`, errors `--fail`.
91
+ - 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.
92
+ - 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).
93
+
94
+ `tests/theme.test.ts` enforces the first two rules and the separator/scrollbar rule.
95
+
68
96
  ## Comparison eligibility
69
97
 
70
98
  A layout is offered only for inputs that actually loaded and rendered. One pure decision (`src/comparison-eligibility.ts`) takes load outcomes, never declared paths: the rig counts after its first successful draw, an image after `decode()`, a pose after it parses, and a video after `seeked` at the held time with a decoded frame. A new video stays pending until then; a held time outside its duration is refused as `REFERENCE_OUT_OF_DOMAIN`, never clamped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/review",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Local human rig inspection and structured feedback export",
6
6
  "license": "MIT",
@@ -0,0 +1,253 @@
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 ChestRegion { name: string; bone: string; shape: 'polygon'; points: CropPoint[]; spacing: number; band: number }
14
+ export interface ChestContour { tolerance: number; margin: number; spacing: number; maxEdge: number; stray: number; bridges: boolean; regions: ChestRegion[] }
15
+ export interface ChestMesh { contour: ChestContour; r?: number; segments?: ChestSegment[] }
16
+ export interface ChestRegionsDocument {
17
+ spec: typeof CHEST_REGIONS_SPEC;
18
+ coordinates: string;
19
+ publicGenerator: string;
20
+ mesh: { name: string; value: ChestMesh };
21
+ sourceRule?: string;
22
+ }
23
+
24
+ export interface ChestRegionInput {
25
+ name: string;
26
+ bone: string;
27
+ center: [number, number];
28
+ semiAxes: [number, number];
29
+ spacing: number;
30
+ band: number;
31
+ /** 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). */
32
+ clip?: number;
33
+ }
34
+ export interface ChestRegionsInput {
35
+ crop: { w: number; h: number };
36
+ generator: string;
37
+ mesh?: string;
38
+ contour: Omit<ChestContour, 'regions'>;
39
+ regions: ChestRegionInput[];
40
+ r?: number;
41
+ segments?: unknown;
42
+ sourceRule?: string;
43
+ /** The loaded model's bone names; when present every region bone must be one of them. */
44
+ bones?: readonly string[] | null;
45
+ }
46
+
47
+ export class ChestRegionError extends Error {
48
+ constructor(readonly code: string, detail: string) { super(`${code} ${detail}`.trim()); }
49
+ }
50
+
51
+ const quantize = (value: number): number => { const q = Math.round(value * CHEST_REGION_GRID) / CHEST_REGION_GRID; return q === 0 ? 0 : q; };
52
+ const onGrid = (value: number): boolean => Number.isInteger(value * CHEST_REGION_GRID);
53
+
54
+ /**
55
+ * The 32-sample ellipse the examples encode: sample i at angle θ = 2πi/samples, starting at +x (screen-right)
56
+ * and turning toward +y (down, crop pixels), each coordinate rounded to the nearest 1/256 pixel. A clip x
57
+ * (on the grid) then stops the side that faces it.
58
+ */
59
+ export function ellipsePolygon(center: [number, number], semiAxes: [number, number], samples = 32, clip?: number): CropPoint[] {
60
+ const [cx, cy] = center, [ax, ay] = semiAxes, points: CropPoint[] = [];
61
+ for (let i = 0; i < samples; i++) {
62
+ const angle = 2 * Math.PI * i / samples;
63
+ let x = quantize(cx + ax * Math.cos(angle));
64
+ const y = quantize(cy + ay * Math.sin(angle));
65
+ if (clip !== undefined) x = clip > cx ? Math.min(x, clip) : Math.max(x, clip);
66
+ points.push([x, y]);
67
+ }
68
+ return points;
69
+ }
70
+
71
+ function finite(value: unknown, region: string, field: string, min: number, inclusive = true): number {
72
+ if (typeof value !== 'number' || !Number.isFinite(value) || (inclusive ? value < min : value <= min)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${region} ${field}`);
73
+ return value;
74
+ }
75
+
76
+ function pointSegmentDistance(p: CropPoint, a: CropPoint, b: CropPoint): number {
77
+ const dx = b[0] - a[0], dy = b[1] - a[1], length = dx * dx + dy * dy;
78
+ const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length));
79
+ return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
80
+ }
81
+ const cross = (o: CropPoint, a: CropPoint, b: CropPoint) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
82
+ function segmentsCross(a: CropPoint, b: CropPoint, c: CropPoint, d: CropPoint): boolean {
83
+ const d1 = cross(c, d, a), d2 = cross(c, d, b), d3 = cross(a, b, c), d4 = cross(a, b, d);
84
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
85
+ }
86
+ function inside(p: CropPoint, polygon: CropPoint[]): boolean {
87
+ let hit = false;
88
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
89
+ const [xi, yi] = polygon[i]!, [xj, yj] = polygon[j]!;
90
+ if ((yi > p[1]) !== (yj > p[1]) && p[0] < (xj - xi) * (p[1] - yi) / (yj - yi) + xi) hit = !hit;
91
+ }
92
+ return hit;
93
+ }
94
+ /** Distance between two closed polygons; 0 when their edges cross or one contains the other. */
95
+ export function polygonDistance(a: CropPoint[], b: CropPoint[]): number {
96
+ if (inside(a[0]!, b) || inside(b[0]!, a)) return 0;
97
+ let best = Infinity;
98
+ for (let i = 0; i < a.length; i++) {
99
+ const a0 = a[i]!, a1 = a[(i + 1) % a.length]!;
100
+ for (let j = 0; j < b.length; j++) {
101
+ const b0 = b[j]!, b1 = b[(j + 1) % b.length]!;
102
+ if (segmentsCross(a0, a1, b0, b1)) return 0;
103
+ best = Math.min(best, pointSegmentDistance(a0, b0, b1), pointSegmentDistance(a1, b0, b1), pointSegmentDistance(b0, a0, a1), pointSegmentDistance(b1, a0, a1));
104
+ }
105
+ }
106
+ return best;
107
+ }
108
+
109
+ /**
110
+ * Review's pre-check for the generator's no-overlap rule: two regions are refused when their supports (polygon
111
+ * grown by its band) overlap, i.e. polygon distance < bandA + bandB. The generator itself refuses per mesh
112
+ * vertex (`RIG_CONTOUR_REGIONS_OVERLAP`, a vertex weighted by two falloffs); without a mesh this geometric test
113
+ * is the stricter side of that rule: it can refuse a pair the generator would accept, never the reverse.
114
+ */
115
+ export function regionsOverlap(a: { points: CropPoint[]; band: number }, b: { points: CropPoint[]; band: number }): boolean {
116
+ return polygonDistance(a.points, b.points) < a.band + b.band;
117
+ }
118
+
119
+ function coordinatesText(crop: { w: number; h: number }, regions: ChestRegionInput[]): string {
120
+ const [left, right] = [...regions].sort((a, b) => a.center[0] - b.center[0]);
121
+ return `${crop.w}x${crop.h} crop pixels, x screen-right and y down; ${left!.bone} screen-left, ${right!.bone} screen-right`;
122
+ }
123
+
124
+ function segmentList(value: unknown): ChestSegment[] {
125
+ if (!Array.isArray(value)) throw new ChestRegionError('CHEST_SEGMENTS_INVALID', 'expected an array');
126
+ const point = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
127
+ return value.map((item: unknown, i) => {
128
+ if (typeof item === 'string' && item) return item;
129
+ 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;
130
+ throw new ChestRegionError('CHEST_SEGMENTS_INVALID', `segments[${i}]`);
131
+ });
132
+ }
133
+
134
+ export function buildChestRegions(input: ChestRegionsInput): ChestRegionsDocument {
135
+ const w = finite(input.crop?.w, 'crop', 'w', 0, false), h = finite(input.crop?.h, 'crop', 'h', 0, false);
136
+ if (typeof input.generator !== 'string' || !input.generator.trim()) throw new ChestRegionError('CHEST_REGIONS_GENERATOR_MISSING', '');
137
+ if (input.regions.length !== 2) throw new ChestRegionError('CHEST_REGIONS_COUNT', String(input.regions.length));
138
+ const c = input.contour;
139
+ for (const field of ['tolerance', 'margin', 'spacing', 'maxEdge', 'stray'] as const) finite(c[field], 'contour', field, 0);
140
+ if (typeof c.bridges !== 'boolean') throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'contour bridges');
141
+ const names = new Set<string>(), regions: ChestRegion[] = [];
142
+ for (const region of input.regions) {
143
+ const name = region.name;
144
+ if (typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,64}$/.test(name)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${String(name)} name`);
145
+ if (names.has(name)) throw new ChestRegionError('CHEST_REGION_NAME_DUPLICATE', name);
146
+ names.add(name);
147
+ if (typeof region.bone !== 'string' || !region.bone) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `${name} bone`);
148
+ if (input.bones && !input.bones.includes(region.bone)) throw new ChestRegionError('CHEST_REGION_BONE_UNKNOWN', region.bone);
149
+ const cx = finite(region.center?.[0], name, 'center x', -Infinity), cy = finite(region.center?.[1], name, 'center y', -Infinity);
150
+ const ax = finite(region.semiAxes?.[0], name, 'semi-axis x', 0, false), ay = finite(region.semiAxes?.[1], name, 'semi-axis y', 0, false);
151
+ const spacing = finite(region.spacing, name, 'spacing', 0, false), band = finite(region.band, name, 'band', 0);
152
+ if (region.clip !== undefined) finite(region.clip, name, 'clip', -Infinity);
153
+ for (const [field, value] of [['center x', cx], ['center y', cy], ['band', band], ...(region.clip === undefined ? [] : [['clip', region.clip]])] as [string, number][]) {
154
+ if (!onGrid(value)) throw new ChestRegionError('CHEST_REGION_OFF_GRID', `${name} ${field}`);
155
+ }
156
+ const points = ellipsePolygon([cx, cy], [ax, ay], 32, region.clip);
157
+ if (points.some(([x, y]) => x < 0 || y < 0 || x > w || y > h)) throw new ChestRegionError('CHEST_REGION_OUT_OF_CROP', name);
158
+ regions.push({ name, bone: region.bone, shape: 'polygon', points, spacing, band });
159
+ }
160
+ if (regionsOverlap(regions[0]!, regions[1]!)) throw new ChestRegionError('CHEST_REGIONS_OVERLAP', `${regions[0]!.name} ${regions[1]!.name}`);
161
+ const mesh: ChestMesh = { contour: { tolerance: c.tolerance, margin: c.margin, spacing: c.spacing, maxEdge: c.maxEdge, stray: c.stray, bridges: c.bridges, regions } };
162
+ if (input.r !== undefined) mesh.r = finite(input.r, 'mesh', 'r', 0);
163
+ if (input.segments !== undefined) mesh.segments = segmentList(input.segments);
164
+ const meshName = input.mesh ?? 'topwear';
165
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(meshName)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'mesh name');
166
+ return { spec: CHEST_REGIONS_SPEC, coordinates: coordinatesText({ w, h }, input.regions), publicGenerator: input.generator, mesh: { name: meshName, value: mesh }, ...(input.sourceRule ? { sourceRule: input.sourceRule } : {}) };
167
+ }
168
+
169
+ /** Python-style JSON text (`json.dumps(value, indent=2)` + newline), the examples' exact formatting; `{float}` marks a Python float. */
170
+ type Emit = { float: number } | number | string | boolean | Emit[] | { [key: string]: Emit };
171
+ const isFloat = (v: unknown): v is { float: number } => !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 1 && 'float' in v;
172
+ function numberText(value: number, float: boolean): string {
173
+ if (!Number.isFinite(value)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', 'non-finite number');
174
+ const v = value === 0 ? 0 : value, text = String(v);
175
+ if (/e/i.test(text)) throw new ChestRegionError('CHEST_REGION_VALUE_INVALID', `number ${text} needs exponent notation`);
176
+ return float && Number.isInteger(v) ? `${text}.0` : text;
177
+ }
178
+ const stringText = (value: string) => JSON.stringify(value).replace(/[\u007f-￿]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
179
+ function emit(value: Emit, depth: number): string {
180
+ const pad = ' '.repeat(depth + 1), close = ' '.repeat(depth);
181
+ if (isFloat(value)) return numberText(value.float, true);
182
+ if (typeof value === 'number') return numberText(value, false);
183
+ if (typeof value === 'string') return stringText(value);
184
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
185
+ if (Array.isArray(value)) return value.length ? `[\n${value.map(v => pad + emit(v, depth + 1)).join(',\n')}\n${close}]` : '[]';
186
+ const entries = Object.entries(value);
187
+ return entries.length ? `{\n${entries.map(([k, v]) => `${pad}${stringText(k)}: ${emit(v, depth + 1)}`).join(',\n')}\n${close}}` : '{}';
188
+ }
189
+
190
+ export function serializeChestRegions(doc: ChestRegionsDocument): string {
191
+ const mesh = doc.mesh.value, contour = mesh.contour;
192
+ const value: { [key: string]: Emit } = {
193
+ spec: doc.spec,
194
+ coordinates: doc.coordinates,
195
+ publicGenerator: doc.publicGenerator,
196
+ [`meshes.${doc.mesh.name}`]: {
197
+ contour: {
198
+ tolerance: contour.tolerance, margin: contour.margin, spacing: contour.spacing, maxEdge: contour.maxEdge, stray: contour.stray, bridges: contour.bridges,
199
+ regions: contour.regions.map(r => ({ 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 })),
200
+ },
201
+ ...(mesh.r === undefined ? {} : { r: mesh.r }),
202
+ ...(mesh.segments === undefined ? {} : { segments: mesh.segments as Emit[] }),
203
+ },
204
+ ...(doc.sourceRule === undefined ? {} : { sourceRule: doc.sourceRule }),
205
+ };
206
+ return emit(value, 0) + '\n';
207
+ }
208
+
209
+ /** Crop → rig world mapping, only when the loaded model fixes it. */
210
+ export type CropWorldMapping =
211
+ | { available: true; crop: { w: number; h: number }; toWorld: (x: number, y: number) => { x: number; y: number }; basis: string }
212
+ | { available: false; reason: string };
213
+
214
+ /**
215
+ * The rig-parts crop convention is world = (x − W/2, H − y): root at the crop's bottom centre, world y up. A
216
+ * model fixes W×H only when its declared `stage` is exactly that frame (x = −width/2, y = 0) and the root bone's
217
+ * drawn world position is the origin. Anything else is reported as missing; no frame is assumed.
218
+ */
219
+ export function cropWorldMapping(stage: { x: number; y: number; width: number; height: number } | null | undefined, root: { x: number; y: number } | null): CropWorldMapping {
220
+ const missing = (what: string): CropWorldMapping => ({ available: false, reason: `CHEST_REGION_MAPPING_UNAVAILABLE: ${what}` });
221
+ if (!stage) return missing('the model declares no stage');
222
+ 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');
223
+ 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`);
224
+ if (!root) return missing('no drawn root bone position');
225
+ 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`);
226
+ const { x: ox, y: oy, width, height } = stage;
227
+ 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)` };
228
+ }
229
+
230
+ export interface ChestRegionsBinding {
231
+ schema: 'rig-review-chest-regions-binding/1';
232
+ file: { name: 'chest-regions.json'; sha256: string };
233
+ package: string;
234
+ model: string | null;
235
+ motion: string | null;
236
+ exportedAt: string;
237
+ tools: { viewerVersion: string; runtimeVersion: string };
238
+ mapping: string;
239
+ evidence: string;
240
+ }
241
+ 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 {
242
+ return {
243
+ schema: 'rig-review-chest-regions-binding/1',
244
+ file: { name: 'chest-regions.json', sha256: input.fileSha256 },
245
+ package: input.sourceHash,
246
+ model: input.reviewedSource.rig?.sha256 ?? null,
247
+ motion: input.reviewedSource.motion?.sha256 ?? null,
248
+ exportedAt: input.exportedAt,
249
+ tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
250
+ mapping: input.mapping,
251
+ evidence: 'Designation is review evidence, not an approval. Nothing was written into the package; regeneration is the producer path.',
252
+ };
253
+ }
@@ -0,0 +1,51 @@
1
+ /** Setup's Chest regions tool: form fields → designation input, without DOM access (main.ts owns the elements). */
2
+ import {buildChestRegions,ChestRegionError,serializeChestRegions,type ChestRegionsDocument,type ChestRegionsInput,type CropWorldMapping} from '../src/chest-regions.ts';
3
+
4
+ export const chestRegionRows=[0,1] as const;
5
+ export const chestRegionFields=['name','bone','cx','cy','ax','ay','spacing','band','clip'] as const;
6
+ export const chestContourFields=[['tolerance','chest-tolerance'],['margin','chest-margin'],['spacing','chest-spacing'],['maxEdge','chest-max-edge'],['stray','chest-stray']] as const;
7
+ export const chestRowId=(row:number,field:typeof chestRegionFields[number])=>`chest-${row}-${field}`;
8
+ /** Every input id the tool owns; the inspection draft captures them like any other form input. */
9
+ export const chestInputIds=[
10
+ 'chest-crop-w','chest-crop-h','chest-generator','chest-source-rule','chest-r','chest-segments','chest-bridges',
11
+ ...chestContourFields.map(([,id])=>id),
12
+ ...chestRegionRows.flatMap(row=>chestRegionFields.map(field=>chestRowId(row,field))),
13
+ ];
14
+
15
+ export interface ChestFieldValue {value:string;checked?:boolean}
16
+ export type ChestFieldReader=(id:string)=>ChestFieldValue|undefined;
17
+
18
+ const invalid=(what:string)=>new ChestRegionError('CHEST_REGION_VALUE_INVALID',what);
19
+ function numberField(read:ChestFieldReader,id:string,what:string,optional=false):number|undefined {
20
+ const raw=(read(id)?.value??'').trim();
21
+ if(!raw){if(optional)return undefined;throw invalid(what);}
22
+ const value=Number(raw);if(!Number.isFinite(value))throw invalid(what);return value;
23
+ }
24
+
25
+ /** Reads the tool's fields; crop comes from the model mapping when it exists, else from the crop fields. */
26
+ export function readChestForm(read:ChestFieldReader,mapping:CropWorldMapping,bones:readonly string[]|null):ChestRegionsInput {
27
+ const crop=mapping.available?mapping.crop:{w:numberField(read,'chest-crop-w','crop w')!,h:numberField(read,'chest-crop-h','crop h')!};
28
+ const contour={tolerance:0,margin:0,spacing:0,maxEdge:0,stray:0,bridges:read('chest-bridges')?.checked===true};
29
+ for(const [key,id] of chestContourFields)contour[key]=numberField(read,id,`contour ${key}`)!;
30
+ const regions=chestRegionRows.map(row=>{
31
+ const name=(read(chestRowId(row,'name'))?.value??'').trim()||`row ${row+1}`,num=(field:typeof chestRegionFields[number],optional=false)=>numberField(read,chestRowId(row,field),`${name} ${field}`,optional);
32
+ const clip=num('clip',true);
33
+ return {name:(read(chestRowId(row,'name'))?.value??'').trim(),bone:read(chestRowId(row,'bone'))?.value??'',center:[num('cx')!,num('cy')!] as [number,number],semiAxes:[num('ax')!,num('ay')!] as [number,number],spacing:num('spacing')!,band:num('band')!,...(clip===undefined?{}:{clip})};
34
+ });
35
+ const segmentsText=(read('chest-segments')?.value??'').trim();let segments:unknown;
36
+ if(segmentsText){try{segments=JSON.parse(segmentsText);}catch{throw new ChestRegionError('CHEST_SEGMENTS_INVALID','not JSON');}}
37
+ const r=numberField(read,'chest-r','mesh r',true),sourceRule=(read('chest-source-rule')?.value??'').trim();
38
+ return {crop,generator:(read('chest-generator')?.value??'').trim(),contour,regions,...(r===undefined?{}:{r}),...(segments===undefined?{}:{segments}),...(sourceRule?{sourceRule}:{}),bones};
39
+ }
40
+
41
+ export type ChestEvaluation={ok:true;document:ChestRegionsDocument;text:string}|{ok:false;error:string};
42
+ export function evaluateChestForm(read:ChestFieldReader,mapping:CropWorldMapping,bones:readonly string[]|null):ChestEvaluation {
43
+ try{const document=buildChestRegions(readChestForm(read,mapping,bones));return {ok:true,document,text:serializeChestRegions(document)};}
44
+ catch(error){if(error instanceof ChestRegionError)return {ok:false,error:error.message};throw error;}
45
+ }
46
+
47
+ /** Polygons in rig world coordinates for the overlay; none without a model-fixed mapping. */
48
+ export function chestOverlayPolygons(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;points:{x:number;y:number}[]}[] {
49
+ if(!evaluation.ok||!mapping.available)return [];
50
+ return evaluation.document.mesh.value.contour.regions.map(region=>({name:region.name,points:region.points.map(([x,y])=>mapping.toWorld(x,y))}));
51
+ }