@hatiolab/figure-model 0.6.7 → 0.7.1
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/editor-workspace.d.ts +1 -1
- package/dist/editor-workspace.d.ts.map +1 -1
- package/dist/editor-workspace.js +13 -6
- 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,147 +0,0 @@
|
|
|
1
|
-
// Experimental executable contract. No product imports and no model-name branches.
|
|
2
|
-
// Scalars are numeric in documented fixture units, NOT statically unit checked yet.
|
|
3
|
-
export class DesignError extends Error {
|
|
4
|
-
constructor(code, path, detail) { super(`${code}: ${path}: ${detail}`); this.code=code; this.path=path }
|
|
5
|
-
}
|
|
6
|
-
const fail=(code,path,detail)=>{throw new DesignError(code,path,detail)}
|
|
7
|
-
const finite=(x,path)=>{if(!Number.isFinite(x))fail('NON_FINITE',path,String(x));return x}
|
|
8
|
-
const add=(a,b)=>a.map((v,i)=>v+b[i])
|
|
9
|
-
const sub=(a,b)=>a.map((v,i)=>v-b[i])
|
|
10
|
-
const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
|
|
11
|
-
const I=()=>[[1,0,0],[0,1,0],[0,0,1]]
|
|
12
|
-
const mv=(r,v)=>r.map(row=>dot(row,v))
|
|
13
|
-
const mm=(a,b)=>a.map(row=>[0,1,2].map(i=>dot(row,b.map(r=>r[i]))))
|
|
14
|
-
export const point=(f,p)=>add(mv(f.rotation,p),f.position)
|
|
15
|
-
function rot(axis,degrees,path) {
|
|
16
|
-
const norm=Math.hypot(...axis)
|
|
17
|
-
if(Math.abs(norm-1)>1e-10)fail('AXIS',path,'unit axis required')
|
|
18
|
-
const [x,y,z]=axis,c=Math.cos(degrees*Math.PI/180),s=Math.sin(degrees*Math.PI/180),k=1-c
|
|
19
|
-
return [[c+x*x*k,x*y*k-z*s,x*z*k+y*s],[y*x*k+z*s,c+y*y*k,y*z*k-x*s],[z*x*k-y*s,z*y*k+x*s,c+z*z*k]]
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function resolve(model,inputs) {
|
|
23
|
-
const values={},active=new Set(),defs=model.values??{},spec=model.inputs??{}
|
|
24
|
-
for(const k of Object.keys(inputs))if(!Object.hasOwn(spec,k))fail('UNKNOWN_INPUT',k,'not declared')
|
|
25
|
-
for(const [k,s] of Object.entries(spec)) {
|
|
26
|
-
if(Object.hasOwn(defs,k))fail('DUPLICATE_WRITER',k,'input and derived')
|
|
27
|
-
const x=finite(inputs[k],k)
|
|
28
|
-
if(x<s.min||x>s.max)fail('INPUT_RANGE',k,`${x} outside [${s.min},${s.max}]`)
|
|
29
|
-
values[k]=x
|
|
30
|
-
}
|
|
31
|
-
function expression(e,path='expression') {
|
|
32
|
-
if(typeof e==='number')return finite(e,path)
|
|
33
|
-
if(typeof e==='string')return value(e)
|
|
34
|
-
if(!e||!Array.isArray(e.args))fail('EXPRESSION',path,'expected number, reference or operator')
|
|
35
|
-
const a=e.args.map(x=>expression(x,path))
|
|
36
|
-
if(e.op==='add')return finite(a.reduce((s,x)=>s+x,0),path)
|
|
37
|
-
if(e.op==='mul'&&a.length===2)return finite(a[0]*a[1],path)
|
|
38
|
-
fail('EXPRESSION',path,'unsupported operator/arity')
|
|
39
|
-
}
|
|
40
|
-
function value(k) {
|
|
41
|
-
if(Object.hasOwn(values,k))return values[k]
|
|
42
|
-
if(!Object.hasOwn(defs,k))fail('UNKNOWN_REFERENCE',k,'no definition')
|
|
43
|
-
if(active.has(k))fail('VALUE_CYCLE',k,[...active,k].join(' -> '))
|
|
44
|
-
active.add(k);values[k]=expression(defs[k],k);active.delete(k);return values[k]
|
|
45
|
-
}
|
|
46
|
-
for(const k of Object.keys(defs))value(k)
|
|
47
|
-
for(const c of model.assertions??[]) {
|
|
48
|
-
const left=expression(c.left,c.id),right=expression(c.right,c.id)
|
|
49
|
-
if(c.op!=='gte')fail('ASSERTION_OPERATOR',c.id,c.op)
|
|
50
|
-
if(left<right)fail('ASSERTION',c.id,`${left} < ${right}; deficit=${right-left}`)
|
|
51
|
-
}
|
|
52
|
-
const frames={},visiting=new Set(),nodes={...model.frames},geometry={...model.geometry},shapeCache={}
|
|
53
|
-
function vector(v,path) {
|
|
54
|
-
if(!Array.isArray(v)||v.length!==3)fail('VECTOR',path,'three coordinates required')
|
|
55
|
-
return v.map(e=>expression(e,path))
|
|
56
|
-
}
|
|
57
|
-
function repeatPlan(r,id) {
|
|
58
|
-
const pitch=expression(r.pitch,id),axis=vector(r.axis,id)
|
|
59
|
-
if(!(pitch>0)||Math.abs(Math.hypot(...axis)-1)>1e-10)fail('REPEAT_DOMAIN',id,'positive pitch and unit axis')
|
|
60
|
-
const count=r.mode==='fixed'?expression(r.count,id):r.mode==='pitch'?Math.floor(expression(r.span,id)/pitch):NaN
|
|
61
|
-
if(!Number.isInteger(count)||count<1||!Number.isInteger(r.limit)||count>r.limit)fail('REPEAT_DOMAIN',id,`count=${count}, limit=${r.limit}`)
|
|
62
|
-
return {count,pitch,axis}
|
|
63
|
-
}
|
|
64
|
-
// Lower one-level repeated assemblies into the SAME frame/geometry evaluator.
|
|
65
|
-
// No index-dependent scripting, cross-instance references or nested patterns yet.
|
|
66
|
-
for(const [name,p] of Object.entries(model.patterns??{})) {
|
|
67
|
-
const {count,pitch,axis}=repeatPlan(p.repeat,name)
|
|
68
|
-
const localFrames=p.frames??{},localShapes=p.geometry??{}
|
|
69
|
-
for(let i=0;i<count;i++) {
|
|
70
|
-
const prefix=kind=>id=>JSON.stringify(['pattern',name,i,kind,id])
|
|
71
|
-
const fid=prefix('frame'),gid=prefix('geometry'),root=fid('$root')
|
|
72
|
-
const insert=(target,id,value)=>{if(Object.hasOwn(target,id))fail('DUPLICATE_ID',id,'expanded pattern');target[id]=value}
|
|
73
|
-
if(Object.hasOwn(localFrames,'$root'))fail('RESERVED_ID',name,'$root')
|
|
74
|
-
insert(nodes,root,{parent:p.parent,position:axis.map(x=>x*i*pitch)})
|
|
75
|
-
for(const [id,n] of Object.entries(localFrames)) {
|
|
76
|
-
if(n.feature&&n.parent)fail('DUPLICATE_PARENT',id,'feature and parent')
|
|
77
|
-
if(n.parent&&!Object.hasOwn(localFrames,n.parent))fail('PATTERN_REFERENCE',name,n.parent)
|
|
78
|
-
if(n.feature&&!Object.hasOwn(localShapes,n.feature.geometry))fail('PATTERN_REFERENCE',name,n.feature.geometry)
|
|
79
|
-
insert(nodes,fid(id),{...n,parent:n.feature?undefined:n.parent?fid(n.parent):root,
|
|
80
|
-
...(n.feature?{feature:{...n.feature,geometry:gid(n.feature.geometry)}}:{})})
|
|
81
|
-
}
|
|
82
|
-
for(const [id,g] of Object.entries(localShapes)) {
|
|
83
|
-
if(!Object.hasOwn(localFrames,g.frame))fail('PATTERN_REFERENCE',name,g.frame)
|
|
84
|
-
if(g.repeat)fail('NESTED_REPEAT',name,id)
|
|
85
|
-
insert(geometry,gid(id),{...g,frame:fid(g.frame)})
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
function shape(id) {
|
|
90
|
-
if(Object.hasOwn(shapeCache,id))return shapeCache[id]
|
|
91
|
-
if(!Object.hasOwn(geometry,id))fail('UNKNOWN_GEOMETRY',id,'no definition')
|
|
92
|
-
const g=geometry[id],dimensions=Object.fromEntries(Object.entries(g.dimensions).map(([k,v])=>[k,expression(v,id)]))
|
|
93
|
-
const required=g.kind==='box'?['width','height','depth']:g.kind==='cylinder'?['radius','length']:null
|
|
94
|
-
if(!required||Object.keys(dimensions).length!==required.length||required.some(k=>!(dimensions[k]>0)))fail('GEOMETRY_DOMAIN',id,'positive provider dimensions required')
|
|
95
|
-
return shapeCache[id]={g,dimensions,orientation:g.orientation?rot(vector(g.orientation.axis,id),expression(g.orientation.angle,id),id):I()}
|
|
96
|
-
}
|
|
97
|
-
function featureFrame(feature,id) {
|
|
98
|
-
const {g,dimensions:d,orientation}=shape(feature.geometry)
|
|
99
|
-
if(g.repeat)fail('AMBIGUOUS_FEATURE',id,'use a pattern instance, not a repeated primitive')
|
|
100
|
-
const owner=frame(g.frame),shapeFrame={position:owner.position,rotation:mm(owner.rotation,orientation)}
|
|
101
|
-
// Named points retain provider axes. These are NOT normal-oriented attachment frames.
|
|
102
|
-
const points=g.kind==='box'?{'x-':[-d.width/2,0,0],'x+':[d.width/2,0,0],'y-':[0,-d.height/2,0],'y+':[0,d.height/2,0],'z-':[0,0,-d.depth/2],'z+':[0,0,d.depth/2]}:
|
|
103
|
-
{'start':[0,-d.length/2,0],'end':[0,d.length/2,0]}
|
|
104
|
-
if(!Object.hasOwn(points,feature.point))fail('UNKNOWN_FEATURE',id,feature.point)
|
|
105
|
-
return {position:point(shapeFrame,points[feature.point]),rotation:shapeFrame.rotation}
|
|
106
|
-
}
|
|
107
|
-
function frame(id) {
|
|
108
|
-
if(Object.hasOwn(frames,id))return frames[id]
|
|
109
|
-
const node=nodes[id]
|
|
110
|
-
if(!Object.hasOwn(nodes,id))fail('UNKNOWN_FRAME',id,'no definition')
|
|
111
|
-
if(visiting.has(id))fail('FRAME_CYCLE',id,[...visiting,id].join(' -> '))
|
|
112
|
-
visiting.add(id)
|
|
113
|
-
if(node.feature&&node.parent)fail('DUPLICATE_PARENT',id,'feature and parent')
|
|
114
|
-
const parent=node.feature?featureFrame(node.feature,id):node.parent?frame(node.parent):{position:[0,0,0],rotation:I()}
|
|
115
|
-
const offset=vector(node.position??[0,0,0],id)
|
|
116
|
-
const rest=node.orientation?rot(vector(node.orientation.axis,id),expression(node.orientation.angle),id):I()
|
|
117
|
-
let localOffset=offset,localRotation=rest
|
|
118
|
-
if(node.joint) {
|
|
119
|
-
const j=node.joint,axis=vector(j.axis,id),q=expression(j.value,id)
|
|
120
|
-
if(Math.abs(Math.hypot(...axis)-1)>1e-10)fail('AXIS',id,'unit axis required')
|
|
121
|
-
if(j.type==='slide')localOffset=add(offset,mv(rest,axis.map(x=>x*q)))
|
|
122
|
-
else if(j.type==='hinge') {
|
|
123
|
-
const pivot=vector(j.pivot??[0,0,0],id),r=rot(axis,q,id)
|
|
124
|
-
localOffset=add(offset,mv(rest,sub(pivot,mv(r,pivot))));localRotation=mm(rest,r)
|
|
125
|
-
} else fail('JOINT',id,j.type)
|
|
126
|
-
}
|
|
127
|
-
frames[id]={position:point(parent,localOffset),rotation:mm(parent.rotation,localRotation)}
|
|
128
|
-
if(![...frames[id].position,...frames[id].rotation.flat()].every(Number.isFinite))fail('NON_FINITE',id,'frame')
|
|
129
|
-
visiting.delete(id);return frames[id]
|
|
130
|
-
}
|
|
131
|
-
for(const id of Object.keys(nodes))frame(id)
|
|
132
|
-
const instances=[]
|
|
133
|
-
for(const [id,g] of Object.entries(geometry)) {
|
|
134
|
-
const owner=frame(g.frame),{dimensions,orientation}=shape(id)
|
|
135
|
-
let count=1,pitch=0,axis=[1,0,0]
|
|
136
|
-
if(g.repeat) {
|
|
137
|
-
;({count,pitch,axis}=repeatPlan(g.repeat,id))
|
|
138
|
-
}
|
|
139
|
-
// Shape-local orientation follows placement. It does not rotate the repetition row.
|
|
140
|
-
for(let i=0;i<count;i++) {
|
|
141
|
-
// Start-anchored identity policy for this experiment. Not approved migration semantics.
|
|
142
|
-
instances.push({id:JSON.stringify([id,i]),source:id,index:i,kind:g.kind,dimensions:{...dimensions},
|
|
143
|
-
frame:{rotation:mm(owner.rotation,orientation),position:point(owner,axis.map(x=>x*i*pitch))}})
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return {values,frames,instances}
|
|
147
|
-
}
|
|
@@ -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
|
-
})
|