axstack 0.36.0 → 0.37.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.
Files changed (134) hide show
  1. package/README.md +10 -6
  2. package/bin/axstack.js +4 -18
  3. package/docs/getting-started.md +6 -6
  4. package/docs/host-operations.md +77 -5
  5. package/docs/installation.md +25 -43
  6. package/docs/workflows.md +12 -9
  7. package/package.json +1 -1
  8. package/profiles/presets/claude-only.json +3 -3
  9. package/profiles/presets/codex-only.json +3 -3
  10. package/profiles/presets/mixed.json +5 -4
  11. package/skills/axstack/references/role-roster.md +5 -3
  12. package/skills/axstack/references/ui-verification.md +6 -10
  13. package/skills/axstack/scripts/single-server.js +164 -0
  14. package/skills/axstack/systemd/axstack-single-server.service +8 -0
  15. package/skills/axstack/systemd/axstack-single-server.timer +10 -0
  16. package/skills/axstack-anatomy/ANATOMY.md +172 -0
  17. package/skills/axstack-anatomy/CHANGELOG.md +70 -0
  18. package/skills/axstack-anatomy/LICENSE +21 -0
  19. package/skills/axstack-anatomy/PROVENANCE.md +21 -0
  20. package/skills/axstack-anatomy/README.md +316 -0
  21. package/skills/axstack-anatomy/SKILL.md +74 -0
  22. package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
  23. package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
  24. package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
  25. package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
  26. package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
  27. package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
  28. package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
  29. package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
  30. package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
  31. package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
  32. package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
  33. package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
  34. package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
  35. package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
  36. package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
  37. package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
  38. package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
  39. package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
  40. package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
  41. package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
  42. package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
  43. package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
  44. package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
  45. package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
  46. package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
  47. package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
  48. package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
  49. package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
  50. package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
  51. package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
  52. package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
  53. package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
  54. package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
  55. package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
  56. package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
  57. package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
  58. package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
  59. package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
  60. package/skills/axstack-anatomy/kit/gl.mjs +184 -0
  61. package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
  62. package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
  63. package/skills/axstack-anatomy/kit/iso.css +106 -0
  64. package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
  65. package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
  66. package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
  67. package/skills/axstack-anatomy/kit/tube.mjs +306 -0
  68. package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
  69. package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
  70. package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
  71. package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
  72. package/skills/axstack-anatomy/references/3d.md +352 -0
  73. package/skills/axstack-anatomy/references/craft.md +325 -0
  74. package/skills/axstack-anatomy/references/kit.md +419 -0
  75. package/skills/axstack-anatomy/references/motion.md +165 -0
  76. package/skills/axstack-anatomy/references/react.md +42 -0
  77. package/skills/axstack-anatomy/references/verify.md +351 -0
  78. package/skills/axstack-anatomy/references/walkthrough.md +75 -0
  79. package/skills/axstack-anatomy/references/webgl.md +473 -0
  80. package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
  81. package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
  82. package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
  83. package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
  84. package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
  85. package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
  86. package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
  87. package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
  88. package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
  89. package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
  90. package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
  91. package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
  92. package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
  93. package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
  94. package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
  95. package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
  96. package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
  97. package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
  98. package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
  99. package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
  100. package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
  101. package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
  102. package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
  103. package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
  104. package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
  105. package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
  106. package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
  107. package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
  108. package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
  109. package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
  110. package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
  111. package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
  112. package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
  113. package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
  114. package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
  115. package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
  116. package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
  117. package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
  118. package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
  119. package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
  120. package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
  121. package/skills/axstack-anatomy/theme.js +56 -0
  122. package/skills/axstack-explain/SKILL.md +28 -15
  123. package/skills/axstack-explain/references/delivery.md +22 -0
  124. package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
  125. package/skills/axstack-explain/references/inline-pages.md +8 -8
  126. package/skills/axstack-explain/references/visual-qa.md +3 -5
  127. package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
  128. package/src/archify-legacy.js +70 -0
  129. package/src/capabilities.js +1 -29
  130. package/src/installer.js +11 -31
  131. package/skills/axstack-diagram/SKILL.md +0 -28
  132. package/skills/axstack-diagram/references/archify.md +0 -76
  133. package/src/archify-pin.js +0 -5
  134. package/src/archify.js +0 -151
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+ import * as A from "../kit/audit.mjs";
9
+ import { turning } from "../kit/turn-build.mjs";
10
+ const P = { origin: [0, 0], scale: 1, azimuth: 45, elevation: 30 };
11
+ const fixtures = [
12
+ ["rotated rectangle", '<rect width="10" height="10" transform="rotate(45)"/>'],
13
+ ["rotated circle", '<circle r="10" transform="rotate(45)"/>'],
14
+ ["rotated ellipse", '<ellipse rx="10" ry="2" transform="rotate(45)"/>'],
15
+ ["nested affine ellipse", '<g transform="translate(10 5) scale(2 .5)"><ellipse cx="2" cy="3" rx="7" ry="2" transform="rotate(30)"/></g>'],
16
+ ["horizontal skew", '<rect x="2" y="3" width="10" height="10" transform="skewX(45)"/>'],
17
+ ["vertical skew", '<rect x="2" y="3" width="10" height="10" transform="skewY(-30)"/>'],
18
+ ["ordinary rectangle", '<rect x="2" y="3" width="10" height="4"/>'],
19
+ ["ordinary circle", '<circle cx="2" cy="3" r="4"/>'],
20
+ ["ordinary transformed line", '<line x1="2" y1="3" x2="10" y2="7" transform="rotate(30)"/>'],
21
+ ];
22
+ let native;
23
+ const directory = await mkdtemp(join(tmpdir(), "anatomy-affine-bounds-"));
24
+ try {
25
+ const page = join(directory, "figure.html");
26
+ await writeFile(page, `<!doctype html><div class="iso-plate"><svg width="400" height="400">${fixtures.map(([,markup],i)=>`<g id="fixture-${i}">${markup}</g>`).join("")}</svg></div>`);
27
+ const expression = `Array.from({length:${fixtures.length}},(_,i)=>{const group=document.querySelector('#fixture-'+i),shape=group.querySelector('rect,circle,ellipse,line'),matrix=shape.getCTM(),points=[];if(shape.tagName==='rect'){const b=shape.getBBox();for(const [x,y] of [[b.x,b.y],[b.x+b.width,b.y],[b.x+b.width,b.y+b.height],[b.x,b.y+b.height]])points.push(new DOMPoint(x,y).matrixTransform(matrix));}else{const length=shape.getTotalLength();for(let j=0;j<=4096;j++)points.push(shape.getPointAtLength(length*j/4096).matrixTransform(matrix));}const b=group.getBBox();return{geometry:[Math.min(...points.map(p=>p.x)),Math.min(...points.map(p=>p.y)),Math.max(...points.map(p=>p.x)),Math.max(...points.map(p=>p.y))],groupBox:[b.x,b.y,b.x+b.width,b.y+b.height]}})`;
28
+ const result = await promisify(execFile)(process.execPath, ["scripts/drive.mjs", page, JSON.stringify([["eval",expression]])], { timeout: 30000 });
29
+ const observations = JSON.parse(result.stdout.match(/eval: (.*)/)[1]);
30
+ console.log("native SVG group bounds:", JSON.stringify(observations.map(v=>v.groupBox)));
31
+ native = observations.map(v=>v.geometry);
32
+ } finally { await rm(directory, { recursive: true, force: true }); }
33
+ fixtures.forEach(([name,markup],i)=>test(`public bounds and fixed parts match native SVG geometry for ${name}`,()=>{
34
+ const expected = native[i];
35
+ const observed = A.markupBox(markup);
36
+ for(let j=0;j<4;j++)assert.ok(Math.abs(observed[j]-expected[j])<.002, `${name} bound ${j}: ${observed[j]} != ${expected[j]}`);
37
+ const T = turning(P);T.layer("figure");
38
+ const part = T.fixed({name:"authored",svg:markup,anchor:[0,0,0],shapes:[A.ball([0,0,0],20)]});
39
+ for(let j=0;j<4;j++)assert.ok(Math.abs(part.spec.box[j]-(expected[j]+(j<2?-.5:.5)))<.002);
40
+ }));
41
+ test("coverage rejects a narrow stand-in for a rotated rectangle",()=>{
42
+ const cam=A.cameraBasis(P);
43
+ const end=cam.D.map(v=>v*Math.sqrt(200)/cam.k);
44
+ const standIn=A.tube([[0,0,0],end],.15);
45
+ const item={name:"plate",svg:fixtures[0][1],shapes:[standIn]};
46
+ const result=A.coverage([item],P,{pad:0});
47
+ assert.equal(result.length,1);assert.ok(result[0].share<.1);
48
+ assert.equal(A.coverage([{...item,shapes:[A.ball([0,0,0],20)]}],P,{pad:0}).length,0);
49
+ });
50
+ test("transformed rectangular clips keep every corner",()=>{
51
+ const clipped='<defs><clipPath id="cut"><rect width="10" height="10"/></clipPath></defs><g transform="rotate(45)" clip-path="url(#cut)"><rect x="-100" y="-100" width="200" height="200"/></g>';
52
+ const observed=A.markupBox(clipped),root=Math.sqrt(50);
53
+ for(const [i,v] of [-root,0,root,2*root].entries())assert.ok(Math.abs(observed[i]-v)<1e-8);
54
+ });
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+
5
+ function fixture(move = [1, 0, 0]) {
6
+ return [
7
+ A.box([0, 0, 0], [2, 2, 2], { name: "moving", move, piece: "moving" }),
8
+ A.box([1.7, 0.6, 0.6], [2.2, 1.4, 1.4], { name: "fixed" }),
9
+ ];
10
+ }
11
+ const inspect = (shapes, options = {}) => A.solidClearances(shapes, { state: 1, sunk: 0.05, ...options });
12
+ test("rebuilds the surface grid when the opted-in cell size changes", () => {
13
+ const shapes = fixture();
14
+ assert.equal(inspect(shapes, { cell: 0.1 }).length, 1);
15
+ const cached = inspect(shapes);
16
+ const fresh = inspect(fixture());
17
+ assert.equal(cached.length, 1);
18
+ assert.deepEqual(cached, fresh);
19
+ assert.ok(Math.abs(cached[0].clearance + 0.8) < 1e-12);
20
+ });
21
+ test("refines surface samples when the opted-in spacing changes", () => {
22
+ const shapes = fixture(); inspect(shapes, { spacing: 2 });
23
+ const refined = inspect(shapes, { spacing: 0.1 });
24
+ const fresh = inspect(fixture(), { spacing: 0.1 });
25
+ assert.deepEqual(refined, fresh);
26
+ assert.ok(Math.abs(refined[0].clearance + 0.8) < 1e-12);
27
+ });
28
+ test("preserves repeated default audits of the same moving overlap", () => {
29
+ const shapes = fixture(); const first = inspect(shapes);
30
+ assert.equal(first.length, 1); assert.deepEqual(inspect(shapes), first);
31
+ });
32
+ test("keeps a separated motion clear across sampling options", () => {
33
+ const shapes = fixture([-1, 0, 0]);
34
+ assert.deepEqual(inspect(shapes, { cell: 0.1 }), []);
35
+ assert.deepEqual(inspect(shapes), []);
36
+ assert.deepEqual(inspect(shapes, { spacing: 0.1 }), []);
37
+ });
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+ const route = (x, r, name) => A.tube([[x, 0, 0], [x, 0, 200]], r, { name, owner: name });
5
+ const run = (tubes, solids, options = {}) => A.clearances(tubes, solids, { spacing: 10, mounts: new Map(), ...options });
6
+ test("large pipe detects the required clearance to a real solid", () => {
7
+ const tube = route(0,20,"pipe"),solid = A.ball([29,0,100],1,{name:"solid"});
8
+ assert.equal(A.sdf(solid,[20,0,100]),8);
9
+ const result = run([tube],[solid]);
10
+ assert.equal(result.length,1);assert.equal(result[0].kind,"close");assert.equal(result[0].need,10);assert.ok(Math.abs(result[0].clearance-8)<1e-8);
11
+ });
12
+ test("large parallel pipes detect required clearance in either order", () => {
13
+ const tubes = [route(0,20,"first"),route(48,20,"second")];
14
+ for(const input of [tubes,[...tubes].reverse()]){
15
+ const result = run(input,[]);assert.equal(result.length,1);assert.equal(result[0].need,10);assert.equal(result[0].clearance,8);
16
+ }
17
+ });
18
+ test("explicit least clearance also controls both broad phases", () => {
19
+ const tube=route(0,1,"pipe"),solid=A.ball([10,0,100],1,{name:"solid"});
20
+ assert.equal(run([tube],[solid],{least:10})[0]?.clearance,8);
21
+ assert.equal(run([tube,route(10,1,"other")],[],{least:10})[0]?.clearance,8);
22
+ });
23
+ test("posed pipes retain their authored required clearance", () => {
24
+ const tube={...route(0,20,"pipe"),move:[5,0,0],ride:"pipe"};
25
+ const solid=A.ball([34,0,100],1,{name:"solid"});
26
+ const result=run([tube],[solid],{state:{shares:{pipe:1},label:"apart"}});
27
+ assert.equal(result.length,1);assert.equal(result[0].clearance,8);assert.equal(result[0].state,"apart");
28
+ });
29
+ test("healthy gaps, explicit smaller clearance and touch exemptions remain accepted", () => {
30
+ const tube=route(0,20,"pipe");
31
+ assert.equal(run([tube],[A.ball([32,0,100],1,{name:"solid"})]).length,0);
32
+ assert.equal(run([tube],[A.ball([29,0,100],1,{name:"solid"})],{ratio:.1}).length,0);
33
+ assert.equal(run([{...tube,touch:["solid"]}],[A.ball([29,0,100],1,{name:"solid"})]).length,0);
34
+ assert.equal(run([tube,route(51,20,"other")],[]).length,0);
35
+ });
36
+ test("existing small penetrating and separated pipes keep their results", () => {
37
+ assert.equal(run([route(0,1,"first"),route(1.5,1,"second")],[])[0]?.kind,"through");
38
+ assert.equal(run([route(0,1,"first"),route(4,1,"second")],[]).length,0);
39
+ });
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+ import * as A from "../kit/audit.mjs";
9
+ import { turning } from "../kit/turn-build.mjs";
10
+ const small='<rect x="2" y="2" width="2" height="2"/>';
11
+ const fixtures=[
12
+ ["fully disjoint primitive",'<defs><clipPath id="cut"><rect width="10" height="10"/></clipPath></defs><rect x="20" y="20" width="10" height="10" clip-path="url(#cut)"/>'+small],
13
+ ["nested disjoint clips",'<defs><clipPath id="one"><rect width="10" height="10"/></clipPath><clipPath id="two"><rect x="20" y="20" width="10" height="10"/></clipPath></defs><g clip-path="url(#one)"><g clip-path="url(#two)"><rect width="64" height="64"/></g></g>'+small],
14
+ ["zero area clip",'<defs><clipPath id="cut"><rect width="0" height="10"/></clipPath></defs><rect width="64" height="64" clip-path="url(#cut)"/>'+small],
15
+ ["empty declared clip",'<defs><clipPath id="cut"/></defs><rect width="64" height="64" clip-path="url(#cut)"/>'+small],
16
+ ["ordinary intersecting clip",'<defs><clipPath id="cut"><rect x="2" y="2" width="4" height="4"/></clipPath></defs><rect width="64" height="64" clip-path="url(#cut)"/>'],
17
+ ["ordinary visible primitives",small+'<rect x="10" y="10" width="4" height="4"/>'],
18
+ ["fully invisible clipped drawing",'<defs><clipPath id="cut"><rect width="10" height="10"/></clipPath></defs><rect x="20" y="20" width="10" height="10" clip-path="url(#cut)"/>'],
19
+ ];
20
+ let pixels;const directory=await mkdtemp(join(tmpdir(),"anatomy-empty-clipping-"));
21
+ try{
22
+ const page=join(directory,"figure.html");await writeFile(page,'<!doctype html><div class="iso-plate"></div>');
23
+ const expression=`(async()=>{const fixtures=${JSON.stringify(fixtures.map(([,m])=>m))};const result=[];for(const m of fixtures){const svg='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'+m+'</svg>';const image=new Image();await new Promise((resolve,reject)=>{image.onload=resolve;image.onerror=reject;image.src='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svg)});const c=document.createElement('canvas');c.width=c.height=64;const ctx=c.getContext('2d');ctx.drawImage(image,0,0);const data=ctx.getImageData(0,0,64,64).data;let box=null;for(let y=0;y<64;y++)for(let x=0;x<64;x++)if(data[(y*64+x)*4+3])box=box?[Math.min(box[0],x),Math.min(box[1],y),Math.max(box[2],x+1),Math.max(box[3],y+1)]:[x,y,x+1,y+1];result.push(box);}return result;})()`;
24
+ const result=await promisify(execFile)(process.execPath,["scripts/drive.mjs",page,JSON.stringify([["eval",expression]])],{timeout:30000});pixels=JSON.parse(result.stdout.match(/eval: (.*)/)[1]);assert.equal(pixels.length,fixtures.length);
25
+ }finally{await rm(directory,{recursive:true,force:true});}
26
+ const P={origin:[0,0],scale:1,azimuth:45,elevation:30};
27
+ fixtures.forEach(([name,markup],i)=>test(`public clipped bounds match actual native SVG pixels for ${name}`,()=>{
28
+ assert.deepEqual(A.markupBox(markup),pixels[i]);
29
+ if(pixels[i]){const T=turning(P);T.layer("figure");const part=T.fixed({name:"fixed",svg:markup,anchor:[0,0,0],shapes:[A.ball([0,0,0],50)]});assert.deepEqual(part.spec.box,pixels[i].map((v,j)=>v+(j<2?-.5:.5)));}
30
+ }));
31
+ test("coverage accepts the true visible plane without an invisible clipped outlier",()=>{
32
+ const cam=A.cameraBasis(P),F={o:[0,0,0],a:cam.V,u:cam.R,v:cam.D};
33
+ const shape=A.prism(F,[[2,2],[4,2],[4,4],[2,4]].map(p=>p.map(v=>v/cam.k)),0,.01);
34
+ assert.deepEqual(A.coverage([{name:"plane",svg:fixtures[0][1],shapes:[shape]}],P),[]);
35
+ });
36
+ test("clipped visible line bounds preserve their degenerate axis",()=>{
37
+ assert.deepEqual(A.markupBox('<defs><clipPath id="cut"><rect width="10" height="10"/></clipPath></defs><line x1="4" y1="2" x2="4" y2="8" clip-path="url(#cut)"/>'),[4,2,4,8]);
38
+ });
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+ import * as G from "../kit/lathe.mjs";
5
+ const P = { origin: [0, 0], scale: 1, azimuth: 0, elevation: 0 };
6
+ const cam = A.cameraBasis(P);
7
+ const at = (screen, depth) => G.add3(G.mul3(cam.R, screen), G.mul3(cam.V, depth));
8
+ const inspect = (items) => A.overlapsOf(items, P, { step: 0.25, tolerance: 0.01, least: 0.01 });
9
+ const chain = (count) => Array.from({ length: count }, (_, i) => ({ name: `ball${i}`, key: 0, shapes: [A.ball(at(i * 1.5, -i * 0.5), 1)] }));
10
+ const assertRules = (overlaps, solved) => {
11
+ for (const pair of overlaps.pairs) {
12
+ const front = pair.front[0].area >= overlaps.least ? pair.a : pair.b;
13
+ const back = front === pair.a ? pair.b : pair.a;
14
+ assert.ok(solved.keys[front] - solved.keys[back] >= 0.02 - 1e-9);
15
+ }
16
+ };
17
+ test("a 410-part acyclic drawn overlap chain converges without losing constraints", () => {
18
+ const items = chain(410); const overlaps = inspect(items);
19
+ assert.equal(overlaps.pairs.length, 409);
20
+ assert.ok(overlaps.pairs.every((p) => p.front[0].area >= overlaps.least && p.front[1].area < overlaps.least));
21
+ const solved = A.orderKeys(items, overlaps);
22
+ assert.deepEqual(solved.dropped, []);
23
+ assert.deepEqual(solved.cycles, []);
24
+ assertRules(overlaps, solved);
25
+ });
26
+ test("an explicit final-round budget does not invent a victim once all rules are satisfied", () => {
27
+ const items = chain(10); const overlaps = inspect(items);
28
+ const solved = A.orderKeys(items, overlaps, { rounds: 9 });
29
+ assert.deepEqual(solved.dropped, []);
30
+ assertRules(overlaps, solved);
31
+ });
32
+ test("small acyclic figures preserve the existing zero-drop solution", () => {
33
+ const items = chain(10); const overlaps = inspect(items); const solved = A.orderKeys(items, overlaps);
34
+ assert.deepEqual(solved.dropped, []);
35
+ assertRules(overlaps, solved);
36
+ assert.ok(Math.abs(solved.keys[0] - 0.18) < 1e-12);
37
+ });
38
+ test("an actual three-item geometric cycle still drops its weakest conflicting rule", () => {
39
+ const items = [
40
+ { name: "a", key: 0, shapes: [A.ball(at(0, 3), 1), A.ball(at(20, 0), 1)] },
41
+ { name: "b", key: 0, shapes: [A.ball(at(0, 0), 1), A.ball(at(10, 3), 1)] },
42
+ { name: "c", key: 0, shapes: [A.ball(at(10, 0), 1), A.ball(at(20, 3), 1)] },
43
+ ];
44
+ const overlaps = inspect(items); assert.equal(overlaps.pairs.length, 3);
45
+ const solved = A.orderKeys(items, overlaps);
46
+ assert.equal(solved.rules, 3);
47
+ assert.equal(solved.dropped.length, 1);
48
+ assert.equal(solved.cycles.length, 0);
49
+ for (const pair of overlaps.pairs) {
50
+ const front = pair.front[0].area >= overlaps.least ? pair.a : pair.b;
51
+ const back = front === pair.a ? pair.b : pair.a;
52
+ if (solved.dropped.some((rule) => rule.front === front && rule.back === back)) continue;
53
+ assert.ok(solved.keys[front] - solved.keys[back] >= 0.02 - 1e-9);
54
+ }
55
+ });
@@ -0,0 +1,26 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+ import { markupBox } from "../kit/audit.mjs";
9
+
10
+ const execute = promisify(execFile);
11
+ for (const [name, markup] of [
12
+ ["relative move after closing a subpath", '<path d="M10 10L20 10L20 20Z m5 0l2 0"/>'],
13
+ ["transformed relative move after closing a subpath", '<g transform="translate(5 3)"><path d="M10 10L20 10L20 20Z m5 0l2 0"/></g>'],
14
+ ["absolute move after closing a subpath", '<path d="M10 10L20 10L20 20Z M15 10L17 10"/>'],
15
+ ["open relative segments", '<path d="M10 10l10 0l0 10m5 0l2 0"/>'],
16
+ ]) test(`audit bounds match actual native SVG for ${name}`, async () => {
17
+ const directory = await mkdtemp(join(tmpdir(), "anatomy-audit-path-origin-"));
18
+ try {
19
+ const page = join(directory, "figure.html");
20
+ await writeFile(page, `<!doctype html><div class="iso-plate"><svg width="100" height="100" viewBox="0 0 100 100">${markup}</svg></div>`);
21
+ const actions = JSON.stringify([["eval", "(()=>{const b=document.querySelector('svg').getBBox();return [b.x,b.y,b.x+b.width,b.y+b.height]})()"]]);
22
+ const result = await execute(process.execPath, ["scripts/drive.mjs", page, actions], { timeout: 30000 });
23
+ const native = JSON.parse(result.stdout.match(/eval: (.*)/)[1]);
24
+ assert.deepEqual(markupBox(markup), native);
25
+ } finally { await rm(directory, { recursive: true, force: true }); }
26
+ });
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+ import * as G from "../kit/lathe.mjs";
5
+ const F = G.frameAlong([0, 0, 0], [0, 0, 1]);
6
+ const radius = (s) => 2 + Math.sin(2 * Math.PI * s / 10);
7
+ const slope = (s) => Math.cos(2 * Math.PI * s / 10) * 2 * Math.PI / 10;
8
+ const profile = [[0, 2], [10, 2]];
9
+ const radialAt = (points, s) => {
10
+ const index = points.findIndex((p) => p[0] >= s);
11
+ if (!index) return points[0][1];
12
+ const [a, ra] = points[index - 1]; const [b, rb] = points[index];
13
+ return ra + (rb - ra) * (s - a) / (b - a);
14
+ };
15
+ test("inflected analytic recording follows the actual band side geometry", () => {
16
+ const bands = G.bandsOf(profile, [], F, { origin: [0, 0], scale: 10 }, { radius, slope });
17
+ const recorded = A.traced(profile, { radius });
18
+ const actual = bands.sidesOf().points.flat();
19
+ const bulge = actual.filter((p) => Math.abs(p[2] - 2.5) < 1e-8);
20
+ assert.equal(bulge.length, 2);
21
+ for (const p of bulge) assert.ok(Math.abs(radialAt(recorded, p[2]) - Math.hypot(p[0], p[1])) <= 0.02);
22
+ });
23
+ test("analytic tracing keeps bulge points inside the recorded solid", () => {
24
+ assert.ok(A.sdf(A.solid(F, profile, { radius }), [0, 2.5, 2.5]) < 0);
25
+ });
26
+ test("analytic tracing keeps neck points outside the recorded solid", () => {
27
+ assert.ok(A.sdf(A.solid(F, profile, { radius }), [0, 1.5, 7.5]) > 0);
28
+ });
29
+ test("smooth Hermite inflection retains its quarter-span curvature", () => {
30
+ const knots = [[0, 2, 1], [1, 2, 1], [2, 3, 1], [3, 3, 1]];
31
+ const curve = G.profileCurve(knots);
32
+ assert.ok(Math.abs(radialAt(A.traced(knots), 1.25) - curve.radius(1.25)) <= 0.02);
33
+ assert.ok(Math.abs(radialAt(A.traced(knots), 1.75) - curve.radius(1.75)) <= 0.02);
34
+ });
35
+ test("constant and linear radius controls keep their exact two endpoints", () => {
36
+ assert.deepEqual(A.traced([[0, 2], [10, 2]], { radius: () => 2 }), [[0, 2], [10, 2]]);
37
+ assert.deepEqual(A.traced([[0, 1], [10, 3]], { radius: (s) => 1 + s / 5 }), [[0, 1], [10, 3]]);
38
+ });
39
+ test("explicit zero recursion depth retains the requested coarse profile", () => {
40
+ assert.deepEqual(A.traced(profile, { radius, depth: 0 }), profile);
41
+ });
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+ const projection = (azimuth, elevation, scale = 1) => ({ origin: [0, 0], scale, azimuth, elevation });
5
+ const options = { step: 0.5, tolerance: 0.1, least: 0.25 };
6
+ const fixture = () => [{ name: "body", key: 0, shapes: [A.cylinder(0, 0, 2, 0, 10)] }, { name: "probe", key: 0, shapes: [A.ball([1.5, 0, 5], 1)] }];
7
+ for (const [name, before, after] of [
8
+ ["azimuth reversal", projection(45, 30), projection(180, 30)],
9
+ ["elevation change", projection(45, 30), projection(45, 60)],
10
+ ["side view to oblique", projection(0, 0), projection(45, 30)],
11
+ ]) test(`reused body ray geometry matches a fresh build after ${name}`, () => {
12
+ const items = fixture();
13
+ A.overlapsOf(items, before, options);
14
+ const reused = A.overlapsOf(items, after, options);
15
+ const fresh = A.overlapsOf(fixture(), after, options);
16
+ assert.equal(fresh.pairs.length, 1);
17
+ assert.deepEqual(reused, fresh);
18
+ });
19
+ test("azimuth reversal retains the physically nearer body in the key solver", () => {
20
+ const items = fixture();
21
+ A.overlapsOf(items, projection(45, 30), options);
22
+ const solved = A.orderKeys(items, A.overlapsOf(items, projection(180, 30), options));
23
+ assert.equal(solved.cycles.length, 0);
24
+ assert.ok(solved.keys[0] > solved.keys[1]);
25
+ });
26
+ test("repeated identical projections keep identical depth results", () => {
27
+ const items = fixture(); const P = projection(45, 30);
28
+ const first = A.overlapsOf(items, P, options);
29
+ assert.equal(first.pairs.length, 1);
30
+ assert.deepEqual(A.overlapsOf(items, P, options), first);
31
+ });
32
+ test("scale and origin changes keep the unchanged camera direction valid", () => {
33
+ const items = fixture(); A.overlapsOf(items, projection(45, 30), options);
34
+ const P = { ...projection(45, 30, 2), origin: [20, 30] };
35
+ assert.deepEqual(A.overlapsOf(items, P, options), A.overlapsOf(fixture(), P, options));
36
+ });
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+ import * as G from "../kit/lathe.mjs";
5
+ const P = { origin: [0, 0], scale: 1, azimuth: 45, elevation: 30 };
6
+ const F = G.frameAlong([0, 0, 0], [0, 0, 1]);
7
+ function scene(axis = [0, 0, 1]) {
8
+ const frame = G.frameAlong([0, 0, 0], axis);
9
+ const move = G.mul3(axis, 8);
10
+ const rod = A.slide(frame, 0, 2, 1, { name: "piston", move, piece: "piston" });
11
+ const obstacle = A.disc(frame, 6, 8, 2, { name: "obstacle" });
12
+ const drawn = G.rodOut(frame, 0, 2, 1, P, { stroke: 8 });
13
+ assert.equal(drawn.reach, 10);
14
+ assert.ok(drawn.paths.fill.length > 0 && drawn.clip.length > 0);
15
+ return { rod, obstacle, frame };
16
+ }
17
+ for (const axis of [[0, 0, 1], [1, 0, 0]]) test(`native solid clearance checks an extending rod along ${axis}`, () => {
18
+ const { rod, obstacle, frame } = scene(axis);
19
+ const state = { label: "stroke", shares: { piston: 1 } };
20
+ const posed = A.moved(rod, A.offsetOf(rod, state));
21
+ const inside = G.add3(frame.o, G.mul3(frame.a, 7));
22
+ assert.equal(posed.slide.to, 10);
23
+ assert.ok(A.sdf(posed, inside) < -0.9 && A.sdf(obstacle, inside) < -0.9);
24
+ const result = A.solidClearances([rod, obstacle], { state, spacing: 0.2, sunk: 0.2 });
25
+ assert.equal(result.length, 1);
26
+ assert.equal(result[0].tube, "piston");
27
+ assert.equal(result[0].other, "obstacle");
28
+ assert.equal(result[0].state, "stroke");
29
+ assert.ok(result[0].clearance < -0.9);
30
+ });
31
+ test("native recorder audit reports an extending rod with a stationary obstacle in either input order", () => {
32
+ const { rod, obstacle } = scene();
33
+ for (const shapes of [[rod, obstacle], [obstacle, rod]]) {
34
+ const R = A.recorder(P, { order: ["piston"] });
35
+ for (const shape of shapes) R.solid(shape);
36
+ const result = A.audit(R, P, { frames: [], apart: true });
37
+ assert.equal(result.apart.length, 1);
38
+ assert.ok(result.apart[0].clearance <= -0.5);
39
+ assert.ok(A.failures(result) > 0);
40
+ }
41
+ });
42
+ test("native solid clearance preserves rest, intermediate and retracted rod controls", () => {
43
+ const { rod, obstacle } = scene();
44
+ for (const state of [0, 0.4, -0.1]) assert.deepEqual(A.solidClearances([rod, obstacle], { state, spacing: 0.2, sunk: 0.2 }), []);
45
+ });
46
+ test("native solid clearance preserves translated solid penetration detection", () => {
47
+ const { obstacle } = scene();
48
+ const moving = A.ball([0, 0, 1], 1, { name: "moving", move: [0, 0, 6] });
49
+ const result = A.solidClearances([moving, obstacle], { state: 1, spacing: 0.2, sunk: 0.2 });
50
+ assert.equal(result.length, 1);
51
+ assert.ok(result[0].clearance < -0.9);
52
+ });
53
+ test("native sliding clearance preserves a seated host exemption", () => {
54
+ const { rod, obstacle } = scene();
55
+ rod.seated = true;
56
+ rod.cut = obstacle;
57
+ assert.deepEqual(A.solidClearances([rod, obstacle], { state: 1, spacing: 0.2, sunk: 0.2 }), []);
58
+ });
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+ import * as A from "../kit/audit.mjs";
9
+ import { turning } from "../kit/turn-build.mjs";
10
+ const P={origin:[0,0],scale:1,azimuth:45,elevation:30};
11
+ const fixtures=[
12
+ ["single quoted rectangle", "<rect x='10' y='20' width='30' height='40'/>"],
13
+ ["spaces around attribute equals", '<rect x = "10" y = "20" width = "30" height = "40"/>'],
14
+ ["single quoted nested transform", "<g transform = 'translate(5 3)'><path d='M10 10L20 10L20 20Z'/></g>"],
15
+ ["numeric XML path references", '<path d="M&#49;0 &#x32;0L40 20L40 60Z"/>'],
16
+ ["numeric XML transform references", '<g transform="translate(&#49;0 5)"><circle cx="10" cy="10" r="5"/></g>'],
17
+ ["mixed attribute quotes and multiline path", "<path d='M10 20\nL40 20L40 60Z' transform=\"translate(2 3)\"/>"],
18
+ ["single quoted ellipse", "<ellipse cx='10' cy='20' rx='4' ry='2'/>"],
19
+ ["quoted attribute text is not geometry", `<rect data-label="x='99'" x='10' y='20' width='30' height='40'/>`],
20
+ ["quoted greater-than delimiter", `<rect data-label='a > b' x="10" y="20" width="30" height="40"/>`],
21
+ ["quoted less-than delimiter", `<rect data-label="a &lt; b" x='10' y='20' width='30' height='40'/>`],
22
+ ["ordinary double quoted rectangle", '<rect x="10" y="20" width="30" height="40"/>'],
23
+ ["ordinary transformed circle", '<g transform="translate(2 3)"><circle cx="10" cy="10" r="5"/></g>'],
24
+ ];
25
+ let native;const directory=await mkdtemp(join(tmpdir(),"anatomy-svg-attributes-"));
26
+ try{
27
+ const page=join(directory,"figure.html");await writeFile(page,`<!doctype html><div class="iso-plate"><svg width="100" height="100">${fixtures.map(([,m],i)=>`<g id="f${i}">${m}</g>`).join("")}</svg></div>`);
28
+ const expression=`Array.from({length:${fixtures.length}},(_,i)=>{const b=document.querySelector('#f'+i).getBBox();return[b.x,b.y,b.x+b.width,b.y+b.height]})`;
29
+ const result=await promisify(execFile)(process.execPath,["scripts/drive.mjs",page,JSON.stringify([["eval",expression]])],{timeout:30000});native=JSON.parse(result.stdout.match(/eval: (.*)/)[1]);
30
+ }finally{await rm(directory,{recursive:true,force:true});}
31
+ fixtures.forEach(([name,markup],i)=>test(`public SVG bounds and fixed parts decode ${name}`,()=>{
32
+ assert.deepEqual(A.markupBox(markup),native[i]);
33
+ const T=turning(P);T.layer("figure");const part=T.fixed({name:"fixed",svg:markup,anchor:[0,0,0],shapes:[A.ball([0,0,0],50)]});
34
+ assert.deepEqual(part.spec.box,native[i].map((v,j)=>v+(j<2?-.5:.5)));
35
+ }));
36
+ test("coverage rejects a stand-in for single quoted real geometry",()=>{
37
+ const item={name:"quoted",svg:fixtures[0][1],shapes:[A.ball([0,0,0],.1)]};assert.equal(A.coverage([item],P).length,1);
38
+ assert.equal(A.coverage([{...item,shapes:[A.ball([0,0,0],50)]}],P).length,0);
39
+ });
40
+ test("quoted and escaped clip identifiers retain authored clipping",()=>{
41
+ const markup="<defs><clipPath id = 'cut&amp;1'><rect x='2' y='3' width='4' height='5'/></clipPath></defs><rect width='100' height='100' clip-path = 'url(#cut&amp;1)'/>";
42
+ assert.deepEqual(A.markupBox(markup),[2,3,6,8]);
43
+ });
44
+ test("quoted halo class remains excluded from authored bounds",()=>{
45
+ assert.deepEqual(A.markupBox("<circle class = 'iso-halo' cx='50' cy='50' r='40'/><rect x='2' y='3' width='4' height='5'/>"),[2,3,6,8]);
46
+ });
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import * as A from "../kit/audit.mjs";
4
+
5
+ const taper = () => A.tube([[0, 0, 0], [10, 0, 0]], t => 1 + 2 * t);
6
+ const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-12, `${actual} differs from ${expected}`);
7
+ test("the narrow portion of a taper does not invent a collision", () => {
8
+ close(A.sdf(taper(), [1, 2, 0]), 0.8 / Math.sqrt(1.04));
9
+ });
10
+ test("the wide portion of a taper does not hide a collision", () => {
11
+ close(A.sdf(taper(), [9, 2.5, 0]), -0.3 / Math.sqrt(1.04));
12
+ });
13
+ test("the actual conical surface has zero signed distance", () => {
14
+ close(A.sdf(taper(), [1, 1.2, 0]), 0);
15
+ });
16
+ test("taper distance follows a rotated and translated tube frame", () => {
17
+ const shape = A.tube([[5, 3, 2], [5, 13, 2]], [1, 3]);
18
+ close(A.sdf(shape, [7, 4, 2]), 0.8 / Math.sqrt(1.04));
19
+ });
20
+ test("constant-radius cylinders retain their sides, caps and interior", () => {
21
+ const shape = A.tube([[0, 0, 0], [10, 0, 0]], 2);
22
+ close(A.sdf(shape, [5, 3, 0]), 1);
23
+ close(A.sdf(shape, [-1, 0, 0]), 1);
24
+ close(A.sdf(shape, [5, 0, 0]), -2);
25
+ });
26
+ test("taper end caps keep their true radii", () => {
27
+ close(A.sdf(taper(), [-1, 0, 0]), 1);
28
+ close(A.sdf(taper(), [11, 0, 0]), 1);
29
+ });
@@ -0,0 +1,131 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import test from "node:test";
8
+
9
+ const execute = promisify(execFile);
10
+
11
+ async function capture(selector, svgOnly = false, keys = false) {
12
+ const directory = await mkdtemp(join(tmpdir(), "anatomy-capture-target-"));
13
+ try {
14
+ const page = join(directory, "figure.html");
15
+ const out = join(directory, "capture.png");
16
+ const content = svgOnly
17
+ ? '<svg width="80" height="40" xmlns="http://www.w3.org/2000/svg"><rect width="80" height="40" fill="green"/></svg>'
18
+ : `<div class="iso-plate" tabindex="0" id="first" style="width:100px;height:80px;background:blue"><span data-readout>first:0</span></div><div class="iso-plate" tabindex="0" id="second" style="width:60px;height:40px;background:red"><span data-readout>second:0</span></div><script>for(const plate of document.querySelectorAll('.iso-plate'))plate.addEventListener('keydown',e=>{if(e.key==='ArrowRight')plate.querySelector('[data-readout]').textContent=plate.id+':1'})</script>`;
19
+ await writeFile(page, '<!doctype html><style>body{margin:20px}</style>' + content);
20
+ const args = ["scripts/capture.mjs", page, "--out", out, "--wait", "0", "--scale", "1"];
21
+ if (selector !== undefined) args.push("--select", selector);
22
+ if (keys) args.push("--key", "ArrowRight");
23
+ let result;
24
+ try {
25
+ result = { code: 0, ...await execute(process.execPath, args, { timeout: 30000 }) };
26
+ } catch (error) {
27
+ result = { code: error.code, stdout: error.stdout, stderr: error.stderr };
28
+ }
29
+ let png;
30
+ try {
31
+ await access(out);
32
+ png = await readFile(out);
33
+ } catch {}
34
+ return { ...result, png };
35
+ } finally {
36
+ await rm(directory, { recursive: true, force: true });
37
+ }
38
+ }
39
+
40
+ function dimensions(png) {
41
+ assert.ok(png);
42
+ assert.equal(png.subarray(1, 4).toString(), "PNG");
43
+ return [png.readUInt32BE(16), png.readUInt32BE(20)];
44
+ }
45
+
46
+ test("an absent explicit figure selector fails without an unrelated screenshot", async () => {
47
+ const result = await capture("#removed-figure");
48
+ assert.equal(result.code, 1);
49
+ assert.match(result.stderr, /Capture target not found: #removed-figure/);
50
+ assert.equal(result.png, undefined);
51
+ assert.doesNotMatch(result.stdout, /saved /);
52
+ });
53
+
54
+ test("a malformed explicit selector retains an actionable capture diagnostic", async () => {
55
+ const result = await capture("[invalid");
56
+ assert.equal(result.code, 1);
57
+ assert.match(result.stderr, /Capture target not found: \[invalid/);
58
+ assert.equal(result.png, undefined);
59
+ });
60
+
61
+ test("a matching selector captures the requested second figure", async () => {
62
+ const result = await capture("#second");
63
+ assert.equal(result.code, 0, result.stderr);
64
+ assert.deepEqual(dimensions(result.png), [72, 52]);
65
+ assert.match(result.stdout, /60x40/);
66
+ });
67
+
68
+ test("default selection retains the first plate", async () => {
69
+ const result = await capture(undefined);
70
+ assert.equal(result.code, 0, result.stderr);
71
+ assert.deepEqual(dimensions(result.png), [112, 92]);
72
+ assert.match(result.stdout, /100x80/);
73
+ });
74
+
75
+ test("default selection retains the standalone SVG fallback", async () => {
76
+ const result = await capture(undefined, true);
77
+ assert.equal(result.code, 0, result.stderr);
78
+ assert.deepEqual(dimensions(result.png), [92, 52]);
79
+ assert.match(result.stdout, /80x40/);
80
+ });
81
+
82
+ test("an explicit target reports that figure's readout", async () => {
83
+ const result = await capture("#second");
84
+ assert.equal(result.code, 0, result.stderr);
85
+ assert.match(result.stdout, /readout "second:0"/);
86
+ });
87
+
88
+ test("keyboard interaction reaches the explicitly selected figure", async () => {
89
+ const result = await capture("#second", false, true);
90
+ assert.equal(result.code, 0, result.stderr);
91
+ assert.match(result.stdout, /readout "second:1"/);
92
+ assert.deepEqual(dimensions(result.png), [72, 52]);
93
+ });
94
+
95
+ async function driveTarget(actions, standaloneLines = false) {
96
+ const directory=await mkdtemp(join(tmpdir(),"anatomy-drive-target-"));
97
+ try{
98
+ const page=join(directory,"figure.html"),out=join(directory,"selected.png"),file=join(directory,"actions.json");
99
+ const svg='<svg width="60" height="40" viewBox="0 0 60 40"><rect x="10" y="10" width="30" height="20" fill="none" stroke="black"/><path class="iso-line" d="M10 20L40 20" fill="none" stroke="black"/></svg>';
100
+ await writeFile(page,`<!doctype html><style>body{margin:20px}</style><div class="iso-plate" id="first" tabindex="0" style="width:100px;height:80px"><span data-readout>first:0</span>${svg}</div><div class="iso-plate" id="second" tabindex="0" style="width:60px;height:40px"><span data-readout>second:0</span>${svg}</div><script>for(const el of document.querySelectorAll('.iso-plate'))el.addEventListener('keydown',event=>{if(event.key==='ArrowRight')el.querySelector('[data-readout]').textContent=el.id+':1'})</script>`);
101
+ await writeFile(file,JSON.stringify(actions.map(action=>action.map(value=>value==='OUTPUT'?out:value))));
102
+ const args=standaloneLines?["scripts/lines.mjs",page,"--select","#missing","--wait","0"]:["scripts/drive.mjs",page,"--actions",file];
103
+ let result;try{result={code:0,...await execute(process.execPath,args,{timeout:30000})};}catch(error){result={code:error.code,stdout:error.stdout,stderr:error.stderr};}
104
+ let png;try{png=await readFile(out);}catch{}return{...result,png};
105
+ }finally{await rm(directory,{recursive:true,force:true});}
106
+ }
107
+ for(const [name,action] of [
108
+ ["shot",["shot","OUTPUT","#missing"]],
109
+ ["move",["move","#missing",.5,.5]],
110
+ ["click",["click","#missing"]],
111
+ ["drag",["drag","#missing",0,0,1,1]],
112
+ ["scroll",["scroll","#missing"]],
113
+ ["key",["key","ArrowRight","#missing"]],
114
+ ["lines",["lines",null,"#missing"]],
115
+ ])test(`drive ${name} rejects an absent explicit target instead of continuing`,async()=>{
116
+ const result=await driveTarget([action,["eval","'after missing target'"]]);assert.equal(result.code,1,result.stdout);assert.match(result.stderr,/(Drive|Line) target not found: #missing/);assert.doesNotMatch(result.stdout,/after missing target/);assert.equal(result.png,undefined);
117
+ });
118
+ test("standalone lines rejects an absent selected figure",async()=>{
119
+ const result=await driveTarget([],true);assert.equal(result.code,1,result.stdout);assert.match(result.stderr,/Line target not found: #missing/);
120
+ });
121
+ test("drive targets the requested figure for keyboard input screenshot and reported readout",async()=>{
122
+ const result=await driveTarget([["key","ArrowRight","#second"],["shot","OUTPUT","#second"],["eval","document.querySelector('#first [data-readout]').textContent"]]);
123
+ assert.equal(result.code,0,result.stderr);assert.match(result.stdout,/readout "second:1"/);assert.match(result.stdout,/eval: "first:0"/);assert.deepEqual(dimensions(result.png),[68,48]);
124
+ });
125
+ test("drive defaults preserve first-figure keyboard focus and reporting",async()=>{
126
+ const result=await driveTarget([["key","ArrowRight"],["shot","OUTPUT"],["eval","document.querySelector('#second [data-readout]').textContent"]]);
127
+ assert.equal(result.code,0,result.stderr);assert.match(result.stdout,/readout "first:1"/);assert.match(result.stdout,/eval: "second:0"/);assert.deepEqual(dimensions(result.png),[108,88]);
128
+ });
129
+ test("line selection preserves real rooted-line checks on the requested SVG",async()=>{
130
+ const result=await driveTarget([["lines",null,"#second svg"]]);assert.equal(result.code,0,result.stderr);assert.match(result.stdout,/line ends: 0 of [1-9]\d* ends on [1-9]\d* lines float/);
131
+ });