@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,144 +0,0 @@
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 './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
- })
@@ -1,182 +0,0 @@
1
- // Design probes, not a implementation. No source assets are modified.
2
- import fs from 'node:fs'
3
- import assert from 'node:assert/strict'
4
- import { test } from 'node:test'
5
- import { createHash } from 'node:crypto'
6
- import { validate } from '../../src/validate.ts'
7
- import { centredPart } from '../../src/origin.ts'
8
- import { sizingPosition, sizingScale, rotatedExtent, repeatPlanOfPart, repeatOffset } from '../../src/sizing.ts'
9
-
10
- const files = [new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url),
11
- new URL('../../src/fixtures/robot-arm-6dof.json',import.meta.url),new URL('../../src/fixtures/gantry-crane-3dof.json',import.meta.url)]
12
- const all = [...JSON.parse(fs.readFileSync(files[0])).map(x=>x.source),...files.slice(1).map(f=>JSON.parse(fs.readFileSync(f)))]
13
- const source = type => all.find(s=>s.type===type)
14
- const part = (s,n) => { const p=s.parts.find(p=>p.name===n);assert.ok(p,n);return p }
15
- const axes=['x','y','z'], unit={x:1,y:1,z:1}
16
- const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-7,`${a} != ${b}`)
17
- // Bounds of the declared rotated box, NOT exact polygon/rounded shape contact.
18
- function bounds(s,n,scale=unit) {
19
- const p=centredPart({...part(s,n),sizing:part(s,n).sizing??'scale'},s.base)
20
- const at=sizingPosition(p,scale,s.base),factor=sizingScale(p,scale,s.base),extent=rotatedExtent(p)
21
- return Object.fromEntries(axes.map(a=>{
22
- const center=at[a]*scale[a]+(a==='y'?s.base.y*scale.y/2:0),size=extent[a]*factor[a]*scale[a]
23
- return [a,{center,size,min:center-size/2,max:center+size/2}]
24
- }))
25
- }
26
- function channel(s,param,target) {
27
- const p=s.parameters.find(p=>p.name===param),c=p.clip.channels.find(c=>c.target===target)
28
- assert.ok(c);assert.equal(c.keys.length,2);assert.equal(c.keys[0].at,0);assert.equal(c.keys[1].at,1)
29
- return {p,c}
30
- }
31
- function sampleChannel(c,t) {
32
- return Object.fromEntries(axes.map(a=>[a,c.keys[0].value[a]*(1-t)+c.keys[1].value[a]*t]))
33
- }
34
-
35
- test('16 sources: validation, finite sizing at 27 scales, baseline positions; provenance',()=>{
36
- let checks=0
37
- for(const s of all) {
38
- assert.deepEqual(validate(s).errors,[],s.type)
39
- for(const x of [.5,1,2])for(const y of [.5,1,2])for(const z of [.5,1,2])for(const p of s.parts) {
40
- const b=bounds(s,p.name,{x,y,z})
41
- for(const a of axes) {assert.ok(Number.isFinite(b[a].center));assert.ok(Number.isFinite(b[a].size)&&b[a].size>0)}
42
- if(x===1&&y===1&&z===1)for(const a of axes)near(b[a].center,p.transform.position[a])
43
- checks++
44
- }
45
- }
46
- console.log(JSON.stringify({models:all.length,partScaleChecks:checks,provenance:files.map(f=>({path:f.pathname,sha256:createHash('sha256').update(fs.readFileSync(f)).digest('hex')})),scope:'finite numbers and source validity, NOT assembly correctness'}))
47
- })
48
-
49
- test('MIXER: fixed motor attachment cannot be inferred from independent scale positions',()=>{
50
- const s=source('MIXER'),body=part(s,'body'),motor=part(s,'motor')
51
- const overlap=body.transform.position.y+body.transform.size.y/2-(motor.transform.position.y-motor.transform.size.y/2)
52
- near(overlap,5)
53
- for(const h of [400,800,1600]) {
54
- // Explicit proposed intent: motor height fixed; body height proportional.
55
- const top=500*h/800,motorCenter=top-overlap+motor.transform.size.y/2
56
- near(top-(motorCenter-80),5)
57
- }
58
- })
59
-
60
- test('AGV: concentric wheels/hubs survive scaling but wheel ground contact does not always',()=>{
61
- const s=source('AGV'),rows=[]
62
- for(const scale of [unit,{x:1,y:2,z:1},{x:2,y:1,z:2}])for(const suffix of ['front-left','front-right','rear-left','rear-right']) {
63
- const w=bounds(s,`wheel-${suffix}`,scale),h=bounds(s,`hub-${suffix}`,scale),r=w.y.size/2
64
- for(const a of axes)near(w[a].center,h[a].center)
65
- near(w.y.size,w.z.size)
66
- rows.push({scale,suffix,bottom:w.y.min,radius:r})
67
- }
68
- assert.ok(rows.some(r=>Math.abs(r.bottom)>1))
69
- console.log(JSON.stringify({agv:rows.filter(r=>r.suffix==='front-left'),gap:'roundness does not imply floor contact; wheel center must reference radius'}))
70
- })
71
-
72
- test('SIGNAL_TOWER_3: real contact chain survives height changes with fixed lamp sizes',()=>{
73
- const s=source('SIGNAL_TOWER_3')
74
- const pairs=[['foot','pole'],['pole','bracket'],['bracket','lamp-lower'],['lamp-lower','ring-lower'],['ring-lower','lamp-middle'],['lamp-middle','ring-upper'],['ring-upper','lamp-upper'],['lamp-upper','cap']]
75
- for(const height of [550,1100,2200,3300])for(const [a,b] of pairs) {
76
- const before=bounds(s,b).y.min-bounds(s,a).y.max
77
- near(bounds(s,b,{x:1,y:height/s.base.y,z:1}).y.min-bounds(s,a,{x:1,y:height/s.base.y,z:1}).y.max,before)
78
- }
79
- for(const n of ['lamp-lower','lamp-middle','lamp-upper'])near(bounds(s,n,{x:1,y:3,z:1}).y.size,90)
80
- })
81
-
82
- test('WORKER: intentional non-round primitives must not be forced to circular profiles',()=>{
83
- const s=source('WORKER'),scale={x:2,y:1,z:.5}
84
- for(const n of ['leg-left','arm-left','hand-left','helmet','helmet-brim']) {
85
- assert.equal(part(s,n).keepRound,false)
86
- const b=bounds(s,n,scale)
87
- for(const a of axes)near(b[a].size,part(s,n).transform.size[a]*scale[a])
88
- }
89
- assert.equal(s.joints,undefined)
90
- console.log(JSON.stringify({worker:'static sample only; no walk/pose animation evidence',gap:'ellipse/ellipsoid semantics must coexist with circular rollers'}))
91
- })
92
-
93
- test('KIOSK: screen aperture margins and protrusion are distinct from same-center attachment',()=>{
94
- const s=source('KIOSK'),screen=bounds(s,'screen'),bezel=bounds(s,'bezel')
95
- near((bezel.x.size-screen.x.size)/2,30);near((bezel.y.size-screen.y.size)/2,30)
96
- near(screen.z.max-bezel.z.max,8)
97
- for(const width of [205,410,820]) {
98
- const opening=width-60;assert.ok(opening>0);near((width-opening)/2,30)
99
- }
100
- assert.ok(50-60<0) // candidate fixed-margin intent must reject a too-small bezel.
101
- })
102
-
103
- for(const type of ['CELL_TRAY','FORMATION_TRAY'])test(`${type}: fixed count, wall clearance and cell/contact correspondence`,()=>{
104
- const s=source(type),cell=part(s,'cell'),tub=part(s,'tub')
105
- for(const scale of [.5,1,2]) {
106
- const plan=repeatPlanOfPart(cell,{x:scale,y:1,z:1},s.base)
107
- assert.equal(plan.count,8)
108
- const edge=(repeatOffset(plan.count-1,plan.count,plan.pitch)+cell.transform.size.x/2)*scale
109
- // Explicit candidate: wall thickness remains 30mm. Geometry fits only above a bound.
110
- const clearance=s.base.x*scale/2-tub.shape.hollow.wall-edge
111
- near(clearance,36*scale-30)
112
- if(type==='FORMATION_TRAY') {
113
- const other=repeatPlanOfPart(part(s,'contact'),{x:scale,y:1,z:1},s.base)
114
- assert.equal(other.count,plan.count);near(other.pitch,plan.pitch)
115
- }
116
- }
117
- console.log(JSON.stringify({type,fixedCount:8,candidateFixedWallClearanceAtHalfScaleMm:-12,minimumScale:30/36,scope:'fixed-wall candidate; not a claim about current rendered wall scaling'}))
118
- })
119
-
120
- test('ROLL_CRADLE: state-driven diameter is geometry change, not rigid joint motion',()=>{
121
- const s=source('ROLL_CRADLE'),{c}=channel(s,'fed','roll'),r=part(s,'roll'),core=part(s,'core')
122
- assert.equal(c.path,'scale')
123
- for(const t of [0,.25,.5,.75,1]) {
124
- const v=sampleChannel(c,t)
125
- near(v.x,v.z);near(v.y,1)
126
- assert.ok(r.transform.size.x*v.x>=core.transform.size.x)
127
- near(r.transform.size.y*v.y,1000)
128
- }
129
- near(r.transform.size.x*sampleChannel(c,1).x,224)
130
- console.log(JSON.stringify({cradle:{diameterRangeMm:[700,224],coreDiameterMm:200},gap:'state-dependent shape dimensions needed beyond rigid J; resizing radius times state factor is bilinear if both are variable'}))
131
- })
132
-
133
- test('MODULE_PALLET: terminals exceed base height; base is not automatic containment',()=>{
134
- const s=source('MODULE_PALLET'),deck=bounds(s,'deck'),module=bounds(s,'module')
135
- near(deck.y.max,module.y.min)
136
- for(const n of ['terminal-left','terminal-right'])near(bounds(s,n).y.max-s.base.y,45)
137
- for(const n of ['foot-front-left','foot-front-right','foot-back-left','foot-back-right']) {
138
- near(bounds(s,n).y.min,0);near(bounds(s,n).y.max,deck.y.min)
139
- }
140
- })
141
-
142
- test('ROLL_PRESS: opening state versus finite web thickness exposes collision intent ambiguity',()=>{
143
- const s=source('ROLL_PRESS'),upper=bounds(s,'roll-upper'),lower=bounds(s,'roll-lower'),web=bounds(s,'web')
144
- const uc=channel(s,'nip','roll-upper').c,wc=channel(s,'nip','web').c,rows=[]
145
- for(const opening of [0,12,24,120,240]) {
146
- const u=sampleChannel(uc,opening/240).y,w=sampleChannel(wc,opening/240).y
147
- near((upper.y.min+u)-lower.y.max,opening)
148
- near(web.y.center+w,(upper.y.min+u+lower.y.max)/2)
149
- const clearance=(opening-web.y.size)/2
150
- near(web.y.min+w-lower.y.max,clearance)
151
- rows.push({opening,clearancePerSideMm:clearance})
152
- }
153
- console.log(JSON.stringify({rollPress:rows,gap:'24mm web at zero gap overlaps both rolls by 12mm; distinguish schematic compression from forbidden collision'}))
154
- })
155
-
156
- test('FORMATION_CHARGER: shelf/lamp repeat pairing and envelope at count thresholds',()=>{
157
- const s=source('FORMATION_CHARGER'),rows=[]
158
- for(const height of [1400,2799.99,2800,2800.01,5600]) {
159
- const scale={x:1,y:height/s.base.y,z:1}
160
- const a=repeatPlanOfPart(part(s,'shelf'),scale,s.base),b=repeatPlanOfPart(part(s,'channel-lamp'),scale,s.base)
161
- assert.equal(a.count,b.count);near(a.pitch,b.pitch)
162
- near(bounds(s,'shelf',scale).y.center,bounds(s,'channel-lamp',scale).y.center)
163
- const first=bounds(s,'shelf',scale).y.center+repeatOffset(0,a.count,a.pitch)-20
164
- rows.push({height,count:a.count,firstShelfBottom:first})
165
- }
166
- console.log(JSON.stringify({charger:rows,gap:'paired instances need common stable index; whole-base count does not guarantee plinth/HMI clearance'}))
167
- })
168
-
169
- test('GANTRY_CRANE_3DOF: 27 travel combinations inherit each translation exactly once',()=>{
170
- const s=source('GANTRY_CRANE_3DOF')
171
- for(const x of [-1600,0,1600])for(const z of [-900,0,900])for(const drop of [0,900,1800]) {
172
- const q={'bridge-travel':x,'trolley-traverse':z,'hoist-drop':drop},offsets={}
173
- for(const p of s.parts) {
174
- const inherited=p.parent?offsets[p.parent]:{x:0,y:0,z:0},j=s.joints.find(j=>j.child===p.name)
175
- if(j)assert.equal(j.type,'prismatic')
176
- offsets[p.name]=Object.fromEntries(axes.map(a=>[a,inherited[a]+(j?j.axis[a]*q[j.name]:0)]))
177
- }
178
- near(offsets['hook-seat'].x,x);near(offsets['hook-seat'].z,z);near(offsets['hook-seat'].y,-drop)
179
- near(part(s,'hook').transform.position.y+offsets.hook.y-(part(s,'hook-seat').transform.position.y+offsets['hook-seat'].y),60)
180
- near(offsets['leg-1'].x,0)
181
- }
182
- })
@@ -1,54 +0,0 @@
1
- import {compilePayloadInspection} from './payload-fit.mjs'
2
- import {ContractError} from './typed-plan.mjs'
3
- const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
4
- const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]
5
- const columns=r=>[0,1,2].map(i=>r.map(row=>row[i]))
6
- export function boxRelation(a,b,tolerance=1e-6){
7
- // Static OBB SAT: 3 + 3 face normals and 9 cross-product axes.
8
- for(const box of [a,b]){
9
- if(!box||!Array.isArray(box.center)||box.center.length!==3||!Array.isArray(box.half)||box.half.length!==3||!Array.isArray(box.axes)||box.axes.length!==3||box.axes.some(v=>!Array.isArray(v)||v.length!==3)||[...box.center,...box.half,...box.axes.flat()].some(v=>!Number.isFinite(v))||box.half.some(v=>v<=0))throw new ContractError('OBB_DOMAIN','collision','finite positive OBB required')
10
- for(let i=0;i<3;i++)for(let j=0;j<3;j++)if(Math.abs(dot(box.axes[i],box.axes[j])-(i===j?1:0))>1e-9)throw new ContractError('OBB_FRAME','collision','orthonormal axes required')
11
- }
12
- if(!Number.isFinite(tolerance)||tolerance<0)throw new ContractError('OBB_DOMAIN','collision','invalid tolerance')
13
- const delta=b.center.map((v,i)=>v-a.center[i]),axes=[...a.axes,...b.axes,...a.axes.flatMap(x=>b.axes.map(y=>cross(x,y)))]
14
- let minOverlap=Infinity,uncertain=false
15
- for(const axis of axes){
16
- const norm=Math.hypot(...axis)
17
- if(norm===0)continue
18
- if(norm<1e-12){uncertain=true;continue}
19
- const n=axis.map(v=>v/norm),radius=box=>box.half.reduce((s,h,i)=>s+h*Math.abs(dot(box.axes[i],n)),0)
20
- const overlap=radius(a)+radius(b)-Math.abs(dot(delta,n))
21
- if(overlap < -tolerance)return {relation:'separated',separationMm:-overlap}
22
- minOverlap=Math.min(minOverlap,overlap)
23
- }
24
- if(uncertain)return {relation:'unverified',reason:'NEAR_PARALLEL_NUMERICS'}
25
- return {relation:minOverlap<=tolerance?'touching':'penetrating',minimumAxisOverlapMm:Math.max(0,minOverlap)}
26
- }
27
- function envelope(g){
28
- const d=g.shape.dimensions;let half
29
- if(['box','roundedBox'].includes(g.shape.provider))half=[d.width/2,d.height/2,d.depth/2]
30
- else if(g.shape.provider==='cylinder')half=[d.radius,d.length/2,d.radius]
31
- else return null
32
- return {center:g.pose.t,axes:columns(g.pose.r),half}
33
- }
34
- export function compileCollisionInspection(model,declaration){
35
- const plan=compilePayloadInspection(model,declaration)
36
- return {evaluate(input,payload){
37
- const result=plan.evaluate(input,payload),binding=result.evaluation.bindings.find(b=>b.id===payload.bindingId)
38
- const a=payload.yawDeg*Math.PI/180,c=Math.cos(a),s=Math.sin(a),r=binding.pose.r
39
- const mv=v=>r.map(row=>dot(row,v)),axes=[[c,0,-s],[0,1,0],[s,0,c]].map(mv)
40
- const box={center:mv(payload.center).map((v,i)=>v+binding.pose.t[i]),axes,half:payload.dimensions.map(v=>v/2)}
41
- const pairs=result.evaluation.geometry.map(g=>{
42
- const bounds=envelope(g)
43
- if(!bounds)return {geometryId:g.id,status:'unverified',reason:'UNSUPPORTED_ENVELOPE'}
44
- const relation=boxRelation(box,bounds),exact=g.shape.provider==='box'
45
- if(relation.relation==='separated')return {geometryId:g.id,status:'satisfied',reason:exact?'BOX_SEPARATED':'CONSERVATIVE_ENVELOPE_SEPARATED',...relation}
46
- if(!exact)return {geometryId:g.id,status:'unverified',reason:'ENVELOPE_OVERLAP_NEEDS_NARROW_PHASE'}
47
- if(relation.relation==='unverified')return {geometryId:g.id,status:'unverified',reason:relation.reason}
48
- return {geometryId:g.id,status:relation.relation==='penetrating'?'failed':'satisfied',reason:relation.relation==='penetrating'?'BOX_PENETRATION':'TOUCH_WITHIN_TOLERANCE',...relation}
49
- })
50
- const status=pairs.some(p=>p.status==='failed')?'failed':pairs.some(p=>p.status==='unverified')?'unverified':'satisfied'
51
- result.inspection.collision={status,scope:'payload-vs-every-model-part-at-current-state',method:'obb-sat-v1',distanceToleranceMm:1e-6,pairs}
52
- return result
53
- }}
54
- }
@@ -1,68 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {boxRelation,compileCollisionInspection} from './payload-collision.mjs'
4
- import {forkliftBindings} from './capability-samples.mjs'
5
- import {defaults} from './full-forklift.mjs'
6
- import {OBB} from '../../../things-scene/node_modules/three/examples/jsm/math/OBB.js'
7
- import * as THREE from '../../../things-scene/node_modules/three/build/three.module.js'
8
- const identity=[[1,0,0],[0,1,0],[0,0,1]],box=(center,half=[1,1,1],axes=identity)=>({center,half,axes})
9
- const payload={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[620,100,850],center:[260,50,0],yawDeg:0}
10
- const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7)
11
- test('SAT separates, touches, penetrates and handles complete containment symmetrically',()=>{
12
- for(const [x,expected] of [[3,'separated'],[2,'touching'],[1,'penetrating'],[0,'penetrating']]){
13
- const a=box([0,0,0]),b=box([x,0,0]);assert.equal(boxRelation(a,b).relation,expected);assert.equal(boxRelation(b,a).relation,expected)
14
- }
15
- assert.equal(boxRelation(box([0,0,0],[10,10,10]),box([0,0,0])).relation,'penetrating')
16
- assert.equal(boxRelation(box([0,0,0]),box([2+2e-6,0,0])).relation,'separated')
17
- assert.equal(boxRelation(box([0,0,0]),box([2+5e-7,0,0])).relation,'touching')
18
- })
19
- test('rotated boxes with overlapping world AABBs can be separated',()=>{
20
- const t=Math.PI/4,axes=[[Math.cos(t),0,-Math.sin(t)],[0,1,0],[Math.sin(t),0,Math.cos(t)]]
21
- const a=box([0,0,0],[5,1,.1],axes),b=box([.5,0,.5],[5,1,.1],axes)
22
- assert.equal(boxRelation(a,b).relation,'separated')
23
- })
24
- test('payload bottom contact does not hide upper fork penetration',()=>{
25
- const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration)
26
- const r=p.evaluate(defaults,{...payload,dimensions:[620,500,850],center:[260,250,0]})
27
- assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.inspection.collision.status,'failed')
28
- for(const side of ['left','right'])assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-high-'+side).reason,'BOX_PENETRATION')
29
- assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-low-left').reason,'TOUCH_WITHIN_TOLERANCE')
30
- assert.equal(r.inspection.safety.status,'unverified')
31
- })
32
- test('all parts are checked, curved envelope overlap stays unverified, never automatically excluded',()=>{
33
- const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration)
34
- const r=p.evaluate(defaults,{...payload,dimensions:[50,50,50],center:[260,343,-1485]})
35
- assert.equal(r.inspection.collision.pairs.length,20)
36
- assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='body').status,'unverified')
37
- assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='body').reason,'ENVELOPE_OVERLAP_NEEDS_NARROW_PHASE')
38
- })
39
- test('225 state combinations retain upper-fork collision and lower-fork touching',()=>{
40
- const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration);let states=0
41
- 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 yawDeg of [-30,-15,0,15,30]){
42
- const r=p.evaluate({...defaults,forkLength,lift,reach},{...payload,dimensions:[620,500,forkLength],center:[260,250,0],yawDeg})
43
- assert.equal(r.inspection.collision.status,'failed')
44
- for(const side of ['left','right'])assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-high-'+side).reason,'BOX_PENETRATION')
45
- states++
46
- }
47
- assert.equal(states,225);console.log({payloadCollisionStates:states,pairChecks:states*20})
48
- })
49
- test('invalid OBB and numerical near-parallel ambiguity fail closed',()=>{
50
- assert.throws(()=>boxRelation(box([0,0,0],[-1,1,1]),box([0,0,0])))
51
- assert.throws(()=>boxRelation(box([0,0,0],[1,1,1],[[2,0,0],[0,1,0],[0,0,1]]),box([0,0,0])))
52
- const a=1e-14,rot=[[Math.cos(a),Math.sin(a),0],[-Math.sin(a),Math.cos(a),0],[0,0,1]]
53
- assert.equal(boxRelation(box([0,0,0]),box([0,0,0],[1,1,1],rot)).relation,'unverified')
54
- })
55
- test('1000 deterministic general 3D pairs agree with Three.js OBB implementation',()=>{
56
- let state=983451,intersections=0,separations=0
57
- const random=()=>{state=(Math.imul(1664525,state)+1013904223)>>>0;return state/2**32}
58
- const make=()=>{const matrix=new THREE.Matrix3().setFromMatrix4(new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(random()*6,random()*6,random()*6)))
59
- const e=matrix.elements,center=Array.from({length:3},()=>random()*20-10),half=Array.from({length:3},()=>.1+random()*6)
60
- return {ours:box(center,half,[[e[0],e[1],e[2]],[e[3],e[4],e[5]],[e[6],e[7],e[8]]]),reference:new OBB(new THREE.Vector3(...center),new THREE.Vector3(...half),matrix)}}
61
- for(let i=0;i<1000;i++){
62
- const a=make(),b=make(),expected=a.reference.intersectsOBB(b.reference),r=boxRelation(a.ours,b.ours)
63
- assert.notEqual(r.relation,'unverified');assert.equal(r.relation!=='separated',expected)
64
- assert.equal(boxRelation(b.ours,a.ours).relation,r.relation)
65
- if(expected)intersections++;else separations++
66
- }
67
- assert.ok(intersections>0&&separations>0);console.log({obbReferencePairs:1000,intersections,separations})
68
- })
@@ -1,58 +0,0 @@
1
- // Bounded geometric inspection, not a load-bearing or collision-safety certificate.
2
- import {compileCapabilities} from './capability-plan.mjs'
3
- import {ContractError} from './typed-plan.mjs'
4
- const fail=(code,message)=>{throw new ContractError(code,'payload',message)}
5
- const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
6
- const mv=(r,v)=>r.map(row=>dot(row,v))
7
- const local=(pose,p)=>[0,1,2].map(i=>pose.r.reduce((s,row,j)=>s+row[i]*(p[j]-pose.t[j]),0))
8
- const world=(pose,p)=>mv(pose.r,p).map((v,i)=>v+pose.t[i])
9
- function clip(poly,axis,bound,sign){
10
- const out=[]
11
- for(let i=0;i<poly.length;i++){
12
- const a=poly[i],b=poly[(i+1)%poly.length],da=sign*(a[axis]-bound),db=sign*(b[axis]-bound),inside=da<=0,next=db<=0
13
- if(inside)out.push(a)
14
- if(inside!==next){const t=da/(da-db);out.push(a.map((v,j)=>v+t*(b[j]-v)))}
15
- }
16
- return out
17
- }
18
- function overlapArea(poly,width,depth){
19
- for(const [axis,bound,sign] of [[0,width/2,1],[0,-width/2,-1],[1,depth/2,1],[1,-depth/2,-1]])poly=clip(poly,axis,bound,sign)
20
- return Math.abs(poly.reduce((sum,p,i)=>{const q=poly[(i+1)%poly.length];return sum+p[0]*q[1]-q[0]*p[1]},0))/2
21
- }
22
- export function compilePayloadInspection(model,declaration){
23
- const plan=compileCapabilities(model,declaration)
24
- return {evaluate(input,payload){
25
- const keys=['bindingId','type','occupied','dimensions','center','yawDeg']
26
- if(!payload||typeof payload!=='object'||Array.isArray(payload)||Object.keys(payload).length!==keys.length||keys.some(k=>!Object.hasOwn(payload,k)))fail('SCHEMA','expected bindingId,type,occupied,dimensions,center,yawDeg')
27
- if(typeof payload.bindingId!=='string')fail('SCHEMA','bindingId required')
28
- for(const key of ['dimensions','center'])if(!Array.isArray(payload[key])||payload[key].length!==3||payload[key].some(v=>typeof v!=='number'||!Number.isFinite(v)))fail('PAYLOAD_DOMAIN','finite 3D vectors required')
29
- if(payload.dimensions.some(v=>v<=0)||!Number.isFinite(payload.yawDeg))fail('PAYLOAD_DOMAIN','positive dimensions and finite yaw required')
30
- // Explicit supported numeric envelope. Never silently clamp a larger asset.
31
- if([...payload.dimensions,...payload.center].some(v=>Math.abs(v)>1e6)||Math.abs(payload.yawDeg)>360)fail('PAYLOAD_LIMIT','experimental envelope: 1e6 mm and yaw ±360 degrees')
32
- const evaluation=plan.evaluate(input,{[payload.bindingId]:{type:payload.type,occupied:payload.occupied}})
33
- const binding=evaluation.bindings.find(b=>b.id===payload.bindingId)
34
- if(!binding)fail('TARGET_ABSENT',payload.bindingId)
35
- const [w,h,d]=payload.dimensions,a=payload.yawDeg*Math.PI/180,c=Math.cos(a),s=Math.sin(a)
36
- const rotate=([x,y,z])=>[c*x+s*z,y,-s*x+c*z].map((v,i)=>v+payload.center[i])
37
- const bottom=[[-w/2,-h/2,-d/2],[w/2,-h/2,-d/2],[w/2,-h/2,d/2],[-w/2,-h/2,d/2]].map(rotate)
38
- const corners=[...bottom,...bottom.map(v=>[v[0],v[1]+h,v[2]])]
39
- const distanceToleranceMm=1e-6,areaToleranceMm2=1e-6
40
- let containment={status:'unverified',reason:'NO_REGION'}
41
- if(binding.region){const excess=Math.max(...corners.flatMap(p=>p.map((v,i)=>Math.abs(v)-binding.region.dimensions[i]/2)));containment={status:excess<=distanceToleranceMm?'satisfied':'failed',reason:excess<=distanceToleranceMm?'BOX_CONTAINED':'OUTSIDE_REGION',excessMm:Math.max(0,excess)}}
42
- const up=mv(binding.pose.r,[0,1,0]),bottomWorld=bottom.map(p=>world(binding.pose,p))
43
- const contacts=binding.contacts.map(surface=>{
44
- const base={geometryId:surface.geometryId,feature:surface.feature}
45
- if(binding.role!=='seat'||surface.provider!=='box'||surface.face!=='top')return {...base,status:'unverified',reason:'UNSUPPORTED_CONTACT_PAIR'}
46
- if(Math.abs(dot(up,surface.normal)-1)>1e-9)return {...base,status:'unverified',reason:'NONPARALLEL_FACES'}
47
- const geometry=evaluation.geometry.find(g=>g.id===surface.geometryId)
48
- if(!geometry)fail('TARGET_ABSENT',surface.geometryId)
49
- const points=bottomWorld.map(p=>local(surface.pose,p)),dim=geometry.shape.dimensions
50
- const overlap=overlapArea(points.map(p=>[p[0],p[2]]),dim.width,dim.depth)
51
- const minGap=Math.min(...points.map(p=>p[1])),maxGap=Math.max(...points.map(p=>p[1]))
52
- const reason=overlap<=areaToleranceMm2?'NO_POSITIVE_CONTACT_AREA':minGap < -distanceToleranceMm?'PENETRATES_SUPPORT_PLANE':maxGap>distanceToleranceMm?'ABOVE_SUPPORT':'PLANAR_CONTACT'
53
- return {...base,status:reason==='PLANAR_CONTACT'?'satisfied':'failed',reason,overlapAreaMm2:overlap,minGapMm:minGap,maxGapMm:maxGap}
54
- })
55
- const contactStatus=!contacts.length?'unverified':contacts.some(c=>c.status==='failed')?'failed':contacts.some(c=>c.status==='unverified')?'unverified':'satisfied'
56
- return {evaluation,inspection:{bindingId:binding.id,method:'box-payload/box-top-v1',distanceToleranceMm,areaToleranceMm2,containment,contact:{status:contactStatus,surfaces:contacts},collision:{status:'unverified',reason:'GLOBAL_COLLISION_NOT_CHECKED'},stability:{status:'unverified',reason:'CENTER_OF_MASS_AND_FORCES_NOT_CHECKED'},safety:{status:'unverified',reason:'NOT_CHECKED'}}}
57
- }}
58
- }
@@ -1,59 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {compilePayloadInspection} from './payload-fit.mjs'
4
- import {forkliftBindings,robotBinding} from './capability-samples.mjs'
5
- import {defaults} from './full-forklift.mjs'
6
- import {defaults as robotDefaults} from './full-robot.mjs'
7
- const payload={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[620,100,850],center:[260,50,0],yawDeg:0}
8
- const plan=s=>compilePayloadInspection(s.model,s.declaration)
9
- const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-5,`${a} != ${b}`)
10
- test('both physical fork surfaces support the payload through size/lift/reach/yaw states',()=>{
11
- const p=plan(forkliftBindings());let states=0
12
- for(const forkLength of [425,850,1700])for(const lift of [0,1000,2000])for(const reach of [0,200,400])for(const yawDeg of [-30,0,30]){
13
- const q={...payload,dimensions:[620,100,forkLength],yawDeg},r=p.evaluate({...defaults,forkLength,lift,reach},q)
14
- assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.inspection.containment.status,'unverified')
15
- assert.equal(r.evaluation.geometry.length,20)
16
- for(const c of r.inspection.contact.surfaces){near(c.minGapMm,0);near(c.maxGapMm,0);assert.ok(c.overlapAreaMm2>0);if(!yawDeg)near(c.overlapAreaMm2,90*forkLength)}
17
- states++
18
- }
19
- console.log({payloadContactStates:states})
20
- })
21
- test('floating, penetration, missing one fork and zero-area edge touch fail explicitly',()=>{
22
- const p=plan(forkliftBindings())
23
- for(const [center,reason] of [[[260,51,0],'ABOVE_SUPPORT'],[[260,49,0],'PENETRATES_SUPPORT_PLANE'],[[0,50,0],'NO_POSITIVE_CONTACT_AREA'],[[-355,50,0],'NO_POSITIVE_CONTACT_AREA']]){
24
- const r=p.evaluate(defaults,{...payload,center}).inspection
25
- assert.equal(r.contact.status,'failed');assert.ok(r.contact.surfaces.some(c=>c.reason===reason))
26
- if(center[0]===-355)assert.equal(r.contact.surfaces[0].overlapAreaMm2,0)
27
- }
28
- // Narrow object fits between the forks: no support, even though a broad region could contain it.
29
- const r=p.evaluate(defaults,{...payload,dimensions:[100,100,850]}).inspection;assert.ok(r.contact.surfaces.every(c=>c.reason==='NO_POSITIVE_CONTACT_AREA'))
30
- })
31
- test('containment checks all rotated box corners and does not imply support',()=>{
32
- const s=forkliftBindings();for(const [id,value] of [['regionW',1200],['regionH',400],['regionD',900]])s.model.constants.push({id,value,unit:'mm'})
33
- s.declaration.bindings[0].region={kind:'centered-box',dimensions:['regionW','regionH','regionD']}
34
- const p=plan(s),ok=p.evaluate(defaults,payload).inspection;assert.equal(ok.containment.status,'satisfied')
35
- const outside=p.evaluate(defaults,{...payload,yawDeg:45}).inspection;assert.equal(outside.containment.status,'failed')
36
- const floating=p.evaluate(defaults,{...payload,center:[260,60,0]}).inspection;assert.equal(floating.containment.status,'satisfied');assert.equal(floating.contact.status,'failed')
37
- })
38
- test('contact result invariant under shared rigid rotation; not a gravity stability proof',()=>{
39
- const original=plan(forkliftBindings()).evaluate(defaults,payload).inspection
40
- for(const angle of [-65,30,90]){
41
- const s=forkliftBindings();s.model.constants.push({id:'tilt',value:angle,unit:'deg'});s.model.nodes.find(n=>n.id==='mastPose').args[5]='tilt'
42
- const r=plan(s).evaluate({...defaults,lift:700,reach:120},payload).inspection
43
- assert.equal(r.contact.status,'satisfied');r.contact.surfaces.forEach((c,i)=>near(c.overlapAreaMm2,original.contact.surfaces[i].overlapAreaMm2));assert.equal(r.stability.status,'unverified')
44
- }
45
- })
46
- test('contact never certifies global collision, eligibility or safety',()=>{
47
- const p=plan(forkliftBindings())
48
- // Height 500 crosses the upper fork level. Bottom contact remains correct, not globally safe.
49
- const r=p.evaluate(defaults,{...payload,type:'NOT_ACCEPTED',occupied:1,dimensions:[620,500,850],center:[260,250,0]})
50
- assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.evaluation.bindings[0].checks.eligibility.status,'failed')
51
- assert.equal(r.inspection.collision.status,'unverified');assert.equal(r.inspection.safety.status,'unverified')
52
- const robot=plan(robotBinding()).evaluate(robotDefaults,{...payload,bindingId:'tool-grip',type:'BOX'})
53
- assert.equal(robot.inspection.contact.status,'unverified')
54
- })
55
- test('invalid payloads reject and subsequent evaluations recover without model mutation',()=>{
56
- const s=forkliftBindings(),snapshot=structuredClone(s),p=plan(s),baseline=p.evaluate(defaults,payload)
57
- for(const change of [{dimensions:[0,1,1]},{center:[NaN,0,0]},{yawDeg:Infinity},{dimensions:[1e7,1,1]},{extra:1}])assert.throws(()=>p.evaluate(defaults,{...payload,...change}))
58
- assert.deepEqual(p.evaluate(defaults,payload),baseline);assert.deepEqual(s,snapshot)
59
- })
@@ -1,60 +0,0 @@
1
- """Software orthographic rendering of the actual generated triangle meshes.
2
- Input: JSON from dryroom-mesh.mjs on stdin. Requires Pillow, no product changes.
3
- Output path must be explicitly supplied. Not a screenshot of the product renderer.
4
- """
5
- import json,sys,math
6
- import numpy as np
7
- from PIL import Image,ImageDraw,ImageFont
8
- data=json.load(sys.stdin)
9
- im=Image.new('RGB',(1440,1000),'#101923');draw=ImageDraw.Draw(im)
10
- font=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',22)
11
- small=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',17)
12
- title=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',29)
13
- draw.text((30,20),'DRY ROOM / actual seed portal mesh',font=title,fill='white')
14
- draw.text((30,62),'Orthographic mesh render, not the application viewport. Original asset unchanged.',font=small,fill='#a9bdcc')
15
- for col,panel in enumerate(data['panels']):
16
- x0=25+col*720
17
- draw.text((x0,108),'BEFORE | bottom -60 mm' if col==0 else 'AFTER | bottom 0 mm',font=font,fill='#ffd189' if col==0 else '#85dfc7')
18
- for zoom in [False,True]:
19
- y0=155 if not zoom else 715
20
- w,h=675,520 if not zoom else 215
21
- canvas=Image.new('RGB',(w,h),'#192733');d=ImageDraw.Draw(canvas)
22
- scale=.151 if not zoom else .235
23
- def project(v):
24
- x,y,z=v;z-=3050
25
- return (w/2+(x+.40*z)*scale, (h-50 if not zoom else h-70)-y*scale+.16*z*scale)
26
- pixels=np.full((h,w,3),(25,39,51),dtype=np.uint8)
27
- zbuffer=np.full((h,w),-np.inf)
28
- for part in panel['parts']:
29
- for tri in part['triangles']:
30
- a,b,c=tri
31
- u=[b[i]-a[i] for i in range(3)];v=[c[i]-a[i] for i in range(3)]
32
- normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]
33
- norm=math.sqrt(sum(x*x for x in normal))
34
- illumination=.58+.42*abs(sum(x*y for x,y in zip(normal,[.3,.4,.866]))/norm)
35
- base=(51,184,176) if part['id']=='placedPortal' else (245,185,60)
36
- color=tuple(int(x*illumination) for x in base)
37
- poly=[project(v) for v in tri]
38
- (ax,ay),(bx,by),(cx,cy)=poly
39
- den=(by-cy)*(ax-cx)+(cx-bx)*(ay-cy)
40
- if abs(den)<1e-9:continue
41
- loX=max(0,math.floor(min(v[0] for v in poly)));hiX=min(w-1,math.ceil(max(v[0] for v in poly)))
42
- loY=max(0,math.floor(min(v[1] for v in poly)));hiY=min(h-1,math.ceil(max(v[1] for v in poly)))
43
- if loX>hiX or loY>hiY:continue
44
- yy,xx=np.mgrid[loY:hiY+1,loX:hiX+1];xx=xx+.5;yy=yy+.5
45
- wa=((by-cy)*(xx-cx)+(cx-bx)*(yy-cy))/den
46
- wb=((cy-ay)*(xx-cx)+(ax-cx)*(yy-cy))/den;wc=1-wa-wb
47
- depths=[v[2]-.40*v[0]+.16*v[1] for v in tri]
48
- zz=wa*depths[0]+wb*depths[1]+wc*depths[2]
49
- region=zbuffer[loY:hiY+1,loX:hiX+1]
50
- mask=(wa>=-1e-8)&(wb>=-1e-8)&(wc>=-1e-8)&(zz>region)
51
- region[mask]=zz[mask];pixels[loY:hiY+1,loX:hiX+1][mask]=color
52
- canvas=Image.fromarray(pixels);d=ImageDraw.Draw(canvas)
53
- # Reference line on front face plane; no opaque floor hiding the original protrusion.
54
- a=project([-2000,0,3200]);b=project([2000,0,3200]);d.line([a,b],fill='#ff6b73',width=3)
55
- d.text((12,h-28),'Y = 0 / floor reference',font=small,fill='#ff9ba1')
56
- if zoom:d.text((12,10),'BOTTOM DETAIL',font=small,fill='white')
57
- im.paste(canvas,(x0,y0))
58
- draw.text((x0,682),'Top 2900 mm / opening top 2200 mm / lamp unchanged',font=small,fill='#a9bdcc')
59
- draw.text((30,954),'Mesh checks: closed, oriented edges; 28 triangles per portal. Both inlet and outlet tested.',font=small,fill='#a9bdcc')
60
- im.save(sys.argv[1])