@rig-c/review 0.6.0 → 0.7.1
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 +206 -12
- package/docs/DESIGN.md +26 -4
- package/docs/REVIEW_PACKAGE.md +113 -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 +10 -4
- package/src/cli.ts +19 -8
- package/src/config-patch.ts +213 -0
- package/src/cut-edits.ts +184 -0
- package/src/environment.ts +19 -6
- package/src/feedback-export.ts +17 -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 +45 -15
- 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 +9 -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 +128 -13
- package/web/chest-regions-tool.ts +2 -1
- package/web/cuts-panel.ts +266 -0
- package/web/idle-panel.ts +280 -0
- package/web/index.html +1 -1
- package/web/inspector-blocks.ts +77 -0
- package/web/main.ts +701 -49
- package/web/mesh-editor.ts +73 -38
- package/web/mesh-panel.ts +273 -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 +241 -0
- package/web/retarget-panel.ts +203 -0
- package/web/rig-fields-panel.ts +269 -0
- package/web/rig-overlays.ts +108 -14
- package/web/setup-pose.ts +30 -0
- package/web/styles.css +28 -5
- package/web/weights-view.ts +159 -0
package/src/server.ts
CHANGED
|
@@ -6,19 +6,38 @@ import { exportReviewStrip, stripJson } from './strip-export.ts';
|
|
|
6
6
|
import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedReference,serveReference} from './reference.ts';
|
|
7
7
|
import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
|
|
8
8
|
import { readSession, saveSession } from './session.ts';
|
|
9
|
+
import { createRebuilds, RebuildError } from './rebuild.ts';
|
|
10
|
+
import { createReadings } from './readings-run.ts';
|
|
11
|
+
import { ReadingsError } from './readings.ts';
|
|
12
|
+
import { BONES_CHECK_LIMIT, createBonesCheck } from './bones-check.ts';
|
|
13
|
+
import { createRequirementsCheck } from './requirements-run.ts';
|
|
14
|
+
import { REQUIREMENTS_LIMIT, RequirementsError } from './requirements.ts';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
9
17
|
const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
|
|
18
|
+
/** Request shapes the page or an edit card got wrong: 400. Everything else a refusal names is an outcome (200). */
|
|
19
|
+
const MALFORMED=new Set(['REBUILD_REQUEST_INVALID','EDIT_PATCH_INVALID','EDIT_PATCH_OP_UNKNOWN','EDIT_PATCH_POINTER_INVALID','EDIT_PATCH_CMD_UNKNOWN','EDIT_PATCH_ARG_UNKNOWN','BONES_OP_OUT_OF_SCOPE','RETARGET_REQUEST_INVALID']);
|
|
10
20
|
function json(data:unknown,status=200){return Response.json(data,{status,headers});}
|
|
11
|
-
async function bodyJson(req:Request):Promise<unknown>{
|
|
21
|
+
async function bodyJson(req:Request,limit=1024*1024,what='Session payload exceeds 1 MiB'):Promise<unknown>{
|
|
12
22
|
if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('Expected application/json');
|
|
13
|
-
if(Number(req.headers.get('content-length')??0)>
|
|
23
|
+
if(Number(req.headers.get('content-length')??0)>limit)throw new Error(what);
|
|
14
24
|
if(!req.body)throw new Error('JSON body required');const reader=req.body.getReader();const chunks:Uint8Array[]=[];let bytes=0;
|
|
15
|
-
while(true){const {value,done}=await reader.read();if(done)break;bytes+=value.length;if(bytes>
|
|
25
|
+
while(true){const {value,done}=await reader.read();if(done)break;bytes+=value.length;if(bytes>limit){await reader.cancel();throw new Error(what);}chunks.push(value);}
|
|
16
26
|
const full=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){full.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(full));
|
|
17
27
|
}
|
|
18
|
-
/** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it.
|
|
19
|
-
|
|
28
|
+
/** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it.
|
|
29
|
+
* `cwd` is where the producer is looked for after the package's own directories (review's working directory by default). */
|
|
30
|
+
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string;cwd?:string}={}){
|
|
20
31
|
const bundle=await buildWeb();
|
|
21
32
|
const unchanged=()=>assertReviewUnchanged(p);
|
|
33
|
+
const stateDir=options.stateDir??process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review'),starts=[p.root,options.cwd??process.cwd()];
|
|
34
|
+
const rebuilds=createRebuilds(p,{stateDir,starts,unchanged,bones:p.feedbackCatalog.bones});
|
|
35
|
+
// Readings (#108): rigc render's readings on the inspected build and on the latest scratch build, into scratch.
|
|
36
|
+
const readings=createReadings(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
|
|
37
|
+
// Bones check (#109): propose --from-config and compare on a scratch copy of the patched handoff config.
|
|
38
|
+
const bonesCheck=createBonesCheck(p,{stateDir,starts,unchanged});
|
|
39
|
+
// Declared requirements (#113): review writes the file into scratch; the installed producer's check measures it.
|
|
40
|
+
const requirements=createRequirementsCheck(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
|
|
22
41
|
const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
|
|
23
42
|
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
|
|
24
43
|
const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
|
|
@@ -30,6 +49,28 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
30
49
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
31
50
|
const input=await bodyJson(req);await unchanged();return json(await saveSession(p,input,options.stateDir));
|
|
32
51
|
}
|
|
52
|
+
// Apply · Rebuild (#99 regions, #102 {patch}): typed data, never a path; the server runs the installed producer into scratch.
|
|
53
|
+
if(req.method==='POST'&&url.pathname==='/api/rebuild'){
|
|
54
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
55
|
+
try{const result=await rebuilds.start(await bodyJson(req,64*1024,'REBUILD_BODY_LIMIT: maximum 64 KiB'));return json(result.body,result.status);}
|
|
56
|
+
// A refusal is an outcome the page shows verbatim (200), so the managed front passes its text through; only a malformed request is a 400.
|
|
57
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
58
|
+
}
|
|
59
|
+
if(req.method==='POST'&&url.pathname==='/api/bones-check'){
|
|
60
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
61
|
+
try{return json(await bonesCheck.run(await bodyJson(req,BONES_CHECK_LIMIT,'BONES_CHECK_BODY_LIMIT: maximum 64 KiB')));}
|
|
62
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message},MALFORMED.has(e.code)?400:200);throw e;}
|
|
63
|
+
}
|
|
64
|
+
if(req.method==='POST'&&url.pathname==='/api/requirements'){
|
|
65
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
66
|
+
try{const result=await requirements.start(await bodyJson(req,REQUIREMENTS_LIMIT,'REQUIREMENTS_BODY_LIMIT: maximum 64 KiB'));return json(result.body,result.status);}
|
|
67
|
+
catch(e){if(e instanceof RequirementsError)return json({status:'refused',error:e.message},e.code==='REQUIREMENTS_REQUEST_INVALID'?400:200);throw e;}
|
|
68
|
+
}
|
|
69
|
+
if(req.method==='POST'&&url.pathname==='/api/readings'){
|
|
70
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
71
|
+
try{const result=await readings.start(await bodyJson(req,16*1024,'READINGS_BODY_LIMIT: maximum 16 KiB'));return json(result.body,result.status);}
|
|
72
|
+
catch(e){if(e instanceof ReadingsError)return json({status:'refused',error:e.message},e.code==='READINGS_REQUEST_INVALID'?400:200);throw e;}
|
|
73
|
+
}
|
|
33
74
|
if(req.method==='POST'&&url.pathname==='/api/strip'){
|
|
34
75
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
35
76
|
const result=await exportReviewStrip(p,await bodyJson(req));
|
|
@@ -38,6 +79,19 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
38
79
|
if(req.method!=='GET'&&req.method!=='HEAD')return json({error:'Method not allowed'},405);
|
|
39
80
|
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
40
81
|
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
82
|
+
if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
|
|
83
|
+
const rebuildStatus=/^\/api\/rebuild\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
84
|
+
if(rebuildStatus){try{return json(rebuilds.read(rebuildStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
|
|
85
|
+
if(url.pathname==='/api/requirements')return json(await requirements.describe());
|
|
86
|
+
const requirementsStatus=/^\/api\/requirements\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
87
|
+
if(requirementsStatus){try{return json(requirements.read(requirementsStatus[1]!));}catch(e){if(e instanceof RequirementsError)return json({error:e.message},404);throw e;}}
|
|
88
|
+
const readingsStatus=/^\/api\/readings\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
89
|
+
if(readingsStatus){try{return json(readings.read(readingsStatus[1]!));}catch(e){if(e instanceof ReadingsError)return json({error:e.message},404);throw e;}}
|
|
90
|
+
const rebuildFile=/^\/rebuild\/([a-f0-9]{64})\/(.+)$/.exec(url.pathname);
|
|
91
|
+
if(rebuildFile){
|
|
92
|
+
let full:string;try{full=await rebuilds.file(rebuildFile[1]!,decodeURIComponent(rebuildFile[2]!));}catch(e){return json({error:e instanceof Error?e.message:'Not found'},404);}
|
|
93
|
+
const file=Bun.file(full);return new Response(req.method==='HEAD'?null:file,{headers:{...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Content-Length':String(file.size)}});
|
|
94
|
+
}
|
|
41
95
|
if(url.pathname==='/review-reference')return await serveReference(req,await safeSelectedReference(p),referenceBinding(p)!.reference.sha256,headers);
|
|
42
96
|
if(url.pathname.startsWith('/assets/')){
|
|
43
97
|
const path=packagePath(decodeURIComponent(url.pathname.slice('/assets/'.length)));if(!p.files.has(path))return json({error:'Asset not declared by package'},404);
|
package/src/session.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import {referenceBinding,type ReferenceBinding} from './reference.ts';
|
|
7
7
|
import { validateFeedbackSelection } from './feedback-validation.ts';
|
|
8
8
|
import { parseEnvironmentContext } from './environment.ts';
|
|
9
|
+
import { appliedCheckRefusal, parseAppliedContext } from './applied-context.ts';
|
|
10
|
+
import { parseReadingsContext } from './readings.ts';
|
|
11
|
+
import { parseRequirementsContext } from './requirements.ts';
|
|
9
12
|
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
10
13
|
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
11
14
|
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
@@ -28,6 +31,12 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
|
|
|
28
31
|
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
32
|
// Older records without an environment stay valid; a present one must have the recorded shape.
|
|
30
33
|
if(context.environment!==undefined)parseEnvironmentContext(context.environment);
|
|
34
|
+
// Likewise the applied state (#99 regions, #102 patch): absent or null stays valid; a present record has its exact shape.
|
|
35
|
+
if(context.applied!==undefined){parseAppliedContext(context.applied);const unchecked=appliedCheckRefusal(context.applied);if(unchecked)throw new Error(unchecked);}
|
|
36
|
+
// The readings chosen (#108): absent or null stays valid; a present record names its pairs and the builds they ran on.
|
|
37
|
+
if(context.readings!==undefined)parseReadingsContext(context.readings);
|
|
38
|
+
// The declared requirements (#113): absent or null stays valid; a present record carries the file's text and its sha256, recomputed here.
|
|
39
|
+
if(context.requirements!==undefined)parseRequirementsContext(context.requirements,text=>createHash('sha256').update(text).digest('hex'));
|
|
31
40
|
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
32
41
|
validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
|
|
33
42
|
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup-space mesh reader shared by Setup · Weights (#107) and Edit Mesh (#100): one slot's mesh attachment read
|
|
3
|
+
* from the compiled model document as stored, plus the published player's skinning formula that places it. Pure — no
|
|
4
|
+
* DOM, no I/O; nothing here edits, normalises or infers a vertex or a weight.
|
|
5
|
+
*
|
|
6
|
+
* `rigc-compiled/3` stores a mesh attachment's setup-pose vertices at `skins[].attachments.<slot>.<name>.vertices`:
|
|
7
|
+
* `{weighted: false, xy}` — x, y pairs local to the slot's bone, so every vertex is the slot's bone at 1.0 — or
|
|
8
|
+
* `{weighted: true, bindings}` — one list per vertex of `{bone, x, y, weight}` (the bone by name). A `linkedmesh`
|
|
9
|
+
* carries no vertices of its own; it draws its source mesh's (`{skin, slot, source}`), read here the same way.
|
|
10
|
+
*
|
|
11
|
+
* Each caller names its refusals (`SetupMeshCodes`), so a missing document is `WEIGHTS_MODEL_UNAVAILABLE` in the
|
|
12
|
+
* weights block and `MESH_EDIT_MODEL_UNAVAILABLE` in Edit Mesh, from the same check.
|
|
13
|
+
*/
|
|
14
|
+
export interface SetupMeshCodes {
|
|
15
|
+
/** No model document, or not `rigc-compiled/3`. */
|
|
16
|
+
unavailable:string;
|
|
17
|
+
/** No slot selected (whole rig). */
|
|
18
|
+
select:string;
|
|
19
|
+
/** The slot has no mesh attachment there. */
|
|
20
|
+
noMesh:string;
|
|
21
|
+
/** The stored entry does not read. */
|
|
22
|
+
invalid:string;
|
|
23
|
+
/** What the caller reads from the document, for the refusal text ("weights are", "the mesh is"). */
|
|
24
|
+
subject:string;
|
|
25
|
+
/** The select refusal's own text after the code. */
|
|
26
|
+
selectText:string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One stored influence of one vertex: the bone, the vertex in that bone's space and the weight, as stored. */
|
|
30
|
+
export interface WeightInfluence {bone:string;x:number;y:number;weight:number}
|
|
31
|
+
export interface SetupMeshRead {
|
|
32
|
+
slot:string;skin:string;attachment:string;
|
|
33
|
+
/** Where the vertices were read: the attachment itself, or a linked mesh's source. */
|
|
34
|
+
source:{skin:string;slot:string;attachment:string};
|
|
35
|
+
/** The attachment is a `linkedmesh` (its vertices are its source's). */
|
|
36
|
+
linked:boolean;
|
|
37
|
+
weighted:boolean;
|
|
38
|
+
/** Per vertex, the influences in stored order. */
|
|
39
|
+
vertices:WeightInfluence[][];
|
|
40
|
+
/** Every bone that influences a vertex, in first-stored order. */
|
|
41
|
+
bones:string[];
|
|
42
|
+
/** The read entry's triangle indices, as stored. */
|
|
43
|
+
triangles:number[];
|
|
44
|
+
/** The read entry's `hull` (the first `hull` vertices, in order, are the outline), or null when it stores none. */
|
|
45
|
+
hull:number|null;
|
|
46
|
+
/** The attachment's own entry as stored (the linked mesh itself when linked) — what a source hash is taken over. */
|
|
47
|
+
entry:Record<string,unknown>;
|
|
48
|
+
}
|
|
49
|
+
export type SetupMeshReadResult={ok:true;mesh:SetupMeshRead}|{ok:false;code:string};
|
|
50
|
+
|
|
51
|
+
type Obj=Record<string,unknown>;
|
|
52
|
+
const isObject=(v:unknown):v is Obj=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
|
53
|
+
const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v);
|
|
54
|
+
|
|
55
|
+
/** The compiled model document, or the refusal that names why there is none (a package without one). */
|
|
56
|
+
export function setupModel(model:unknown,codes:Pick<SetupMeshCodes,'unavailable'|'subject'>):{ok:true;model:Obj}|{ok:false;code:string} {
|
|
57
|
+
if(model===null||model===undefined)return {ok:false,code:`${codes.unavailable}: the package supplies no compiled model document (skeleton.model.json); ${codes.subject} read from it`};
|
|
58
|
+
if(!isObject(model)||model.spec!=='rigc-compiled/3')return {ok:false,code:`${codes.unavailable}: the model document is not rigc-compiled/3`};
|
|
59
|
+
return {ok:true,model};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function attachmentOf(model:Obj,skin:string,slot:string,name:string):Obj|undefined {
|
|
63
|
+
const skins=Array.isArray(model.skins)?model.skins:[];
|
|
64
|
+
const found=skins.find(s=>isObject(s)&&s.name===skin) as Obj|undefined;
|
|
65
|
+
const slots=found&&isObject(found.attachments)?found.attachments[slot]:undefined;
|
|
66
|
+
const entry=isObject(slots)?slots[name]:undefined;
|
|
67
|
+
return isObject(entry)?entry:undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Without an identity: the slot's mesh attachment named like the slot, else its only mesh (first skin that has it). */
|
|
70
|
+
function guessFree(model:Obj,slot:string):{skin:string;attachment:string}|null {
|
|
71
|
+
const found:{skin:string;attachment:string}[]=[];
|
|
72
|
+
for(const s of Array.isArray(model.skins)?model.skins:[]){
|
|
73
|
+
if(!isObject(s)||typeof s.name!=='string'||!isObject(s.attachments)||!isObject(s.attachments[slot]))continue;
|
|
74
|
+
for(const [name,a]of Object.entries(s.attachments[slot] as Obj))if(isObject(a)&&(a.kind==='mesh'||a.kind==='linkedmesh'))found.push({skin:s.name,attachment:name});
|
|
75
|
+
}
|
|
76
|
+
return found.find(f=>f.attachment===slot)??(found.length===1?found[0]!:null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The setup-pose mesh of `slot`'s attachment `active` (the identity the setup pose resolved; null takes the slot's mesh
|
|
81
|
+
* named like the slot, else its only one), exactly as stored.
|
|
82
|
+
*/
|
|
83
|
+
export function readSetupMesh(model:unknown,slot:string|null,active:{skin:string;attachment:string}|null,codes:SetupMeshCodes):SetupMeshReadResult {
|
|
84
|
+
const doc=setupModel(model,codes);if(!doc.ok)return doc;
|
|
85
|
+
if(!slot)return {ok:false,code:`${codes.select}: ${codes.selectText}`};
|
|
86
|
+
const slots=Array.isArray(doc.model.slots)?doc.model.slots:[];
|
|
87
|
+
const slotEntry=slots.find(s=>isObject(s)&&s.name===slot) as Obj|undefined;
|
|
88
|
+
if(!slotEntry||typeof slotEntry.bone!=='string')return {ok:false,code:`${codes.noMesh} ${slot}: the model has no such slot`};
|
|
89
|
+
const key=active??guessFree(doc.model,slot);
|
|
90
|
+
if(!key)return {ok:false,code:`${codes.noMesh} ${slot}: no mesh attachment`};
|
|
91
|
+
const own=attachmentOf(doc.model,key.skin,slot,key.attachment);let entry=own,source={skin:key.skin,slot,attachment:key.attachment};
|
|
92
|
+
if(entry?.kind==='linkedmesh'){
|
|
93
|
+
if(typeof entry.skin!=='string'||typeof entry.slot!=='string'||typeof entry.source!=='string')return {ok:false,code:`${codes.invalid}: ${slot} / ${key.attachment} is a linked mesh without skin, slot and source`};
|
|
94
|
+
source={skin:entry.skin,slot:entry.slot,attachment:entry.source};entry=attachmentOf(doc.model,source.skin,source.slot,source.attachment);
|
|
95
|
+
}
|
|
96
|
+
if(!own||!entry||entry.kind!=='mesh')return {ok:false,code:`${codes.noMesh} ${slot}: ${key.skin} / ${key.attachment} is not a mesh attachment`};
|
|
97
|
+
const bones=new Set((Array.isArray(doc.model.bones)?doc.model.bones:[]).map(b=>isObject(b)&&typeof b.name==='string'?b.name:'').filter(Boolean));
|
|
98
|
+
const v=entry.vertices,invalid=(why:string)=>({ok:false as const,code:`${codes.invalid}: ${source.slot} / ${source.attachment} ${why}`});
|
|
99
|
+
if(!isObject(v)||typeof v.weighted!=='boolean')return invalid('vertices are not { weighted, xy | bindings }');
|
|
100
|
+
const vertices:WeightInfluence[][]=[];
|
|
101
|
+
if(!v.weighted){
|
|
102
|
+
if(!Array.isArray(v.xy)||v.xy.length%2||!v.xy.every(finite))return invalid('xy is not an even list of finite numbers');
|
|
103
|
+
const bone=slotEntry.bone as string;
|
|
104
|
+
for(let i=0;i<v.xy.length;i+=2)vertices.push([{bone,x:v.xy[i] as number,y:v.xy[i+1] as number,weight:1}]);
|
|
105
|
+
}else{
|
|
106
|
+
if(!Array.isArray(v.bindings))return invalid('bindings is not a list');
|
|
107
|
+
for(const [i,influences]of v.bindings.entries()){
|
|
108
|
+
if(!Array.isArray(influences)||!influences.length)return invalid(`bindings[${i}] is not a non-empty list`);
|
|
109
|
+
const row:WeightInfluence[]=[];
|
|
110
|
+
for(const b of influences){
|
|
111
|
+
if(!isObject(b)||typeof b.bone!=='string'||!bones.has(b.bone)||!finite(b.x)||!finite(b.y)||!finite(b.weight))return invalid(`bindings[${i}] names an unknown bone or a non-finite x, y or weight`);
|
|
112
|
+
row.push({bone:b.bone,x:b.x,y:b.y,weight:b.weight});
|
|
113
|
+
}
|
|
114
|
+
vertices.push(row);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const triangles=entry.triangles;
|
|
118
|
+
if(!Array.isArray(triangles)||triangles.length%3||!triangles.every(t=>Number.isSafeInteger(t)&&(t as number)>=0&&(t as number)<vertices.length))return invalid('triangles are not whole indices into its vertices');
|
|
119
|
+
const hull=Number.isSafeInteger(entry.hull)&&(entry.hull as number)>=0&&(entry.hull as number)<=vertices.length?entry.hull as number:null;
|
|
120
|
+
const order:string[]=[];for(const row of vertices)for(const i of row)if(!order.includes(i.bone))order.push(i.bone);
|
|
121
|
+
return {ok:true,mesh:{slot,skin:key.skin,attachment:key.attachment,source,linked:own.kind==='linkedmesh',weighted:v.weighted,vertices,bones:order,triangles:[...triangles] as number[],hull,entry:own}};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A bone's world transform as the published player reports it (`BoneWorld`'s x, y, a, b, c, d). */
|
|
125
|
+
export interface WorldTransform {x:number;y:number;a:number;b:number;c:number;d:number}
|
|
126
|
+
/**
|
|
127
|
+
* One vertex's world position from its stored influences and the bones' world transforms: the published player's own
|
|
128
|
+
* skinning formula (`worldVertices` in `@rig-c/play`: coordinates and weights through `Math.fround`, each influence
|
|
129
|
+
* `(x·a + y·b + worldX, x·c + y·d + worldY) · weight`, summed; an unweighted vertex is its slot bone's transform at 1).
|
|
130
|
+
*/
|
|
131
|
+
export function skinVertex(influences:readonly WeightInfluence[],world:(bone:string)=>WorldTransform,weighted:boolean):{x:number;y:number} {
|
|
132
|
+
let wx=0,wy=0;
|
|
133
|
+
for(const i of influences){
|
|
134
|
+
const t=world(i.bone),x=Math.fround(i.x),y=Math.fround(i.y),w=weighted?Math.fround(i.weight):1;
|
|
135
|
+
wx+=(x*t.a+y*t.b+t.x)*w;wy+=(x*t.c+y*t.d+t.y)*w;
|
|
136
|
+
}
|
|
137
|
+
return {x:wx,y:wy};
|
|
138
|
+
}
|
|
139
|
+
/** Every vertex of a read mesh placed in world space through `world` (flat x, y pairs), or null when one is not finite. */
|
|
140
|
+
export function placeSetupMesh(mesh:Pick<SetupMeshRead,'vertices'|'weighted'>,world:(bone:string)=>WorldTransform):number[]|null {
|
|
141
|
+
const out:number[]=[];
|
|
142
|
+
for(const influences of mesh.vertices){const p=skinVertex(influences,world,mesh.weighted);if(!Number.isFinite(p.x)||!Number.isFinite(p.y))return null;out.push(p.x,p.y);}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
package/src/weights.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup-space mesh weights (#107), read from the compiled model document as stored. Watch-only: nothing here edits,
|
|
3
|
+
* normalises or infers a weight. The reader itself is shared with Edit Mesh (#100): `src/setup-mesh.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import {readSetupMesh,setupModel,type SetupMeshCodes,type WeightInfluence} from './setup-mesh.ts';
|
|
6
|
+
export const WEIGHTS_MODEL_UNAVAILABLE='WEIGHTS_MODEL_UNAVAILABLE';
|
|
7
|
+
export const WEIGHTS_SELECT_SLOT='WEIGHTS_SELECT_SLOT';
|
|
8
|
+
export const WEIGHTS_NO_MESH='WEIGHTS_NO_MESH';
|
|
9
|
+
export const WEIGHTS_MODEL_INVALID='WEIGHTS_MODEL_INVALID';
|
|
10
|
+
|
|
11
|
+
export type {WeightInfluence,WorldTransform} from './setup-mesh.ts';
|
|
12
|
+
export {skinVertex} from './setup-mesh.ts';
|
|
13
|
+
export interface MeshWeights {
|
|
14
|
+
slot:string;skin:string;attachment:string;
|
|
15
|
+
/** Where the vertices were read: the attachment itself, or a linked mesh's source. */
|
|
16
|
+
source:{skin:string;slot:string;attachment:string};
|
|
17
|
+
weighted:boolean;
|
|
18
|
+
/** Per vertex, the influences in stored order. */
|
|
19
|
+
vertices:WeightInfluence[][];
|
|
20
|
+
/** Every bone that influences a vertex, in first-stored order. */
|
|
21
|
+
bones:string[];
|
|
22
|
+
/** The attachment's triangle indices, as stored. */
|
|
23
|
+
triangles:number[];
|
|
24
|
+
}
|
|
25
|
+
export type WeightsResult={ok:true;weights:MeshWeights}|{ok:false;code:string};
|
|
26
|
+
|
|
27
|
+
/** The weights block's refusals over the shared setup-space reader (`src/setup-mesh.ts`). */
|
|
28
|
+
export const WEIGHTS_CODES:SetupMeshCodes=Object.freeze({unavailable:WEIGHTS_MODEL_UNAVAILABLE,select:WEIGHTS_SELECT_SLOT,noMesh:WEIGHTS_NO_MESH,invalid:WEIGHTS_MODEL_INVALID,subject:'weights are',selectText:'select one mesh slot in the parts list'});
|
|
29
|
+
|
|
30
|
+
/** The compiled model document, or the refusal that names why there is none (bar 2: a package without one). */
|
|
31
|
+
export const weightsModel=(model:unknown)=>setupModel(model,WEIGHTS_CODES);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The setup-pose weights of `slot`'s mesh attachment `active` (the identity the held pose resolved; null takes the
|
|
35
|
+
* slot's mesh named like the slot, else its only one). Weights are returned exactly as stored.
|
|
36
|
+
*/
|
|
37
|
+
export function meshWeights(model:unknown,slot:string|null,active:{skin:string;attachment:string}|null):WeightsResult {
|
|
38
|
+
const read=readSetupMesh(model,slot,active,WEIGHTS_CODES);if(!read.ok)return read;
|
|
39
|
+
const {slot:s,skin,attachment,source,weighted,vertices,bones,triangles}=read.mesh;
|
|
40
|
+
return {ok:true,weights:{slot:s,skin,attachment,source,weighted,vertices,bones,triangles}};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `bone`'s stored weight on one vertex (0 when it does not influence it; a bone stored twice is summed). */
|
|
44
|
+
export function weightOf(influences:readonly Pick<WeightInfluence,'bone'|'weight'>[],bone:string):number {
|
|
45
|
+
let found:number|null=null;for(const i of influences)if(i.bone===bone)found=found===null?i.weight:found+i.weight;return found??0;
|
|
46
|
+
}
|
|
47
|
+
/** The dominant influence of one vertex: the largest stored weight (the first stored on a tie). */
|
|
48
|
+
export function dominantInfluence<T extends Pick<WeightInfluence,'bone'|'weight'>>(influences:readonly T[]):T|null {
|
|
49
|
+
let best:T|null=null;for(const i of influences)if(!best||i.weight>best.weight)best=i;return best;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `#rrggbb` (or `#rgb`) → [r, g, b]; null for anything else. */
|
|
53
|
+
export function parseHex(value:string):[number,number,number]|null {
|
|
54
|
+
const m=/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());if(!m)return null;
|
|
55
|
+
const h=m[1]!.length===3?[...m[1]!].map(c=>c+c).join(''):m[1]!;
|
|
56
|
+
return [0,2,4].map(i=>parseInt(h.slice(i,i+2),16)) as [number,number,number];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The ramp colour at `t` (clamped to 0..1) over evenly spaced stops (the theme's weight tokens, low → high), as
|
|
60
|
+
* `rgb(r, g, b)`. A stop that is not a hex colour makes the ramp unavailable (null) rather than guessing one.
|
|
61
|
+
*/
|
|
62
|
+
export function rampColor(stops:readonly string[],t:number):string|null {
|
|
63
|
+
const rgb=stops.map(parseHex);if(rgb.length<2||rgb.some(c=>!c)||!Number.isFinite(t))return null;
|
|
64
|
+
const x=Math.max(0,Math.min(1,t))*(rgb.length-1),i=Math.min(rgb.length-2,Math.floor(x)),f=x-i,a=rgb[i]!,b=rgb[i+1]!;
|
|
65
|
+
return `rgb(${[0,1,2].map(k=>Math.round(a[k]!+(b[k]!-a[k]!)*f)).join(', ')})`;
|
|
66
|
+
}
|
|
67
|
+
/** A stored weight as text: the number as stored, never rounded (an integer keeps one decimal). */
|
|
68
|
+
export const weightText=(w:number)=>Number.isInteger(w)?w.toFixed(1):String(w);
|
|
69
|
+
const rgbOf=(value:string):[number,number,number]|null=>{const hex=parseHex(value);if(hex)return hex;const m=/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(value.trim());return m?[Number(m[1]),Number(m[2]),Number(m[3])]:null;};
|
|
70
|
+
/** The mean of hex or `rgb()` colours as `rgb(r, g, b)`; null when one is neither. */
|
|
71
|
+
export function meanColor(colors:readonly string[]):string|null {
|
|
72
|
+
const rgb=colors.map(rgbOf);if(!rgb.length||rgb.some(c=>!c))return null;
|
|
73
|
+
return `rgb(${[0,1,2].map(k=>Math.round(rgb.reduce((n,c)=>n+c![k]!,0)/rgb.length)).join(', ')})`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup · Bones (#109), page side: the producer config's bones drawn on the stage at their `at`, chain `points` and
|
|
3
|
+
* `tip` (rig px through the crop → world mapping the chest and weights views use), draggable in whole rig px; per-anchor
|
|
4
|
+
* x / y fields for the picked bone; the pending-ops list with per-op revert; Check (`POST /api/bones-check`:
|
|
5
|
+
* `propose --from-config` LINT and coverage lines, and `compare`'s origin distances against the handoff config); Apply
|
|
6
|
+
* (the generic patch runner's whole-rig `rig --config` → check, which needs the reproduced baseline) and Reset. The
|
|
7
|
+
* source draft keeps the pending ops (`#bones-pending`); the generated fields carry no ids.
|
|
8
|
+
*/
|
|
9
|
+
import {anchorAt,boneAnchors,boneOps,bonesApplyEnabled,configBones,dragAnchor,draftEdits,draftText,movedRows,setAnchor,type BoneAnchor,type BoneEdit,type ConfigBone,type RigPoint} from '../src/bone-anchors.ts';
|
|
10
|
+
import type {PatchOp} from '../src/config-patch.ts';
|
|
11
|
+
import type {CropWorldMapping} from '../src/chest-regions.ts';
|
|
12
|
+
import type {BonesCheckResult} from '../src/bones-check.ts';
|
|
13
|
+
import {canvasToCrop} from './chest-editor.ts';
|
|
14
|
+
import type {OverlayAnchors,OverlayPoint,OverlayProject} from './rig-overlays.ts';
|
|
15
|
+
import {textEntryFocused} from './view-controls.ts';
|
|
16
|
+
|
|
17
|
+
export type BonesCheckView=BonesCheckResult|{status:'refused';error:string};
|
|
18
|
+
export interface BonesPanelDeps {
|
|
19
|
+
/** Receives capture-phase pointer events before the viewport's pan. */
|
|
20
|
+
pane:HTMLElement;
|
|
21
|
+
/** The overlay canvas: pointer coordinates are local to it, as the projection's are. */
|
|
22
|
+
canvas:HTMLElement;
|
|
23
|
+
project:()=>OverlayProject|null;
|
|
24
|
+
mapping:()=>CropWorldMapping;
|
|
25
|
+
inspector:()=>string|undefined;
|
|
26
|
+
/** Playback stops while bones are edited on the stage. */
|
|
27
|
+
pause:()=>void;
|
|
28
|
+
/** Redraw the overlay. */
|
|
29
|
+
changed:()=>void;
|
|
30
|
+
/** The draft record changed (the panel already wrote `#bones-pending`). */
|
|
31
|
+
saved:()=>void;
|
|
32
|
+
check:(ops:PatchOp[])=>Promise<BonesCheckView>;
|
|
33
|
+
apply:(ops:PatchOp[])=>Promise<void>;
|
|
34
|
+
reset:()=>Promise<void>;
|
|
35
|
+
token:(name:string)=>string;
|
|
36
|
+
}
|
|
37
|
+
export interface BonesPanel {
|
|
38
|
+
load(list:unknown[]|null,reason:string|null):void;
|
|
39
|
+
anchors():BoneAnchor[];edits():BoneEdit[];
|
|
40
|
+
set(path:string,value:RigPoint):void;
|
|
41
|
+
select(name:string|null):void;
|
|
42
|
+
active():boolean;setActive(on:boolean):void;
|
|
43
|
+
/** Every drawn anchor's client (viewport) position, for automation that drags with a real pointer. */
|
|
44
|
+
handles():{path:string;label:string;x:number;y:number}[];
|
|
45
|
+
overlay():OverlayAnchors|null;
|
|
46
|
+
check():Promise<BonesCheckView|null>;
|
|
47
|
+
checked(view:BonesCheckView|null):void;
|
|
48
|
+
applied(ops:readonly PatchOp[]|null):void;
|
|
49
|
+
status(text:string,error:boolean):void;
|
|
50
|
+
busy(value:boolean):void;
|
|
51
|
+
restore():void;clear():void;sync():void;dispose():void;
|
|
52
|
+
}
|
|
53
|
+
/** Overlay colours: theme token names. */
|
|
54
|
+
export const boneTokens={line:'--rv-bone-line',origin:'--rv-bone-origin',point:'--rv-bone-point',tip:'--rv-bone-tip',pending:'--rv-bone-pending',selected:'--rv-bone-selected',ink:'--rv-bone-ink',labelBg:'--rv-bone-label-bg'} as const;
|
|
55
|
+
/** The anchor under the pointer (canvas px), within 9 px; the nearest wins. */
|
|
56
|
+
export function hitAnchor(points:readonly {path:string;at:OverlayPoint}[],p:OverlayPoint):string|null {
|
|
57
|
+
let best:string|null=null,distance=9;
|
|
58
|
+
for(const a of points){const d=Math.hypot(a.at.x-p.x,a.at.y-p.y);if(d<=distance){best=a.path;distance=d;}}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
/** The lines the overlay draws: each chain point to the next and to its tip; a single bone's origin to its tip. */
|
|
62
|
+
export function anchorLines(bones:readonly ConfigBone[],at:(path:string)=>RigPoint|null):{from:RigPoint;to:RigPoint}[] {
|
|
63
|
+
const out:{from:RigPoint;to:RigPoint}[]=[];
|
|
64
|
+
for(const b of bones){
|
|
65
|
+
const base=`/bones/${b.index}`,chain=[...(b.at?[`${base}/at`]:[]),...b.points.map((_,k)=>`${base}/points/${k}`),...(b.tip?[`${base}/tip`]:[])].map(at).filter((p):p is RigPoint=>!!p);
|
|
66
|
+
for(let i=1;i<chain.length;i++)out.push({from:chain[i-1]!,to:chain[i]!});
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const fmt=(p:RigPoint)=>`[${p[0]}, ${p[1]}]`;
|
|
71
|
+
const node=<T extends HTMLElement>(id:string)=>{const found=document.getElementById(id);if(!found)throw new Error(`Missing UI element: ${id}`);return found as T;};
|
|
72
|
+
const make=<K extends keyof HTMLElementTagNameMap>(tag:K,props:Record<string,unknown>={},...children:(Node|string)[]):HTMLElementTagNameMap[K]=>{const item=document.createElement(tag);Object.assign(item,props);item.append(...children);return item;};
|
|
73
|
+
|
|
74
|
+
export function bindBonesPanel(deps:BonesPanelDeps):BonesPanel {
|
|
75
|
+
const state=node('bones-panel-state'),toggle=node<HTMLButtonElement>('bones-edit'),picker=node<HTMLSelectElement>('bones-select'),editor=node('bones-anchor-editor'),pending=node('bones-pending-list');
|
|
76
|
+
const checkButton=node<HTMLButtonElement>('bones-check'),applyButton=node<HTMLButtonElement>('bones-apply'),resetButton=node<HTMLButtonElement>('bones-reset-build'),statusOut=node('bones-status'),lintArea=node('bones-lint'),compareArea=node('bones-compare'),draft=node<HTMLTextAreaElement>('bones-pending');
|
|
77
|
+
let bones:ConfigBone[]=[],anchors:BoneAnchor[]=[],edits=new Map<string,BoneEdit>(),selected:string|null=null,hover:string|null=null,appliedOps:string[]=[],lint:string[]=[],busy=false,active=false,space=false;
|
|
78
|
+
let drag:{path:string;pointer:number;start:RigPoint;from:RigPoint}|null=null;
|
|
79
|
+
|
|
80
|
+
function panelStatus(text:string,error:boolean){statusOut.textContent=text;statusOut.title=text;statusOut.classList.toggle('error',error);}
|
|
81
|
+
const save=()=>{draft.value=draftText(edits);draft.dispatchEvent(new Event('input',{bubbles:true}));deps.saved();};
|
|
82
|
+
/** Any change to the pending ops makes the last Check's lines stale, so they are cleared. */
|
|
83
|
+
const change=(next:Map<string,BoneEdit>,persist=true)=>{edits=next;showCheck(null);if(persist)save();render();deps.changed();};
|
|
84
|
+
const anchorOf=(path:string)=>anchors.find(a=>a.path===path);
|
|
85
|
+
/** Each anchor in canvas px, from its current value through the crop → world mapping and the camera projection. */
|
|
86
|
+
const projected=():{path:string;at:OverlayPoint}[]=>{
|
|
87
|
+
const project=deps.project(),mapping=deps.mapping();if(!project||!mapping.available)return [];
|
|
88
|
+
return anchors.map(a=>{const [x,y]=anchorAt(a,edits),w=mapping.toWorld(x,y);return {path:a.path,at:project(w.x,w.y)};});
|
|
89
|
+
};
|
|
90
|
+
const local=(event:PointerEvent):OverlayPoint=>{const rect=deps.canvas.getBoundingClientRect();return {x:event.clientX-rect.left,y:event.clientY-rect.top};};
|
|
91
|
+
const cropAt=(p:OverlayPoint):RigPoint|null=>{const project=deps.project(),mapping=deps.mapping();return project&&mapping.available?canvasToCrop(project,mapping.toWorld,p):null;};
|
|
92
|
+
|
|
93
|
+
function renderPicker(){
|
|
94
|
+
const keep=picker.value;
|
|
95
|
+
picker.replaceChildren(make('option',{value:'',textContent:bones.length?'Pick a bone (or click a handle)':'No bones in the config'}),...bones.map(b=>make('option',{value:b.name,textContent:`${b.name}${b.chain?` (chain, ${b.points.length} link${b.points.length===1?'':'s'})`:''} · bones[${b.index}]`})));
|
|
96
|
+
picker.value=selected&&bones.some(b=>b.name===selected)?selected:bones.some(b=>b.name===keep)?keep:'';
|
|
97
|
+
picker.disabled=busy||!bones.length;
|
|
98
|
+
}
|
|
99
|
+
function renderEditor(){
|
|
100
|
+
const bone=bones.find(b=>b.name===selected);
|
|
101
|
+
if(!bone){editor.replaceChildren(make('p',{className:'hint',textContent:'Pick a bone to edit its anchors by number, or drag a handle on the stage.'}));return;}
|
|
102
|
+
const rows=anchors.filter(a=>a.index===bone.index).map(a=>{
|
|
103
|
+
const now=anchorAt(a,edits),field=(axis:0|1)=>{const input=make('input',{type:'number',step:'1',value:String(now[axis]),disabled:busy,inputMode:'numeric'});input.setAttribute('aria-label',`${a.label} ${axis?'y':'x'}`);
|
|
104
|
+
input.onchange=()=>{const value=Number(input.value);if(!input.value.trim()||!Number.isFinite(value)){panelStatus(`BONES_VALUE_INVALID: ${a.label} ${axis?'y':'x'} must be a number`,true);return;}const next:RigPoint=[...anchorAt(a,edits)];next[axis]=value;change(setAnchor(edits,a,next));};return input;};
|
|
105
|
+
return make('div',{className:edits.has(a.path)?'bones-anchor pending':'bones-anchor'},make('code',{textContent:a.path}),make('span',{textContent:a.label}),field(0),field(1),make('output',{textContent:edits.has(a.path)?`was ${fmt(a.value)}`:''}));
|
|
106
|
+
});
|
|
107
|
+
editor.replaceChildren(make('p',{className:'physics-editor-title'},make('strong',{textContent:bone.name}),` · ${bone.chain?'chain':'bone'} · parent ${bone.parent??'—'} · bones[${bone.index}]`),...rows);
|
|
108
|
+
}
|
|
109
|
+
function renderPending(){
|
|
110
|
+
const items=[...edits.values()];
|
|
111
|
+
pending.replaceChildren(...items.map(e=>{
|
|
112
|
+
const revert=make('button',{type:'button',textContent:'Revert',disabled:busy});revert.setAttribute('aria-label',`Revert ${e.path}`);
|
|
113
|
+
revert.onclick=()=>{const next=new Map(edits);next.delete(e.path);change(next);};
|
|
114
|
+
const applied=appliedOps.includes(JSON.stringify({path:e.path,value:e.value}));
|
|
115
|
+
const delta=`Δ (${e.value[0]-e.from[0]>=0?'+':''}${Number((e.value[0]-e.from[0]).toFixed(6))}, ${e.value[1]-e.from[1]>=0?'+':''}${Number((e.value[1]-e.from[1]).toFixed(6))})`;
|
|
116
|
+
return make('li',{className:applied?'applied':''},make('code',{textContent:`replace ${e.path}`}),` ${fmt(e.from)} → ${fmt(e.value)} ${delta} · ${e.name}${applied?' · applied':''}`,revert);
|
|
117
|
+
}));
|
|
118
|
+
if(!items.length)pending.append(make('li',{className:'hint',textContent:'No pending ops.'}));
|
|
119
|
+
applyButton.disabled=!bonesApplyEnabled({busy,pending:items.length,lint});checkButton.disabled=busy||!items.length;resetButton.disabled=busy;
|
|
120
|
+
toggle.disabled=!anchors.length;toggle.setAttribute('aria-pressed',String(active));
|
|
121
|
+
}
|
|
122
|
+
function render(){renderPicker();renderEditor();renderPending();}
|
|
123
|
+
let skipped:string[]=[],loaded=false;
|
|
124
|
+
/** The block's state line: the config's bones and anchors, what was not read, and whether handles can be drawn. */
|
|
125
|
+
function renderState(){
|
|
126
|
+
if(!loaded)return;const mapping=deps.mapping();
|
|
127
|
+
state.classList.toggle('error',!mapping.available||!!skipped.length);
|
|
128
|
+
state.textContent=`${bones.length} config bones · ${anchors.length} anchors (origins, chain points, tips) in rig px${skipped.length?` · not drawn: ${skipped.join('; ')}`:''}${mapping.available?'':` · ${mapping.reason}: no handles are drawn`}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function showCheck(view:BonesCheckView|null){
|
|
132
|
+
lint=[];
|
|
133
|
+
if(!view){lintArea.replaceChildren();compareArea.replaceChildren();return;}
|
|
134
|
+
if(view.status==='refused'){lintArea.replaceChildren(make('code',{className:'error',textContent:view.error,title:view.error}));compareArea.replaceChildren();return;}
|
|
135
|
+
const p=view.propose;lint=[...p.lint];
|
|
136
|
+
const head=make('p',{className:'eyebrow',textContent:`PROPOSE --FROM-CONFIG${p.exitCode===null?'':` · EXIT ${p.exitCode}`} · LINT BLOCKS NOTHING`});
|
|
137
|
+
const lines=[...p.lint.map(text=>make('code',{className:'lint',textContent:text,title:text})),make('code',{className:p.status==='ran'&&p.line===p.summary?'':'error',textContent:p.line,title:p.line})];
|
|
138
|
+
const coverage=p.coverage.length?[make('details',{className:'bones-coverage'},make('summary',{textContent:p.coverage.at(-1)!}),...p.coverage.slice(0,-1).map(text=>make('code',{textContent:text,title:text})))]:[];
|
|
139
|
+
lintArea.replaceChildren(head,...lines,...coverage);
|
|
140
|
+
const c=view.compare,moved=movedRows(c.origins);
|
|
141
|
+
compareArea.replaceChildren(make('p',{className:'eyebrow',textContent:'COMPARE --LEFT <HANDOFF CONFIG> --RIGHT <PATCHED>'}),
|
|
142
|
+
...(c.exitCode===0?[...moved.map(r=>make('code',{textContent:r.text,title:r.text})),...(moved.length?[]:[make('code',{textContent:'no bone moved'})]),...c.summary.filter(l=>/^(origin|tip):/.test(l)).map(text=>make('code',{textContent:text,title:text}))]:[make('code',{className:'error',textContent:c.line,title:c.line})]));
|
|
143
|
+
renderPending();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const setActive=(on:boolean)=>{
|
|
147
|
+
active=on&&anchors.length>0;if(active)deps.pause();
|
|
148
|
+
if(!active){drag=null;hover=null;delete deps.pane.dataset.bonesHover;}
|
|
149
|
+
render();deps.changed();
|
|
150
|
+
};
|
|
151
|
+
toggle.onclick=()=>setActive(!active);
|
|
152
|
+
picker.onchange=()=>{selected=picker.value||null;render();deps.changed();};
|
|
153
|
+
checkButton.onclick=()=>{void panel.check();};
|
|
154
|
+
applyButton.onclick=()=>{if(!edits.size||busy)return;void deps.apply(boneOps(edits));};
|
|
155
|
+
resetButton.onclick=()=>{if(busy)return;void deps.reset();};
|
|
156
|
+
const keyDown=(event:KeyboardEvent)=>{if(event.code==='Space'&&!textEntryFocused(event.target))space=true;};
|
|
157
|
+
const keyUp=(event:KeyboardEvent)=>{if(event.code==='Space')space=false;};
|
|
158
|
+
const blur=()=>{space=false;};
|
|
159
|
+
const down=(event:PointerEvent)=>{
|
|
160
|
+
if(!active||busy||space||!event.isPrimary||event.button!==0)return;
|
|
161
|
+
const target=event.target;if(target instanceof Element&&target.closest('button,input,select,textarea,a'))return;
|
|
162
|
+
const p=local(event),path=hitAnchor(projected(),p),anchor=path?anchorOf(path):undefined,start=cropAt(p);if(!anchor||!start)return;
|
|
163
|
+
// A handle drag edits values; it never reaches the viewport's pan.
|
|
164
|
+
event.stopPropagation();event.preventDefault();
|
|
165
|
+
drag={path:anchor.path,pointer:event.pointerId,start,from:anchorAt(anchor,edits)};selected=anchor.name;
|
|
166
|
+
deps.pane.setPointerCapture(event.pointerId);deps.pane.dataset.bonesHover='move';render();deps.changed();
|
|
167
|
+
};
|
|
168
|
+
const move=(event:PointerEvent)=>{
|
|
169
|
+
if(drag&&event.pointerId===drag.pointer){
|
|
170
|
+
event.stopPropagation();const now=cropAt(local(event)),anchor=anchorOf(drag.path);if(!now||!anchor)return;
|
|
171
|
+
const next=dragAnchor(drag.from,drag.start,now),current=anchorAt(anchor,edits);
|
|
172
|
+
if(next[0]!==current[0]||next[1]!==current[1])change(setAnchor(edits,anchor,next),false);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if(!active||space)return;
|
|
176
|
+
const path=hitAnchor(projected(),local(event));if(path!==hover){hover=path;if(path)deps.pane.dataset.bonesHover='move';else delete deps.pane.dataset.bonesHover;deps.changed();}
|
|
177
|
+
};
|
|
178
|
+
const end=(event:PointerEvent)=>{
|
|
179
|
+
if(!drag||event.pointerId!==drag.pointer)return;
|
|
180
|
+
drag=null;delete deps.pane.dataset.bonesHover;if(deps.pane.hasPointerCapture(event.pointerId))deps.pane.releasePointerCapture(event.pointerId);
|
|
181
|
+
save();render();deps.changed();
|
|
182
|
+
};
|
|
183
|
+
deps.pane.addEventListener('pointerdown',down,true);deps.pane.addEventListener('pointermove',move,true);
|
|
184
|
+
for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)deps.pane.addEventListener(type,end,true);
|
|
185
|
+
document.addEventListener('keydown',keyDown,true);window.addEventListener('keyup',keyUp,true);window.addEventListener('blur',blur);
|
|
186
|
+
// Leaving Setup ends the stage mode: follow the header switch's pressed state, as the chest editor does.
|
|
187
|
+
const observer=new MutationObserver(()=>panel.sync());
|
|
188
|
+
for(const selector of document.querySelectorAll('[data-inspector]'))observer.observe(selector,{attributes:true,attributeFilter:['aria-pressed','aria-selected']});
|
|
189
|
+
|
|
190
|
+
const panel:BonesPanel={
|
|
191
|
+
load(list,reason){
|
|
192
|
+
if(list===null){bones=[];anchors=[];skipped=[];loaded=false;state.textContent=reason??'BONES_UNAVAILABLE: no producer handoff';state.classList.add('error');setActive(false);render();return;}
|
|
193
|
+
const read=configBones(list);bones=read.bones;anchors=boneAnchors(bones);skipped=read.skipped;loaded=true;
|
|
194
|
+
edits=draftEdits(draft.value,anchors);renderState();render();deps.changed();
|
|
195
|
+
},
|
|
196
|
+
anchors:()=>anchors.map(a=>({...a,value:[...a.value] as RigPoint})),
|
|
197
|
+
edits:()=>[...edits.values()].map(e=>({...e,from:[...e.from] as RigPoint,value:[...e.value] as RigPoint})),
|
|
198
|
+
set(path,value){const anchor=anchorOf(path);if(!anchor)throw new Error(`BONES_ANCHOR_UNKNOWN: ${path}`);change(setAnchor(edits,anchor,value));},
|
|
199
|
+
select(name){selected=name;render();deps.changed();},
|
|
200
|
+
active:()=>active,setActive,
|
|
201
|
+
handles(){const rect=deps.canvas.getBoundingClientRect();return projected().map(h=>({path:h.path,label:anchorOf(h.path)!.label,x:h.at.x+rect.left,y:h.at.y+rect.top}));},
|
|
202
|
+
overlay(){
|
|
203
|
+
if(!active||!anchors.length)return null;const mapping=deps.mapping();if(!mapping.available)return null;
|
|
204
|
+
const world=([x,y]:RigPoint)=>mapping.toWorld(x,y),current=new Map(anchors.map(a=>[a.path,anchorAt(a,edits)]));
|
|
205
|
+
const colors=Object.fromEntries(Object.entries(boneTokens).map(([k,t])=>[k,deps.token(t)])) as OverlayAnchors['colors'];
|
|
206
|
+
const focus=selected?bones.find(b=>b.name===selected)?.index:undefined;
|
|
207
|
+
return {lines:anchorLines(bones,path=>current.get(path)??null).map(l=>({from:world(l.from),to:world(l.to)})),
|
|
208
|
+
anchors:anchors.map(a=>({at:world(current.get(a.path)!),from:edits.has(a.path)?world(a.value):null,kind:a.kind,label:`${a.label} ${fmt(current.get(a.path)!)}`,selected:drag?.path===a.path||(focus===a.index&&a.kind==='at'),hover:hover===a.path})),colors};
|
|
209
|
+
},
|
|
210
|
+
async check(){
|
|
211
|
+
if(!edits.size||busy){panelStatus('BONES_NOTHING_PENDING: no pending ops',true);return null;}
|
|
212
|
+
busy=true;render();panelStatus(`Checking ${edits.size} op(s): propose --from-config and compare on a scratch copy of the patched config…`,false);
|
|
213
|
+
try{const view=await deps.check(boneOps(edits));showCheck(view);
|
|
214
|
+
if(view.status==='refused')panelStatus(view.error,true);
|
|
215
|
+
else{const p=view.propose,o=view.compare;panelStatus(`Checked · ${p.status==='ran'?`${p.lint.length} LINT line(s) (blocks nothing)`:'LINT not run'} · compare ${o.line} · ${(((p.wallMs??0)+o.wallMs)/1000).toFixed(1)} s`,p.status!=='ran');}
|
|
216
|
+
return view;}
|
|
217
|
+
finally{busy=false;render();}
|
|
218
|
+
},
|
|
219
|
+
checked:showCheck,
|
|
220
|
+
applied(ops){appliedOps=(ops??[]).map(o=>JSON.stringify({path:o.path,value:o.value}));renderPending();},
|
|
221
|
+
status:panelStatus,
|
|
222
|
+
busy(value){busy=value;render();},
|
|
223
|
+
restore(){edits=draftEdits(draft.value,anchors);render();deps.changed();},
|
|
224
|
+
clear(){change(new Map());showCheck(null);},
|
|
225
|
+
sync(){renderState();if(active&&deps.inspector()!=='rig')setActive(false);},
|
|
226
|
+
dispose(){observer.disconnect();deps.pane.removeEventListener('pointerdown',down,true);deps.pane.removeEventListener('pointermove',move,true);for(const type of ['pointerup','pointercancel','lostpointercapture'] as const)deps.pane.removeEventListener(type,end,true);document.removeEventListener('keydown',keyDown,true);window.removeEventListener('keyup',keyUp,true);window.removeEventListener('blur',blur);},
|
|
227
|
+
};
|
|
228
|
+
render();
|
|
229
|
+
return panel;
|
|
230
|
+
}
|