@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.
- package/CHANGELOG.md +49 -0
- package/README.md +8 -4
- package/dist/asset-types.d.ts +8 -13
- package/dist/asset-types.d.ts.map +1 -1
- package/dist/asset-types.js.map +1 -1
- package/dist/asset.js +4 -4
- package/dist/asset.js.map +1 -1
- package/dist/authoring-actions.js +18 -5
- package/dist/authoring-actions.js.map +1 -1
- package/dist/authoring-types.d.ts +6 -11
- package/dist/authoring-types.d.ts.map +1 -1
- package/dist/authoring-types.js.map +1 -1
- package/dist/data-interface.d.ts.map +1 -1
- package/dist/data-interface.js +8 -4
- package/dist/data-interface.js.map +1 -1
- package/dist/editor-workspace.d.ts +1 -1
- package/dist/editor-workspace.d.ts.map +1 -1
- package/dist/editor-workspace.js +2 -5
- package/dist/editor-workspace.js.map +1 -1
- package/dist/extensions.d.ts +15 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +63 -0
- package/dist/extensions.js.map +1 -0
- package/dist/gate.d.ts +1 -1
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +28 -33
- package/dist/gate.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/input-preview.d.ts +48 -0
- package/dist/input-preview.d.ts.map +1 -0
- package/dist/{authoring-session.js → input-preview.js} +7 -63
- package/dist/input-preview.js.map +1 -0
- package/dist/kernel-version.d.ts +1 -1
- package/dist/kernel-version.js +1 -1
- package/dist/names.d.ts.map +1 -1
- package/dist/names.js +5 -1
- package/dist/names.js.map +1 -1
- package/dist/package.d.ts.map +1 -1
- package/dist/package.js +18 -15
- package/dist/package.js.map +1 -1
- package/dist/proposal.d.ts +5 -1
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +18 -5
- package/dist/proposal.js.map +1 -1
- package/dist/release-contract.d.ts +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +13 -24
- package/dist/release-contract.js.map +1 -1
- package/dist/shape-edit.d.ts +1 -1
- package/dist/shape-edit.d.ts.map +1 -1
- package/dist/size-edit.d.ts +1 -1
- package/dist/size-edit.d.ts.map +1 -1
- package/docs/asset-persistence.md +6 -3
- package/docs/authoring-transactions.md +21 -19
- package/docs/capability-bindings.md +6 -3
- package/docs/common-kernel-experiment.md +3 -0
- package/docs/document-contract.md +6 -3
- package/docs/dryroom-profile-validation.md +3 -0
- package/docs/editor-integration.md +5 -2
- package/docs/full-conveyor-validation.md +5 -2
- package/docs/full-dryroom-validation.md +6 -3
- package/docs/full-forklift-validation.md +5 -2
- package/docs/full-robot-validation.md +5 -2
- package/docs/payload-collision-validation.md +5 -2
- package/docs/payload-contact-validation.md +5 -2
- package/docs/prototypes/README.md +27 -61
- package/docs/repeated-binding-validation.md +6 -3
- package/docs/review-2026-09-22.md +3 -0
- package/docs/runtime-extraction.md +9 -2
- package/docs/sample-validation-expanded.md +3 -0
- package/docs/sample-validation.md +3 -0
- package/docs/shape-contract.md +7 -5
- package/docs/spatial-contracts.md +3 -0
- package/docs/status.md +2 -1
- package/docs/storage-integration.md +6 -3
- package/docs/typed-repeat-contracts.md +3 -0
- package/package.json +9 -9
- package/dist/authoring-session.d.ts +0 -121
- package/dist/authoring-session.d.ts.map +0 -1
- package/dist/authoring-session.js.map +0 -1
- package/dist/carrier.d.ts +0 -23
- package/dist/carrier.d.ts.map +0 -1
- package/dist/carrier.js +0 -65
- package/dist/carrier.js.map +0 -1
- package/docs/prototypes/asset-samples.mjs +0 -15
- package/docs/prototypes/asset-store.mjs +0 -52
- package/docs/prototypes/asset-store.test.mjs +0 -59
- package/docs/prototypes/asset.mjs +0 -1
- package/docs/prototypes/asset.schema.json +0 -36
- package/docs/prototypes/asset.test.mjs +0 -68
- package/docs/prototypes/authoring-session.mjs +0 -1
- package/docs/prototypes/authoring-session.test.mjs +0 -65
- package/docs/prototypes/capability-plan.mjs +0 -1
- package/docs/prototypes/capability-plan.test.mjs +0 -56
- package/docs/prototypes/capability-samples.mjs +0 -23
- package/docs/prototypes/document.mjs +0 -2
- package/docs/prototypes/document.schema.json +0 -66
- package/docs/prototypes/document.test.mjs +0 -55
- package/docs/prototypes/dryroom-mesh.mjs +0 -60
- package/docs/prototypes/dryroom-mesh.test.mjs +0 -30
- package/docs/prototypes/editor-browser.mjs +0 -97
- package/docs/prototypes/editor-workspace.mjs +0 -1
- package/docs/prototypes/editor-workspace.test.mjs +0 -35
- package/docs/prototypes/feature-pattern.test.mjs +0 -131
- package/docs/prototypes/full-conveyor.mjs +0 -60
- package/docs/prototypes/full-conveyor.test.mjs +0 -61
- package/docs/prototypes/full-dryroom.mjs +0 -67
- package/docs/prototypes/full-dryroom.test.mjs +0 -54
- package/docs/prototypes/full-forklift.mjs +0 -40
- package/docs/prototypes/full-forklift.test.mjs +0 -37
- package/docs/prototypes/full-robot.mjs +0 -35
- package/docs/prototypes/full-robot.test.mjs +0 -68
- package/docs/prototypes/kernel.mjs +0 -147
- package/docs/prototypes/kernel.test.mjs +0 -144
- package/docs/prototypes/more-samples.test.mjs +0 -182
- package/docs/prototypes/payload-collision.mjs +0 -54
- package/docs/prototypes/payload-collision.test.mjs +0 -68
- package/docs/prototypes/payload-fit.mjs +0 -58
- package/docs/prototypes/payload-fit.test.mjs +0 -59
- package/docs/prototypes/render-dryroom.py +0 -60
- package/docs/prototypes/render-full-dryroom.mjs +0 -86
- package/docs/prototypes/repeated-binding.mjs +0 -13
- package/docs/prototypes/repeated-binding.test.mjs +0 -46
- package/docs/prototypes/runtime-browser.mjs +0 -76
- package/docs/prototypes/samples.test.mjs +0 -144
- package/docs/prototypes/spatial-plan.test.mjs +0 -102
- 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])
|