@hatiolab/figure-model 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +8 -4
  3. package/dist/asset-types.d.ts +8 -13
  4. package/dist/asset-types.d.ts.map +1 -1
  5. package/dist/asset-types.js.map +1 -1
  6. package/dist/asset.js +4 -4
  7. package/dist/asset.js.map +1 -1
  8. package/dist/authoring-actions.js +18 -5
  9. package/dist/authoring-actions.js.map +1 -1
  10. package/dist/authoring-types.d.ts +6 -11
  11. package/dist/authoring-types.d.ts.map +1 -1
  12. package/dist/authoring-types.js.map +1 -1
  13. package/dist/data-interface.d.ts.map +1 -1
  14. package/dist/data-interface.js +8 -4
  15. package/dist/data-interface.js.map +1 -1
  16. package/dist/editor-workspace.d.ts +1 -1
  17. package/dist/editor-workspace.d.ts.map +1 -1
  18. package/dist/editor-workspace.js +2 -5
  19. package/dist/editor-workspace.js.map +1 -1
  20. package/dist/extensions.d.ts +15 -0
  21. package/dist/extensions.d.ts.map +1 -0
  22. package/dist/extensions.js +63 -0
  23. package/dist/extensions.js.map +1 -0
  24. package/dist/gate.d.ts +1 -1
  25. package/dist/gate.d.ts.map +1 -1
  26. package/dist/gate.js +28 -33
  27. package/dist/gate.js.map +1 -1
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/input-preview.d.ts +48 -0
  33. package/dist/input-preview.d.ts.map +1 -0
  34. package/dist/{authoring-session.js → input-preview.js} +7 -63
  35. package/dist/input-preview.js.map +1 -0
  36. package/dist/kernel-version.d.ts +1 -1
  37. package/dist/kernel-version.js +1 -1
  38. package/dist/names.d.ts.map +1 -1
  39. package/dist/names.js +5 -1
  40. package/dist/names.js.map +1 -1
  41. package/dist/package.d.ts.map +1 -1
  42. package/dist/package.js +18 -15
  43. package/dist/package.js.map +1 -1
  44. package/dist/proposal.d.ts +5 -1
  45. package/dist/proposal.d.ts.map +1 -1
  46. package/dist/proposal.js +18 -5
  47. package/dist/proposal.js.map +1 -1
  48. package/dist/release-contract.d.ts +1 -1
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +13 -24
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/shape-edit.d.ts +1 -1
  53. package/dist/shape-edit.d.ts.map +1 -1
  54. package/dist/size-edit.d.ts +1 -1
  55. package/dist/size-edit.d.ts.map +1 -1
  56. package/docs/asset-persistence.md +6 -3
  57. package/docs/authoring-transactions.md +21 -19
  58. package/docs/capability-bindings.md +6 -3
  59. package/docs/common-kernel-experiment.md +3 -0
  60. package/docs/document-contract.md +6 -3
  61. package/docs/dryroom-profile-validation.md +3 -0
  62. package/docs/editor-integration.md +5 -2
  63. package/docs/full-conveyor-validation.md +5 -2
  64. package/docs/full-dryroom-validation.md +6 -3
  65. package/docs/full-forklift-validation.md +5 -2
  66. package/docs/full-robot-validation.md +5 -2
  67. package/docs/payload-collision-validation.md +5 -2
  68. package/docs/payload-contact-validation.md +5 -2
  69. package/docs/prototypes/README.md +27 -61
  70. package/docs/repeated-binding-validation.md +6 -3
  71. package/docs/review-2026-09-22.md +3 -0
  72. package/docs/runtime-extraction.md +9 -2
  73. package/docs/sample-validation-expanded.md +3 -0
  74. package/docs/sample-validation.md +3 -0
  75. package/docs/shape-contract.md +7 -5
  76. package/docs/spatial-contracts.md +3 -0
  77. package/docs/status.md +2 -1
  78. package/docs/storage-integration.md +6 -3
  79. package/docs/typed-repeat-contracts.md +3 -0
  80. package/package.json +9 -9
  81. package/dist/authoring-session.d.ts +0 -121
  82. package/dist/authoring-session.d.ts.map +0 -1
  83. package/dist/authoring-session.js.map +0 -1
  84. package/dist/carrier.d.ts +0 -23
  85. package/dist/carrier.d.ts.map +0 -1
  86. package/dist/carrier.js +0 -65
  87. package/dist/carrier.js.map +0 -1
  88. package/docs/prototypes/asset-samples.mjs +0 -15
  89. package/docs/prototypes/asset-store.mjs +0 -52
  90. package/docs/prototypes/asset-store.test.mjs +0 -59
  91. package/docs/prototypes/asset.mjs +0 -1
  92. package/docs/prototypes/asset.schema.json +0 -36
  93. package/docs/prototypes/asset.test.mjs +0 -68
  94. package/docs/prototypes/authoring-session.mjs +0 -1
  95. package/docs/prototypes/authoring-session.test.mjs +0 -65
  96. package/docs/prototypes/capability-plan.mjs +0 -1
  97. package/docs/prototypes/capability-plan.test.mjs +0 -56
  98. package/docs/prototypes/capability-samples.mjs +0 -23
  99. package/docs/prototypes/document.mjs +0 -2
  100. package/docs/prototypes/document.schema.json +0 -66
  101. package/docs/prototypes/document.test.mjs +0 -55
  102. package/docs/prototypes/dryroom-mesh.mjs +0 -60
  103. package/docs/prototypes/dryroom-mesh.test.mjs +0 -30
  104. package/docs/prototypes/editor-browser.mjs +0 -97
  105. package/docs/prototypes/editor-workspace.mjs +0 -1
  106. package/docs/prototypes/editor-workspace.test.mjs +0 -35
  107. package/docs/prototypes/feature-pattern.test.mjs +0 -131
  108. package/docs/prototypes/full-conveyor.mjs +0 -60
  109. package/docs/prototypes/full-conveyor.test.mjs +0 -61
  110. package/docs/prototypes/full-dryroom.mjs +0 -67
  111. package/docs/prototypes/full-dryroom.test.mjs +0 -54
  112. package/docs/prototypes/full-forklift.mjs +0 -40
  113. package/docs/prototypes/full-forklift.test.mjs +0 -37
  114. package/docs/prototypes/full-robot.mjs +0 -35
  115. package/docs/prototypes/full-robot.test.mjs +0 -68
  116. package/docs/prototypes/kernel.mjs +0 -147
  117. package/docs/prototypes/kernel.test.mjs +0 -144
  118. package/docs/prototypes/more-samples.test.mjs +0 -182
  119. package/docs/prototypes/payload-collision.mjs +0 -54
  120. package/docs/prototypes/payload-collision.test.mjs +0 -68
  121. package/docs/prototypes/payload-fit.mjs +0 -58
  122. package/docs/prototypes/payload-fit.test.mjs +0 -59
  123. package/docs/prototypes/render-dryroom.py +0 -60
  124. package/docs/prototypes/render-full-dryroom.mjs +0 -86
  125. package/docs/prototypes/repeated-binding.mjs +0 -13
  126. package/docs/prototypes/repeated-binding.test.mjs +0 -46
  127. package/docs/prototypes/runtime-browser.mjs +0 -76
  128. package/docs/prototypes/samples.test.mjs +0 -144
  129. package/docs/prototypes/spatial-plan.test.mjs +0 -102
  130. package/docs/prototypes/typed-repeat.test.mjs +0 -96
@@ -1,61 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './full-conveyor.mjs'
4
- import {compile} from './typed-plan.mjs'
5
- import {meshAudit} from './dryroom-mesh.mjs'
6
- const near=(a,b)=>assert.ok(Math.abs(a-b)<.001,`${a} != ${b}`)
7
- const vertices=g=>{const a=g.attributes.position;return Array.from({length:a.count},(_,i)=>[a.getX(i),a.getY(i),a.getZ(i)])}
8
- const deviation=(a,b)=>Math.max(...a.map(p=>Math.min(...b.map(q=>Math.hypot(...p.map((v,i)=>v-q[i]))))))
9
- test('all 17 source part types represented; 16 nonrepeated shapes reproduce seed',()=>{
10
- const result=evaluateFull();assert.equal(new Set(result.map(g=>g.sourceName)).size,17)
11
- for(const p of seed.parts.filter(p=>!p.repeat)){
12
- const item=result.find(g=>g.id===p.name);assert.deepEqual(item.material,p.material)
13
- const a=sourceGeometry(p),b=geometryOf(item),av=vertices(a),bv=vertices(b)
14
- near(Math.max(deviation(av,bv),deviation(bv,av)),0);a.dispose();b.dispose()
15
- }
16
- const rollers=result.filter(g=>g.sourceName==='roller');assert.equal(rollers.length,14)
17
- // Deliberate layout correction: old first roller overlaps the end roller by 108mm.
18
- near(75+48-Math.abs(-1125-(-1140)),108)
19
- near(rollers[0].pose.t[0],-975);near(rollers.at(-1).pose.t[0],975)
20
- const rollerSource=sourceGeometry(seed.parts.find(p=>p.name==='roller'))
21
- for(const r of rollers){const g=geometryOf(r);g.translate(-r.pose.t[0],0,0);near(Math.max(deviation(vertices(g),vertices(rollerSource)),deviation(vertices(rollerSource),vertices(g))),0);g.dispose()}
22
- rollerSource.dispose()
23
- })
24
- test('whole conveyor: dimensions × motion; feet/legs/frames stay joined and roller clearances hold',()=>{
25
- let cases=0,meshes=0
26
- for(const length of [900,2400,6000])for(const width of [400,600,1200])for(const rollerHeight of [400,500,1200])for(const angle of [-180,-37,0,73,180]){
27
- const result=evaluateFull({length,width,rollerHeight,angle}),by=Object.fromEntries(result.filter(g=>!g.key).map(g=>[g.id,g]))
28
- for(const end of ['head','tail'])for(const side of ['left','right']){
29
- const l=by[`leg-${end}-${side}`],f=by[`foot-${end}`],frame=by[`frame-${side}`]
30
- near(l.pose.t[1]-l.shape.dimensions.height/2,f.pose.t[1]+f.shape.dimensions.height/2)
31
- near(l.pose.t[1]+l.shape.dimensions.height/2,frame.pose.t[1]-frame.shape.dimensions.height/2)
32
- near(l.pose.t[0],f.pose.t[0]);near(l.pose.t[2]+(side==='left'?-32:32),frame.pose.t[2]+(side==='left'?6:-6))
33
- }
34
- const rollers=result.filter(g=>g.key);const count=1+Math.floor((length-290-96)/150);assert.equal(rollers.length,count)
35
- for(const [i,r] of rollers.entries()){
36
- near(r.pose.t[1],rollerHeight);near(r.pose.t[2],0);near(r.shape.dimensions.length,width-80)
37
- // Rotation must preserve the cylinder axle direction, including non-quarter angles.
38
- near(r.pose.r[0][1],0);near(r.pose.r[1][1],0);near(r.pose.r[2][1],1)
39
- if(i)near(r.pose.t[0]-rollers[i-1].pose.t[0],150)
40
- }
41
- assert.ok(rollers[0].pose.t[0]-48-(by['roller-head'].pose.t[0]+75)>=10-1e-6)
42
- assert.ok(by['roller-tail'].pose.t[0]-75-(rollers.at(-1).pose.t[0]+48)>=10-1e-6)
43
- for(const item of result){const g=geometryOf(item),a=meshAudit(g);assert.equal(a.badEdges,0);assert.equal(a.euler,2);assert.ok(a.volume>0&&a.minArea>0);g.dispose();meshes++}
44
- cases++
45
- }
46
- assert.equal(cases,135);console.log({wholeConveyorCases:cases,meshChecks:meshes})
47
- })
48
- test('resize preserves repeat identities; motion cannot affect topology; rejected input does not corrupt next evaluation',()=>{
49
- const p=compile(fullModel()),base=p.evaluate(defaults),large=p.evaluate({...defaults,length:3000})
50
- const old=base.geometry.filter(g=>g.key),next=large.geometry.filter(g=>g.key);old.forEach((g,i)=>assert.equal(g.id,next[i].id))
51
- for(const angle of [-360,37,360])assert.deepEqual(p.evaluate({...defaults,angle}).geometry.map(g=>g.id),base.geometry.map(g=>g.id))
52
- assert.throws(()=>p.evaluate({...defaults,width:0}),e=>e.code==='INPUT_RANGE');assert.deepEqual(p.evaluate(defaults),base)
53
- })
54
- test('full model crosses a repeat count boundary without violating reserved end space',()=>{
55
- for(const [length,count] of [[985.999,4],[986,5],[986.001,5]]){
56
- const r=evaluateFull({...defaults,length}),rollers=r.filter(g=>g.key)
57
- assert.equal(rollers.length,count)
58
- assert.ok(rollers[0].pose.t[0]-48-(-length/2+60+75)>=10-1e-6)
59
- assert.ok(length/2-60-75-rollers.at(-1).pose.t[0]-48>=10-1e-6)
60
- }
61
- })
@@ -1,67 +0,0 @@
1
- import * as THREE from '../../../things-scene/node_modules/three/build/three.module.js'
2
- import {seed} from './dryroom-mesh.mjs'
3
- import {compile} from './typed-plan.mjs'
4
- export {seed,THREE}
5
- const c=(id,value,unit='mm')=>({id,value,unit})
6
- const n=(id,op,args,outputs,params)=>({id,op,args,outputs,...(params?{params}:{})})
7
- export function fullModel(){
8
- const m={version:'r3-spatial-1',inputs:[['width',3000,6000],['height',2400,4500],['depth',4000,9000],['bottom',-60,500],['lampLift',-100,100]].map(([id,min,max])=>({id,unit:'mm',min,max})),constants:[c('zero',0),c('a0',0,'rad'),c('turn',90,'deg')],nodes:[]}
9
- const constant=(id,v,u='mm')=>{m.constants.push(c(id,v,u));return id}
10
- const calc=(id,op,a,b)=>{m.nodes.push(n(id,op,[a,b],{value:id}));return id}
11
- const half=constant('half',.5,'ratio'),minus=constant('minus',-1,'ratio')
12
- calc('halfHeight','mul@1','height',half);calc('halfDepth','mul@1','depth',half)
13
- calc('top','add@1','height',constant('topGap',-100));calc('openingTop','add@1','height',constant('openingGap',-800))
14
- calc('right','mul@1','width',constant('outerRatio',.325,'ratio'));calc('left','mul@1','right',minus)
15
- calc('ir','mul@1','width',constant('innerRatio',.175,'ratio'));calc('il','mul@1','ir',minus)
16
- calc('portalZ','add@1','halfDepth',constant('portalOverhang',50));calc('backZ','mul@1','portalZ',minus)
17
- const rigid=(id,from,xyz,rotation=['a0','a0','a0'],to='world')=>m.nodes.push(n(id,'rigid@1',[...xyz,...rotation],{pose:id},{from,to}))
18
- const place=(name,shape,pose)=>m.nodes.push(n(name,'place@1',[shape,pose],{placed:`${name}Placed`}))
19
- m.nodes.push(n('shellShape','box-shape@1',['width','height','depth'],{shape:'shellShape'},{frame:'shell'}));rigid('shellPose','shell',['zero','halfHeight','zero']);place('shell','shellShape','shellPose')
20
- for(const side of ['in','out']){
21
- const portal=`portal-${side}`,lamp=`lamp-${side}`,pf=`${portal}Front`
22
- m.nodes.push(n(`${portal}Shape`,'portal-profile@1',['left','right','bottom','top','il','ir','openingTop',constant(`${portal}Depth`,300)],{shape:`${portal}Shape`},{frame:portal}))
23
- rigid(`${portal}Pose`,portal,['zero','zero',side==='in'?'portalZ':'backZ']);place(portal,`${portal}Shape`,`${portal}Pose`)
24
- m.nodes.push(n(pf,'feature@1',[`${portal}Shape`],{pose:pf},{frame:pf,name:'frontTop'}),n(`${pf}World`,'compose@1',[`${portal}Pose`,pf],{pose:`${pf}World`}))
25
- calc(`${lamp}OffsetY`,'add@1','lampLift',constant(`${lamp}Drop`,-300))
26
- m.nodes.push(n(`${lamp}Shape`,'cylinder-shape@1',[constant(`${lamp}Radius`,150),constant(`${lamp}Length`,160)],{shape:`${lamp}Shape`},{frame:lamp}))
27
- rigid(`${lamp}Offset`,lamp,['zero',`${lamp}OffsetY`,constant(`${lamp}OffsetZ`,side==='in'?-30:-270)],['turn','a0','a0'],pf)
28
- m.nodes.push(n(`${lamp}Pose`,'compose@1',[`${pf}World`,`${lamp}Offset`],{pose:`${lamp}Pose`}));place(lamp,`${lamp}Shape`,`${lamp}Pose`)
29
- }
30
- const d=seed.parts.find(p=>p.name==='readout')
31
- m.nodes.push(n('readoutShape','rounded-box@1',['x','y','z'].map(a=>constant(`readout${a}`,d.transform.size[a])).concat(constant('readoutRound',d.shape.round)),{shape:'readoutShape'},{frame:'readout'}))
32
- calc('readoutX','mul@1','width',constant('readoutRatio',.25,'ratio'))
33
- rigid('readoutOffset','readout',['readoutX',constant('readoutDrop',-1150),constant('readoutOffsetZ',20)],undefined,'portal-inFront')
34
- m.nodes.push(n('readoutPose','compose@1',['portal-inFrontWorld','readoutOffset'],{pose:'readoutPose'}));place('readout','readoutShape','readoutPose')
35
- return m
36
- }
37
- export const defaults={width:4000,height:3000,depth:6000,bottom:-60,lampLift:0}
38
- export function evaluateFull(args=defaults){const r=compile(fullModel()).evaluate(args);return r.geometry.map(g=>({...g,material:structuredClone(seed.parts.find(p=>p.name===g.id).material)}))}
39
- // Match current product XZ rounded profile construction, including quadratic curves sampled at 8.
40
- export function rounded(width,depth,r){
41
- const x=-width/2,y=-depth/2,p=new THREE.Path()
42
- p.moveTo(x+r,y);p.lineTo(x+width-r,y);p.quadraticCurveTo(x+width,y,x+width,y+r)
43
- p.lineTo(x+width,y+depth-r);p.quadraticCurveTo(x+width,y+depth,x+width-r,y+depth)
44
- p.lineTo(x+r,y+depth);p.quadraticCurveTo(x,y+depth,x,y+depth-r)
45
- p.lineTo(x,y+r);p.quadraticCurveTo(x,y,x+r,y)
46
- return new THREE.Shape(p.getPoints(8))
47
- }
48
- export function geometryOf(item){
49
- const d=item.shape.dimensions;let g
50
- if(item.shape.provider==='box')g=new THREE.BoxGeometry(d.width,d.height,d.depth)
51
- else if(item.shape.provider==='roundedBox'){g=new THREE.ExtrudeGeometry(rounded(d.width,d.depth,d.round),{depth:d.height,steps:1,bevelEnabled:false});g.rotateX(Math.PI/2);g.translate(0,d.height/2,0)}
52
- else if(item.shape.provider==='cylinder')g=new THREE.CylinderGeometry(d.radius,d.radius,d.length,16)
53
- else {const p=item.shape.profile,s=new THREE.Shape(p.map(v=>new THREE.Vector2(v.x,v.y)));g=new THREE.ExtrudeGeometry(s,{depth:d.depth,steps:1,bevelEnabled:false});g.translate(0,0,-d.depth/2)}
54
- const r=item.pose.r,t=item.pose.t;g.applyMatrix4(new THREE.Matrix4().set(...r[0],t[0],...r[1],t[1],...r[2],t[2],0,0,0,1))
55
- return g.index?g.toNonIndexed():g
56
- }
57
- export function sourceGeometry(p){
58
- const d=p.transform.size;let g
59
- if(p.primitive==='wall')g=new THREE.BoxGeometry(d.x,d.y,d.z)
60
- else if(p.primitive==='cylinder')g=new THREE.CylinderGeometry(d.x/2,d.z/2,d.y,p.segments)
61
- else {
62
- const s=p.primitive==='polygon'?new THREE.Shape(p.shape.path.map(v=>new THREE.Vector2(v.x,v.y))):rounded(d.x,d.z,p.shape.round)
63
- g=new THREE.ExtrudeGeometry(s,{depth:d.y,steps:1,bevelEnabled:false});g.rotateX(Math.PI/2);g.translate(0,d.y/2,0)
64
- }
65
- const r=p.transform.rotation??{};g.rotateZ((r.z??0)*Math.PI/180);g.rotateY((r.y??0)*Math.PI/180);g.rotateX((r.x??0)*Math.PI/180)
66
- const t=p.transform.position;g.translate(t.x,t.y,t.z);return g.index?g.toNonIndexed():g
67
- }
@@ -1,54 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import {test} from 'node:test'
3
- import {performance} from 'node:perf_hooks'
4
- import {fullModel,evaluateFull,geometryOf,sourceGeometry,seed,defaults} from './full-dryroom.mjs'
5
- import {compile} from './typed-plan.mjs'
6
- import {meshAudit} from './dryroom-mesh.mjs'
7
- const near=(a,b,tol=1e-3)=>assert.ok(Math.abs(a-b)<tol,`${a} != ${b}`)
8
- const vertices=g=>{const a=g.attributes.position;return Array.from({length:a.count},(_,i)=>[a.getX(i),a.getY(i),a.getZ(i)])}
9
- function deviation(a,b){return Math.max(...a.map(p=>Math.min(...b.map(q=>Math.hypot(...p.map((x,i)=>x-q[i]))))))}
10
- test('whole seed: all six actual shapes, material descriptors and surface vertices reproduced',()=>{
11
- const result=evaluateFull();assert.equal(result.length,seed.parts.length)
12
- for(const p of seed.parts){
13
- const item=result.find(g=>g.id===p.name);assert.deepEqual(item.material,p.material)
14
- const a=sourceGeometry(p),b=geometryOf(item),av=vertices(a),bv=vertices(b)
15
- const delta=Math.max(deviation(av,bv),deviation(bv,av))
16
- assert.ok(delta<.001,`${p.name}: max vertex-set deviation ${delta}mm`)
17
- const x=meshAudit(a),y=meshAudit(b)
18
- near(x.volume,y.volume,2);assert.equal(y.badEdges,0);assert.equal(y.euler,2)
19
- console.log(JSON.stringify({part:p.name,triangles:y.faces,maxVertexDeviationMm:delta}))
20
- a.dispose();b.dispose()
21
- }
22
- })
23
- test('whole model 27 dimensions × two bottom edits × three lamp edits',()=>{
24
- const plan=compile(fullModel());let cases=0
25
- for(const width of [3000,4000,6000])for(const height of [2400,3000,4500])for(const depth of [4000,6000,9000])for(const bottom of [-60,0])for(const lampLift of [-100,0,100]){
26
- const r=plan.evaluate({width,height,depth,bottom,lampLift}),by=Object.fromEntries(r.geometry.map(g=>[g.id,g]))
27
- assert.equal(r.geometry.length,6)
28
- near(by.shell.shape.dimensions.width,width)
29
- for(const side of ['in','out']){
30
- const p=by[`portal-${side}`],l=by[`lamp-${side}`],sign=side==='in'?1:-1
31
- near(p.shape.dimensions.bottom,bottom);near(p.shape.dimensions.top,height-100)
32
- near(p.shape.dimensions.openingTop,height-800);near(p.pose.t[2],sign*(depth/2+50))
33
- near(l.pose.t[1],height-400+lampLift);near(l.pose.t[2],sign*(depth/2+170))
34
- near(l.shape.dimensions.radius,150)
35
- }
36
- near(by.readout.pose.t[0],width/4);near(by.readout.pose.t[1],height-1250);near(by.readout.pose.t[2],depth/2+220)
37
- assert.equal(by.readout.shape.dimensions.round,12)
38
- for(const g of r.geometry){const mesh=geometryOf(g),audit=meshAudit(mesh);assert.equal(audit.badEdges,0);assert.ok(audit.volume>0);mesh.dispose()}
39
- cases++
40
- }
41
- assert.equal(cases,162);console.log(JSON.stringify({fullModelCases:cases,meshChecks:cases*6}))
42
- })
43
- test('independent repeated evaluation after invalid input; source is immutable',()=>{
44
- const original=JSON.stringify(seed),p=compile(fullModel()),baseline=p.evaluate(defaults)
45
- assert.throws(()=>p.evaluate({...defaults,height:100}),e=>e.code==='INPUT_RANGE')
46
- assert.deepEqual(p.evaluate(defaults),baseline);assert.equal(JSON.stringify(seed),original)
47
- })
48
- test('local CPU timings are measurements, not product performance guarantees',()=>{
49
- const m=fullModel(),stats={},run=(name,count,fn)=>{const times=[];for(let i=0;i<count;i++){const t=performance.now();fn();times.push(performance.now()-t)}times.sort((a,b)=>a-b);stats[name]={runs:count,p50ms:times[Math.floor(count*.5)],p95ms:times[Math.floor(count*.95)]}}
50
- const p=compile(m);for(let i=0;i<50;i++)p.evaluate(defaults)
51
- run('compile',100,()=>compile(m));run('resolve',500,()=>p.evaluate(defaults))
52
- run('sixMeshes',100,()=>p.evaluate(defaults).geometry.forEach(item=>geometryOf(item).dispose()))
53
- console.log(JSON.stringify({cpuBenchmark:stats,node:process.version}))
54
- })
@@ -1,40 +0,0 @@
1
- import fs from 'node:fs'
2
- import {compile} from './typed-plan.mjs'
3
- import {geometryOf,sourceGeometry as primitiveGeometry} from './full-conveyor.mjs'
4
- export {geometryOf}
5
- export const seed=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url))).find(s=>s.type==='FORKLIFT').source
6
- export const defaults={forkLength:850,lift:0,reach:0,drive:0}
7
- export function fullModel(){
8
- const m={version:'r3-spatial-1',inputs:[{id:'forkLength',unit:'mm',min:425,max:1700},...['lift','reach'].map(id=>({id,unit:'mm',min:0,max:id==='lift'?2000:400,role:'state'})),{id:'drive',unit:'deg',min:-360,max:360,role:'state'}],constants:[],nodes:[]}
9
- const c=(id,value,unit='mm')=>{m.constants.push({id,value,unit});return id}
10
- const n=(id,op,args,outputs,params)=>m.nodes.push({id,op,args,outputs,...(params?{params}:{})})
11
- c('zero',0);c('a0',0,'deg');c('half',.5,'ratio')
12
- n('halfLift','mul@1',['lift','half'],{value:'halfLift'})
13
- n('halfFork','mul@1',['forkLength','half'],{value:'halfFork'});n('forkZ','add@1',['halfFork',c('forkRear',360)],{value:'forkZ'})
14
- const rigid=(id,from,to,xyz,rot=['a0','a0','a0'])=>n(id,'rigid@1',[...xyz,...rot],{pose:id},{from,to})
15
- rigid('mastPose','mast','world',['zero','zero','reach'])
16
- rigid('stageLocal','stage','mast',['zero','halfLift','zero']);n('stagePose','compose@1',['mastPose','stageLocal'],{pose:'stagePose'})
17
- rigid('carriageLocal','carriageFrame','stage',['zero','halfLift','zero']);n('carriagePose','compose@1',['stagePose','carriageLocal'],{pose:'carriagePose'})
18
- for(const p of seed.parts){
19
- const id=p.name,d=p.transform.size,t=p.transform.position,shape=id+'Shape',pose=id+'PartPose'
20
- const isFork=id.startsWith('fork-'),group=isFork||id==='carriage'?'carriage':id.startsWith('stage-')?'stage':id.startsWith('mast-')?'mast':null
21
- const dims=['x','y','z'].map(a=>isFork&&a==='z'?'forkLength':c(id+'Size'+a,d[a]))
22
- if(p.primitive==='cylinder')n(shape,'cylinder-shape@1',[c(id+'Radius',d.x/2),dims[1]],{shape},{frame:id})
23
- else if(p.shape?.round)n(shape,'rounded-box@1',[...dims,c(id+'Round',p.shape.round)],{shape},{frame:id})
24
- else n(shape,'box-shape@1',dims,{shape},{frame:id})
25
- const xyz=['x','y','z'].map(a=>isFork&&a==='z'?'forkZ':c(id+'Pos'+a,t[a]))
26
- const rot=['x','y','z'].map(a=>c(id+'Rot'+a,p.transform.rotation?.[a]??0,'deg'))
27
- const frame=group==='carriage'?'carriageFrame':group??'world'
28
- rigid(id+'Rest',id,frame,xyz,rot)
29
- let local=id+'Rest'
30
- if(id==='wheel-left'||id==='wheel-right'){
31
- rigid(id+'Spin',id,id,['zero','zero','zero'],['a0','drive','a0'])
32
- n(id+'Moving','compose@1',[local,id+'Spin'],{pose:id+'Moving'});local=id+'Moving'
33
- }
34
- if(group){n(pose,'compose@1',[group+'Pose',local],{pose});local=pose}
35
- n(id,'place@1',[shape,local],{placed:id+'Placed'})
36
- }
37
- return m
38
- }
39
- export function evaluateFull(args=defaults){return compile(fullModel()).evaluate(args).geometry.map(g=>{const p=seed.parts.find(p=>p.name===g.id);return {...g,segments:p.segments??8,material:structuredClone(p.material)}})}
40
- export const sourceGeometry=p=>primitiveGeometry({...p,...(p.primitive==='rect'&&!p.shape?.round?{primitive:'cube'}:{})})
@@ -1,37 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './full-forklift.mjs'
4
- import {compile} from './typed-plan.mjs'
5
- import {meshAudit} from './dryroom-mesh.mjs'
6
- const near=(a,b,tol=1e-6)=>assert.ok(Math.abs(a-b)<tol,`${a} != ${b}`)
7
- const vertices=g=>{const a=g.attributes.position;return Array.from({length:a.count},(_,i)=>[a.getX(i),a.getY(i),a.getZ(i)])}
8
- const deviation=(a,b)=>Math.max(...a.map(p=>Math.min(...b.map(q=>Math.hypot(...p.map((v,i)=>v-q[i]))))))
9
- test('all 20 original part shapes and materials reproduced at rest',()=>{
10
- const result=evaluateFull();assert.equal(result.length,20)
11
- for(const p of seed.parts){const item=result.find(g=>g.id===p.name),a=sourceGeometry(p),b=geometryOf(item);near(Math.max(deviation(vertices(a),vertices(b)),deviation(vertices(b),vertices(a))),0,.001);assert.deepEqual(item.material,p.material);a.dispose();b.dispose()}
12
- })
13
- test('full forklift: dimensions + simultaneous lift/reach/drive, compared against seed channels',()=>{
14
- let states=0,meshes=0
15
- for(const forkLength of [425,850,1700])for(const lift of [0,500,1000,1500,2000])for(const reach of [0,200,400])for(const drive of [-180,-37,0,73,180]){
16
- const result=evaluateFull({forkLength,lift,reach,drive}),by=Object.fromEntries(result.map(g=>[g.id,g]))
17
- for(const p of seed.parts){
18
- const item=by[p.name],expected={...p.transform.position}
19
- // Read original channel endpoints, not the implementation's assembly/group mapping.
20
- for(const param of seed.parameters){const ch=param.clip.channels.find(c=>c.target===p.name);if(!ch)continue;const fraction=({lift,reach}[param.name]-param.range.min)/(param.range.max-param.range.min);for(const a of ['x','y','z'])expected[a]+=ch.keys[0].value[a]+fraction*(ch.keys.at(-1).value[a]-ch.keys[0].value[a])}
21
- if(p.name.startsWith('fork-'))expected.z+=(forkLength-850)/2
22
- for(const [i,a] of ['x','y','z'].entries())near(item.pose.t[i],expected[a])
23
- if(p.name.startsWith('fork-')){near(item.shape.dimensions.depth,forkLength);near(item.pose.t[2]-forkLength/2,360+reach)}
24
- if(p.name.startsWith('wheel-')){near(item.pose.r[0][1],-1);near(item.pose.r[1][1],0);near(item.pose.r[2][1],0);near(item.pose.t[1],item.shape.dimensions.radius)}
25
- const g=geometryOf(item),audit=meshAudit(g);assert.equal(audit.badEdges,0);assert.equal(audit.euler,2);assert.ok(audit.volume>0&&audit.minArea>0);g.dispose();meshes++
26
- }
27
- near(by['fork-high-left'].pose.t[1]-by['fork-low-left'].pose.t[1],400)
28
- near(by.carriage.pose.t[1]-by['fork-low-left'].pose.t[1],218)
29
- states++
30
- }
31
- assert.equal(states,225);console.log({wholeForkliftCases:states,meshChecks:meshes})
32
- })
33
- test('invalid state rejected, no stale result; full declaration reorder independent',()=>{
34
- const m=fullModel(),p=compile(m),initial=p.evaluate(defaults)
35
- for(const change of [{lift:2001},{reach:-1},{forkLength:0},{drive:361}])assert.throws(()=>p.evaluate({...defaults,...change}),e=>e.code==='INPUT_RANGE')
36
- assert.deepEqual(p.evaluate(defaults),initial);m.nodes.reverse();const sort=r=>r.geometry.sort((a,b)=>a.id.localeCompare(b.id));assert.deepEqual(sort(compile(m).evaluate(defaults)),sort(initial))
37
- })
@@ -1,35 +0,0 @@
1
- import fs from 'node:fs'
2
- import {compile} from './typed-plan.mjs'
3
- import {geometryOf,sourceGeometry as primitiveGeometry} from './full-conveyor.mjs'
4
- export {geometryOf}
5
- export const seed=JSON.parse(fs.readFileSync(new URL('../../src/fixtures/robot-arm-6dof.json',import.meta.url)))
6
- export const defaults={upperLength:420,foreLength:380,gripper:0,...Object.fromEntries(seed.joints.map(j=>[j.name,0]))}
7
- export function fullModel(){
8
- const m={version:'r3-spatial-1',inputs:[{id:'upperLength',unit:'mm',min:210,max:840},{id:'foreLength',unit:'mm',min:190,max:760},{id:'gripper',unit:'percent',min:0,max:100,role:'state'},...seed.joints.map(j=>({id:j.name,unit:'deg',min:j.limits?.min??-360,max:j.limits?.max??360,role:'state'}))],constants:[],nodes:[]}
9
- const c=(id,value,unit='mm')=>{m.constants.push({id,value,unit});return id}
10
- const n=(id,op,args,outputs,params)=>m.nodes.push({id,op,args,outputs,...(params?{params}:{})})
11
- c('zero',0);c('a0',0,'deg');c('stroke',20);c('neg',-1,'ratio')
12
- n('strokeValue','mul@1',['gripper','stroke'],{value:'strokeValue'});n('negativeStroke','mul@1',['strokeValue','neg'],{value:'negativeStroke'})
13
- n('leftX','add@1',[c('leftRest',-35),'strokeValue'],{value:'leftX'});n('rightX','add@1',[c('rightRest',35),'negativeStroke'],{value:'rightX'})
14
- for(const p of seed.parts){
15
- const id=p.name,d=p.transform.size,shape=id+'Shape',pose=id+'Pose'
16
- const height=id==='upper-arm'?'upperLength':id==='forearm'?'foreLength':c(id+'Height',d.y)
17
- if(p.primitive==='cylinder')n(shape,'cylinder-shape@1',[c(id+'Radius',d.x/2),height],{shape},{frame:id})
18
- else n(shape,'box-shape@1',[c(id+'Width',d.x),height,c(id+'Depth',d.z)],{shape},{frame:id})
19
- for(const face of ['top','bottom'])n(id+face,'feature@1',[shape],{pose:id+face},{frame:id+face,name:p.primitive==='cylinder'?(face==='top'?'end':'start'):face})
20
- if(!p.parent)n(pose,'rigid@1',['zero',c('baseCenter',50),'zero','a0','a0','a0'],{pose},{from:id,to:'world'})
21
- else{
22
- const j=seed.joints.find(j=>j.child===id),rot=['a0','a0','a0']
23
- if(j)rot[['x','y','z'].findIndex(a=>j.axis[a]===1)]=j.name
24
- const x=id==='finger-left'?'leftX':id==='finger-right'?'rightX':'zero'
25
- n(id+'Connection','rigid@1',[x,'zero','zero',...rot],{pose:id+'Connection'},{from:id+'bottom',to:p.parent+'top'})
26
- n(pose,'attach@1',[p.parent+'WorldTop',id+'Connection',id+'bottom'],{pose})
27
- }
28
- n(id+'WorldTop','compose@1',[pose,id+'top'],{pose:id+'WorldTop'})
29
- n(id+'WorldBottom','compose@1',[pose,id+'bottom'],{pose:id+'WorldBottom'})
30
- n(id,'place@1',[shape,pose],{placed:id+'Placed'})
31
- }
32
- return m
33
- }
34
- export function evaluateFull(args=defaults){return compile(fullModel()).evaluate(args).geometry.map(g=>{const p=seed.parts.find(p=>p.name===g.id);return {...g,segments:p.segments??8,material:structuredClone(p.material??{token:'palette.neutral'}),...(p.capability?{capability:structuredClone(p.capability)}:{})}})}
35
- export const sourceGeometry=p=>primitiveGeometry({...p,segments:p.segments??8})
@@ -1,68 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './full-robot.mjs'
4
- import {THREE} from './full-dryroom.mjs'
5
- import {compile} from './typed-plan.mjs'
6
- import {meshAudit} from './dryroom-mesh.mjs'
7
- const near=(a,b,tol=1e-7)=>assert.ok(Math.abs(a-b)<tol,`${a} != ${b}`)
8
- const vec=(a,b)=>a.forEach((v,i)=>near(v,b[i]))
9
- const vertices=g=>{const a=g.attributes.position;return Array.from({length:a.count},(_,i)=>[a.getX(i),a.getY(i),a.getZ(i)])}
10
- const deviation=(a,b)=>Math.max(...a.map(p=>Math.min(...b.map(q=>Math.hypot(...p.map((v,i)=>v-q[i]))))))
11
- const length=(p,args)=>p.name==='upper-arm'?args.upperLength:p.name==='forearm'?args.foreLength:p.transform.size.y
12
- // Independent Three.js matrix FK, without compiler/feature/attachment helpers.
13
- function oracle(args){
14
- const matrices={}
15
- for(const p of seed.parts){
16
- const h=length(p,args),parent=seed.parts.find(x=>x.name===p.parent),j=seed.joints.find(j=>j.child===p.name)
17
- const m=parent?matrices[parent.name].clone().multiply(new THREE.Matrix4().makeTranslation(0,length(parent,args)/2,0)):new THREE.Matrix4()
18
- if(j)m.multiply(new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(j.axis.x,j.axis.y,j.axis.z),args[j.name]*Math.PI/180))
19
- const x=p.name==='finger-left'?-35+args.gripper*.2:p.name==='finger-right'?35-args.gripper*.2:0
20
- matrices[p.name]=m.multiply(new THREE.Matrix4().makeTranslation(x,h/2,0))
21
- }
22
- return matrices
23
- }
24
- test('all ten parts: original rest vertices, materials and tool capability preserved',()=>{
25
- const out=evaluateFull();assert.equal(out.length,10)
26
- for(const p of seed.parts){
27
- const item=out.find(g=>g.id===p.name),a=sourceGeometry(p),b=geometryOf(item)
28
- near(Math.max(deviation(vertices(a),vertices(b)),deviation(vertices(b),vertices(a))),0,.001)
29
- if(p.material)assert.deepEqual(item.material,p.material)
30
- if(p.capability)assert.deepEqual(item.capability,p.capability)
31
- a.dispose();b.dispose()
32
- }
33
- })
34
- test('whole robot: independent link lengths × simultaneous six-joint states × gripper',()=>{
35
- const plan=compile(fullModel());let cases=0,meshes=0,pivots=0
36
- for(const upperLength of [210,420,840])for(const foreLength of [190,380,760])for(let k=0;k<32;k++)for(const gripper of [0,50,100]){
37
- const args={...defaults,upperLength,foreLength,gripper,...Object.fromEntries(seed.joints.map((j,i)=>[j.name,k===0?0:(j.limits?.min??-360)+(k*(i+1)%31)/30*((j.limits?.max??360)-(j.limits?.min??-360))]))}
38
- const r=plan.evaluate(args),expected=oracle(args);assert.equal(r.geometry.length,10)
39
- for(const item of r.geometry){
40
- const p=seed.parts.find(p=>p.name===item.id),matrix=expected[p.name],t=new THREE.Vector3().setFromMatrixPosition(matrix);vec(item.pose.t,t.toArray())
41
- const e=matrix.elements;item.pose.r.forEach((row,i)=>row.forEach((v,j)=>near(v,e[j*4+i])))
42
- if(seed.joints.some(j=>j.child===p.name)){vec(r.values[p.name+'WorldBottom'].t,r.values[p.parent+'WorldTop'].t);pivots++}
43
- const g=geometryOf({...item,segments:8}),a=meshAudit(g);assert.equal(a.badEdges,0);assert.equal(a.euler,2);assert.ok(a.volume>0&&a.minArea>0);g.dispose();meshes++
44
- }
45
- const a=r.values['finger-leftPose'].t,b=r.values['finger-rightPose'].t
46
- near(Math.hypot(...a.map((v,i)=>v-b[i])),70-gripper*.4)
47
- cases++
48
- }
49
- assert.equal(cases,864);console.log({wholeRobotCases:cases,meshChecks:meshes,pivotChecks:pivots})
50
- })
51
- test('joint limits and length errors reject, recovery and declaration order deterministic',()=>{
52
- const m=fullModel(),p=compile(m),r=p.evaluate(defaults)
53
- for(const j of seed.joints){const max=j.limits?.max??360;assert.throws(()=>p.evaluate({...defaults,[j.name]:max+1}),e=>e.code==='INPUT_RANGE')}
54
- assert.throws(()=>p.evaluate({...defaults,upperLength:0}),e=>e.code==='INPUT_RANGE');assert.deepEqual(p.evaluate(defaults),r)
55
- const original=JSON.stringify(seed);m.nodes.reverse();const reversed=compile(m).evaluate(defaults)
56
- const sort=x=>[...x.geometry].sort((a,b)=>a.id.localeCompare(b.id));assert.deepEqual(sort(reversed),sort(r));assert.equal(JSON.stringify(seed),original)
57
- })
58
- test('known source defect: gripper intersects tool-seat beyond 45 percent; not a collision-free certificate',()=>{
59
- // All three boxes share flange orientation, so exact local interval intersection suffices.
60
- for(const gripper of [0,45,50,100]){
61
- const gap=58-.4*gripper,seatWidth=seed.parts.find(p=>p.name==='tool-seat').transform.size.x
62
- const fingerCenter=35-.2*gripper
63
- const overlapX=Math.max(0,Math.min(fingerCenter+6,seatWidth/2)-Math.max(fingerCenter-6,-seatWidth/2))
64
- const overlapY=20,overlapZ=24
65
- assert.equal(overlapX*overlapY*overlapZ>0,gripper>45)
66
- if(gripper===100){near(gap,18);near(overlapX,11)}
67
- }
68
- })
@@ -1,147 +0,0 @@
1
- // Experimental executable contract. No product imports and no model-name branches.
2
- // Scalars are numeric in documented fixture units, NOT statically unit checked yet.
3
- export class DesignError extends Error {
4
- constructor(code, path, detail) { super(`${code}: ${path}: ${detail}`); this.code=code; this.path=path }
5
- }
6
- const fail=(code,path,detail)=>{throw new DesignError(code,path,detail)}
7
- const finite=(x,path)=>{if(!Number.isFinite(x))fail('NON_FINITE',path,String(x));return x}
8
- const add=(a,b)=>a.map((v,i)=>v+b[i])
9
- const sub=(a,b)=>a.map((v,i)=>v-b[i])
10
- const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
11
- const I=()=>[[1,0,0],[0,1,0],[0,0,1]]
12
- const mv=(r,v)=>r.map(row=>dot(row,v))
13
- const mm=(a,b)=>a.map(row=>[0,1,2].map(i=>dot(row,b.map(r=>r[i]))))
14
- export const point=(f,p)=>add(mv(f.rotation,p),f.position)
15
- function rot(axis,degrees,path) {
16
- const norm=Math.hypot(...axis)
17
- if(Math.abs(norm-1)>1e-10)fail('AXIS',path,'unit axis required')
18
- const [x,y,z]=axis,c=Math.cos(degrees*Math.PI/180),s=Math.sin(degrees*Math.PI/180),k=1-c
19
- return [[c+x*x*k,x*y*k-z*s,x*z*k+y*s],[y*x*k+z*s,c+y*y*k,y*z*k-x*s],[z*x*k-y*s,z*y*k+x*s,c+z*z*k]]
20
- }
21
-
22
- export function resolve(model,inputs) {
23
- const values={},active=new Set(),defs=model.values??{},spec=model.inputs??{}
24
- for(const k of Object.keys(inputs))if(!Object.hasOwn(spec,k))fail('UNKNOWN_INPUT',k,'not declared')
25
- for(const [k,s] of Object.entries(spec)) {
26
- if(Object.hasOwn(defs,k))fail('DUPLICATE_WRITER',k,'input and derived')
27
- const x=finite(inputs[k],k)
28
- if(x<s.min||x>s.max)fail('INPUT_RANGE',k,`${x} outside [${s.min},${s.max}]`)
29
- values[k]=x
30
- }
31
- function expression(e,path='expression') {
32
- if(typeof e==='number')return finite(e,path)
33
- if(typeof e==='string')return value(e)
34
- if(!e||!Array.isArray(e.args))fail('EXPRESSION',path,'expected number, reference or operator')
35
- const a=e.args.map(x=>expression(x,path))
36
- if(e.op==='add')return finite(a.reduce((s,x)=>s+x,0),path)
37
- if(e.op==='mul'&&a.length===2)return finite(a[0]*a[1],path)
38
- fail('EXPRESSION',path,'unsupported operator/arity')
39
- }
40
- function value(k) {
41
- if(Object.hasOwn(values,k))return values[k]
42
- if(!Object.hasOwn(defs,k))fail('UNKNOWN_REFERENCE',k,'no definition')
43
- if(active.has(k))fail('VALUE_CYCLE',k,[...active,k].join(' -> '))
44
- active.add(k);values[k]=expression(defs[k],k);active.delete(k);return values[k]
45
- }
46
- for(const k of Object.keys(defs))value(k)
47
- for(const c of model.assertions??[]) {
48
- const left=expression(c.left,c.id),right=expression(c.right,c.id)
49
- if(c.op!=='gte')fail('ASSERTION_OPERATOR',c.id,c.op)
50
- if(left<right)fail('ASSERTION',c.id,`${left} < ${right}; deficit=${right-left}`)
51
- }
52
- const frames={},visiting=new Set(),nodes={...model.frames},geometry={...model.geometry},shapeCache={}
53
- function vector(v,path) {
54
- if(!Array.isArray(v)||v.length!==3)fail('VECTOR',path,'three coordinates required')
55
- return v.map(e=>expression(e,path))
56
- }
57
- function repeatPlan(r,id) {
58
- const pitch=expression(r.pitch,id),axis=vector(r.axis,id)
59
- if(!(pitch>0)||Math.abs(Math.hypot(...axis)-1)>1e-10)fail('REPEAT_DOMAIN',id,'positive pitch and unit axis')
60
- const count=r.mode==='fixed'?expression(r.count,id):r.mode==='pitch'?Math.floor(expression(r.span,id)/pitch):NaN
61
- if(!Number.isInteger(count)||count<1||!Number.isInteger(r.limit)||count>r.limit)fail('REPEAT_DOMAIN',id,`count=${count}, limit=${r.limit}`)
62
- return {count,pitch,axis}
63
- }
64
- // Lower one-level repeated assemblies into the SAME frame/geometry evaluator.
65
- // No index-dependent scripting, cross-instance references or nested patterns yet.
66
- for(const [name,p] of Object.entries(model.patterns??{})) {
67
- const {count,pitch,axis}=repeatPlan(p.repeat,name)
68
- const localFrames=p.frames??{},localShapes=p.geometry??{}
69
- for(let i=0;i<count;i++) {
70
- const prefix=kind=>id=>JSON.stringify(['pattern',name,i,kind,id])
71
- const fid=prefix('frame'),gid=prefix('geometry'),root=fid('$root')
72
- const insert=(target,id,value)=>{if(Object.hasOwn(target,id))fail('DUPLICATE_ID',id,'expanded pattern');target[id]=value}
73
- if(Object.hasOwn(localFrames,'$root'))fail('RESERVED_ID',name,'$root')
74
- insert(nodes,root,{parent:p.parent,position:axis.map(x=>x*i*pitch)})
75
- for(const [id,n] of Object.entries(localFrames)) {
76
- if(n.feature&&n.parent)fail('DUPLICATE_PARENT',id,'feature and parent')
77
- if(n.parent&&!Object.hasOwn(localFrames,n.parent))fail('PATTERN_REFERENCE',name,n.parent)
78
- if(n.feature&&!Object.hasOwn(localShapes,n.feature.geometry))fail('PATTERN_REFERENCE',name,n.feature.geometry)
79
- insert(nodes,fid(id),{...n,parent:n.feature?undefined:n.parent?fid(n.parent):root,
80
- ...(n.feature?{feature:{...n.feature,geometry:gid(n.feature.geometry)}}:{})})
81
- }
82
- for(const [id,g] of Object.entries(localShapes)) {
83
- if(!Object.hasOwn(localFrames,g.frame))fail('PATTERN_REFERENCE',name,g.frame)
84
- if(g.repeat)fail('NESTED_REPEAT',name,id)
85
- insert(geometry,gid(id),{...g,frame:fid(g.frame)})
86
- }
87
- }
88
- }
89
- function shape(id) {
90
- if(Object.hasOwn(shapeCache,id))return shapeCache[id]
91
- if(!Object.hasOwn(geometry,id))fail('UNKNOWN_GEOMETRY',id,'no definition')
92
- const g=geometry[id],dimensions=Object.fromEntries(Object.entries(g.dimensions).map(([k,v])=>[k,expression(v,id)]))
93
- const required=g.kind==='box'?['width','height','depth']:g.kind==='cylinder'?['radius','length']:null
94
- if(!required||Object.keys(dimensions).length!==required.length||required.some(k=>!(dimensions[k]>0)))fail('GEOMETRY_DOMAIN',id,'positive provider dimensions required')
95
- return shapeCache[id]={g,dimensions,orientation:g.orientation?rot(vector(g.orientation.axis,id),expression(g.orientation.angle,id),id):I()}
96
- }
97
- function featureFrame(feature,id) {
98
- const {g,dimensions:d,orientation}=shape(feature.geometry)
99
- if(g.repeat)fail('AMBIGUOUS_FEATURE',id,'use a pattern instance, not a repeated primitive')
100
- const owner=frame(g.frame),shapeFrame={position:owner.position,rotation:mm(owner.rotation,orientation)}
101
- // Named points retain provider axes. These are NOT normal-oriented attachment frames.
102
- const points=g.kind==='box'?{'x-':[-d.width/2,0,0],'x+':[d.width/2,0,0],'y-':[0,-d.height/2,0],'y+':[0,d.height/2,0],'z-':[0,0,-d.depth/2],'z+':[0,0,d.depth/2]}:
103
- {'start':[0,-d.length/2,0],'end':[0,d.length/2,0]}
104
- if(!Object.hasOwn(points,feature.point))fail('UNKNOWN_FEATURE',id,feature.point)
105
- return {position:point(shapeFrame,points[feature.point]),rotation:shapeFrame.rotation}
106
- }
107
- function frame(id) {
108
- if(Object.hasOwn(frames,id))return frames[id]
109
- const node=nodes[id]
110
- if(!Object.hasOwn(nodes,id))fail('UNKNOWN_FRAME',id,'no definition')
111
- if(visiting.has(id))fail('FRAME_CYCLE',id,[...visiting,id].join(' -> '))
112
- visiting.add(id)
113
- if(node.feature&&node.parent)fail('DUPLICATE_PARENT',id,'feature and parent')
114
- const parent=node.feature?featureFrame(node.feature,id):node.parent?frame(node.parent):{position:[0,0,0],rotation:I()}
115
- const offset=vector(node.position??[0,0,0],id)
116
- const rest=node.orientation?rot(vector(node.orientation.axis,id),expression(node.orientation.angle),id):I()
117
- let localOffset=offset,localRotation=rest
118
- if(node.joint) {
119
- const j=node.joint,axis=vector(j.axis,id),q=expression(j.value,id)
120
- if(Math.abs(Math.hypot(...axis)-1)>1e-10)fail('AXIS',id,'unit axis required')
121
- if(j.type==='slide')localOffset=add(offset,mv(rest,axis.map(x=>x*q)))
122
- else if(j.type==='hinge') {
123
- const pivot=vector(j.pivot??[0,0,0],id),r=rot(axis,q,id)
124
- localOffset=add(offset,mv(rest,sub(pivot,mv(r,pivot))));localRotation=mm(rest,r)
125
- } else fail('JOINT',id,j.type)
126
- }
127
- frames[id]={position:point(parent,localOffset),rotation:mm(parent.rotation,localRotation)}
128
- if(![...frames[id].position,...frames[id].rotation.flat()].every(Number.isFinite))fail('NON_FINITE',id,'frame')
129
- visiting.delete(id);return frames[id]
130
- }
131
- for(const id of Object.keys(nodes))frame(id)
132
- const instances=[]
133
- for(const [id,g] of Object.entries(geometry)) {
134
- const owner=frame(g.frame),{dimensions,orientation}=shape(id)
135
- let count=1,pitch=0,axis=[1,0,0]
136
- if(g.repeat) {
137
- ;({count,pitch,axis}=repeatPlan(g.repeat,id))
138
- }
139
- // Shape-local orientation follows placement. It does not rotate the repetition row.
140
- for(let i=0;i<count;i++) {
141
- // Start-anchored identity policy for this experiment. Not approved migration semantics.
142
- instances.push({id:JSON.stringify([id,i]),source:id,index:i,kind:g.kind,dimensions:{...dimensions},
143
- frame:{rotation:mm(owner.rotation,orientation),position:point(owner,axis.map(x=>x*i*pitch))}})
144
- }
145
- }
146
- return {values,frames,instances}
147
- }