@hatiolab/figure-model 0.1.30 → 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 (143) 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 +15 -4
  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/docs/assembly-constraints-v3.md +141 -0
  63. package/docs/prototypes/README.md +73 -0
  64. package/docs/prototypes/render-dryroom.py +60 -0
  65. package/docs/prototypes/render-full-dryroom.mjs +86 -0
  66. package/docs/prototypes/shapeassembly-probe-results.json +325 -0
  67. package/docs/prototypes/shapeassembly-probe.py +84 -0
  68. package/docs/prototypes/v3-asset-samples.mjs +15 -0
  69. package/docs/prototypes/v3-asset-store.mjs +52 -0
  70. package/docs/prototypes/v3-asset-store.test.mjs +59 -0
  71. package/docs/prototypes/v3-asset.mjs +1 -0
  72. package/docs/prototypes/v3-asset.schema.json +44 -0
  73. package/docs/prototypes/v3-asset.test.mjs +68 -0
  74. package/docs/prototypes/v3-authoring-session.mjs +1 -0
  75. package/docs/prototypes/v3-authoring-session.test.mjs +65 -0
  76. package/docs/prototypes/v3-capability-plan.mjs +1 -0
  77. package/docs/prototypes/v3-capability-plan.test.mjs +56 -0
  78. package/docs/prototypes/v3-capability-samples.mjs +23 -0
  79. package/docs/prototypes/v3-document.mjs +2 -0
  80. package/docs/prototypes/v3-document.schema.json +66 -0
  81. package/docs/prototypes/v3-document.test.mjs +55 -0
  82. package/docs/prototypes/v3-dryroom-mesh.mjs +60 -0
  83. package/docs/prototypes/v3-dryroom-mesh.test.mjs +30 -0
  84. package/docs/prototypes/v3-editor-browser.mjs +97 -0
  85. package/docs/prototypes/v3-editor-workspace.mjs +1 -0
  86. package/docs/prototypes/v3-editor-workspace.test.mjs +35 -0
  87. package/docs/prototypes/v3-feature-pattern.test.mjs +131 -0
  88. package/docs/prototypes/v3-full-conveyor.mjs +60 -0
  89. package/docs/prototypes/v3-full-conveyor.test.mjs +61 -0
  90. package/docs/prototypes/v3-full-dryroom.mjs +67 -0
  91. package/docs/prototypes/v3-full-dryroom.test.mjs +54 -0
  92. package/docs/prototypes/v3-full-forklift.mjs +40 -0
  93. package/docs/prototypes/v3-full-forklift.test.mjs +37 -0
  94. package/docs/prototypes/v3-full-robot.mjs +35 -0
  95. package/docs/prototypes/v3-full-robot.test.mjs +68 -0
  96. package/docs/prototypes/v3-kernel.mjs +147 -0
  97. package/docs/prototypes/v3-kernel.test.mjs +144 -0
  98. package/docs/prototypes/v3-layout.test.mjs +281 -0
  99. package/docs/prototypes/v3-linear-comparison.py +183 -0
  100. package/docs/prototypes/v3-more-samples.test.mjs +182 -0
  101. package/docs/prototypes/v3-payload-collision.mjs +54 -0
  102. package/docs/prototypes/v3-payload-collision.test.mjs +68 -0
  103. package/docs/prototypes/v3-payload-fit.mjs +58 -0
  104. package/docs/prototypes/v3-payload-fit.test.mjs +59 -0
  105. package/docs/prototypes/v3-repeated-binding.mjs +13 -0
  106. package/docs/prototypes/v3-repeated-binding.test.mjs +46 -0
  107. package/docs/prototypes/v3-runtime-browser.mjs +76 -0
  108. package/docs/prototypes/v3-samples.test.mjs +144 -0
  109. package/docs/prototypes/v3-semantics.test.mjs +55 -0
  110. package/docs/prototypes/v3-spatial-plan.test.mjs +102 -0
  111. package/docs/prototypes/v3-typed-plan.mjs +3 -0
  112. package/docs/prototypes/v3-typed-plan.test.mjs +70 -0
  113. package/docs/prototypes/v3-typed-repeat.test.mjs +96 -0
  114. package/docs/v3-asset-persistence.md +66 -0
  115. package/docs/v3-authoring-transactions.md +57 -0
  116. package/docs/v3-capability-bindings.md +109 -0
  117. package/docs/v3-common-kernel-experiment.md +116 -0
  118. package/docs/v3-core-semantics.md +303 -0
  119. package/docs/v3-cutover.md +27 -0
  120. package/docs/v3-design.md +388 -0
  121. package/docs/v3-document-contract.md +55 -0
  122. package/docs/v3-dryroom-profile-validation.md +87 -0
  123. package/docs/v3-editor-integration.md +59 -0
  124. package/docs/v3-full-conveyor-validation.md +69 -0
  125. package/docs/v3-full-dryroom-validation.md +55 -0
  126. package/docs/v3-full-forklift-validation.md +41 -0
  127. package/docs/v3-full-robot-validation.md +64 -0
  128. package/docs/v3-layout-system.md +318 -0
  129. package/docs/v3-minimal-model.md +255 -0
  130. package/docs/v3-operator-contracts.md +148 -0
  131. package/docs/v3-payload-collision-validation.md +42 -0
  132. package/docs/v3-payload-contact-validation.md +56 -0
  133. package/docs/v3-repeated-binding-validation.md +42 -0
  134. package/docs/v3-research-review.md +160 -0
  135. package/docs/v3-runtime-extraction.md +107 -0
  136. package/docs/v3-sample-validation-expanded.md +106 -0
  137. package/docs/v3-sample-validation.md +97 -0
  138. package/docs/v3-shapeassembly-reproduction.md +99 -0
  139. package/docs/v3-spatial-contracts.md +84 -0
  140. package/docs/v3-status.md +41 -0
  141. package/docs/v3-storage-integration.md +58 -0
  142. package/docs/v3-typed-repeat-contracts.md +79 -0
  143. package/package.json +1 -1
@@ -0,0 +1,147 @@
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
+ }
@@ -0,0 +1,144 @@
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 robot=JSON.parse(fs.readFileSync(new URL('../../src/fixtures/robot-arm-6dof.json',import.meta.url)))
8
+ const source=t=>seeds.find(s=>s.type===t).source
9
+ const part=(s,n)=>s.parts.find(p=>p.name===n)
10
+ const add=(...args)=>({op:'add',args}),mul=(a,b)=>({op:'mul',args:[a,b]})
11
+ const range=(min,max)=>({min,max})
12
+ const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-7,`${a} != ${b}`)
13
+ const xyz=v=>[v.x,v.y,v.z]
14
+ const equal=(a,b)=>a.forEach((v,i)=>near(v,b[i]))
15
+ const diff=(a,b)=>a.map((x,i)=>x-b[i])
16
+
17
+ // Authored fixture adapters only. They are not general-purpose v2 migration.
18
+ function conveyor() {
19
+ const s=source('CONVEYOR'),p=part(s,'roller'),pitch=p.repeat.pitch,r=p.transform.size.x/2
20
+ return {inputs:{width:range(100,16000),depth:range(81,1800),angle:range(-360,360)},
21
+ values:{length:add('depth',-80)},assertions:[],
22
+ frames:{row:{position:[add(mul(-.5,'width'),pitch/2),p.transform.position.y,0],orientation:{axis:[1,0,0],angle:90},joint:{type:'hinge',axis:[0,1,0],value:'angle'}}},
23
+ geometry:{roller:{frame:'row',kind:'cylinder',dimensions:{radius:r,length:'length'},repeat:{mode:'pitch',span:'width',pitch,axis:[1,0,0],limit:100}}}}
24
+ }
25
+ // Repetition is a placement operation, not movement around the roller's own axis.
26
+ // Give each roller a spinning feature via separate evaluation in the test below.
27
+ // The candidate above deliberately exposes whether joint order corrupts row placement.
28
+ function cradle() {
29
+ const s=source('ROLL_CRADLE'),roll=part(s,'roll'),core=part(s,'core')
30
+ return {inputs:{radial:range(.5,2),axial:range(.5,2),fed:range(0,100)},
31
+ values:{fraction:add(1,mul(-.0068,'fed')),radius:mul(mul(roll.transform.size.x/2,'radial'),'fraction'),coreRadius:mul(core.transform.size.x/2,'radial')},
32
+ assertions:[{id:'roll-outside-core',op:'gte',left:'radius',right:'coreRadius'}],
33
+ frames:{axis:{position:xyz(roll.transform.position),orientation:{axis:[0,0,1],angle:90}}},
34
+ geometry:{roll:{frame:'axis',kind:'cylinder',dimensions:{radius:'radius',length:mul(roll.transform.size.y,'axial')}},core:{frame:'axis',kind:'cylinder',dimensions:{radius:'coreRadius',length:mul(core.transform.size.y,'axial')}}}}
35
+ }
36
+ function robotModel() {
37
+ const model={inputs:{linkScale:range(.5,2)},values:{},frames:{},geometry:{}}
38
+ for(const j of robot.joints)model.inputs[j.name]=range(j.limits?.min??-360,j.limits?.max??360)
39
+ for(const p of robot.parts) {
40
+ const parent=p.parent?xyz(part(robot,p.parent).transform.position):[0,0,0]
41
+ const relative=diff(xyz(p.transform.position),parent),j=robot.joints.find(j=>j.child===p.name)
42
+ model.frames[p.name]={parent:p.parent,position:relative.map((v,i)=>i===1?mul(v,'linkScale'):v)}
43
+ if(j)model.frames[p.name].joint={type:'hinge',axis:xyz(j.axis),value:j.name,
44
+ pivot:diff(xyz(j.origin),xyz(p.transform.position)).map((v,i)=>i===1?mul(v,'linkScale'):v)}
45
+ const size=p.transform.size
46
+ model.geometry[p.name]={frame:p.name,kind:p.primitive==='cylinder'?'cylinder':'box',dimensions:p.primitive==='cylinder'?
47
+ {radius:size.x/2,length:mul(size.y,'linkScale')}:{width:size.x,height:mul(size.y,'linkScale'),depth:size.z}}
48
+ }
49
+ return model
50
+ }
51
+ function forklift() {
52
+ const s=source('FORKLIFT'),m={inputs:{heightScale:range(.5,2),lift:range(0,2000),reach:range(0,400)},values:{},frames:{},geometry:{}}
53
+ // Motion-only frames at rest origin; all descendants retain original source rest coordinates.
54
+ m.frames.reach={joint:{type:'slide',axis:[0,0,1],value:'reach'}}
55
+ m.frames.stage={parent:'reach',joint:{type:'slide',axis:[0,1,0],value:mul(.5,'lift')}}
56
+ m.frames.carriageMotion={parent:'stage',joint:{type:'slide',axis:[0,1,0],value:mul(.5,'lift')}}
57
+ for(const p of s.parts) {
58
+ const parent=p.name.startsWith('mast-')?'reach':p.name.startsWith('stage-')?'stage':(p.name==='carriage'||p.name.startsWith('fork-'))?'carriageMotion':undefined
59
+ m.frames[p.name]={parent,position:xyz(p.transform.position).map((v,i)=>i===1?mul(v,'heightScale'):v)}
60
+ if(p.transform.rotation)m.frames[p.name].orientation={axis:[0,0,1],angle:p.transform.rotation.z}
61
+ const d=p.transform.size
62
+ m.geometry[p.name]={frame:p.name,kind:p.primitive==='cylinder'?'cylinder':'box',dimensions:p.primitive==='cylinder'?{radius:d.x/2,length:d.y}:{width:d.x,height:mul(d.y,'heightScale'),depth:d.z}}
63
+ }
64
+ return m
65
+ }
66
+
67
+ test('robot: common kernel, 3 link scales × 32 poses; shared pivots and rest geometry',()=>{
68
+ const model=robotModel();let checks=0
69
+ for(const scale of [.5,1,2])for(let n=0;n<32;n++) {
70
+ const inputs={linkScale:scale,...Object.fromEntries(robot.joints.map((j,i)=>[j.name,n===0?0:(j.limits?.min??-360)+(n*(i+1)%31)/30*((j.limits?.max??360)-(j.limits?.min??-360))]))}
71
+ const out=resolve(model,inputs)
72
+ assert.equal(out.instances.length,10)
73
+ for(const j of robot.joints) {
74
+ const p=part(robot,j.child),parent=part(robot,p.parent)
75
+ const childPivot=diff(xyz(j.origin),xyz(p.transform.position)).map((v,i)=>i===1?v*scale:v)
76
+ const parentPivot=diff(xyz(j.origin),xyz(parent.transform.position)).map((v,i)=>i===1?v*scale:v)
77
+ equal(point(out.frames[p.name],childPivot),point(out.frames[parent.name],parentPivot));checks++
78
+ }
79
+ if(n===0)for(const p of robot.parts)equal(out.frames[p.name].position,xyz(p.transform.position).map((v,i)=>i===1?v*scale:v))
80
+ }
81
+ console.log({robotStates:96,pivotChecks:checks})
82
+ })
83
+
84
+ test('cradle: common kernel, 3 radial × 3 axial × 5 feed states',()=>{
85
+ const model=cradle()
86
+ for(const radial of [.5,1,2])for(const axial of [.5,1,2])for(const fed of [0,25,50,75,100]) {
87
+ const out=resolve(model,{radial,axial,fed}),[roll,core]=out.instances
88
+ near(roll.dimensions.radius,350*radial*(1-.0068*fed));near(roll.dimensions.length,1000*axial)
89
+ assert.ok(roll.dimensions.radius>=core.dimensions.radius)
90
+ equal(roll.frame.position,core.frame.position)
91
+ }
92
+ })
93
+
94
+ test('forklift: common kernel, 3 sizes × 3 lifts × 3 reaches, no double inheritance',()=>{
95
+ const model=forklift()
96
+ for(const heightScale of [.5,1,2])for(const lift of [0,1000,2000])for(const reach of [0,200,400]) {
97
+ const {frames:f,instances}=resolve(model,{heightScale,lift,reach})
98
+ assert.equal(instances.length,20)
99
+ equal(f.carriage.position,[0,260*heightScale+lift,338+reach])
100
+ near(f['stage-left'].position[1],940*heightScale+lift/2)
101
+ near(f['fork-high-left'].position[1]-f['fork-low-left'].position[1],400*heightScale)
102
+ near(f.body.position[2],-700)
103
+ }
104
+ })
105
+
106
+ test('conveyor: spin must not rotate repeat offsets; prototype rejects naive composition via oracle',()=>{
107
+ const m=conveyor(),a=resolve(m,{width:2400,depth:600,angle:0}),b=resolve(m,{width:2400,depth:600,angle:90})
108
+ // Counterexample: placing repetition after owner joint makes the entire row orbit.
109
+ assert.ok(Math.hypot(...diff(a.instances[1].frame.position,b.instances[1].frame.position))>100)
110
+ })
111
+
112
+ test('conveyor: corrected placement-before-local-orientation, 5 widths × 3 depths × 3 spins',()=>{
113
+ const m=conveyor()
114
+ delete m.frames.row.joint
115
+ m.geometry.roller.orientation={axis:[0,1,0],angle:'angle'}
116
+ for(const width of [1200,2399.99,2400,2400.01,4800])for(const depth of [300,600,1200])for(const angle of [0,90,180]) {
117
+ const out=resolve(m,{width,depth,angle})
118
+ assert.equal(out.instances.length,Math.floor(width/150))
119
+ for(const [i,p] of out.instances.entries()) {
120
+ equal(p.frame.position,[-width/2+75+i*150,500,0])
121
+ near(p.dimensions.radius,48);near(p.dimensions.length,depth-80)
122
+ assert.ok(p.frame.position[0]-48>=-width/2&&p.frame.position[0]+48<=width/2)
123
+ equal(point(p.frame,[0,p.dimensions.length/2,0]),[p.frame.position[0],500,p.dimensions.length/2])
124
+ }
125
+ }
126
+ const a=resolve(m,{width:2399.99,depth:600,angle:0}),b=resolve(m,{width:2400,depth:600,angle:0})
127
+ assert.deepEqual(a.instances.map(x=>x.id),b.instances.slice(0,15).map(x=>x.id))
128
+ })
129
+
130
+ test('rejection is explicit and does not mutate source; order independent',()=>{
131
+ const m=cradle(),before=JSON.stringify(m),input={radial:1,axial:1,fed:50}
132
+ const result=resolve(m,input),reversed={...m,values:Object.fromEntries(Object.entries(m.values).reverse()),frames:Object.fromEntries(Object.entries(m.frames).reverse())}
133
+ assert.deepEqual(resolve(reversed,input).instances,result.instances)
134
+ for(const [model,args,code] of [
135
+ [m,{...input,fed:101},'INPUT_RANGE'],
136
+ [{...m,values:{...m.values,radius:'missing'}},input,'UNKNOWN_REFERENCE'],
137
+ [{...m,values:{...m.values,radius:'radius'}},input,'VALUE_CYCLE'],
138
+ [{...m,frames:{axis:{parent:'axis'}}},input,'FRAME_CYCLE'],
139
+ [{...m,values:{...m.values,radius:0}},input,'ASSERTION'],
140
+ [conveyor(),{width:16000,depth:600,angle:0},'REPEAT_DOMAIN'],
141
+ [m,{...input,radial:NaN},'NON_FINITE']
142
+ ])assert.throws(()=>resolve(model,args),e=>e instanceof DesignError&&e.code===code)
143
+ assert.equal(JSON.stringify(m),before)
144
+ })
@@ -0,0 +1,281 @@
1
+ // Design witnesses only. Not imported by production; not a complete v3 evaluator.
2
+ import test from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+
5
+ const eps = 1e-8
6
+ const near = (a, b) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`)
7
+ const sum = xs => xs.reduce((a, b) => a + b, 0)
8
+ const finite = x => assert.ok(Number.isFinite(x))
9
+ const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x))
10
+
11
+ // Share an amount proportionally, freezing capped members simultaneously.
12
+ // A production implementation can sort breakpoints to reduce O(n²) to O(n log n).
13
+ function share(amount, capacities, weights) {
14
+ assert.ok(amount >= 0 && Number.isFinite(amount))
15
+ capacities.forEach(c => assert.ok(c >= 0 && !Number.isNaN(c)))
16
+ weights.forEach(w => assert.ok(Number.isFinite(w) && w >= 0))
17
+ assert.equal(capacities.length, weights.length)
18
+ const result = capacities.map(() => 0)
19
+ let active = weights.map((w, i) => i).filter(i => weights[i] > 0 && capacities[i] > 0)
20
+ let remaining = amount
21
+ while (remaining > 0 && active.length) {
22
+ const total = sum(active.map(i => weights[i]))
23
+ const frozen = active.filter(i => remaining * weights[i] / total >= capacities[i])
24
+ if (!frozen.length) {
25
+ for (const i of active) result[i] = remaining * weights[i] / total
26
+ remaining = 0
27
+ break
28
+ }
29
+ for (const i of frozen) {
30
+ result[i] = capacities[i]
31
+ remaining -= capacities[i]
32
+ }
33
+ const set = new Set(frozen)
34
+ active = active.filter(i => !set.has(i))
35
+ }
36
+ return { values: result, remaining: Math.max(0, remaining) }
37
+ }
38
+
39
+ function flex(length, gap, items, justify = 'start') {
40
+ assert.ok(Number.isFinite(length) && length >= 0)
41
+ assert.ok(Number.isFinite(gap) && gap >= 0)
42
+ assert.ok(['start', 'center', 'end', 'space-between'].includes(justify))
43
+ for (const i of items) {
44
+ finite(i.basis); finite(i.min); finite(i.grow); finite(i.shrink)
45
+ assert.ok(i.min >= 0 && i.max >= i.min && !Number.isNaN(i.max))
46
+ assert.ok(i.grow >= 0 && i.shrink >= 0)
47
+ }
48
+ const available = length - gap * Math.max(0, items.length - 1)
49
+ const base = items.map(i => clamp(i.basis, i.min, i.max))
50
+ const delta = available - sum(base)
51
+ const shares = share(Math.abs(delta),
52
+ items.map((i, k) => delta >= 0 ? i.max - base[k] : base[k] - i.min),
53
+ items.map(i => delta >= 0 ? i.grow : i.shrink))
54
+ const sizes = base.map((v, i) => v + (delta >= 0 ? 1 : -1) * shares.values[i])
55
+ const leftover = available - sum(sizes)
56
+ const free = Math.max(0, leftover)
57
+ let leading = 0, effectiveGap = gap
58
+ if (leftover >= 0) {
59
+ if (justify === 'center') leading = free / 2
60
+ if (justify === 'end') leading = free
61
+ if (justify === 'space-between' && items.length > 1) effectiveGap += free / (items.length - 1)
62
+ }
63
+ let cursor = -length / 2 + leading
64
+ const centers = sizes.map(s => {
65
+ const center = cursor + s / 2
66
+ cursor += s + effectiveGap
67
+ return center
68
+ })
69
+ return { sizes, centers, overflow: Math.max(0, -leftover), free, effectiveGap }
70
+ }
71
+ const item = (basis, grow = 0, min = 0, max = Infinity, shrink = 0) =>
72
+ ({ basis, grow, min, max, shrink })
73
+
74
+ function gridFr(length, gap, tracks) {
75
+ const bases = tracks.map(t => t.kind === 'fixed' ? t.size : t.min)
76
+ const available = length - gap * Math.max(0, tracks.length - 1)
77
+ const extra = share(Math.max(0, available - sum(bases)),
78
+ tracks.map((t, i) => t.kind === 'fixed' ? 0 : t.max - bases[i]),
79
+ tracks.map(t => t.kind === 'fixed' ? 0 : t.weight))
80
+ const sizes = bases.map((s, i) => s + extra.values[i])
81
+ return { sizes, overflow: Math.max(0, sum(sizes) - available) }
82
+ }
83
+ const fr = (weight = 1, min = 0, max = Infinity) => ({ kind: 'fr', weight, min, max })
84
+
85
+ function autoTracks(tracks, requirements, gap) {
86
+ const sizes = tracks.map(t => t.kind === 'fixed' ? t.size : t.min)
87
+ const sorted = [...requirements].sort((a, b) => a.span-b.span || a.start-b.start ||
88
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
89
+ for (const r of sorted) {
90
+ assert.ok(r.start >= 0 && r.span > 0 && r.start+r.span <= tracks.length)
91
+ const current = sum(sizes.slice(r.start, r.start+r.span)) + gap*(r.span-1)
92
+ const indices = Array.from({ length: r.span }, (_, k) => r.start+k)
93
+ const extra = share(Math.max(0, r.preferred-current),
94
+ indices.map(i => tracks[i].kind === 'auto' ? tracks[i].max-sizes[i] : 0),
95
+ indices.map(i => tracks[i].kind === 'auto' ? 1 : 0))
96
+ indices.forEach((i, k) => { sizes[i] += extra.values[k] })
97
+ }
98
+ return sizes
99
+ }
100
+
101
+ // Minimal value-DAG witness. No source-schema or production diagnostics implementation.
102
+ function evaluateValues(nodes) {
103
+ const values = new Map(), visiting = new Set()
104
+ const byId = new Map(nodes.map(n => [n.id, n]))
105
+ assert.equal(byId.size, nodes.length, 'duplicate writer')
106
+ function visit(id) {
107
+ if (values.has(id)) return values.get(id)
108
+ assert.ok(byId.has(id), 'unknown reference')
109
+ assert.ok(!visiting.has(id), 'layout-cycle')
110
+ visiting.add(id)
111
+ const n = byId.get(id)
112
+ const result = n.compute(...n.deps.map(visit))
113
+ finite(result)
114
+ visiting.delete(id); values.set(id, result)
115
+ return result
116
+ }
117
+ nodes.forEach(n => visit(n.id))
118
+ return values
119
+ }
120
+
121
+ function vecNear(a, b) { a.forEach((v, i) => near(v, b[i])) }
122
+ const add = (a, b) => a.map((v, i) => v + b[i])
123
+ const rotateZ = (p, a) => [Math.cos(a)*p[0]-Math.sin(a)*p[1],
124
+ Math.sin(a)*p[0]+Math.cos(a)*p[1], p[2]]
125
+
126
+ test('two door leaves preserve margins, gap, and fully-open inner edges over 100 widths', () => {
127
+ for (let k = 0; k < 100; k++) {
128
+ const width = 60 + k * 73, margin = 10, gap = 10
129
+ const f = flex(width - 2*margin, gap, [item(0, 1), item(0, 1)])
130
+ const w = (width - 30)/2
131
+ near(f.sizes[0], w); near(f.sizes[1], w)
132
+ near(f.centers[0] - w/2, -width/2 + margin)
133
+ near(f.centers[1] - w/2 - (f.centers[0]+w/2), gap)
134
+ near(f.centers[1] + w/2, width/2 - margin)
135
+ const travel = (width-gap)/2
136
+ near(f.centers[0] - travel + w/2, -width/2)
137
+ near(f.centers[1] + travel - w/2, width/2)
138
+ near(f.overflow, 0)
139
+ }
140
+ })
141
+
142
+ test('grow saturates max then redistributes instead of losing space', () => {
143
+ const f = flex(300, 0, [item(50, 1, 0, 80), item(50, 3)])
144
+ vecNear(f.sizes, [80, 220])
145
+ near(sum(f.sizes), 300)
146
+ })
147
+
148
+ test('shrink freezes minima, remaining deficit is explicit', () => {
149
+ const f = flex(100, 0, [item(100, 0, 80, Infinity, 1), item(100, 0, 40, Infinity, 1)])
150
+ vecNear(f.sizes, [80, 40])
151
+ near(f.overflow, 20)
152
+ near(f.centers[0] - f.sizes[0]/2, -50)
153
+ })
154
+
155
+ test('zero weights do not silently resize fixed items', () => {
156
+ const f = flex(300, 10, [item(40), item(50)], 'center')
157
+ vecNear(f.sizes, [40, 50])
158
+ near(f.free, 200)
159
+ vecNear(f.centers, [-30, 25])
160
+ })
161
+
162
+ test('single and empty space-between have defined finite behavior', () => {
163
+ const one = flex(100, 5, [item(20)], 'space-between')
164
+ vecNear(one.centers, [-40])
165
+ assert.deepEqual(flex(100, 5, [], 'space-between').sizes, [])
166
+ })
167
+
168
+ test('gap itself can overflow without negative sizes or negative gap', () => {
169
+ const f = flex(5, 10, [item(0), item(0)])
170
+ near(f.overflow, 5)
171
+ vecNear(f.sizes, [0, 0])
172
+ near(f.effectiveGap, 10)
173
+ })
174
+
175
+ test('fr distributes after fixed tracks, gaps and minima', () => {
176
+ const f = gridFr(420, 10, [{ kind: 'fixed', size: 100 }, fr(1), fr(2)])
177
+ vecNear(f.sizes, [100, 100, 200])
178
+ near(f.overflow, 0)
179
+ })
180
+
181
+ test('fr maximum caps and fixed-track overflow are not hidden', () => {
182
+ vecNear(gridFr(500, 0, [fr(1, 0, 100), fr(1)]).sizes, [100, 400])
183
+ near(gridFr(100, 10, [{ kind: 'fixed', size: 60 }, { kind: 'fixed', size: 60 }]).overflow, 30)
184
+ })
185
+
186
+ test('50% plus 50% and a gap overflows, while 1fr plus 1fr fits', () => {
187
+ near(gridFr(100, 10, [{ kind: 'fixed', size: 50 }, { kind: 'fixed', size: 50 }]).overflow, 10)
188
+ vecNear(gridFr(100, 10, [fr(), fr()]).sizes, [45, 45])
189
+ })
190
+
191
+ test('rotated slot alignment preserves a chosen face center under global rigid transform', () => {
192
+ for (const a of [0, Math.PI/2, 0.713]) {
193
+ const target = [30, 80, 100], size = [40, 60, 20]
194
+ const anchorOffset = rotateZ([0, size[1]/2, 0], a)
195
+ const center = target.map((v, i) => v-anchorOffset[i])
196
+ vecNear(add(center, anchorOffset), target)
197
+ const worldOrigin = [-200, 500, 80], globalAngle = 0.421
198
+ vecNear(add(add(worldOrigin, rotateZ(center, globalAngle)), rotateZ(anchorOffset, globalAngle)),
199
+ add(worldOrigin, rotateZ(target, globalAngle)))
200
+ }
201
+ })
202
+
203
+ test('contain of a 90-degree rotated box uses rotated extent, not original dimensions', () => {
204
+ const original = [200, 100, 50], slot = [100, 100, 100]
205
+ const rotatedExtent = [original[1], original[0], original[2]]
206
+ const scale = Math.min(...slot.map((s, i) => s/rotatedExtent[i]))
207
+ near(scale, 0.5)
208
+ vecNear(rotatedExtent.map(x => x*scale), [50, 100, 25])
209
+ })
210
+
211
+ test('constant-weight allocation satisfies bounds and conservation across deterministic cases', () => {
212
+ let seed = 1847
213
+ const random = () => { seed = (1664525*seed+1013904223) >>> 0; return seed/2**32 }
214
+ for (let n = 1; n <= 8; n++) {
215
+ for (let run = 0; run < 100; run++) {
216
+ const caps = Array.from({ length: n }, () => random()*200)
217
+ const weights = Array.from({ length: n }, () => random()<0.2 ? 0 : 1+random()*4)
218
+ const amount = random()*1000
219
+ const result = share(amount, caps, weights)
220
+ result.values.forEach((x, i) => {
221
+ assert.ok(x >= -eps && x <= caps[i]+eps)
222
+ if (!weights[i]) near(x, 0)
223
+ })
224
+ near(sum(result.values)+result.remaining, amount)
225
+ near(sum(result.values), Math.min(amount, sum(caps.filter((_, i) => weights[i]>0))))
226
+ }
227
+ }
228
+ })
229
+
230
+ test('min/max branch boundaries are continuous but not one global affine formula', () => {
231
+ const evaluate = L => flex(L, 0, [item(0, 1, 0, 50), item(0, 1)]).sizes
232
+ vecNear(evaluate(99), [49.5, 49.5])
233
+ vecNear(evaluate(100), [50, 50])
234
+ vecNear(evaluate(101), [50, 51])
235
+ // Endpoints alone cannot establish an interior bound for a general piecewise-affine function.
236
+ const f = x => Math.min(x, 2-x)
237
+ near(f(0), 0); near(f(2), 0); near(f(1), 1)
238
+ })
239
+
240
+ test('invalid geometry-independent allocation inputs fail instead of becoming NaN', () => {
241
+ assert.throws(() => flex(NaN, 0, []))
242
+ assert.throws(() => flex(100, -1, []))
243
+ assert.throws(() => flex(100, 0, [item(5, -1)]))
244
+ assert.throws(() => flex(100, 0, [item(5, 1, 10, 2)]))
245
+ })
246
+
247
+ test('auto grid span allocation is stable under item array reordering and honors maxima', () => {
248
+ const tracks = Array.from({ length: 3 }, () => ({ kind: 'auto', min: 0, max: 100 }))
249
+ const requirements = [
250
+ { id: 'b', start: 1, span: 2, preferred: 180 },
251
+ { id: 'a', start: 0, span: 2, preferred: 150 }
252
+ ]
253
+ vecNear(autoTracks(tracks, requirements, 10), [70, 100, 70])
254
+ vecNear(autoTracks(tracks, [...requirements].reverse(), 10), [70, 100, 70])
255
+ })
256
+
257
+ test('span can retain unmet intrinsic demand without exceeding auto maxima', () => {
258
+ const sizes = autoTracks([
259
+ { kind: 'fixed', size: 20 }, { kind: 'auto', min: 0, max: 30 }
260
+ ], [{ id: 'a', start: 0, span: 2, preferred: 200 }], 10)
261
+ vecNear(sizes, [20, 30])
262
+ near(200-sum(sizes)-10, 140) // Must be reported/checked after final slot allocation.
263
+ })
264
+
265
+ test('parent intrinsic versus child percentage cycle is rejected, not guessed', () => {
266
+ assert.throws(() => evaluateValues([
267
+ { id: 'parent.measure', deps: ['child.size'], compute: x => x+20 },
268
+ { id: 'child.size', deps: ['parent.measure'], compute: x => x/2 }
269
+ ]), /layout-cycle/)
270
+ })
271
+
272
+ test('defined parent and intrinsic children resolve independently of source node order', () => {
273
+ const nodes = [
274
+ { id: 'child.size', deps: ['parent.size'], compute: x => x/2 },
275
+ { id: 'parent.size', deps: [], compute: () => 400 }
276
+ ]
277
+ near(evaluateValues(nodes).get('child.size'), 200)
278
+ near(evaluateValues([...nodes].reverse()).get('child.size'), 200)
279
+ assert.throws(() => evaluateValues([...nodes, nodes[0]]), /duplicate writer/)
280
+ assert.throws(() => evaluateValues([nodes[0]]), /unknown reference/)
281
+ })