@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 +87 -12
- package/docs/DESIGN.md +18 -8
- package/docs/REVIEW_PACKAGE.md +2 -0
- package/package.json +1 -1
- package/src/chest-regions.ts +306 -0
- package/src/environment.ts +213 -0
- package/src/feedback-export.ts +9 -1
- package/src/mesh-edit.ts +287 -0
- package/src/session.ts +3 -0
- package/web/chest-editor.ts +179 -0
- package/web/chest-regions-tool.ts +56 -0
- package/web/compare.css +1 -0
- package/web/index.html +3 -6
- package/web/main.ts +210 -42
- package/web/mesh-editor.ts +268 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/rig-overlays.ts +74 -3
- package/web/styles.css +104 -23
- package/web/view-controls.ts +11 -5
- package/web/workspace-layout.ts +2 -1
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +26 -12
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.
|
|
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.
|
|
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;
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.**
|
|
42
|
-
- **
|
|
43
|
-
- **
|
|
44
|
-
- **
|
|
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
|
|
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
|
|
package/docs/REVIEW_PACKAGE.md
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|