@hatiolab/figure-model 0.1.29 → 0.1.31

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 (146) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/release-contract.d.ts +2 -2
  7. package/dist/release-contract.d.ts.map +1 -1
  8. package/dist/release-contract.js +45 -3
  9. package/dist/release-contract.js.map +1 -1
  10. package/dist/types.d.ts +16 -5
  11. package/dist/types.d.ts.map +1 -1
  12. package/dist/types.js +15 -4
  13. package/dist/types.js.map +1 -1
  14. package/dist/v3-asset-types.d.ts +116 -0
  15. package/dist/v3-asset-types.d.ts.map +1 -0
  16. package/dist/v3-asset-types.js +2 -0
  17. package/dist/v3-asset-types.js.map +1 -0
  18. package/dist/v3-asset.d.ts +8 -0
  19. package/dist/v3-asset.d.ts.map +1 -0
  20. package/dist/v3-asset.js +135 -0
  21. package/dist/v3-asset.js.map +1 -0
  22. package/dist/v3-authoring-session.d.ts +119 -0
  23. package/dist/v3-authoring-session.d.ts.map +1 -0
  24. package/dist/v3-authoring-session.js +132 -0
  25. package/dist/v3-authoring-session.js.map +1 -0
  26. package/dist/v3-capabilities.d.ts +3 -0
  27. package/dist/v3-capabilities.d.ts.map +1 -0
  28. package/dist/v3-capabilities.js +221 -0
  29. package/dist/v3-capabilities.js.map +1 -0
  30. package/dist/v3-contract.d.ts +7 -0
  31. package/dist/v3-contract.d.ts.map +1 -0
  32. package/dist/v3-contract.js +12 -0
  33. package/dist/v3-contract.js.map +1 -0
  34. package/dist/v3-create.d.ts +25 -0
  35. package/dist/v3-create.d.ts.map +1 -0
  36. package/dist/v3-create.js +50 -0
  37. package/dist/v3-create.js.map +1 -0
  38. package/dist/v3-document.d.ts +5 -0
  39. package/dist/v3-document.d.ts.map +1 -0
  40. package/dist/v3-document.js +31 -0
  41. package/dist/v3-document.js.map +1 -0
  42. package/dist/v3-editor-workspace.d.ts +334 -0
  43. package/dist/v3-editor-workspace.d.ts.map +1 -0
  44. package/dist/v3-editor-workspace.js +130 -0
  45. package/dist/v3-editor-workspace.js.map +1 -0
  46. package/dist/v3-graph-types.d.ts +91 -0
  47. package/dist/v3-graph-types.d.ts.map +1 -0
  48. package/dist/v3-graph-types.js +2 -0
  49. package/dist/v3-graph-types.js.map +1 -0
  50. package/dist/v3-graph.d.ts +5 -0
  51. package/dist/v3-graph.d.ts.map +1 -0
  52. package/dist/v3-graph.js +631 -0
  53. package/dist/v3-graph.js.map +1 -0
  54. package/dist/v3-json.d.ts +13 -0
  55. package/dist/v3-json.d.ts.map +1 -0
  56. package/dist/v3-json.js +94 -0
  57. package/dist/v3-json.js.map +1 -0
  58. package/dist/v3-part-edit.d.ts +12 -0
  59. package/dist/v3-part-edit.d.ts.map +1 -0
  60. package/dist/v3-part-edit.js +53 -0
  61. package/dist/v3-part-edit.js.map +1 -0
  62. package/dist/validate.d.ts.map +1 -1
  63. package/dist/validate.js +12 -11
  64. package/dist/validate.js.map +1 -1
  65. package/docs/assembly-constraints-v3.md +141 -0
  66. package/docs/prototypes/README.md +73 -0
  67. package/docs/prototypes/render-dryroom.py +60 -0
  68. package/docs/prototypes/render-full-dryroom.mjs +86 -0
  69. package/docs/prototypes/shapeassembly-probe-results.json +325 -0
  70. package/docs/prototypes/shapeassembly-probe.py +84 -0
  71. package/docs/prototypes/v3-asset-samples.mjs +15 -0
  72. package/docs/prototypes/v3-asset-store.mjs +52 -0
  73. package/docs/prototypes/v3-asset-store.test.mjs +59 -0
  74. package/docs/prototypes/v3-asset.mjs +1 -0
  75. package/docs/prototypes/v3-asset.schema.json +44 -0
  76. package/docs/prototypes/v3-asset.test.mjs +68 -0
  77. package/docs/prototypes/v3-authoring-session.mjs +1 -0
  78. package/docs/prototypes/v3-authoring-session.test.mjs +65 -0
  79. package/docs/prototypes/v3-capability-plan.mjs +1 -0
  80. package/docs/prototypes/v3-capability-plan.test.mjs +56 -0
  81. package/docs/prototypes/v3-capability-samples.mjs +23 -0
  82. package/docs/prototypes/v3-document.mjs +2 -0
  83. package/docs/prototypes/v3-document.schema.json +66 -0
  84. package/docs/prototypes/v3-document.test.mjs +55 -0
  85. package/docs/prototypes/v3-dryroom-mesh.mjs +60 -0
  86. package/docs/prototypes/v3-dryroom-mesh.test.mjs +30 -0
  87. package/docs/prototypes/v3-editor-browser.mjs +97 -0
  88. package/docs/prototypes/v3-editor-workspace.mjs +1 -0
  89. package/docs/prototypes/v3-editor-workspace.test.mjs +35 -0
  90. package/docs/prototypes/v3-feature-pattern.test.mjs +131 -0
  91. package/docs/prototypes/v3-full-conveyor.mjs +60 -0
  92. package/docs/prototypes/v3-full-conveyor.test.mjs +61 -0
  93. package/docs/prototypes/v3-full-dryroom.mjs +67 -0
  94. package/docs/prototypes/v3-full-dryroom.test.mjs +54 -0
  95. package/docs/prototypes/v3-full-forklift.mjs +40 -0
  96. package/docs/prototypes/v3-full-forklift.test.mjs +37 -0
  97. package/docs/prototypes/v3-full-robot.mjs +35 -0
  98. package/docs/prototypes/v3-full-robot.test.mjs +68 -0
  99. package/docs/prototypes/v3-kernel.mjs +147 -0
  100. package/docs/prototypes/v3-kernel.test.mjs +144 -0
  101. package/docs/prototypes/v3-layout.test.mjs +281 -0
  102. package/docs/prototypes/v3-linear-comparison.py +183 -0
  103. package/docs/prototypes/v3-more-samples.test.mjs +182 -0
  104. package/docs/prototypes/v3-payload-collision.mjs +54 -0
  105. package/docs/prototypes/v3-payload-collision.test.mjs +68 -0
  106. package/docs/prototypes/v3-payload-fit.mjs +58 -0
  107. package/docs/prototypes/v3-payload-fit.test.mjs +59 -0
  108. package/docs/prototypes/v3-repeated-binding.mjs +13 -0
  109. package/docs/prototypes/v3-repeated-binding.test.mjs +46 -0
  110. package/docs/prototypes/v3-runtime-browser.mjs +76 -0
  111. package/docs/prototypes/v3-samples.test.mjs +144 -0
  112. package/docs/prototypes/v3-semantics.test.mjs +55 -0
  113. package/docs/prototypes/v3-spatial-plan.test.mjs +102 -0
  114. package/docs/prototypes/v3-typed-plan.mjs +3 -0
  115. package/docs/prototypes/v3-typed-plan.test.mjs +70 -0
  116. package/docs/prototypes/v3-typed-repeat.test.mjs +96 -0
  117. package/docs/v3-asset-persistence.md +66 -0
  118. package/docs/v3-authoring-transactions.md +57 -0
  119. package/docs/v3-capability-bindings.md +109 -0
  120. package/docs/v3-common-kernel-experiment.md +116 -0
  121. package/docs/v3-core-semantics.md +303 -0
  122. package/docs/v3-cutover.md +27 -0
  123. package/docs/v3-design.md +388 -0
  124. package/docs/v3-document-contract.md +55 -0
  125. package/docs/v3-dryroom-profile-validation.md +87 -0
  126. package/docs/v3-editor-integration.md +59 -0
  127. package/docs/v3-full-conveyor-validation.md +69 -0
  128. package/docs/v3-full-dryroom-validation.md +55 -0
  129. package/docs/v3-full-forklift-validation.md +41 -0
  130. package/docs/v3-full-robot-validation.md +64 -0
  131. package/docs/v3-layout-system.md +318 -0
  132. package/docs/v3-minimal-model.md +255 -0
  133. package/docs/v3-operator-contracts.md +148 -0
  134. package/docs/v3-payload-collision-validation.md +42 -0
  135. package/docs/v3-payload-contact-validation.md +56 -0
  136. package/docs/v3-repeated-binding-validation.md +42 -0
  137. package/docs/v3-research-review.md +160 -0
  138. package/docs/v3-runtime-extraction.md +107 -0
  139. package/docs/v3-sample-validation-expanded.md +106 -0
  140. package/docs/v3-sample-validation.md +97 -0
  141. package/docs/v3-shapeassembly-reproduction.md +99 -0
  142. package/docs/v3-spatial-contracts.md +84 -0
  143. package/docs/v3-status.md +41 -0
  144. package/docs/v3-storage-integration.md +58 -0
  145. package/docs/v3-typed-repeat-contracts.md +79 -0
  146. package/package.json +1 -1
@@ -0,0 +1,131 @@
1
+ import fs from 'node:fs'
2
+ import assert from 'node:assert/strict'
3
+ import {test} from 'node:test'
4
+ import {resolve,point,DesignError} from './v3-kernel.mjs'
5
+
6
+ const seeds=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url)))
7
+ const src=t=>seeds.find(s=>s.type===t).source
8
+ const part=(s,n)=>s.parts.find(p=>p.name===n)
9
+ const add=(...args)=>({op:'add',args}),mul=(...args)=>({op:'mul',args})
10
+ const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-7,`${a} != ${b}`)
11
+ const same=(a,b)=>a.forEach((x,i)=>near(x,b[i]))
12
+ const fid=(p,i,n)=>JSON.stringify(['pattern',p,i,'frame',n])
13
+ const gid=(p,i,n)=>JSON.stringify(['pattern',p,i,'geometry',n])
14
+ const instance=(r,p,i,n)=>r.instances.find(g=>g.source===gid(p,i,n))
15
+ const limits=(min,max)=>({min,max})
16
+
17
+ function tray() {
18
+ const s=src('FORMATION_TRAY'),cell=part(s,'cell'),contact=part(s,'contact')
19
+ const c=cell.transform,t=contact.transform
20
+ return {inputs:{scale:limits(.5,2),angle:limits(-180,180)},
21
+ frames:{assembly:{position:[mul(-3.5*cell.repeat.pitch,'scale'),c.position.y,0],orientation:{axis:[0,1,0],angle:'angle'}}},
22
+ patterns:{slots:{parent:'assembly',repeat:{mode:'fixed',count:8,pitch:mul(cell.repeat.pitch,'scale'),axis:[1,0,0],limit:32},
23
+ frames:{cell:{},contact:{feature:{geometry:'cell',point:'y+'},position:[0,t.position.y-c.position.y-c.size.y/2,0]}},
24
+ geometry:{cell:{frame:'cell',kind:'box',dimensions:{width:mul(c.size.x,'scale'),height:c.size.y,depth:c.size.z}},contact:{frame:'contact',kind:'box',dimensions:{width:mul(t.size.x,'scale'),height:t.size.y,depth:t.size.z}}}}}}
25
+ }
26
+ function charger() {
27
+ const s=src('FORMATION_CHARGER'),shelf=part(s,'shelf'),lamp=part(s,'channel-lamp')
28
+ return {inputs:{height:limits(600,8000),depth:limits(300,1200),angle:limits(-180,180)},values:{shelfDepth:add('depth',-140)},
29
+ frames:{cabinet:{orientation:{axis:[0,0,1],angle:'angle'}},start:{parent:'cabinet',position:[0,200,shelf.transform.position.z]}},
30
+ patterns:{levels:{parent:'start',repeat:{mode:'pitch',span:add('height',-400),pitch:shelf.repeat.pitch,axis:[0,1,0],limit:20},
31
+ frames:{shelf:{},lamp:{feature:{geometry:'shelf',point:'z+'},position:[lamp.transform.position.x,0,15]}},
32
+ geometry:{shelf:{frame:'shelf',kind:'box',dimensions:{width:1400,height:40,depth:'shelfDepth'}},lamp:{frame:'lamp',kind:'box',dimensions:{width:90,height:60,depth:40}}}}}}
33
+ }
34
+ function conveyor() {
35
+ const p=part(src('CONVEYOR'),'roller')
36
+ return {inputs:{width:limits(300,15000),depth:limits(100,1200),spin:limits(-360,360)},
37
+ frames:{row:{position:[add(mul(-.5,'width'),75),p.transform.position.y,0]}},
38
+ patterns:{rollers:{parent:'row',repeat:{mode:'pitch',span:'width',pitch:p.repeat.pitch,axis:[1,0,0],limit:100},
39
+ frames:{roller:{orientation:{axis:[1,0,0],angle:90},joint:{type:'hinge',axis:[0,1,0],value:'spin'}},end:{feature:{geometry:'roller',point:'end'}}},
40
+ geometry:{roller:{frame:'roller',kind:'cylinder',dimensions:{radius:48,length:add('depth',-80)}}}}}}
41
+ }
42
+
43
+ test('fixed paired tray: 5 widths × 5 poses, actual cell/contact rest offset shared',()=>{
44
+ const model=tray(),before=JSON.stringify(model)
45
+ for(const scale of [.5,.75,1,1.5,2])for(const angle of [-90,-30,0,45,180]) {
46
+ const out=resolve(model,{scale,angle});assert.equal(out.instances.length,16)
47
+ for(let i=0;i<8;i++) {
48
+ const cell=instance(out,'slots',i,'cell'),contact=instance(out,'slots',i,'contact')
49
+ same(contact.frame.position,point(cell.frame,[0,90,0]))
50
+ near(contact.frame.position[1]-contact.dimensions.height/2-(cell.frame.position[1]+cell.dimensions.height/2),-2)
51
+ if(i)near(Math.hypot(...cell.frame.position.map((x,a)=>x-instance(out,'slots',i-1,'cell').frame.position[a])),68*scale)
52
+ }
53
+ }
54
+ assert.equal(JSON.stringify(model),before)
55
+ })
56
+
57
+ test('feature uses changed geometry dimension once, not an independent stale attachment value',()=>{
58
+ const m=tray();m.inputs.cellHeight=limits(50,300);m.patterns.slots.geometry.cell.dimensions.height='cellHeight'
59
+ for(const cellHeight of [50,140,300]) {
60
+ const out=resolve(m,{scale:1,angle:0,cellHeight})
61
+ for(let i=0;i<8;i++)near(instance(out,'slots',i,'contact').frame.position[1]-instance(out,'slots',i,'cell').frame.position[1],cellHeight/2+20)
62
+ }
63
+ })
64
+
65
+ test('pitch paired shelves: 5 count boundaries × 3 depths × 3 poses',()=>{
66
+ const m=charger()
67
+ for(const height of [2799.99,2800,2800.01,3200,5600])for(const depth of [300,700,1200])for(const angle of [-30,0,90]) {
68
+ const out=resolve(m,{height,depth,angle}),count=Math.floor((height-400)/400)
69
+ assert.equal(out.instances.length,count*2)
70
+ for(let i=0;i<count;i++) {
71
+ const shelf=instance(out,'levels',i,'shelf'),lamp=instance(out,'levels',i,'lamp')
72
+ same(lamp.frame.position,point(shelf.frame,[-620,0,shelf.dimensions.depth/2+15]))
73
+ if(angle===0){near(shelf.frame.position[1],200+i*400);near(lamp.frame.position[2],20+(depth-140)/2+15)}
74
+ }
75
+ }
76
+ })
77
+
78
+ test('pattern conveyor: actual child hinges now spin without moving repetition centers',()=>{
79
+ const m=conveyor()
80
+ for(const width of [1200,2400,4800])for(const depth of [300,600,1200])for(const spin of [-180,-90,0,90,180]) {
81
+ const out=resolve(m,{width,depth,spin})
82
+ for(let i=0;i<out.instances.length;i++) {
83
+ const g=instance(out,'rollers',i,'roller')
84
+ same(g.frame.position,[-width/2+75+i*150,500,0])
85
+ same(out.frames[fid('rollers',i,'end')].position,[-width/2+75+i*150,500,(depth-80)/2])
86
+ }
87
+ }
88
+ })
89
+
90
+ test('grow/shrink/regrow: retained ids and placement stable; removed references fail explicitly',()=>{
91
+ const m=charger(),args={depth:700,angle:0},small=resolve(m,{...args,height:2799.99}),big=resolve(m,{...args,height:2800})
92
+ assert.equal(small.instances.length,10);assert.equal(big.instances.length,12)
93
+ for(const g of small.instances)assert.deepEqual(big.instances.find(h=>h.id===g.id),g)
94
+ assert.deepEqual(resolve(m,{...args,height:2799.99}).instances,small.instances)
95
+ assert.deepEqual(resolve(m,{...args,height:2800}).instances,big.instances)
96
+ const referenced=structuredClone(m)
97
+ referenced.frames.target={parent:fid('levels',5,'shelf')}
98
+ resolve(referenced,{...args,height:2800})
99
+ assert.throws(()=>resolve(referenced,{...args,height:2799.99}),e=>e.code==='UNKNOWN_FRAME')
100
+ // This experiment rejects dangling references, it does not implement automatic rebinding.
101
+ })
102
+
103
+ test('feature includes shape-local rotation as well as owner motion',()=>{
104
+ const m={inputs:{spin:limits(-180,180)},frames:{base:{position:[1,2,3],joint:{type:'hinge',axis:[0,0,1],value:'spin'}},tip:{feature:{geometry:'beam',point:'end'}}},
105
+ geometry:{beam:{frame:'base',kind:'cylinder',dimensions:{radius:5,length:100},orientation:{axis:[1,0,0],angle:90}}}}
106
+ for(const spin of [-90,0,90]) {
107
+ const r=resolve(m,{spin});same(r.frames.tip.position,point(r.instances[0].frame,[0,50,0]))
108
+ }
109
+ })
110
+
111
+ test('invalid feature cycles, unknown labels, ambiguous repeats, local escape and duplicate parents reject',()=>{
112
+ const base=tray(),args={scale:1,angle:0}
113
+ const cases=[]
114
+ let m=structuredClone(base);m.patterns.slots.frames.cell={feature:{geometry:'cell',point:'y+'}};cases.push([m,'FRAME_CYCLE'])
115
+ m=structuredClone(base);m.patterns.slots.frames.contact.feature.point='not-a-face';cases.push([m,'UNKNOWN_FEATURE'])
116
+ m=structuredClone(base);m.patterns.slots.frames.contact.parent='cell';cases.push([m,'DUPLICATE_PARENT'])
117
+ m=structuredClone(base);m.patterns.slots.frames.contact={parent:'outside'};cases.push([m,'PATTERN_REFERENCE'])
118
+ m=structuredClone(base);m.patterns.slots.repeat.count=1.5;cases.push([m,'REPEAT_DOMAIN'])
119
+ m=structuredClone(base);m.patterns.slots.geometry.cell.repeat={};cases.push([m,'NESTED_REPEAT'])
120
+ for(const [model,code] of cases)assert.throws(()=>resolve(model,args),e=>e instanceof DesignError&&e.code===code)
121
+ const repeated={frames:{base:{},tip:{feature:{geometry:'beam',point:'end'}}},geometry:{beam:{frame:'base',kind:'cylinder',dimensions:{radius:5,length:10},repeat:{mode:'fixed',count:2,pitch:20,axis:[1,0,0],limit:5}}}}
122
+ assert.throws(()=>resolve(repeated,{}),e=>e.code==='AMBIGUOUS_FEATURE')
123
+ })
124
+
125
+ test('declaration order is irrelevant across geometry feature and repeated frame dependencies',()=>{
126
+ const m=tray(),args={scale:1.5,angle:45},expected=resolve(m,args)
127
+ const reverse=o=>Object.fromEntries(Object.entries(o).reverse())
128
+ m.patterns.slots.frames=reverse(m.patterns.slots.frames);m.patterns.slots.geometry=reverse(m.patterns.slots.geometry)
129
+ const sort=r=>r.instances.toSorted((a,b)=>a.id.localeCompare(b.id))
130
+ assert.deepEqual(sort(resolve(m,args)),sort(expected))
131
+ })
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs'
2
+ import {compile} from './v3-typed-plan.mjs'
3
+ import {THREE,rounded} from './v3-full-dryroom.mjs'
4
+ 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==='CONVEYOR').source
5
+ export const defaults={length:2400,width:600,rollerHeight:500,angle:0}
6
+ export function fullModel(){
7
+ const m={version:'r3-spatial-1',inputs:[['length',900,6000],['width',400,1200],['rollerHeight',400,1200]].map(([id,min,max])=>({id,unit:'mm',min,max})).concat({id:'angle',unit:'deg',min:-360,max:360,role:'state'}),constants:[],nodes:[]}
8
+ const c=(id,value,unit='mm')=>{m.constants.push({id,value,unit});return id}
9
+ const n=(id,op,args,outputs,params)=>m.nodes.push({id,op,args,outputs,...(params?{params}:{})})
10
+ const calc=(id,op,a,b)=>{n(id,op,[a,b],{value:id});return id}
11
+ c('zero',0);c('a0',0,'deg');c('quarter',90,'deg');c('half',.5,'ratio');c('negative',-1,'ratio')
12
+ const add=(id,a,v)=>calc(id,'add@1',a,c(id+'Offset',v))
13
+ calc('halfLength','mul@1','length','half');calc('halfWidth','mul@1','width','half')
14
+ add('endX','halfLength',-60);add('legX','halfLength',-260);add('frameZ','halfWidth',-18);add('guardZ','halfWidth',-8);add('legZ','halfWidth',-56)
15
+ add('frameY','rollerHeight',-54);add('guardY','rollerHeight',52);add('deckY','rollerHeight',-110)
16
+ add('legHeight','rollerHeight',-144);calc('halfLeg','mul@1','legHeight','half');add('legY','halfLeg',42)
17
+ add('rollerLength','width',-80);add('endLength','width',-40);add('deckWidth','width',-100);add('footWidth','width',20)
18
+ add('motorZ','halfWidth',40);add('gearZ','halfWidth',-48);add('driveY','rollerHeight',-170);add('controlY','rollerHeight',-270)
19
+ const neg=id=>{const key='neg'+id;if(!m.nodes.some(n=>n.id===key))calc(key,'mul@1',id,'negative');return key}
20
+ const rigid=(id,from,to,xyz,rot=['a0','a0','a0'])=>n(id,'rigid@1',[...xyz,...rot],{pose:id},{from,to})
21
+ for(const p of seed.parts){
22
+ const id=p.name,d=p.transform.size;let xyz=['zero','zero','zero'],dims=['x','y','z'].map(a=>c(id+a,d[a]))
23
+ if(id==='deck'){dims=['length',dims[1],'deckWidth'];xyz=['zero','deckY','zero']}
24
+ else if(id.startsWith('frame-')||id.startsWith('guard-')){const family=id.split('-')[0];dims[0]='length';xyz=['zero',family+'Y',id.endsWith('left')?neg(family+'Z'):family+'Z']}
25
+ else if(id.startsWith('leg-')){dims[1]='legHeight';xyz=[id.includes('head')?neg('legX'):'legX','legY',id.endsWith('left')?neg('legZ'):'legZ']}
26
+ else if(id.startsWith('foot-')){dims[2]='footWidth';xyz=[id.endsWith('head')?neg('legX'):'legX',c(id+'Y',24),'zero']}
27
+ else if(id.startsWith('roller')){dims[1]=id==='roller'?'rollerLength':'endLength';xyz=[id==='roller'?'zero':id.endsWith('head')?neg('endX'):'endX','rollerHeight','zero']}
28
+ else if(id==='motor'||id==='gearbox')xyz=[neg('endX'),'driveY',id==='motor'?'motorZ':'gearZ']
29
+ else if(id==='control-box')xyz=['legX','controlY','halfWidth']
30
+ else throw Error('Unmapped part '+id)
31
+ const shape=id+'Shape',pose=id+'Pose'
32
+ if(p.primitive==='cylinder')n(shape,'cylinder-shape@1',[c(id+'Radius',d.x/2),dims[1]],{shape},{frame:id})
33
+ else if(p.primitive==='rect')n(shape,'rounded-box@1',[...dims,c(id+'Round',p.shape.round)],{shape},{frame:id})
34
+ else n(shape,'box-shape@1',dims,{shape},{frame:id})
35
+ rigid(pose,id,id==='roller'?'rollerItem':'world',xyz,p.primitive==='cylinder'?['quarter',id==='roller'?'angle':'a0','a0']:undefined)
36
+ n(id,id==='roller'?'member@1':'place@1',[shape,pose],{placed:id+'Placed'})
37
+ }
38
+ // End roller center is 60mm from the edge, radius 75mm; reserve an additional 10mm gap.
39
+ n('layout','fit-pitch@1',['length',c('margin',145),'margin',c('extent',96),c('pitch',150),c('align',.5,'ratio'),c('limit',100,'count')],{layout:'layout'})
40
+ n('assembly','assembly@1',['rollerPlaced'],{assembly:'assembly'})
41
+ rigid('repeatRoot','rollerItem','world',[neg('halfLength'),'zero','zero'])
42
+ n('rollers','repeat@1',['layout','assembly','repeatRoot'],{collection:'rollers'},{axis:'x'})
43
+ return m
44
+ }
45
+ export function evaluateFull(args=defaults){return compile(fullModel()).evaluate(args).geometry.map(g=>{const p=seed.parts.find(p=>p.name===(g.key?'roller':g.id));return {...g,sourceName:p.name,segments:p.segments,material:structuredClone(p.material)}})}
46
+ export function geometryOf(item){
47
+ const d=item.shape.dimensions;let g
48
+ if(item.shape.provider==='box')g=new THREE.BoxGeometry(d.width,d.height,d.depth)
49
+ 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)}
50
+ else g=new THREE.CylinderGeometry(d.radius,d.radius,d.length,item.segments)
51
+ 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));return g.index?g.toNonIndexed():g
52
+ }
53
+ export function sourceGeometry(p){
54
+ const d=p.transform.size;let g
55
+ if(p.primitive==='cube')g=new THREE.BoxGeometry(d.x,d.y,d.z)
56
+ else if(p.primitive==='cylinder')g=new THREE.CylinderGeometry(d.x/2,d.z/2,d.y,p.segments)
57
+ else{g=new THREE.ExtrudeGeometry(rounded(d.x,d.z,p.shape.round),{depth:d.y,steps:1,bevelEnabled:false});g.rotateX(Math.PI/2);g.translate(0,d.y/2,0)}
58
+ 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);const t=p.transform.position;g.translate(t.x,t.y,t.z);return g.index?g.toNonIndexed():g
59
+ }
60
+ export function sourceItems(){return seed.parts.flatMap(p=>{const count=p.repeat?Math.floor(seed.base.x/p.repeat.pitch):1;return Array.from({length:count},(_,i)=>{const geometry=sourceGeometry(p);if(p.repeat)geometry.translate((i-(count-1)/2)*p.repeat.pitch,0,0);return {id:p.name+'-'+i,material:p.material,geometry}})})}
@@ -0,0 +1,61 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './v3-full-conveyor.mjs'
4
+ import {compile} from './v3-typed-plan.mjs'
5
+ import {meshAudit} from './v3-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
+ })
@@ -0,0 +1,67 @@
1
+ import * as THREE from '../../../things-scene/node_modules/three/build/three.module.js'
2
+ import {seed} from './v3-dryroom-mesh.mjs'
3
+ import {compile} from './v3-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
+ }
@@ -0,0 +1,54 @@
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 './v3-full-dryroom.mjs'
5
+ import {compile} from './v3-typed-plan.mjs'
6
+ import {meshAudit} from './v3-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
+ })
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs'
2
+ import {compile} from './v3-typed-plan.mjs'
3
+ import {geometryOf,sourceGeometry as primitiveGeometry} from './v3-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'}:{})})
@@ -0,0 +1,37 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './v3-full-forklift.mjs'
4
+ import {compile} from './v3-typed-plan.mjs'
5
+ import {meshAudit} from './v3-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
+ })
@@ -0,0 +1,35 @@
1
+ import fs from 'node:fs'
2
+ import {compile} from './v3-typed-plan.mjs'
3
+ import {geometryOf,sourceGeometry as primitiveGeometry} from './v3-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})
@@ -0,0 +1,68 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {seed,defaults,fullModel,evaluateFull,geometryOf,sourceGeometry} from './v3-full-robot.mjs'
4
+ import {THREE} from './v3-full-dryroom.mjs'
5
+ import {compile} from './v3-typed-plan.mjs'
6
+ import {meshAudit} from './v3-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
+ })