@rig-c/review 0.5.0 → 0.7.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 +242 -7
- package/docs/DESIGN.md +33 -5
- package/docs/REVIEW_PACKAGE.md +115 -0
- package/package.json +1 -1
- package/src/applied-context.ts +285 -0
- package/src/baseline.ts +175 -0
- package/src/bone-anchors.ts +123 -0
- package/src/bones-check.ts +105 -0
- package/src/build-route.ts +109 -0
- package/src/chest-landmarks.ts +122 -0
- package/src/chest-regions.ts +65 -6
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +226 -0
- package/src/feedback-export.ts +25 -1
- package/src/front.ts +11 -0
- package/src/handoff.ts +208 -0
- package/src/idle-tracks.ts +401 -0
- package/src/manifest.ts +15 -6
- package/src/mesh-edit.ts +317 -0
- package/src/mesh-entry.ts +351 -0
- package/src/open.ts +12 -5
- package/src/physics-tuning.ts +236 -0
- package/src/producer-files.ts +11 -0
- package/src/producer.ts +158 -0
- package/src/readings-run.ts +129 -0
- package/src/readings.ts +235 -0
- package/src/rebuild.ts +938 -0
- package/src/reference.ts +4 -3
- package/src/requirements-run.ts +160 -0
- package/src/requirements.ts +230 -0
- package/src/retarget.ts +211 -0
- package/src/retune-route.ts +52 -0
- package/src/rig-fields.ts +295 -0
- package/src/server.ts +59 -5
- package/src/session.ts +12 -0
- package/src/setup-mesh.ts +144 -0
- package/src/weights.ts +74 -0
- package/web/bones-panel.ts +230 -0
- package/web/chest-editor.ts +294 -0
- package/web/chest-regions-tool.ts +10 -4
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +2 -2
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +813 -24
- package/web/mesh-editor.ts +303 -0
- package/web/mesh-panel.ts +273 -0
- package/web/part-selection.ts +13 -5
- package/web/part-thumbnails.ts +76 -0
- package/web/physics-panel.ts +151 -0
- package/web/readings-panel.ts +105 -0
- package/web/rebuild-client.ts +216 -0
- package/web/requirements-panel.ts +238 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +171 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +52 -5
- package/web/weights-view.ts +159 -0
- package/web/workspace-preferences.ts +17 -12
- package/web/workspace-ui.ts +5 -3
package/src/manifest.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { parseReport, type ReportSeeds } from './report.ts';
|
|
|
6
6
|
import { readFile, realpath, stat, lstat, readdir } from 'node:fs/promises';
|
|
7
7
|
import { createReadStream } from 'node:fs';
|
|
8
8
|
import { basename, dirname, isAbsolute, relative, resolve, posix } from 'node:path';
|
|
9
|
+
import { parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
|
|
9
10
|
|
|
10
11
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
11
12
|
export interface Reference { id: string; label: string; type: 'image' | 'video' | 'pose'; path: string; width?: number; height?: number }
|
|
@@ -16,9 +17,13 @@ export interface ReviewManifest {
|
|
|
16
17
|
checkpoints: Checkpoint[]; authoring: { label: string; path: string }[];
|
|
17
18
|
axes: { id: string; label: string; animation: string; min: number; max: number }[];
|
|
18
19
|
audio: { clips: { id: string; label?: string; path: string }[]; cues: { id: string; clip: string; time: number; animation?: string }[] };
|
|
20
|
+
/** Optional producer handoff (#101): package-relative literal paths; checked when the package loads. */
|
|
21
|
+
producer?: HandoffDeclaration;
|
|
19
22
|
}
|
|
20
|
-
export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null }
|
|
21
|
-
export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog
|
|
23
|
+
export interface ReviewedSource { readonly packageRevision:string; readonly viewerVersion:string; readonly runtimeVersion:string; readonly rig:{readonly path:string;readonly sha256:string}|null; readonly motion:{readonly path:string;readonly sha256:string}|null; readonly handoff?:HandoffRecord }
|
|
24
|
+
export interface ReviewPackage { root: string; manifestPath: string; manifest: ReviewManifest; sourceHash: string; reviewedSource:ReviewedSource; files: Set<string>; report:ReportSeeds|null; feedbackCatalog:FeedbackCatalog;
|
|
25
|
+
/** The resolved producer handoff (#101) and, when there is none, the refusal line naming where it was looked for. */
|
|
26
|
+
handoff: Handoff|null; handoffReason: string|null; handoffInputs?: HandoffFlags }
|
|
22
27
|
function fail(field: string, expected: string): never { throw new Error(`${field}: expected ${expected}`); }
|
|
23
28
|
export function object(value: unknown, field: string): Record<string, unknown> { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(field, 'object'); return value as Record<string, unknown>; }
|
|
24
29
|
export function string(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim() || value.length > 10000) fail(field, 'nonempty string (at most 10000 characters)'); return value; }
|
|
@@ -56,6 +61,7 @@ export function parseManifest(input: unknown): ReviewManifest {
|
|
|
56
61
|
checkpoints: list(m.checkpoints,'checkpoints',(v,f) => ({id:id(v.id,f+'.id'),title:string(v.title,f+'.title'),time:optionalNumber(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation'),reference:optionalString(v.reference,f+'.reference'),note:optionalString(v.note,f+'.note'),workspace:v.workspace === undefined ? undefined : choice(v.workspace,['modeling','motion'] as const,f+'.workspace'),axis:v.axis===undefined?undefined:(()=>{const a=object(v.axis,f+'.axis');return {id:id(a.id,f+'.axis.id'),value:number(a.value,f+'.axis.value',-Number.MAX_VALUE)};})(),camera:v.camera === undefined ? undefined : (() => {const c=object(v.camera,f+'.camera'); const zoom=number(c.zoom,f+'.camera.zoom',Number.EPSILON); return {zoom,x:number(c.x,f+'.camera.x',-Number.MAX_VALUE),y:number(c.y,f+'.camera.y',-Number.MAX_VALUE)};})()})),
|
|
57
62
|
authoring:list(m.authoring,'authoring',(v,f)=>({label:string(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),
|
|
58
63
|
axes:list(m.axes,'axes',(v,f)=>({id:id(v.id,f+'.id'),label:string(v.label,f+'.label'),animation:string(v.animation,f+'.animation'),min:number(v.min,f+'.min',-Number.MAX_VALUE),max:number(v.max,f+'.max',-Number.MAX_VALUE)})),
|
|
64
|
+
...(m.producer===undefined?{}:{producer:parseHandoffDeclaration(m.producer)}),
|
|
59
65
|
audio:{clips:list(audio.clips,'audio.clips',(v,f)=>({id:id(v.id,f+'.id'),label:optionalString(v.label,f+'.label'),path:packagePath(v.path,f+'.path')})),cues:list(audio.cues,'audio.cues',(v,f)=>({id:id(v.id,f+'.id'),clip:string(v.clip,f+'.clip'),time:number(v.time,f+'.time'),animation:optionalString(v.animation,f+'.animation')}))}
|
|
60
66
|
};
|
|
61
67
|
if(manifest.model&&!manifest.model.endsWith('.model.json'))fail('model','compiled model path ending .model.json (the play runtime input)');
|
|
@@ -65,9 +71,10 @@ export function parseManifest(input: unknown): ReviewManifest {
|
|
|
65
71
|
for(const c of manifest.checkpoints) if(c.axis){const a=manifest.axes.find(a=>a.id===c.axis!.id);if(!a)fail(`checkpoint ${c.id}.axis`,'declared axis id');if(c.axis.value<a.min||c.axis.value>a.max)fail(`checkpoint ${c.id}.axis.value`,`number in [${a.min}, ${a.max}]`);}
|
|
66
72
|
return manifest;
|
|
67
73
|
}
|
|
68
|
-
|
|
74
|
+
/** `options.handoff` are the explicit `--config …` flags; they win over the manifest block and discovery. */
|
|
75
|
+
export async function loadReview(input: string, options: { handoff?: HandoffFlags } = {}): Promise<ReviewPackage> {
|
|
69
76
|
const inputPath=await realpath(resolve(input));const directory=(await stat(inputPath)).isDirectory();
|
|
70
|
-
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'));}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
|
|
77
|
+
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}}
|
|
71
78
|
const manifestPath=inputPath;const root=directory?inputPath:await realpath(dirname(inputPath));
|
|
72
79
|
const manifest=directory?await folderManifest(root):parseManifest(JSON.parse(await readFile(manifestPath,'utf8')));
|
|
73
80
|
const files = new Set([...(directory?[]:[relative(root,manifestPath)]),...manifest.references.map(v=>v.path),...manifest.authoring.map(v=>v.path),...manifest.audio.clips.map(v=>v.path)]);
|
|
@@ -98,9 +105,11 @@ export async function loadReview(input: string): Promise<ReviewPackage> {
|
|
|
98
105
|
if(p===manifest.model){if(fileHashes.get(p)!==modelBytesHash)throw new Error(`Model ${p} changed while hashing; retry with stable package inputs`);modelMetadata=object(JSON.parse(Buffer.concat(metadataChunks).toString('utf8')),'model');}
|
|
99
106
|
if(p===reportPath){let value:unknown;try{value=JSON.parse(Buffer.concat(metadataChunks).toString('utf8'));}catch(e){throw new Error(`${p}: ${e instanceof Error?e.message:String(e)}`);}report=parseReport(value,p);}
|
|
100
107
|
}
|
|
101
|
-
|
|
108
|
+
// The handoff files are hashed beside the package: their record joins the reviewed identity (and so the session file).
|
|
109
|
+
const {handoff,reason:handoffReason}=await resolveHandoff(root,manifest.producer,options.handoff);
|
|
110
|
+
const reviewedSource:ReviewedSource={packageRevision:manifest.revision,...await toolVersions(),rig:rigPath?{path:rigPath,sha256:fileHashes.get(rigPath)!}:null,motion:motionPath?{path:motionPath,sha256:fileHashes.get(motionPath)!}:null,...(handoff?{handoff:handoff.record}:{})};
|
|
102
111
|
const feedbackCatalog:FeedbackCatalog={rig:manifest.id,bones:modelNames(modelMetadata,'bones'),slots:modelNames(modelMetadata,'slots'),animations:modelAnimations(modelMetadata),clock:report?.clock??null,clockAnimation:report?.animation??null};
|
|
103
|
-
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog};
|
|
112
|
+
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/** Same text for every command; `root` lets the open launcher name the refused input. */
|
package/src/mesh-edit.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
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's entry in the hash-verified compiled model document, in **setup space** (#100): its
|
|
5
|
+
* stored setup-pose vertices (`src/setup-mesh.ts`, the reader Setup · Weights shares) placed through the setup-pose
|
|
6
|
+
* bones and mapped to crop pixels of the rig canvas (x right, y down) through the same crop mapping as the chest
|
|
7
|
+
* regions — the space the producer's `rig-parts remesh --delta` binds positions in, so the delta is a producer input
|
|
8
|
+
* as it is (`basis: "setup"`). A package without the model document refuses `MESH_EDIT_MODEL_UNAVAILABLE`. Ops are
|
|
9
|
+
* recorded in order; nothing is retriangulated here — the preview draws the delta over the original triangulation and
|
|
10
|
+
* the producer owns the mesher. Vertex ids are stable: the source vertices keep their indices, an added vertex takes
|
|
11
|
+
* the next id after every vertex that existed before it, and a removed vertex keeps its id (it is only marked). The
|
|
12
|
+
* producer owns every triangulation edge: only a constraint edge this delta added can be removed (`unedge`), and a
|
|
13
|
+
* vertex that ends a constraint edge is removed only after that edge is.
|
|
14
|
+
*/
|
|
15
|
+
import type {SetupMeshCodes} from './setup-mesh.ts';
|
|
16
|
+
export type Vec = [number, number];
|
|
17
|
+
export type MeshOp =
|
|
18
|
+
| { op: 'move'; v: number; from: Vec; to: Vec }
|
|
19
|
+
| { op: 'add'; at: Vec; onEdge?: [number, number] }
|
|
20
|
+
| { op: 'remove'; v: number }
|
|
21
|
+
| { op: 'edge'; a: number; b: number }
|
|
22
|
+
| { op: 'unedge'; a: number; b: number };
|
|
23
|
+
export type EdgeKind = 'hull' | 'interior' | 'constraint';
|
|
24
|
+
export interface MeshEdge { a: number; b: number; kind: EdgeKind }
|
|
25
|
+
export interface MeshSource {
|
|
26
|
+
slot: string; skin: string; attachment: string;
|
|
27
|
+
/** Setup-pose crop px of the rig canvas, x right, y down, one per source vertex, unrounded (the producer holds `move.from` to 1e-3 px). */
|
|
28
|
+
vertices: readonly Vec[];
|
|
29
|
+
triangles: readonly number[];
|
|
30
|
+
/** The model's `hull`: the first `hull` vertices, in order, are the hull. */
|
|
31
|
+
hull: number;
|
|
32
|
+
/** SHA-256 of the attachment's entry in the hash-verified model document (see `attachmentHashText`). */
|
|
33
|
+
hash: string;
|
|
34
|
+
}
|
|
35
|
+
export interface MeshView {
|
|
36
|
+
/** Current position per vertex id; null when removed. */
|
|
37
|
+
vertices: (Vec | null)[];
|
|
38
|
+
edges: MeshEdge[];
|
|
39
|
+
/** Edges the delta removed (explicitly, or with a removed vertex), for the preview. */
|
|
40
|
+
removedEdges: MeshEdge[];
|
|
41
|
+
added: Set<number>;
|
|
42
|
+
/** Moved vertex id → its source (or creation) position. */
|
|
43
|
+
moved: Map<number, Vec>;
|
|
44
|
+
removed: Set<number>;
|
|
45
|
+
}
|
|
46
|
+
export type MeshEditResult = { ok: true; changed: boolean; code?: string } | { ok: false; code: string };
|
|
47
|
+
|
|
48
|
+
export const MESH_EDIT_SCHEMA = 'rig-review-mesh-edit/1';
|
|
49
|
+
export const MESH_EDIT_BINDING_SCHEMA = 'rig-review-mesh-edit-binding/1';
|
|
50
|
+
/** `/2` since #100: a `/1` draft holds ops drawn over a posed basis, which are not restored over the setup-space source. */
|
|
51
|
+
export const MESH_EDIT_DRAFT_SCHEMA = 'rig-review-mesh-edit-draft/2';
|
|
52
|
+
export const MESH_EDIT_SELECT_SLOT = 'MESH_EDIT_SELECT_SLOT';
|
|
53
|
+
export const MESH_EDIT_NO_MESH = 'MESH_EDIT_NO_MESH';
|
|
54
|
+
/** The package supplies no compiled model document (a Spine export without `skeleton.model.json`): no setup mesh to edit. */
|
|
55
|
+
export const MESH_EDIT_MODEL_UNAVAILABLE = 'MESH_EDIT_MODEL_UNAVAILABLE';
|
|
56
|
+
export const MESH_EDIT_MODEL_INVALID = 'MESH_EDIT_MODEL_INVALID';
|
|
57
|
+
export const MESH_EDIT_RIG_UNAVAILABLE = 'MESH_EDIT_RIG_UNAVAILABLE';
|
|
58
|
+
export const MESH_EDIT_CONSTRAINT_END = 'MESH_EDIT_CONSTRAINT_END';
|
|
59
|
+
/** The export's `basis`: the producer's delta reader accepts exactly this (rig-c/parts `remesh --delta`). */
|
|
60
|
+
export const MESH_EDIT_BASIS = 'setup';
|
|
61
|
+
/** Edit Mesh's refusals over the shared setup-space reader. */
|
|
62
|
+
export const MESH_EDIT_CODES: SetupMeshCodes = Object.freeze({ unavailable: MESH_EDIT_MODEL_UNAVAILABLE, select: MESH_EDIT_SELECT_SLOT, noMesh: MESH_EDIT_NO_MESH, invalid: MESH_EDIT_MODEL_INVALID, subject: 'the setup mesh is', selectText: 'select one slot in the parts list; whole-rig mesh edit is not a thing' });
|
|
63
|
+
export const MESH_EDIT_HULL_EDGE = 'MESH_EDIT_HULL_EDGE';
|
|
64
|
+
export const MESH_EDIT_EDGE_EXISTS = 'MESH_EDIT_EDGE_EXISTS';
|
|
65
|
+
export const MESH_EDIT_TRIANGULATION_EDGE = 'MESH_EDIT_TRIANGULATION_EDGE';
|
|
66
|
+
export const MESH_EDIT_INVALID_OP = 'MESH_EDIT_INVALID_OP';
|
|
67
|
+
export const MESH_EDIT_OPS_LIMIT = 'MESH_EDIT_OPS_LIMIT';
|
|
68
|
+
/** The draft rides in one form field (≤ 32768 characters), so the delta is bounded. */
|
|
69
|
+
export const MESH_EDIT_MAX_OPS = 300;
|
|
70
|
+
export const MESH_EDIT_PREVIEW_NOTE = 'preview only — the producer retriangulates';
|
|
71
|
+
/** Screen-space hit radii in CSS px. */
|
|
72
|
+
export const MESH_HIT = Object.freeze({ vertex: 6, vertexTouch: 8, edge: 4 });
|
|
73
|
+
|
|
74
|
+
const round = (value: number) => Math.round(value * 100) / 100;
|
|
75
|
+
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)];
|
|
76
|
+
export const edgeKey = (a: number, b: number) => (a < b ? `${a}:${b}` : `${b}:${a}`);
|
|
77
|
+
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;
|
|
78
|
+
|
|
79
|
+
/** The original triangulation's unique edges; consecutive hull vertices (and the closing pair) are hull edges. */
|
|
80
|
+
export function sourceEdges(source: Pick<MeshSource, 'triangles' | 'hull' | 'vertices'>): MeshEdge[] {
|
|
81
|
+
const seen = new Map<string, MeshEdge>(), n = source.vertices.length;
|
|
82
|
+
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' }); };
|
|
83
|
+
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]!); }
|
|
84
|
+
for (let i = 0; i < source.hull && source.hull > 2; i++) add(i, (i + 1) % source.hull);
|
|
85
|
+
return [...seen.values()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const finiteVec = (v: unknown): v is Vec => Array.isArray(v) && v.length === 2 && v.every(n => typeof n === 'number' && Number.isFinite(n));
|
|
89
|
+
const index = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
90
|
+
|
|
91
|
+
/** Replays ops over the source. Throws `MESH_EDIT_INVALID_OP` naming the first op that does not apply. */
|
|
92
|
+
export function applyMeshOps(source: MeshSource, ops: readonly MeshOp[]): MeshView {
|
|
93
|
+
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() };
|
|
94
|
+
const edges = new Map(sourceEdges(source).map(e => [edgeKey(e.a, e.b), { ...e }]));
|
|
95
|
+
const fail = (i: number, why: string): never => { throw new Error(`${MESH_EDIT_INVALID_OP}: op ${i} ${why}`); };
|
|
96
|
+
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; };
|
|
97
|
+
const removeEdge = (key: string) => { const edge = edges.get(key); if (edge) { edges.delete(key); view.removedEdges.push(edge); } };
|
|
98
|
+
ops.forEach((op, i) => {
|
|
99
|
+
switch (op?.op) {
|
|
100
|
+
case 'move': {
|
|
101
|
+
const v = live(op.v, i); if (!finiteVec(op.from) || !finiteVec(op.to)) fail(i, 'needs finite from/to');
|
|
102
|
+
if (!view.moved.has(v)) view.moved.set(v, [...view.vertices[v]!] as Vec);
|
|
103
|
+
view.vertices[v] = [op.to[0], op.to[1]]; break;
|
|
104
|
+
}
|
|
105
|
+
case 'add': {
|
|
106
|
+
if (!finiteVec(op.at)) fail(i, 'needs a finite at');
|
|
107
|
+
const id = view.vertices.length;
|
|
108
|
+
if (op.onEdge !== undefined) {
|
|
109
|
+
if (!Array.isArray(op.onEdge) || op.onEdge.length !== 2) fail(i, 'onEdge must be a pair');
|
|
110
|
+
const [a, b] = [live(op.onEdge![0], i), live(op.onEdge![1], i)], key = edgeKey(a, b), edge = edges.get(key);
|
|
111
|
+
if (!edge) fail(i, `splits a missing edge ${a}-${b}`);
|
|
112
|
+
edges.delete(key); view.vertices.push([op.at[0], op.at[1]]);
|
|
113
|
+
edges.set(edgeKey(a, id), { a: Math.min(a, id), b: Math.max(a, id), kind: edge!.kind });
|
|
114
|
+
edges.set(edgeKey(id, b), { a: Math.min(b, id), b: Math.max(b, id), kind: edge!.kind });
|
|
115
|
+
} else view.vertices.push([op.at[0], op.at[1]]);
|
|
116
|
+
view.added.add(id); break;
|
|
117
|
+
}
|
|
118
|
+
case 'remove': {
|
|
119
|
+
const v = live(op.v, i);
|
|
120
|
+
for (const edge of edges.values()) if (edge.kind === 'constraint' && (edge.a === v || edge.b === v)) fail(i, `removes vertex ${v}, an end of constraint edge ${edge.a}-${edge.b}; unedge it first`);
|
|
121
|
+
view.vertices[v] = null; view.removed.add(v); view.moved.delete(v);
|
|
122
|
+
for (const [key, edge] of [...edges]) if (edge.a === v || edge.b === v) removeEdge(key);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'edge': {
|
|
126
|
+
const a = live(op.a, i), b = live(op.b, i); if (a === b) fail(i, 'joins a vertex to itself');
|
|
127
|
+
if (edges.has(edgeKey(a, b))) fail(i, `duplicates edge ${a}-${b}`);
|
|
128
|
+
edges.set(edgeKey(a, b), { a: Math.min(a, b), b: Math.max(a, b), kind: 'constraint' }); break;
|
|
129
|
+
}
|
|
130
|
+
case 'unedge': {
|
|
131
|
+
const a = live(op.a, i), b = live(op.b, i), edge = edges.get(edgeKey(a, b));
|
|
132
|
+
if (!edge) fail(i, `removes a missing edge ${a}-${b}`); if (edge!.kind === 'hull') fail(i, `removes hull edge ${a}-${b}`);
|
|
133
|
+
if (edge!.kind !== 'constraint') fail(i, `removes triangulation edge ${a}-${b}; only a constraint edge this delta added can be removed`);
|
|
134
|
+
removeEdge(edgeKey(a, b)); break;
|
|
135
|
+
}
|
|
136
|
+
default: fail(i, 'has an unknown op');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
view.edges = [...edges.values()];
|
|
140
|
+
return view;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 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. */
|
|
144
|
+
export class MeshEditSession {
|
|
145
|
+
private list: MeshOp[] = [];
|
|
146
|
+
private steps: number[] = [];
|
|
147
|
+
private redoSteps: MeshOp[][] = [];
|
|
148
|
+
private current: MeshView;
|
|
149
|
+
constructor(readonly source: MeshSource, ops: readonly MeshOp[] = []) {
|
|
150
|
+
this.current = applyMeshOps(source, ops); this.list = ops.map(op => structuredClone(op)); this.steps = this.list.map(() => 1);
|
|
151
|
+
}
|
|
152
|
+
ops(): MeshOp[] { return this.list.map(op => structuredClone(op)); }
|
|
153
|
+
view(): MeshView { return this.current; }
|
|
154
|
+
canUndo(): boolean { return this.steps.length > 0; }
|
|
155
|
+
canRedo(): boolean { return this.redoSteps.length > 0; }
|
|
156
|
+
/** Records one step; refuses the whole step when any op does not apply. */
|
|
157
|
+
record(ops: readonly MeshOp[]): MeshEditResult {
|
|
158
|
+
if (!ops.length) return { ok: true, changed: false };
|
|
159
|
+
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` };
|
|
160
|
+
let next: MeshView;
|
|
161
|
+
try { next = applyMeshOps(this.source, [...this.list, ...ops]); } catch (error) { return { ok: false, code: error instanceof Error ? error.message : String(error) }; }
|
|
162
|
+
this.list.push(...ops.map(op => structuredClone(op))); this.steps.push(ops.length); this.redoSteps = []; this.current = next;
|
|
163
|
+
return { ok: true, changed: true };
|
|
164
|
+
}
|
|
165
|
+
undo(): boolean {
|
|
166
|
+
const count = this.steps.pop(); if (count === undefined) return false;
|
|
167
|
+
this.redoSteps.push(this.list.splice(this.list.length - count, count));
|
|
168
|
+
this.current = applyMeshOps(this.source, this.list); return true;
|
|
169
|
+
}
|
|
170
|
+
redo(): boolean {
|
|
171
|
+
const ops = this.redoSteps.pop(); if (!ops) return false;
|
|
172
|
+
this.list.push(...ops); this.steps.push(ops.length); this.current = applyMeshOps(this.source, this.list); return true;
|
|
173
|
+
}
|
|
174
|
+
reset(): void { this.list = []; this.steps = []; this.redoSteps = []; this.current = applyMeshOps(this.source, []); }
|
|
175
|
+
|
|
176
|
+
/** Moves vertices by a crop-px offset from their positions at `base` (the drag's start view): one move op per vertex. */
|
|
177
|
+
moveOps(ids: readonly number[], dx: number, dy: number, base: MeshView = this.current): MeshOp[] {
|
|
178
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy) || (dx === 0 && dy === 0)) return [];
|
|
179
|
+
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 }]; });
|
|
180
|
+
}
|
|
181
|
+
/** Create: a vertex on empty space, or a split of an edge at the click point. */
|
|
182
|
+
addVertex(at: Vec, onEdge?: [number, number]): MeshEditResult { return this.record([{ op: 'add', at: roundVec(at), ...(onEdge ? { onEdge: [onEdge[0], onEdge[1]] as [number, number] } : {}) }]); }
|
|
183
|
+
/** Delete on a vertex: the vertex and its triangulation edges; a vertex that ends a constraint edge is refused until that edge is removed. */
|
|
184
|
+
removeVertex(v: number): MeshEditResult {
|
|
185
|
+
const held = this.current.edges.find(e => e.kind === 'constraint' && (e.a === v || e.b === v));
|
|
186
|
+
if (held) return { ok: false, code: `${MESH_EDIT_CONSTRAINT_END}: vertex ${v} ends constraint edge ${held.a}-${held.b} — remove that edge first` };
|
|
187
|
+
return this.record([{ op: 'remove', v }]);
|
|
188
|
+
}
|
|
189
|
+
/** Delete on an edge: only a constraint edge this delta added; hull and triangulation edges are the producer's. */
|
|
190
|
+
deleteEdge(a: number, b: number): MeshEditResult {
|
|
191
|
+
const edge = this.findEdge(a, b); if (!edge) return { ok: false, code: `${MESH_EDIT_INVALID_OP}: no edge ${a}-${b}` };
|
|
192
|
+
if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
|
|
193
|
+
if (edge.kind !== 'constraint') return { ok: false, code: `${MESH_EDIT_TRIANGULATION_EDGE}: ${a}-${b} is a triangulation edge — the producer retriangulates; only an added constraint edge can be removed` };
|
|
194
|
+
return this.record([{ op: 'unedge', a, b }]);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Edges mode on a pair: a new pair adds a constraint edge; an existing constraint edge is removed; a hull edge is
|
|
198
|
+
* refused; any other existing edge is a duplicate and records nothing.
|
|
199
|
+
*/
|
|
200
|
+
toggleConstraint(a: number, b: number): MeshEditResult {
|
|
201
|
+
if (a === b) return { ok: true, changed: false };
|
|
202
|
+
const edge = this.findEdge(a, b);
|
|
203
|
+
if (!edge) return this.record([{ op: 'edge', a, b }]);
|
|
204
|
+
if (edge.kind === 'hull') return { ok: false, code: `${MESH_EDIT_HULL_EDGE}: ${a}-${b} is a hull edge and cannot be removed` };
|
|
205
|
+
if (edge.kind === 'constraint') return this.record([{ op: 'unedge', a, b }]);
|
|
206
|
+
return { ok: true, changed: false, code: `${MESH_EDIT_EDGE_EXISTS}: ${a}-${b} is already a triangulation edge` };
|
|
207
|
+
}
|
|
208
|
+
findEdge(a: number, b: number): MeshEdge | undefined { const key = edgeKey(a, b); return this.current.edges.find(e => edgeKey(e.a, e.b) === key); }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ScreenPoint { x: number; y: number }
|
|
212
|
+
export type MeshHit = { kind: 'vertex'; v: number } | { kind: 'edge'; a: number; b: number; t: number; at: ScreenPoint };
|
|
213
|
+
/** Screen-space hit test: the nearest vertex within the radius wins over any edge; then the nearest edge within 4 px. */
|
|
214
|
+
export function hitMesh(points: readonly (ScreenPoint | null)[], edges: readonly Pick<MeshEdge, 'a' | 'b'>[], p: ScreenPoint, touch = false): MeshHit | null {
|
|
215
|
+
const radius = touch ? MESH_HIT.vertexTouch : MESH_HIT.vertex;
|
|
216
|
+
let best: MeshHit | null = null, bestDistance = Infinity;
|
|
217
|
+
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; } });
|
|
218
|
+
if (best) return best;
|
|
219
|
+
for (const edge of edges) {
|
|
220
|
+
const a = points[edge.a], b = points[edge.b]; if (!a || !b) continue;
|
|
221
|
+
const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy; if (length === 0) continue;
|
|
222
|
+
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);
|
|
223
|
+
if (d <= MESH_HIT.edge && d < bestDistance) { best = { kind: 'edge', a: edge.a, b: edge.b, t, at }; bestDistance = d; }
|
|
224
|
+
}
|
|
225
|
+
return best;
|
|
226
|
+
}
|
|
227
|
+
/** Vertex ids inside a screen rectangle (any corner order). */
|
|
228
|
+
export function verticesInBox(points: readonly (ScreenPoint | null)[], from: ScreenPoint, to: ScreenPoint): number[] {
|
|
229
|
+
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)];
|
|
230
|
+
return points.flatMap((p, v) => p && p.x >= x0 && p.x <= x1 && p.y >= y0 && p.y <= y1 ? [v] : []);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A 2D affine map x' = a x + c y + e, y' = b x + d y + f. */
|
|
234
|
+
export interface Affine { a: number; b: number; c: number; d: number; e: number; f: number }
|
|
235
|
+
/** Samples an affine function at three points. */
|
|
236
|
+
export function affineOf(fn: (x: number, y: number) => ScreenPoint): Affine {
|
|
237
|
+
const o = fn(0, 0), x = fn(1, 0), y = fn(0, 1);
|
|
238
|
+
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 };
|
|
239
|
+
}
|
|
240
|
+
export function invertAffine(m: Affine): Affine | null {
|
|
241
|
+
const det = m.a * m.d - m.b * m.c; if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
|
|
242
|
+
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 };
|
|
243
|
+
}
|
|
244
|
+
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 });
|
|
245
|
+
/** A screen-px offset as a crop-px offset through the inverse of crop → screen. */
|
|
246
|
+
export function screenDeltaToCrop(cropToScreen: Affine, dx: number, dy: number): Vec | null {
|
|
247
|
+
const inverse = invertAffine(cropToScreen); if (!inverse) return null;
|
|
248
|
+
return [inverse.a * dx + inverse.c * dy, inverse.b * dx + inverse.d * dy];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The text hashed for `source.hash`: the attachment's entry from the hash-verified model, as `JSON.stringify` writes it. */
|
|
252
|
+
export const attachmentHashText = (entry: unknown) => JSON.stringify(entry);
|
|
253
|
+
|
|
254
|
+
export interface MeshEditDocument {
|
|
255
|
+
schema: typeof MESH_EDIT_SCHEMA;
|
|
256
|
+
slot: string; skin: string; attachment: string;
|
|
257
|
+
source: { vertexCount: number; triangleCount: number; hullLength: number; hash: string };
|
|
258
|
+
units: string; basis: typeof MESH_EDIT_BASIS; preview: string;
|
|
259
|
+
ops: MeshOp[];
|
|
260
|
+
}
|
|
261
|
+
/** The export: `rig-review-mesh-edit/1`, `basis: "setup"`, as rig-c/parts `remesh --delta` reads it. */
|
|
262
|
+
export function meshEditDocument(session: MeshEditSession): MeshEditDocument {
|
|
263
|
+
const s = session.source;
|
|
264
|
+
return {
|
|
265
|
+
schema: MESH_EDIT_SCHEMA, slot: s.slot, skin: s.skin, attachment: s.attachment,
|
|
266
|
+
source: { vertexCount: s.vertices.length, triangleCount: Math.floor(s.triangles.length / 3), hullLength: s.hull, hash: s.hash },
|
|
267
|
+
units: 'setup-pose crop px of the rig canvas, x right, y down; vertex ids are source indices, added vertices take the next id in op order, removed ids are not reused',
|
|
268
|
+
basis: MESH_EDIT_BASIS, preview: `${MESH_EDIT_PREVIEW_NOTE}; the ops are a request over the original triangulation, not a mesh`,
|
|
269
|
+
ops: session.ops(),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The delta's bytes: what Download JSON saves and what Apply · Rebuild (#135) writes as the producer's
|
|
274
|
+
* `mesh-edit.json`, so the binding's `file.sha256` and the applied record's `deltaSha256` name the same bytes.
|
|
275
|
+
*/
|
|
276
|
+
export const meshEditText = (document: unknown) => JSON.stringify(document, null, 2) + '\n';
|
|
277
|
+
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 } }) {
|
|
278
|
+
return {
|
|
279
|
+
schema: MESH_EDIT_BINDING_SCHEMA,
|
|
280
|
+
file: { name: 'mesh-edit.json', sha256: input.fileSha256 },
|
|
281
|
+
package: input.sourceHash,
|
|
282
|
+
model: input.reviewedSource.rig?.sha256 ?? null,
|
|
283
|
+
motion: input.reviewedSource.motion?.sha256 ?? null,
|
|
284
|
+
attachment: { ...input.attachment },
|
|
285
|
+
exportedAt: input.exportedAt,
|
|
286
|
+
tools: { viewerVersion: input.reviewedSource.viewerVersion, runtimeVersion: input.reviewedSource.runtimeVersion },
|
|
287
|
+
mapping: input.mapping,
|
|
288
|
+
evidence: 'Mesh edit is review evidence, not an approval. Nothing was written into the package; retriangulation and regeneration are the producer path.',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Draft: per attachment, the ops bound to the attachment hash. A draft for another hash is not restored. */
|
|
293
|
+
export interface MeshEditDraft { schema: typeof MESH_EDIT_DRAFT_SCHEMA; entries: Record<string, { hash: string; ops: MeshOp[] }> }
|
|
294
|
+
export const draftKey = (s: Pick<MeshSource, 'slot' | 'skin' | 'attachment'>) => `${s.skin}/${s.slot}/${s.attachment}`;
|
|
295
|
+
export function parseMeshDraft(text: string): MeshEditDraft {
|
|
296
|
+
const empty: MeshEditDraft = { schema: MESH_EDIT_DRAFT_SCHEMA, entries: {} };
|
|
297
|
+
if (!text.trim()) return empty;
|
|
298
|
+
let value: unknown; try { value = JSON.parse(text); } catch { return empty; }
|
|
299
|
+
const o = value as Partial<MeshEditDraft> | null;
|
|
300
|
+
if (!o || typeof o !== 'object' || o.schema !== MESH_EDIT_DRAFT_SCHEMA || !o.entries || typeof o.entries !== 'object' || Array.isArray(o.entries)) return empty;
|
|
301
|
+
const entries: MeshEditDraft['entries'] = {};
|
|
302
|
+
for (const [key, entry] of Object.entries(o.entries)) {
|
|
303
|
+
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;
|
|
304
|
+
entries[key] = { hash: entry.hash, ops: entry.ops as MeshOp[] };
|
|
305
|
+
}
|
|
306
|
+
return { schema: MESH_EDIT_DRAFT_SCHEMA, entries };
|
|
307
|
+
}
|
|
308
|
+
/** Ops a draft holds for this source, or null when the draft has none or names another hash / an op that does not apply. */
|
|
309
|
+
export function draftOps(draft: MeshEditDraft, source: MeshSource): MeshOp[] | null {
|
|
310
|
+
const entry = draft.entries[draftKey(source)]; if (!entry || entry.hash !== source.hash) return null;
|
|
311
|
+
try { applyMeshOps(source, entry.ops); return entry.ops; } catch { return null; }
|
|
312
|
+
}
|
|
313
|
+
export function writeMeshDraft(draft: MeshEditDraft, source: MeshSource, ops: readonly MeshOp[]): string {
|
|
314
|
+
const entries = { ...draft.entries }; const key = draftKey(source);
|
|
315
|
+
if (ops.length) entries[key] = { hash: source.hash, ops: ops.map(op => structuredClone(op)) }; else delete entries[key];
|
|
316
|
+
return Object.keys(entries).length ? JSON.stringify({ schema: MESH_EDIT_DRAFT_SCHEMA, entries }) : '';
|
|
317
|
+
}
|