@rig-c/review 0.5.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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Mesh edit (Setup): a typed delta over the actual loaded mesh attachment of one slot. Pure — no DOM, no I/O.
3
+ *
4
+ * The source is the attachment the hash-verified model declares, with its vertices taken from the last drawn pose of
5
+ * that slot and mapped to crop pixels (x right, y down) through the same crop mapping as the chest regions. Ops are
6
+ * recorded in order; nothing is retriangulated here — the preview draws the delta over the original triangulation and
7
+ * the producer (#45) owns the mesher. Vertex ids are stable: the source vertices keep their indices, an added vertex
8
+ * takes the next id after every vertex that existed before it, and a removed vertex keeps its id (it is only marked).
9
+ */
10
+ export type Vec = [number, number];
11
+ export type MeshOp =
12
+ | { op: 'move'; v: number; from: Vec; to: Vec }
13
+ | { op: 'add'; at: Vec; onEdge?: [number, number] }
14
+ | { op: 'remove'; v: number }
15
+ | { op: 'edge'; a: number; b: number }
16
+ | { op: 'unedge'; a: number; b: number };
17
+ export type EdgeKind = 'hull' | 'interior' | 'constraint';
18
+ export interface MeshEdge { a: number; b: number; kind: EdgeKind }
19
+ export interface MeshSource {
20
+ slot: string; skin: string; attachment: string;
21
+ /** Crop px, x right, y down, one per source vertex. */
22
+ vertices: readonly Vec[];
23
+ triangles: readonly number[];
24
+ /** The model's `hull`: the first `hull` vertices, in order, are the hull. */
25
+ hull: number;
26
+ /** SHA-256 of the attachment's entry in the hash-verified model (see `attachmentHashText`). */
27
+ hash: string;
28
+ }
29
+ export interface MeshView {
30
+ /** Current position per vertex id; null when removed. */
31
+ vertices: (Vec | null)[];
32
+ edges: MeshEdge[];
33
+ /** Edges the delta removed (explicitly, or with a removed vertex), for the preview. */
34
+ removedEdges: MeshEdge[];
35
+ added: Set<number>;
36
+ /** Moved vertex id → its source (or creation) position. */
37
+ moved: Map<number, Vec>;
38
+ removed: Set<number>;
39
+ }
40
+ export type MeshEditResult = { ok: true; changed: boolean; code?: string } | { ok: false; code: string };
41
+
42
+ export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
43
+ export const MESH_EDIT_BINDING_SCHEMA = 'rig-review-mesh-edit-binding/1';
44
+ export const MESH_EDIT_DRAFT_SCHEMA = 'rig-review-mesh-edit-draft/1';
45
+ export const MESH_EDIT_SELECT_SLOT = 'MESH_EDIT_SELECT_SLOT';
46
+ export const MESH_EDIT_NO_MESH = 'MESH_EDIT_NO_MESH';
47
+ export const MESH_EDIT_HULL_EDGE = 'MESH_EDIT_HULL_EDGE';
48
+ export const MESH_EDIT_EDGE_EXISTS = 'MESH_EDIT_EDGE_EXISTS';
49
+ export const MESH_EDIT_TRIANGULATION_EDGE = 'MESH_EDIT_TRIANGULATION_EDGE';
50
+ export const MESH_EDIT_INVALID_OP = 'MESH_EDIT_INVALID_OP';
51
+ export const MESH_EDIT_OPS_LIMIT = 'MESH_EDIT_OPS_LIMIT';
52
+ /** The draft rides in one form field (≤ 32768 characters), so the delta is bounded. */
53
+ export const MESH_EDIT_MAX_OPS = 300;
54
+ export const MESH_EDIT_PREVIEW_NOTE = 'preview only — the producer retriangulates';
55
+ /** Screen-space hit radii in CSS px. */
56
+ export const MESH_HIT = Object.freeze({ vertex: 6, vertexTouch: 8, edge: 4 });
57
+
58
+ const round = (value: number) => Math.round(value * 100) / 100;
59
+ export const roundVec = (p: { x: number; y: number } | Vec): Vec => Array.isArray(p) ? [round(p[0]), round(p[1])] : [round(p.x), round(p.y)];
60
+ export const edgeKey = (a: number, b: number) => (a < b ? `${a}:${b}` : `${b}:${a}`);
61
+ const isHullPair = (a: number, b: number, hull: number) => a < hull && b < hull && (Math.abs(a - b) === 1 || Math.abs(a - b) === hull - 1) && hull > 2;
62
+
63
+ /** The original triangulation's unique edges; consecutive hull vertices (and the closing pair) are hull edges. */
64
+ export function sourceEdges(source: Pick<MeshSource, 'triangles' | 'hull' | 'vertices'>): MeshEdge[] {
65
+ const seen = new Map<string, MeshEdge>(), n = source.vertices.length;
66
+ const add = (a: number, b: number) => { if (a === b || a < 0 || b < 0 || a >= n || b >= n) return; const key = edgeKey(a, b); if (!seen.has(key)) seen.set(key, { a: Math.min(a, b), b: Math.max(a, b), kind: isHullPair(a, b, source.hull) ? 'hull' : 'interior' }); };
67
+ for (let i = 0; i + 2 < source.triangles.length; i += 3) { const t = source.triangles; add(t[i]!, t[i + 1]!); add(t[i + 1]!, t[i + 2]!); add(t[i + 2]!, t[i]!); }
68
+ for (let i = 0; i < source.hull && source.hull > 2; i++) add(i, (i + 1) % source.hull);
69
+ return [...seen.values()];
70
+ }
71
+
72
+ const finiteVec = (v: unknown): v is Vec => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
73
+ const index = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
74
+
75
+ /** Replays ops over the source. Throws `MESH_EDIT_INVALID_OP` naming the first op that does not apply. */
76
+ export function applyMeshOps(source: MeshSource, ops: readonly MeshOp[]): MeshView {
77
+ const view: MeshView = { vertices: source.vertices.map(v => [v[0], v[1]] as Vec), edges: [], removedEdges: [], added: new Set(), moved: new Map(), removed: new Set() };
78
+ const edges = new Map(sourceEdges(source).map(e => [edgeKey(e.a, e.b), { ...e }]));
79
+ const fail = (i: number, why: string): never => { throw new Error(`${MESH_EDIT_INVALID_OP}: op ${i} ${why}`); };
80
+ const live = (v: unknown, i: number): number => { if (!index(v) || v >= view.vertices.length || !view.vertices[v]) fail(i, `names no live vertex ${String(v)}`); return v as number; };
81
+ const removeEdge = (key: string) => { const edge = edges.get(key); if (edge) { edges.delete(key); view.removedEdges.push(edge); } };
82
+ ops.forEach((op, i) => {
83
+ switch (op?.op) {
84
+ case 'move': {
85
+ const v = live(op.v, i); if (!finiteVec(op.from) || !finiteVec(op.to)) fail(i, 'needs finite from/to');
86
+ if (!view.moved.has(v)) view.moved.set(v, [...view.vertices[v]!] as Vec);
87
+ view.vertices[v] = [op.to[0], op.to[1]]; break;
88
+ }
89
+ case 'add': {
90
+ if (!finiteVec(op.at)) fail(i, 'needs a finite at');
91
+ const id = view.vertices.length;
92
+ if (op.onEdge !== undefined) {
93
+ if (!Array.isArray(op.onEdge) || op.onEdge.length !== 2) fail(i, 'onEdge must be a pair');
94
+ const [a, b] = [live(op.onEdge![0], i), live(op.onEdge![1], i)], key = edgeKey(a, b), edge = edges.get(key);
95
+ if (!edge) fail(i, `splits a missing edge ${a}-${b}`);
96
+ edges.delete(key); view.vertices.push([op.at[0], op.at[1]]);
97
+ edges.set(edgeKey(a, id), { a: Math.min(a, id), b: Math.max(a, id), kind: edge!.kind });
98
+ edges.set(edgeKey(id, b), { a: Math.min(b, id), b: Math.max(b, id), kind: edge!.kind });
99
+ } else view.vertices.push([op.at[0], op.at[1]]);
100
+ view.added.add(id); break;
101
+ }
102
+ case 'remove': {
103
+ const v = live(op.v, i); view.vertices[v] = null; view.removed.add(v); view.moved.delete(v);
104
+ for (const [key, edge] of [...edges]) if (edge.a === v || edge.b === v) removeEdge(key);
105
+ break;
106
+ }
107
+ case 'edge': {
108
+ const a = live(op.a, i), b = live(op.b, i); if (a === b) fail(i, 'joins a vertex to itself');
109
+ if (edges.has(edgeKey(a, b))) fail(i, `duplicates edge ${a}-${b}`);
110
+ edges.set(edgeKey(a, b), { a: Math.min(a, b), b: Math.max(a, b), kind: 'constraint' }); break;
111
+ }
112
+ case 'unedge': {
113
+ const a = live(op.a, i), b = live(op.b, i), edge = edges.get(edgeKey(a, b));
114
+ if (!edge) fail(i, `removes a missing edge ${a}-${b}`); if (edge!.kind === 'hull') fail(i, `removes hull edge ${a}-${b}`);
115
+ removeEdge(edgeKey(a, b)); break;
116
+ }
117
+ default: fail(i, 'has an unknown op');
118
+ }
119
+ });
120
+ view.edges = [...edges.values()];
121
+ return view;
122
+ }
123
+
124
+ /** The ordered delta with per-action undo/redo. One user action (a drag of an edge or of a multi-selection) is one step of one or more ops. */
125
+ export class MeshEditSession {
126
+ private list: MeshOp[] = [];
127
+ private steps: number[] = [];
128
+ private redoSteps: MeshOp[][] = [];
129
+ private current: MeshView;
130
+ constructor(readonly source: MeshSource, ops: readonly MeshOp[] = []) {
131
+ this.current = applyMeshOps(source, ops); this.list = ops.map(op => structuredClone(op)); this.steps = this.list.map(() => 1);
132
+ }
133
+ ops(): MeshOp[] { return this.list.map(op => structuredClone(op)); }
134
+ view(): MeshView { return this.current; }
135
+ canUndo(): boolean { return this.steps.length > 0; }
136
+ canRedo(): boolean { return this.redoSteps.length > 0; }
137
+ /** Records one step; refuses the whole step when any op does not apply. */
138
+ record(ops: readonly MeshOp[]): MeshEditResult {
139
+ if (!ops.length) return { ok: true, changed: false };
140
+ if (this.list.length + ops.length > MESH_EDIT_MAX_OPS) return { ok: false, code: `${MESH_EDIT_OPS_LIMIT}: at most ${MESH_EDIT_MAX_OPS} ops per attachment — download and reset to continue` };
141
+ let next: MeshView;
142
+ try { next = applyMeshOps(this.source, [...this.list, ...ops]); } catch (error) { return { ok: false, code: error instanceof Error ? error.message : String(error) }; }
143
+ this.list.push(...ops.map(op => structuredClone(op))); this.steps.push(ops.length); this.redoSteps = []; this.current = next;
144
+ return { ok: true, changed: true };
145
+ }
146
+ undo(): boolean {
147
+ const count = this.steps.pop(); if (count === undefined) return false;
148
+ this.redoSteps.push(this.list.splice(this.list.length - count, count));
149
+ this.current = applyMeshOps(this.source, this.list); return true;
150
+ }
151
+ redo(): boolean {
152
+ const ops = this.redoSteps.pop(); if (!ops) return false;
153
+ this.list.push(...ops); this.steps.push(ops.length); this.current = applyMeshOps(this.source, this.list); return true;
154
+ }
155
+ reset(): void { this.list = []; this.steps = []; this.redoSteps = []; this.current = applyMeshOps(this.source, []); }
156
+
157
+ /** Moves vertices by a crop-px offset from their positions at `base` (the drag's start view): one move op per vertex. */
158
+ moveOps(ids: readonly number[], dx: number, dy: number, base: MeshView = this.current): MeshOp[] {
159
+ if (!Number.isFinite(dx) || !Number.isFinite(dy) || (dx === 0 && dy === 0)) return [];
160
+ return [...new Set(ids)].flatMap(v => { const from = base.vertices[v]; if (!from) return []; const to = roundVec([from[0] + dx, from[1] + dy]); return to[0] === from[0] && to[1] === from[1] ? [] : [{ op: 'move' as const, v, from: [from[0], from[1]] as Vec, to }]; });
161
+ }
162
+ /** Create: a vertex on empty space, or a split of an edge at the click point. */
163
+ addVertex(at: Vec, onEdge?: [number, number]): MeshEditResult { return this.record([{ op: 'add', at: roundVec(at), ...(onEdge ? { onEdge: [onEdge[0], onEdge[1]] as [number, number] } : {}) }]); }
164
+ /** Delete on a vertex: the vertex and its edges. */
165
+ removeVertex(v: number): MeshEditResult { return this.record([{ op: 'remove', v }]); }
166
+ /** Delete on an edge: any edge but a hull edge. */
167
+ deleteEdge(a: number, b: number): MeshEditResult {
168
+ const edge = this.findEdge(a, b); if (!edge) return { ok: false, code: `${MESH_EDIT_INVALID_OP}: no edge ${a}-${b}` };
169
+ if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
170
+ return this.record([{ op: 'unedge', a, b }]);
171
+ }
172
+ /**
173
+ * Edges mode on a pair: a new pair adds a constraint edge; an existing constraint edge is removed; a hull edge is
174
+ * refused; any other existing edge is a duplicate and records nothing.
175
+ */
176
+ toggleConstraint(a: number, b: number): MeshEditResult {
177
+ if (a === b) return { ok: true, changed: false };
178
+ const edge = this.findEdge(a, b);
179
+ if (!edge) return this.record([{ op: 'edge', a, b }]);
180
+ if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
181
+ if (edge.kind === 'constraint') return this.record([{ op: 'unedge', a, b }]);
182
+ return { ok: true, changed: false, code: `${MESH_EDIT_EDGE_EXISTS}: ${a}-${b} is already a triangulation edge` };
183
+ }
184
+ findEdge(a: number, b: number): MeshEdge | undefined { const key = edgeKey(a, b); return this.current.edges.find(e => edgeKey(e.a, e.b) === key); }
185
+ }
186
+
187
+ export interface ScreenPoint { x: number; y: number }
188
+ export type MeshHit = { kind: 'vertex'; v: number } | { kind: 'edge'; a: number; b: number; t: number; at: ScreenPoint };
189
+ /** Screen-space hit test: the nearest vertex within the radius wins over any edge; then the nearest edge within 4 px. */
190
+ export function hitMesh(points: readonly (ScreenPoint | null)[], edges: readonly Pick<MeshEdge, 'a' | 'b'>[], p: ScreenPoint, touch = false): MeshHit | null {
191
+ const radius = touch ? MESH_HIT.vertexTouch : MESH_HIT.vertex;
192
+ let best: MeshHit | null = null, bestDistance = Infinity;
193
+ points.forEach((q, v) => { if (!q) return; const d = Math.hypot(p.x - q.x, p.y - q.y); if (d <= radius && d < bestDistance) { best = { kind: 'vertex', v }; bestDistance = d; } });
194
+ if (best) return best;
195
+ for (const edge of edges) {
196
+ const a = points[edge.a], b = points[edge.b]; if (!a || !b) continue;
197
+ const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy; if (length === 0) continue;
198
+ const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length)), at = { x: a.x + dx * t, y: a.y + dy * t }, d = Math.hypot(p.x - at.x, p.y - at.y);
199
+ if (d <= MESH_HIT.edge && d < bestDistance) { best = { kind: 'edge', a: edge.a, b: edge.b, t, at }; bestDistance = d; }
200
+ }
201
+ return best;
202
+ }
203
+ /** Vertex ids inside a screen rectangle (any corner order). */
204
+ export function verticesInBox(points: readonly (ScreenPoint | null)[], from: ScreenPoint, to: ScreenPoint): number[] {
205
+ const [x0, x1] = [Math.min(from.x, to.x), Math.max(from.x, to.x)], [y0, y1] = [Math.min(from.y, to.y), Math.max(from.y, to.y)];
206
+ return points.flatMap((p, v) => p && p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 ? [v] : []);
207
+ }
208
+
209
+ /** A 2D affine map x' = a x + c y + e, y' = b x + d y + f. */
210
+ export interface Affine { a: number; b: number; c: number; d: number; e: number; f: number }
211
+ /** Samples an affine function at three points. */
212
+ export function affineOf(fn: (x: number, y: number) => ScreenPoint): Affine {
213
+ const o = fn(0, 0), x = fn(1, 0), y = fn(0, 1);
214
+ return { a: x.x - o.x, b: x.y - o.y, c: y.x - o.x, d: y.y - o.y, e: o.x, f: o.y };
215
+ }
216
+ export function invertAffine(m: Affine): Affine | null {
217
+ const det = m.a * m.d - m.b * m.c; if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
218
+ return { a: m.d / det, b: -m.b / det, c: -m.c / det, d: m.a / det, e: (m.c * m.f - m.d * m.e) / det, f: (m.b * m.e - m.a * m.f) / det };
219
+ }
220
+ export const applyAffine = (m: Affine, x: number, y: number): ScreenPoint => ({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
221
+ /** A screen-px offset as a crop-px offset through the inverse of crop → screen. */
222
+ export function screenDeltaToCrop(cropToScreen: Affine, dx: number, dy: number): Vec | null {
223
+ const inverse = invertAffine(cropToScreen); if (!inverse) return null;
224
+ return [inverse.a * dx + inverse.c * dy, inverse.b * dx + inverse.d * dy];
225
+ }
226
+
227
+ /** The text hashed for `source.hash`: the attachment's entry from the hash-verified model, as `JSON.stringify` writes it. */
228
+ export const attachmentHashText = (entry: unknown) => JSON.stringify(entry);
229
+
230
+ export interface MeshEditDocument {
231
+ schema: typeof MESH_EDIT_SCHEMA;
232
+ slot: string; skin: string; attachment: string;
233
+ source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
234
+ units: string; basis: string; preview: string;
235
+ ops: MeshOp[];
236
+ }
237
+ export function meshEditDocument(session: MeshEditSession, basis: string): MeshEditDocument {
238
+ const s = session.source;
239
+ return {
240
+ schema: MESH_EDIT_SCHEMA, slot: s.slot, skin: s.skin, attachment: s.attachment,
241
+ source: { vertexCount: s.vertices.length, triangleCount: Math.floor(s.triangles.length / 3), hullLength: s.hull, hash: s.hash },
242
+ units: 'crop px, x right, y down; vertex ids are source indices, added vertices take the next id in op order, removed ids are not reused',
243
+ basis, preview: `${MESH_EDIT_PREVIEW_NOTE}; the ops are a request over the original triangulation, not a mesh`,
244
+ ops: session.ops(),
245
+ };
246
+ }
247
+ export function meshEditBinding(input: { fileSha256: string; sourceHash: string; reviewedSource: { viewerVersion: string; runtimeVersion: string; rig: { sha256: string } | null; motion: { sha256: string } | null }; exportedAt: string; mapping: string; attachment: { slot: string; skin: string; name: string; sha256: string } }) {
248
+ return {
249
+ schema: MESH_EDIT_BINDING_SCHEMA,
250
+ file: { name: 'mesh-edit.json', sha256: input.fileSha256 },
251
+ package: input.sourceHash,
252
+ model: input.reviewedSource.rig?.sha256 ?? null,
253
+ motion: input.reviewedSource.motion?.sha256 ?? null,
254
+ attachment: { ...input.attachment },
255
+ exportedAt: input.exportedAt,
256
+ tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
257
+ mapping: input.mapping,
258
+ evidence: 'Mesh edit is review evidence, not an approval. Nothing was written into the package; retriangulation and regeneration are the producer path.',
259
+ };
260
+ }
261
+
262
+ /** Draft: per attachment, the ops bound to the attachment hash. A draft for another hash is not restored. */
263
+ export interface MeshEditDraft { schema: typeof MESH_EDIT_DRAFT_SCHEMA; entries: Record<string, { hash: string; ops: MeshOp[] }> }
264
+ export const draftKey = (s: Pick<MeshSource, 'slot' | 'skin' | 'attachment'>) => `${s.skin}/${s.slot}/${s.attachment}`;
265
+ export function parseMeshDraft(text: string): MeshEditDraft {
266
+ const empty: MeshEditDraft = { schema: MESH_EDIT_DRAFT_SCHEMA, entries: {} };
267
+ if (!text.trim()) return empty;
268
+ let value: unknown; try { value = JSON.parse(text); } catch { return empty; }
269
+ const o = value as Partial<MeshEditDraft> | null;
270
+ if (!o || typeof o !== 'object' || o.schema !== MESH_EDIT_DRAFT_SCHEMA || !o.entries || typeof o.entries !== 'object' || Array.isArray(o.entries)) return empty;
271
+ const entries: MeshEditDraft['entries'] = {};
272
+ for (const [key, entry] of Object.entries(o.entries)) {
273
+ if (['__proto__', 'constructor', 'prototype'].includes(key) || !entry || typeof entry.hash !== 'string' || !/^[a-f0-9]{64}$/.test(entry.hash) || !Array.isArray(entry.ops) || entry.ops.length > MESH_EDIT_MAX_OPS) continue;
274
+ entries[key] = { hash: entry.hash, ops: entry.ops as MeshOp[] };
275
+ }
276
+ return { schema: MESH_EDIT_DRAFT_SCHEMA, entries };
277
+ }
278
+ /** Ops a draft holds for this source, or null when the draft has none or names another hash / an op that does not apply. */
279
+ export function draftOps(draft: MeshEditDraft, source: MeshSource): MeshOp[] | null {
280
+ const entry = draft.entries[draftKey(source)]; if (!entry || entry.hash !== source.hash) return null;
281
+ try { applyMeshOps(source, entry.ops); return entry.ops; } catch { return null; }
282
+ }
283
+ export function writeMeshDraft(draft: MeshEditDraft, source: MeshSource, ops: readonly MeshOp[]): string {
284
+ const entries = { ...draft.entries }; const key = draftKey(source);
285
+ if (ops.length) entries[key] = { hash: source.hash, ops: ops.map(op => structuredClone(op)) }; else delete entries[key];
286
+ return Object.keys(entries).length ? JSON.stringify({ schema: MESH_EDIT_DRAFT_SCHEMA, entries }) : '';
287
+ }
package/src/session.ts CHANGED
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from 'node:util';
5
5
  import { homedir } from 'node:os';
6
6
  import {referenceBinding,type ReferenceBinding} from './reference.ts';
7
7
  import { validateFeedbackSelection } from './feedback-validation.ts';
8
+ import { parseEnvironmentContext } from './environment.ts';
8
9
  import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
9
10
  export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
10
11
  export function parseFeedback(item:unknown,strict=true):Feedback {
@@ -25,6 +26,8 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
25
26
  if(context.interval!==undefined&&!isDeepStrictEqual(context.interval,interval))throw new Error('feedback.context.interval: must match chosen inclusive frameStart/frameEnd');
26
27
  if(context.animation!==undefined&&context.animation!==null&&typeof context.animation!=='string')throw new Error('feedback.context.animation: expected animation name or null');
27
28
  if(animation!==undefined&&context.animation!==undefined&&context.animation!==null&&context.animation!==animation)throw new Error('feedback.context.animation: does not match selected comparison animation');
29
+ // Older records without an environment stay valid; a present one must have the recorded shape.
30
+ if(context.environment!==undefined)parseEnvironmentContext(context.environment);
28
31
  const selected=animation??(typeof context.animation==='string'?context.animation:null);
29
32
  validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
30
33
  return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The chest-region editor (Setup): a view-bar toggle, an editor bar of R / L circles and direct manipulation of the
3
+ * circles on the rig pane. The values live in the Chest regions detail block's fields (row 0 = R, row 1 = L), so the
4
+ * draft, export and binding are the detail block's. Review never regenerates anything: Apply · Rebuild refuses by name.
5
+ */
6
+ import {clampNonOverlapping,CHEST_CIRCLE_LIMITS,type ChestCircle,type CropWorldMapping} from '../src/chest-regions.ts';
7
+ import {chestRowId,type ChestFieldReader} from './chest-regions-tool.ts';
8
+ import {projectCircle,type OverlayPoint,type OverlayProject} from './rig-overlays.ts';
9
+ import {textEntryFocused} from './view-controls.ts';
10
+
11
+ export type BustSide='r'|'l';
12
+ export type BustMode='move'|'r'|'band';
13
+ export const bustSides=[{side:'r',row:0,tag:'R'},{side:'l',row:1,tag:'L'}] as const;
14
+ export const bustFields=['x','y','r','band'] as const;
15
+ export const bustInputId=(side:BustSide,field:typeof bustFields[number])=>`bust-${side}-${field}`;
16
+ export const MODELING_INPUT_UNAVAILABLE='MODELING_INPUT_UNAVAILABLE: no producer handoff — export the JSON and rebuild with the producer';
17
+ export const CHEST_REGIONS_CLAMPED='CHEST_REGIONS_CLAMPED';
18
+ const sideRow=(side:BustSide)=>side==='r'?0:1;
19
+ const sideTag=(side:BustSide)=>side==='r'?'R':'L';
20
+
21
+ /** The toggle belongs to Setup only. */
22
+ export function chestEditVisible(inspector:string|undefined):boolean {return inspector==='rig';}
23
+
24
+ const number=(read:ChestFieldReader,id:string)=>{const raw=(read(id)?.value??'').trim();if(!raw)return null;const value=Number(raw);return Number.isFinite(value)?value:null;};
25
+ /** A row is a circle when its semi-axes are equal and it has no medial clip; then r is that semi-axis. */
26
+ export function rowCircle(read:ChestFieldReader,row:number):ChestCircle|null {
27
+ const cx=number(read,chestRowId(row,'cx')),cy=number(read,chestRowId(row,'cy')),ax=number(read,chestRowId(row,'ax')),ay=number(read,chestRowId(row,'ay')),band=number(read,chestRowId(row,'band'));
28
+ if(cx===null||cy===null||ax===null||band===null||ax<=0||ay!==ax||(read(chestRowId(row,'clip'))?.value??'').trim())return null;
29
+ return {cx,cy,r:ax,band};
30
+ }
31
+ /** Field values that store a circle in a detail row: r writes both semi-axes. */
32
+ export function circleFields(row:number,circle:Partial<ChestCircle>):Record<string,string> {
33
+ const out:Record<string,string>={};
34
+ if(circle.cx!==undefined)out[chestRowId(row,'cx')]=String(circle.cx);
35
+ if(circle.cy!==undefined)out[chestRowId(row,'cy')]=String(circle.cy);
36
+ if(circle.r!==undefined){out[chestRowId(row,'ax')]=String(circle.r);out[chestRowId(row,'ay')]=String(circle.r);}
37
+ if(circle.band!==undefined)out[chestRowId(row,'band')]=String(circle.band);
38
+ return out;
39
+ }
40
+ export const bustLabel=(side:BustSide,c:ChestCircle)=>`${sideTag(side)} (${Math.round(c.cx)},${Math.round(c.cy)}) r:${Math.round(c.r)} b:${Math.round(c.band)}`;
41
+
42
+ export interface ProjectedBust {side:BustSide;center:OverlayPoint;pxR:number;pxBand:number}
43
+ /** The example's hit order: core handle or core edge → r, band handle or outer edge → band, inside the core → move. */
44
+ export function hitBustHandle(circles:readonly ProjectedBust[],p:OverlayPoint):{side:BustSide;mode:BustMode}|null {
45
+ for(const c of circles){
46
+ const toR=Math.hypot(p.x-(c.center.x+c.pxR),p.y-c.center.y),toBand=Math.hypot(p.x-c.center.x,p.y-(c.center.y-c.pxBand)),toCenter=Math.hypot(p.x-c.center.x,p.y-c.center.y);
47
+ if(toR<=10||Math.abs(toCenter-c.pxR)<=6)return {side:c.side,mode:'r'};
48
+ if(toBand<=10||Math.abs(toCenter-c.pxBand)<=6)return {side:c.side,mode:'band'};
49
+ if(toCenter<=Math.max(12,c.pxR))return {side:c.side,mode:'move'};
50
+ }
51
+ return null;
52
+ }
53
+ const limit=(value:number,[low,high]:readonly [number,number])=>Math.max(low,Math.min(high,value));
54
+ export interface BustDrag {side:BustSide;mode:BustMode;start:OverlayPoint;centre:OverlayPoint;from:ChestCircle}
55
+ /** Pointer (canvas px) → source px through the camera scale (canvas px per source px), rounded as the example does. */
56
+ export function dragBust(drag:BustDrag,p:OverlayPoint,scale:number):ChestCircle {
57
+ const from=drag.from;if(!(scale>0)||!Number.isFinite(scale))return {...from};
58
+ if(drag.mode==='move')return {...from,cx:Math.round(from.cx+(p.x-drag.start.x)/scale),cy:Math.round(from.cy+(p.y-drag.start.y)/scale)};
59
+ const distance=Math.hypot(p.x-drag.centre.x,p.y-drag.centre.y)/scale;
60
+ if(drag.mode==='r')return {...from,r:limit(Math.round(distance),CHEST_CIRCLE_LIMITS.r)};
61
+ return {...from,band:limit(Math.round(distance-from.r),CHEST_CIRCLE_LIMITS.band)};
62
+ }
63
+ /** Mirror: L takes R's y, core radius and band; L keeps its own x. */
64
+ export const mirrorBust=(right:ChestCircle,left:Pick<ChestCircle,'cx'>):ChestCircle=>({cx:left.cx,cy:right.cy,r:right.r,band:right.band});
65
+ const same=(a:ChestCircle,b:ChestCircle)=>a.cx===b.cx&&a.cy===b.cy&&a.r===b.r&&a.band===b.band;
66
+ /** Live non-overlap clamp for a pair of circles; names the sides it changed. */
67
+ export function settleBust(right:ChestCircle,left:ChestCircle):{right:ChestCircle;left:ChestCircle;clamped:BustSide[]} {
68
+ const out=clampNonOverlapping(right,left),round=clampNonOverlapping(right,left,-Infinity);
69
+ // `round` is the same integer/range pass without the distance rule (−∞ margin leaves every pair fitting), so only the non-overlap change flashes.
70
+ return {right:out.right,left:out.left,clamped:(['r','l'] as const).filter(side=>!same(side==='r'?out.right:out.left,side==='r'?round.right:round.left))};
71
+ }
72
+ /** Apply · Rebuild: review has no producer handoff (#45), so it refuses by name and runs nothing. */
73
+ export function requestRebuild(notify:(text:string,error:boolean)=>void):{ok:false;error:string} {
74
+ notify(MODELING_INPUT_UNAVAILABLE,true);return {ok:false,error:MODELING_INPUT_UNAVAILABLE};
75
+ }
76
+
77
+ export interface ChestEditorHost {
78
+ /** Receives capture-phase pointer events before the viewport's pan. */
79
+ pane:HTMLElement;
80
+ /** The overlay canvas: pointer coordinates are local to it, as the projection's are. */
81
+ canvas:HTMLElement;
82
+ project:()=>OverlayProject|null;
83
+ mapping:()=>CropWorldMapping;
84
+ /** Re-render the detail block and the overlay after a value changed. */
85
+ changed:()=>void;
86
+ download:()=>void;
87
+ message:(text:string,error?:boolean)=>void;
88
+ inspector:()=>string|undefined;
89
+ }
90
+ export interface ChestEditor {active:()=>boolean;flashing:(side:BustSide)=>boolean;sync:()=>void;dispose:()=>void}
91
+
92
+ export function bindChestEditor(host:ChestEditorHost):ChestEditor {
93
+ const node=<T extends HTMLElement>(id:string)=>document.getElementById(id) as T;
94
+ const toggle=node<HTMLButtonElement>('chest-edit'),bar=node<HTMLElement>('chest-editor-bar'),status=node<HTMLElement>('chest-editor-status');
95
+ const field=(id:string)=>document.getElementById(id) as HTMLInputElement|null;
96
+ const read:ChestFieldReader=id=>{const f=field(id);return f?{value:f.value}:undefined;};
97
+ let active=false,space=false,drag:(BustDrag&{pointer:number;other:ChestCircle|null})|null=null,flashTimer:ReturnType<typeof setTimeout>|null=null;
98
+ const flash=new Set<BustSide>();
99
+ const write=(row:number,circle:Partial<ChestCircle>)=>{for(const [id,value] of Object.entries(circleFields(row,circle))){const f=field(id);if(f)f.value=value;}};
100
+ const flashSides=(sides:readonly BustSide[])=>{
101
+ if(!sides.length)return;for(const side of sides)flash.add(side);
102
+ host.message(`${CHEST_REGIONS_CLAMPED}: ${sides.map(sideTag).join(' and ')} — both cores plus 8 px must fit the centre distance minus 2 px and the bands share the rest`,true);
103
+ if(flashTimer)clearTimeout(flashTimer);flashTimer=setTimeout(()=>{flash.clear();flashTimer=null;host.changed();},700);
104
+ };
105
+ /** Writes a changed side; when both rows are circles the pair is clamped as the rebuild script would. */
106
+ const commit=(side:BustSide,next:ChestCircle,other:ChestCircle|null)=>{
107
+ if(!other){write(sideRow(side),next);return;}
108
+ const [right,left]=side==='r'?[next,other]:[other,next],settled=settleBust(right,left);
109
+ write(0,settled.right);write(1,settled.left);flashSides(settled.clamped);
110
+ };
111
+ const projected=():ProjectedBust[]=>{
112
+ const project=host.project(),mapping=host.mapping();if(!project||!mapping.available)return [];
113
+ return bustSides.flatMap(({side,row})=>{const c=rowCircle(read,row);if(!c)return [];const at=projectCircle(project,{center:mapping.toWorld(c.cx,c.cy),r:c.r,band:c.band});return at?[{side,...at}]:[];});
114
+ };
115
+ const local=(event:PointerEvent):OverlayPoint=>{const rect=host.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
116
+ const scale=()=>{const project=host.project();if(!project)return 0;const a=project(0,0),b=project(1,0);return Math.hypot(b.x-a.x,b.y-a.y);};
117
+ const sync=()=>{
118
+ const visible=chestEditVisible(host.inspector());toggle.hidden=!visible;
119
+ if(!visible&&active)setActive(false);
120
+ for(const {side,row} of bustSides){
121
+ const circle=rowCircle(read,row),values={x:read(chestRowId(row,'cx'))?.value??'',y:read(chestRowId(row,'cy'))?.value??'',r:circle?String(circle.r):'',band:read(chestRowId(row,'band'))?.value??''};
122
+ for(const f of bustFields){const input=field(bustInputId(side,f));if(input&&document.activeElement!==input)input.value=values[f];}
123
+ const r=field(bustInputId(side,'r'));if(r)r.placeholder=circle||!(read(chestRowId(row,'ax'))?.value??'').trim()?'':'ellipse';
124
+ }
125
+ const mapping=host.mapping();
126
+ status.textContent=mapping.available?'Drag a centre to move · the right-edge handle sets r · the dashed top handle sets band · Space-drag pans':`${mapping.reason} — the values still edit and export; nothing is drawn on the stage.`;
127
+ status.classList.toggle('error',!mapping.available);
128
+ };
129
+ const setActive=(on:boolean)=>{active=on;toggle.setAttribute('aria-pressed',String(on));bar.hidden=!on;if(!on){drag=null;delete host.pane.dataset.chestHover;}sync();host.changed();};
130
+ toggle.addEventListener('click',()=>setActive(!active));
131
+ bar.addEventListener('input',event=>{
132
+ const target=event.target as HTMLInputElement,match=/^bust-([rl])-(x|y|r|band)$/.exec(target.id);if(!match)return;
133
+ const value=Number(target.value);if(!target.value.trim()||!Number.isFinite(value))return;
134
+ const side=match[1] as BustSide,key=({x:'cx',y:'cy',r:'r',band:'band'} as const)[match[2] as typeof bustFields[number]];
135
+ write(sideRow(side),{[key]:value});host.changed();
136
+ });
137
+ bar.addEventListener('change',event=>{
138
+ const target=event.target as HTMLInputElement,match=/^bust-([rl])-/.exec(target.id);if(!match)return;
139
+ const side=match[1] as BustSide,mine=rowCircle(read,sideRow(side)),other=rowCircle(read,sideRow(side==='r'?'l':'r'));
140
+ if(mine)commit(side,{...mine,r:limit(Math.round(mine.r),CHEST_CIRCLE_LIMITS.r),band:limit(Math.round(mine.band),CHEST_CIRCLE_LIMITS.band)},other);
141
+ sync();host.changed();
142
+ });
143
+ node<HTMLButtonElement>('bust-mirror').addEventListener('click',()=>{
144
+ const right=rowCircle(read,0),leftX=Number((read(chestRowId(1,'cx'))?.value??'').trim()||NaN);
145
+ if(!right||!Number.isFinite(leftX)){host.message('Mirror needs a complete R circle and an L x.',true);return;}
146
+ commit('l',mirrorBust(right,{cx:leftX}),right);sync();host.changed();
147
+ });
148
+ node<HTMLButtonElement>('bust-download').addEventListener('click',()=>host.download());
149
+ node<HTMLButtonElement>('bust-apply').addEventListener('click',()=>{const result=requestRebuild((text,error)=>host.message(text,error));status.textContent=result.error;status.classList.add('error');});
150
+ const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&!textEntryFocused(event.target))space=true;};
151
+ const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
152
+ const blur=()=>{space=false;};
153
+ const down=(event:PointerEvent)=>{
154
+ if(!active||space||!event.isPrimary||event.button!==0)return;
155
+ const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a'))return;
156
+ const p=local(event),circles=projected(),hit=hitBustHandle(circles,p);if(!hit)return;
157
+ const row=sideRow(hit.side),from=rowCircle(read,row),at=circles.find(c=>c.side===hit.side);if(!from||!at)return;
158
+ // A handle drag edits values; it never reaches the viewport's pan and never moves the camera.
159
+ event.stopPropagation();event.preventDefault();
160
+ drag={side:hit.side,mode:hit.mode,start:p,centre:at.center,from,pointer:event.pointerId,other:rowCircle(read,sideRow(hit.side==='r'?'l':'r'))};
161
+ host.pane.setPointerCapture(event.pointerId);host.pane.dataset.chestHover=hit.mode;
162
+ };
163
+ const move=(event:PointerEvent)=>{
164
+ if(drag&&event.pointerId===drag.pointer){event.stopPropagation();commit(drag.side,dragBust(drag,local(event),scale()),drag.other);sync();host.changed();return;}
165
+ if(active&&!space&&!drag){const hit=hitBustHandle(projected(),local(event));if(hit)host.pane.dataset.chestHover=hit.mode;else delete host.pane.dataset.chestHover;}
166
+ };
167
+ const end=(event:PointerEvent)=>{if(!drag||event.pointerId!==drag.pointer)return;drag=null;delete host.pane.dataset.chestHover;if(host.pane.hasPointerCapture(event.pointerId))host.pane.releasePointerCapture(event.pointerId);host.changed();};
168
+ host.pane.addEventListener('pointerdown',down,true);host.pane.addEventListener('pointermove',move,true);
169
+ for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)host.pane.addEventListener(type,end,true);
170
+ document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);window.addEventListener('blur',blur);
171
+ // The selected section is the header switch's pressed state; follow it without touching the switch's own wiring.
172
+ const observer=new MutationObserver(()=>sync());
173
+ for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
174
+ sync();
175
+ return {
176
+ active:()=>active,flashing:side=>flash.has(side),sync,
177
+ dispose:()=>{observer.disconnect();if(flashTimer)clearTimeout(flashTimer);host.pane.removeEventListener('pointerdown',down,true);host.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)host.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);window.removeEventListener('blur',blur);},
178
+ };
179
+ }
@@ -44,8 +44,13 @@ export function evaluateChestForm(read:ChestFieldReader,mapping:CropWorldMapping
44
44
  catch(error){if(error instanceof ChestRegionError)return {ok:false,error:error.message};throw error;}
45
45
  }
46
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}[]}[] {
47
+ /** Polygon regions in rig world coordinates for the overlay; none without a model-fixed mapping. Circles are drawn as circles. */
48
+ export function chestOverlayPolygons(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;row:number;points:{x:number;y:number}[]}[] {
49
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))}));
50
+ return evaluation.document.mesh.value.contour.regions.flatMap((region,row)=>region.shape==='polygon'?[{name:region.name,row,points:region.points.map(([x,y])=>mapping.toWorld(x,y))}]:[]);
51
+ }
52
+ /** Circle regions of a valid designation: world centre, core r and band (crop px = world px under the mapping). */
53
+ export function chestOverlayCircles(evaluation:ChestEvaluation,mapping:CropWorldMapping):{name:string;row:number;center:{x:number;y:number};r:number;band:number}[] {
54
+ if(!evaluation.ok||!mapping.available)return [];
55
+ return evaluation.document.mesh.value.contour.regions.flatMap((region,row)=>region.shape==='circle'?[{name:region.name,row,center:mapping.toWorld(region.cx,region.cy),r:region.r,band:region.band}]:[]);
51
56
  }