@hatiolab/figure-model 0.1.30 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/release-contract.d.ts +2 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +45 -3
- package/dist/release-contract.js.map +1 -1
- package/dist/types.d.ts +15 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +15 -4
- package/dist/types.js.map +1 -1
- package/dist/v3-asset-types.d.ts +116 -0
- package/dist/v3-asset-types.d.ts.map +1 -0
- package/dist/v3-asset-types.js +2 -0
- package/dist/v3-asset-types.js.map +1 -0
- package/dist/v3-asset.d.ts +8 -0
- package/dist/v3-asset.d.ts.map +1 -0
- package/dist/v3-asset.js +135 -0
- package/dist/v3-asset.js.map +1 -0
- package/dist/v3-authoring-session.d.ts +119 -0
- package/dist/v3-authoring-session.d.ts.map +1 -0
- package/dist/v3-authoring-session.js +132 -0
- package/dist/v3-authoring-session.js.map +1 -0
- package/dist/v3-capabilities.d.ts +3 -0
- package/dist/v3-capabilities.d.ts.map +1 -0
- package/dist/v3-capabilities.js +221 -0
- package/dist/v3-capabilities.js.map +1 -0
- package/dist/v3-contract.d.ts +7 -0
- package/dist/v3-contract.d.ts.map +1 -0
- package/dist/v3-contract.js +12 -0
- package/dist/v3-contract.js.map +1 -0
- package/dist/v3-create.d.ts +25 -0
- package/dist/v3-create.d.ts.map +1 -0
- package/dist/v3-create.js +50 -0
- package/dist/v3-create.js.map +1 -0
- package/dist/v3-document.d.ts +5 -0
- package/dist/v3-document.d.ts.map +1 -0
- package/dist/v3-document.js +31 -0
- package/dist/v3-document.js.map +1 -0
- package/dist/v3-editor-workspace.d.ts +334 -0
- package/dist/v3-editor-workspace.d.ts.map +1 -0
- package/dist/v3-editor-workspace.js +130 -0
- package/dist/v3-editor-workspace.js.map +1 -0
- package/dist/v3-graph-types.d.ts +91 -0
- package/dist/v3-graph-types.d.ts.map +1 -0
- package/dist/v3-graph-types.js +2 -0
- package/dist/v3-graph-types.js.map +1 -0
- package/dist/v3-graph.d.ts +5 -0
- package/dist/v3-graph.d.ts.map +1 -0
- package/dist/v3-graph.js +631 -0
- package/dist/v3-graph.js.map +1 -0
- package/dist/v3-json.d.ts +13 -0
- package/dist/v3-json.d.ts.map +1 -0
- package/dist/v3-json.js +94 -0
- package/dist/v3-json.js.map +1 -0
- package/dist/v3-part-edit.d.ts +12 -0
- package/dist/v3-part-edit.d.ts.map +1 -0
- package/dist/v3-part-edit.js +53 -0
- package/dist/v3-part-edit.js.map +1 -0
- package/docs/assembly-constraints-v3.md +141 -0
- package/docs/prototypes/README.md +73 -0
- package/docs/prototypes/render-dryroom.py +60 -0
- package/docs/prototypes/render-full-dryroom.mjs +86 -0
- package/docs/prototypes/shapeassembly-probe-results.json +325 -0
- package/docs/prototypes/shapeassembly-probe.py +84 -0
- package/docs/prototypes/v3-asset-samples.mjs +15 -0
- package/docs/prototypes/v3-asset-store.mjs +52 -0
- package/docs/prototypes/v3-asset-store.test.mjs +59 -0
- package/docs/prototypes/v3-asset.mjs +1 -0
- package/docs/prototypes/v3-asset.schema.json +44 -0
- package/docs/prototypes/v3-asset.test.mjs +68 -0
- package/docs/prototypes/v3-authoring-session.mjs +1 -0
- package/docs/prototypes/v3-authoring-session.test.mjs +65 -0
- package/docs/prototypes/v3-capability-plan.mjs +1 -0
- package/docs/prototypes/v3-capability-plan.test.mjs +56 -0
- package/docs/prototypes/v3-capability-samples.mjs +23 -0
- package/docs/prototypes/v3-document.mjs +2 -0
- package/docs/prototypes/v3-document.schema.json +66 -0
- package/docs/prototypes/v3-document.test.mjs +55 -0
- package/docs/prototypes/v3-dryroom-mesh.mjs +60 -0
- package/docs/prototypes/v3-dryroom-mesh.test.mjs +30 -0
- package/docs/prototypes/v3-editor-browser.mjs +97 -0
- package/docs/prototypes/v3-editor-workspace.mjs +1 -0
- package/docs/prototypes/v3-editor-workspace.test.mjs +35 -0
- package/docs/prototypes/v3-feature-pattern.test.mjs +131 -0
- package/docs/prototypes/v3-full-conveyor.mjs +60 -0
- package/docs/prototypes/v3-full-conveyor.test.mjs +61 -0
- package/docs/prototypes/v3-full-dryroom.mjs +67 -0
- package/docs/prototypes/v3-full-dryroom.test.mjs +54 -0
- package/docs/prototypes/v3-full-forklift.mjs +40 -0
- package/docs/prototypes/v3-full-forklift.test.mjs +37 -0
- package/docs/prototypes/v3-full-robot.mjs +35 -0
- package/docs/prototypes/v3-full-robot.test.mjs +68 -0
- package/docs/prototypes/v3-kernel.mjs +147 -0
- package/docs/prototypes/v3-kernel.test.mjs +144 -0
- package/docs/prototypes/v3-layout.test.mjs +281 -0
- package/docs/prototypes/v3-linear-comparison.py +183 -0
- package/docs/prototypes/v3-more-samples.test.mjs +182 -0
- package/docs/prototypes/v3-payload-collision.mjs +54 -0
- package/docs/prototypes/v3-payload-collision.test.mjs +68 -0
- package/docs/prototypes/v3-payload-fit.mjs +58 -0
- package/docs/prototypes/v3-payload-fit.test.mjs +59 -0
- package/docs/prototypes/v3-repeated-binding.mjs +13 -0
- package/docs/prototypes/v3-repeated-binding.test.mjs +46 -0
- package/docs/prototypes/v3-runtime-browser.mjs +76 -0
- package/docs/prototypes/v3-samples.test.mjs +144 -0
- package/docs/prototypes/v3-semantics.test.mjs +55 -0
- package/docs/prototypes/v3-spatial-plan.test.mjs +102 -0
- package/docs/prototypes/v3-typed-plan.mjs +3 -0
- package/docs/prototypes/v3-typed-plan.test.mjs +70 -0
- package/docs/prototypes/v3-typed-repeat.test.mjs +96 -0
- package/docs/v3-asset-persistence.md +66 -0
- package/docs/v3-authoring-transactions.md +57 -0
- package/docs/v3-capability-bindings.md +109 -0
- package/docs/v3-common-kernel-experiment.md +116 -0
- package/docs/v3-core-semantics.md +303 -0
- package/docs/v3-cutover.md +27 -0
- package/docs/v3-design.md +388 -0
- package/docs/v3-document-contract.md +55 -0
- package/docs/v3-dryroom-profile-validation.md +87 -0
- package/docs/v3-editor-integration.md +59 -0
- package/docs/v3-full-conveyor-validation.md +69 -0
- package/docs/v3-full-dryroom-validation.md +55 -0
- package/docs/v3-full-forklift-validation.md +41 -0
- package/docs/v3-full-robot-validation.md +64 -0
- package/docs/v3-layout-system.md +318 -0
- package/docs/v3-minimal-model.md +255 -0
- package/docs/v3-operator-contracts.md +148 -0
- package/docs/v3-payload-collision-validation.md +42 -0
- package/docs/v3-payload-contact-validation.md +56 -0
- package/docs/v3-repeated-binding-validation.md +42 -0
- package/docs/v3-research-review.md +160 -0
- package/docs/v3-runtime-extraction.md +107 -0
- package/docs/v3-sample-validation-expanded.md +106 -0
- package/docs/v3-sample-validation.md +97 -0
- package/docs/v3-shapeassembly-reproduction.md +99 -0
- package/docs/v3-spatial-contracts.md +84 -0
- package/docs/v3-status.md +41 -0
- package/docs/v3-storage-integration.md +58 -0
- package/docs/v3-typed-repeat-contracts.md +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {repeatedBinding} from './v3-repeated-binding.mjs'
|
|
4
|
+
import {compileCapabilities} from './v3-capability-plan.mjs'
|
|
5
|
+
import {compileDocument,parseDocument,serializeDocument} from './v3-document.mjs'
|
|
6
|
+
import {defaults} from './v3-full-conveyor.mjs'
|
|
7
|
+
import Ajv from '../../../things-factory/node_modules/ajv/lib/ajv.js'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7)
|
|
10
|
+
const args={...defaults,selectedIndex:13}
|
|
11
|
+
test('whole conveyor: selected occurrence follows dimensions and motion without another mesh',()=>{
|
|
12
|
+
const s=repeatedBinding(),p=compileCapabilities(s.model,s.declaration);let states=0
|
|
13
|
+
for(const length of [2400,2550,4800])for(const width of [400,600,1200])for(const angle of [-90,0,37,90]){
|
|
14
|
+
const r=p.evaluate({...args,length,width,angle}),b=r.bindings[0],item=r.geometry.find(g=>g.id===b.targetGeometryId)
|
|
15
|
+
assert.equal(item.index,13);assert.equal(item.key,'rollerPlaced');assert.equal(b.contacts[0].geometryId,item.id)
|
|
16
|
+
assert.equal(r.geometry.length,16+1+Math.floor((length-386)/150))
|
|
17
|
+
near(b.pose.t[0],item.pose.t[0]);near(b.pose.t[1],500);near(b.pose.t[2],(width-80)/2)
|
|
18
|
+
b.contacts[0].normal.forEach((v,i)=>near(v,[0,0,1][i]));states++
|
|
19
|
+
}
|
|
20
|
+
console.log({repeatedBindingStates:states})
|
|
21
|
+
})
|
|
22
|
+
test('shrink deletes selected occurrence: fail atomically, no nearest fallback, regrow restores id',()=>{
|
|
23
|
+
const s=repeatedBinding(),p=compileCapabilities(s.model,s.declaration),before=p.evaluate(args)
|
|
24
|
+
assert.throws(()=>p.evaluate({...args,length:2335.999}),e=>e.code==='TARGET_ABSENT')
|
|
25
|
+
const boundary=p.evaluate({...args,length:2336});assert.equal(boundary.bindings[0].targetGeometryId,before.bindings[0].targetGeometryId)
|
|
26
|
+
assert.deepEqual(p.evaluate(args),before)
|
|
27
|
+
const changed=p.evaluate({...args,selectedIndex:0});assert.notEqual(changed.bindings[0].targetGeometryId,before.bindings[0].targetGeometryId)
|
|
28
|
+
assert.throws(()=>p.evaluate({...args,selectedIndex:100}),e=>e.code==='TARGET_ABSENT')
|
|
29
|
+
})
|
|
30
|
+
test('reference origin rejects foreign feature/member, animated index, wrong binding frame',()=>{
|
|
31
|
+
for(const [code,edit] of [
|
|
32
|
+
['SURFACE_OWNER',s=>s.model.nodes.find(n=>n.id==='rollerEnd').args[0]='roller-headShape'],
|
|
33
|
+
['SURFACE_PROVENANCE',s=>s.model.nodes.find(n=>n.id==='selectedRoller').params.member='deckPlaced'],
|
|
34
|
+
['TARGET_STATE',s=>s.model.inputs.find(i=>i.id==='selectedIndex').role='state'],
|
|
35
|
+
['FRAME_TYPE',s=>s.declaration.bindings[0].frame='wrong']
|
|
36
|
+
]){const s=repeatedBinding();edit(s);assert.throws(()=>compileCapabilities(s.model,s.declaration),e=>e.code===code)}
|
|
37
|
+
})
|
|
38
|
+
test('schema/serialization/reorder preserve occurrence identity and deletion behavior',()=>{
|
|
39
|
+
const s=repeatedBinding(),doc={version:'figure-v3-prototype-1',model:s.model,capabilities:s.declaration}
|
|
40
|
+
const validate=new Ajv().compile(JSON.parse(fs.readFileSync(new URL('./v3-document.schema.json',import.meta.url))))
|
|
41
|
+
assert.ok(validate(doc),JSON.stringify(validate.errors))
|
|
42
|
+
const restored=parseDocument(serializeDocument(doc));restored.model.nodes.reverse()
|
|
43
|
+
const a=compileDocument(doc).evaluate(args),b=compileDocument(restored).evaluate(args)
|
|
44
|
+
assert.deepEqual(a.bindings,b.bindings);assert.equal(a.geometry.length,b.geometry.length)
|
|
45
|
+
assert.throws(()=>compileDocument(restored).evaluate({...args,length:900}),e=>e.code==='TARGET_ABSENT')
|
|
46
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Run npm run build first. Exercises emitted ESM in Chrome, not a Node polyfill.
|
|
2
|
+
import {readFile} from 'node:fs/promises'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import {sampleAsset} from './v3-asset-samples.mjs'
|
|
5
|
+
import {compileV3Graph} from '../../src/v3-graph.ts'
|
|
6
|
+
import {compileV3Asset} from '../../src/v3-asset.ts'
|
|
7
|
+
import {chromium} from '/Users/super/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs'
|
|
8
|
+
const url=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64')
|
|
9
|
+
const contract=url(await readFile(new URL('../../dist/v3-contract.js',import.meta.url),'utf8'))
|
|
10
|
+
const source=await readFile(new URL('../../dist/v3-json.js',import.meta.url),'utf8')
|
|
11
|
+
assert.ok(source.includes('./v3-contract.js'))
|
|
12
|
+
const entry=url(source.replace('./v3-contract.js',contract))
|
|
13
|
+
const graphSource=await readFile(new URL('../../dist/v3-graph.js',import.meta.url),'utf8')
|
|
14
|
+
const graphEntry=url(graphSource.replace('./v3-contract.js',contract))
|
|
15
|
+
const modules=new Map()
|
|
16
|
+
async function moduleUrl(name){
|
|
17
|
+
if(modules.has(name))return modules.get(name)
|
|
18
|
+
assert.match(name,/^v3-[a-z-]+\.js$/)
|
|
19
|
+
let code=await readFile(new URL('../../dist/'+name,import.meta.url),'utf8')
|
|
20
|
+
for(const match of [...code.matchAll(/from ['"]\.\/(v3-[a-z-]+\.js)['"]/g)]){
|
|
21
|
+
code=code.replace(match[0],'from '+JSON.stringify(await moduleUrl(match[1])))
|
|
22
|
+
}
|
|
23
|
+
const entry=url(code);modules.set(name,entry);return entry
|
|
24
|
+
}
|
|
25
|
+
const assetEntry=await moduleUrl('v3-asset.js')
|
|
26
|
+
const workspaceEntry=await moduleUrl('v3-editor-workspace.js')
|
|
27
|
+
const createEntry=await moduleUrl('v3-create.js')
|
|
28
|
+
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'})
|
|
29
|
+
try{
|
|
30
|
+
const page=await browser.newPage()
|
|
31
|
+
const result=await page.evaluate(async entry=>{
|
|
32
|
+
const module=await import(entry)
|
|
33
|
+
const value=module.parseV3Json('{"name":"드라이룸 😀"}')
|
|
34
|
+
let duplicate
|
|
35
|
+
try{module.parseV3Json('{"a":1,"\\u0061":2}')}catch(e){duplicate=e.code}
|
|
36
|
+
return {buffer:typeof globalThis.Buffer,process:typeof globalThis.process,value,bytes:module.v3Utf8ByteLength('드라이룸 😀'),duplicate}
|
|
37
|
+
},entry)
|
|
38
|
+
assert.deepEqual(result,{buffer:'undefined',process:'undefined',value:{name:'드라이룸 😀'},bytes:17,duplicate:'JSON_DUPLICATE_KEY'})
|
|
39
|
+
console.log(JSON.stringify(result,null,2))
|
|
40
|
+
for(const kind of ['dryroom','conveyor','robot','forklift']){
|
|
41
|
+
const asset=sampleAsset(kind),model=asset.document.model,inputs={...asset.designInputs,...asset.stateDefaults}
|
|
42
|
+
const expected=compileV3Graph(model).evaluate(inputs)
|
|
43
|
+
const actual=await page.evaluate(async({entry,model,inputs})=>(await import(entry)).compileV3Graph(model).evaluate(inputs),{entry:graphEntry,model,inputs})
|
|
44
|
+
// JSON normalizes -0 across the browser transport, not meaningful to the geometry contract.
|
|
45
|
+
assert.deepEqual(JSON.parse(JSON.stringify(actual)),JSON.parse(JSON.stringify(expected)))
|
|
46
|
+
console.log(kind+': browser/server values and geometry match; '+actual.geometry.length+' meshes')
|
|
47
|
+
const restored=await page.evaluate(async({entry,text})=>{const m=await import(entry);return m.compileV3Asset(m.parseV3Asset(text)).evaluate()},{entry:assetEntry,text:JSON.stringify(asset)})
|
|
48
|
+
assert.deepEqual(JSON.parse(JSON.stringify(restored)),JSON.parse(JSON.stringify(compileV3Asset(asset).evaluate())))
|
|
49
|
+
console.log(kind+': serialized asset, materials, palette and capability bindings match')
|
|
50
|
+
}
|
|
51
|
+
const editing=await page.evaluate(async({entry,forklift,robot})=>{
|
|
52
|
+
const w=(await import(entry)).createV3EditorWorkspace(),first=w.open(forklift)
|
|
53
|
+
const edit=(revision,value)=>({context:first.context,origin:'ai',expectedRevision:revision,changes:[{kind:'set-design',input:'forkLength',unit:'mm',value}]})
|
|
54
|
+
const before=w.read();w.preview(edit(0,1275));const previewUnchanged=JSON.stringify(w.read())===JSON.stringify(before)
|
|
55
|
+
w.apply(edit(0,1275));const pending=w.export({context:first.context});w.apply(edit(1,1500))
|
|
56
|
+
let staleSave;try{w.acknowledgeSaved(pending)}catch(e){staleSave=e.code}
|
|
57
|
+
w.undo({context:first.context,origin:'ui',expectedRevision:2});w.redo({context:first.context,origin:'ui',expectedRevision:3})
|
|
58
|
+
const revision=w.read().revision,snapshot=w.export({context:first.context});w.acknowledgeSaved(snapshot)
|
|
59
|
+
const next=w.open(robot);let staleEdit;try{w.apply(edit(0,1000))}catch(e){staleEdit=e.code}
|
|
60
|
+
w.open(snapshot.text);const restored=w.read().fields.find(f=>f.id==='forkLength').value
|
|
61
|
+
return {previewUnchanged,staleSave,revision,robotMeshes:next.geometry.length,staleEdit,restored,empty:w.close().geometry.length}
|
|
62
|
+
},{entry:workspaceEntry,forklift:JSON.stringify(sampleAsset('forklift')),robot:JSON.stringify(sampleAsset('robot'))})
|
|
63
|
+
assert.deepEqual(editing,{previewUnchanged:true,staleSave:'REVISION_CONFLICT',revision:4,robotMeshes:10,staleEdit:'CONTEXT_CONFLICT',restored:1500,empty:0})
|
|
64
|
+
console.log(JSON.stringify({browserWorkspace:editing},null,2))
|
|
65
|
+
const authored=await page.evaluate(async({createEntry,assetEntry,workspaceEntry})=>{
|
|
66
|
+
const c=await import(createEntry),a=await import(assetEntry),w=(await import(workspaceEntry)).createV3EditorWorkspace()
|
|
67
|
+
const empty=c.createV3Asset('New V3'),emptyCount=a.compileV3Asset(empty).evaluate().geometry.length
|
|
68
|
+
const model=c.addV3Box(empty,{id:'gate',size:{x:800,y:2000,z:100},position:{x:0,y:1000,z:0}})
|
|
69
|
+
const opened=w.open(a.serializeV3Asset(model))
|
|
70
|
+
w.apply({context:opened.context,origin:'ui',expectedRevision:0,changes:[{kind:'set-design',input:'gate.size.y',unit:'mm',value:1500}]})
|
|
71
|
+
const saved=w.export({context:opened.context}),restored=a.compileV3Asset(a.parseV3Asset(saved.text)).evaluate()
|
|
72
|
+
return {emptyCount,parts:restored.geometry.length,height:restored.geometry[0].shape.dimensions.height}
|
|
73
|
+
},{createEntry,assetEntry,workspaceEntry})
|
|
74
|
+
assert.deepEqual(authored,{emptyCount:0,parts:1,height:1500})
|
|
75
|
+
console.log(JSON.stringify({newV3Authoring:authored}))
|
|
76
|
+
}finally{await browser.close()}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Read-only design probes against checked-in product samples. NOT a v3 renderer.
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import { repeatPlanOfPart } from '../../src/sizing.ts'
|
|
7
|
+
|
|
8
|
+
const actualSeedURL = new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json', import.meta.url)
|
|
9
|
+
const actualRobotURL = new URL('../../src/fixtures/robot-arm-6dof.json', import.meta.url)
|
|
10
|
+
const samples = JSON.parse(fs.readFileSync(actualSeedURL))
|
|
11
|
+
const robot = JSON.parse(fs.readFileSync(actualRobotURL))
|
|
12
|
+
const source = type => samples.find(s => s.type === type).source
|
|
13
|
+
const part = (s, name) => s.parts.find(p => p.name === name)
|
|
14
|
+
const vec = v => [v.x, v.y, v.z]
|
|
15
|
+
const plus = (a,b) => a.map((x,i) => x+b[i])
|
|
16
|
+
const minus = (a,b) => a.map((x,i) => x-b[i])
|
|
17
|
+
const near = (a,b) => assert.ok(Math.abs(a-b)<1e-8, `${a} != ${b}`)
|
|
18
|
+
const same = (a,b) => a.forEach((x,i) => near(x,b[i]))
|
|
19
|
+
const dot = (a,b) => a.reduce((s,x,i)=>s+x*b[i],0)
|
|
20
|
+
const I = [[1,0,0],[0,1,0],[0,0,1]]
|
|
21
|
+
const mv = (r,v) => r.map(row=>dot(row,v))
|
|
22
|
+
const mm = (a,b) => a.map(row=>[0,1,2].map(i=>dot(row,b.map(r=>r[i]))))
|
|
23
|
+
const apply = (f,p) => plus(mv(f.r,p),f.t)
|
|
24
|
+
const compose = (a,b) => ({r:mm(a.r,b.r),t:apply(a,b.t)})
|
|
25
|
+
function rotation(axis, degrees) {
|
|
26
|
+
const [x,y,z] = axis, c=Math.cos(degrees*Math.PI/180),s=Math.sin(degrees*Math.PI/180),k=1-c
|
|
27
|
+
near(dot(axis,axis),1)
|
|
28
|
+
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]]
|
|
29
|
+
}
|
|
30
|
+
// One affine evaluator used for derived positions; cycles and duplicate writers rejected.
|
|
31
|
+
function evaluate(defs, inputs) {
|
|
32
|
+
const values={...inputs}, active=new Set()
|
|
33
|
+
assert.ok(!Object.keys(defs).some(k=>k in inputs))
|
|
34
|
+
function visit(k) {
|
|
35
|
+
if(k in values)return values[k]
|
|
36
|
+
assert.ok(k in defs && !active.has(k), `unknown or cycle: ${k}`)
|
|
37
|
+
active.add(k)
|
|
38
|
+
const [constant,terms]=defs[k]
|
|
39
|
+
values[k]=constant+Object.entries(terms).reduce((s,[ref,c])=>s+c*visit(ref),0)
|
|
40
|
+
assert.ok(Number.isFinite(values[k]));active.delete(k);return values[k]
|
|
41
|
+
}
|
|
42
|
+
Object.keys(defs).forEach(visit);return values
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('record actual input provenance; seed catalogue plus separate robot fixture', () => {
|
|
46
|
+
console.log(JSON.stringify({sources:[actualSeedURL,actualRobotURL].map(url=>({path:url.pathname,sha256:createHash('sha256').update(fs.readFileSync(url)).digest('hex')})),catalogue:samples.map(s=>({type:s.type,parts:s.source.parts.length})),robotParts:robot.parts.length}))
|
|
47
|
+
for(const s of [...samples.map(s=>s.source),robot]) {
|
|
48
|
+
assert.equal(new Set(s.parts.map(p=>p.name)).size,s.parts.length)
|
|
49
|
+
for(const p of s.parts) if(p.parent)assert.ok(part(s,p.parent))
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('robot: relative rest frames reproduce source; all six pivots remain shared under combined poses', () => {
|
|
54
|
+
const states=[Object.fromEntries(robot.joints.map(j=>[j.name,0]))]
|
|
55
|
+
for(let n=0;n<31;n++) states.push(Object.fromEntries(robot.joints.map((j,i)=>[j.name,(j.limits?.min??-360)+((n*(i+1)%31)/30)*((j.limits?.max??360)-(j.limits?.min??-360))])))
|
|
56
|
+
let checks=0
|
|
57
|
+
for(const q of states) {
|
|
58
|
+
const frames={}
|
|
59
|
+
for(const p of robot.parts) {
|
|
60
|
+
const parent=p.parent?frames[p.parent]:{r:I,t:[0,0,0]}
|
|
61
|
+
const parentRest=p.parent?vec(part(robot,p.parent).transform.position):[0,0,0]
|
|
62
|
+
const offset=minus(vec(p.transform.position),parentRest)
|
|
63
|
+
const rest=compose(parent,{r:I,t:offset})
|
|
64
|
+
const j=robot.joints.find(j=>j.child===p.name)
|
|
65
|
+
let motion={r:I,t:[0,0,0]}
|
|
66
|
+
if(j) {
|
|
67
|
+
const pivot=minus(vec(j.origin),vec(p.transform.position))
|
|
68
|
+
const r=rotation(vec(j.axis),q[j.name])
|
|
69
|
+
motion={r,t:minus(pivot,mv(r,pivot))}
|
|
70
|
+
const child=compose(rest,motion)
|
|
71
|
+
same(apply(child,pivot),apply(parent,minus(vec(j.origin),parentRest)))
|
|
72
|
+
checks++
|
|
73
|
+
}
|
|
74
|
+
frames[p.name]=compose(rest,motion)
|
|
75
|
+
near(dot(frames[p.name].r[0],frames[p.name].r[0]),1)
|
|
76
|
+
if(q===states[0])same(frames[p.name].t,vec(p.transform.position))
|
|
77
|
+
}
|
|
78
|
+
// The gripper and external attachment seat keep their relative rigid distances.
|
|
79
|
+
const a=frames['finger-left'].t,b=frames['finger-right'].t
|
|
80
|
+
near(Math.hypot(...minus(a,b)),70)
|
|
81
|
+
}
|
|
82
|
+
console.log(JSON.stringify({robot:{poses:states.length,pivotChecks:checks,notTested:['resizing link lengths','self collision','inverse kinematics','renderer parity','gripper motion']}}))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('conveyor: actual repeat rule requires changing topology, outside an affine-only DAG', () => {
|
|
86
|
+
const s=source('CONVEYOR'),roller=part(s,'roller')
|
|
87
|
+
const rows=[]
|
|
88
|
+
for(const width of [1200,2399.99,2400,2400.01,4800]) {
|
|
89
|
+
const plan=repeatPlanOfPart(roller,{x:width/s.base.x,y:1,z:1},s.base)
|
|
90
|
+
assert.equal(plan.count,Math.floor(width/roller.repeat.pitch))
|
|
91
|
+
const d=evaluate({length:[-80,{depth:1}],radius:[roller.transform.size.x/2,{}]}, {depth:600})
|
|
92
|
+
near(d.length,roller.transform.size.y);near(d.radius,48)
|
|
93
|
+
rows.push({width,count:plan.count})
|
|
94
|
+
}
|
|
95
|
+
assert.notEqual(rows[1].count,rows[2].count)
|
|
96
|
+
console.log(JSON.stringify({conveyor:rows,designGap:'stable repeated-instance IDs, disappearance/binding policy and explicit end clearance are not specified by r2'}))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('forklift: simultaneous lift/reach channels reduce to shared affine state without double motion', () => {
|
|
100
|
+
const s=source('FORKLIFT'),defs={}
|
|
101
|
+
for(const p of s.parts) for(const axis of ['x','y','z']) {
|
|
102
|
+
const terms={}
|
|
103
|
+
for(const param of s.parameters) {
|
|
104
|
+
const ch=param.clip.channels.find(c=>c.target===p.name&&c.path==='translation')
|
|
105
|
+
if(!ch)continue
|
|
106
|
+
assert.equal(ch.keys.length,2);assert.equal(ch.keys[0].at,0);assert.equal(ch.keys[1].at,1)
|
|
107
|
+
assert.equal(ch.keys[0].value[axis],0);assert.equal(param.range.min,0)
|
|
108
|
+
terms[param.name]=ch.keys[1].value[axis]/param.range.max
|
|
109
|
+
}
|
|
110
|
+
defs[`${p.name}.${axis}`]=[p.transform.position[axis],terms]
|
|
111
|
+
}
|
|
112
|
+
for(const lift of [0,1000,2000])for(const reach of [0,200,400]) {
|
|
113
|
+
const v=evaluate(defs,{lift,reach})
|
|
114
|
+
near(v['carriage.y'],260+lift);near(v['carriage.z'],338+reach)
|
|
115
|
+
near(v['stage-left.y'],940+lift/2)
|
|
116
|
+
near(v['fork-high-left.y']-v['fork-low-left.y'],400)
|
|
117
|
+
near(v['fork-low-left.y']-v['carriage.y'],42-260)
|
|
118
|
+
near(v['fork-low-left.z']-v['carriage.z'],785-338)
|
|
119
|
+
near(v['body.z'],-700)
|
|
120
|
+
}
|
|
121
|
+
console.log(JSON.stringify({forklift:{combinedStates:9,designGap:'hierarchical migration must subtract inherited motion; copying both parent and child channels doubles travel'}}))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('dryroom: actual U profile protrudes below floor; trim profile, not scale bounding box', () => {
|
|
125
|
+
const s=source('DRY_ROOM'),rows=[]
|
|
126
|
+
for(const name of ['portal-in','portal-out']) {
|
|
127
|
+
const p=part(s,name),y=p.transform.position.y
|
|
128
|
+
// figure-shape rotates extrusion +90 X; part -90 X cancels it.
|
|
129
|
+
assert.equal(p.transform.rotation.x,-90)
|
|
130
|
+
const original=p.shape.path.map(v=>({...v}))
|
|
131
|
+
const low=Math.min(...original.map(v=>v.y+y)),high=Math.max(...original.map(v=>v.y+y))
|
|
132
|
+
near(low,-60);near(high,2900)
|
|
133
|
+
// Only this known orthogonal U profile: moving its four lowest vertices is safe.
|
|
134
|
+
// NOT a general polygon clipping algorithm or an automatic migration.
|
|
135
|
+
const clipped=original.map(v=>({...v,y:Math.max(v.y,-y)}))
|
|
136
|
+
near(Math.min(...clipped.map(v=>v.y+y)),0)
|
|
137
|
+
near(Math.max(...clipped.map(v=>v.y+y)),high)
|
|
138
|
+
near(clipped[2].y+y,2200);near(clipped[3].x-clipped[2].x,1400)
|
|
139
|
+
assert.equal(clipped.filter((v,i)=>v.y!==original[i].y).length,4)
|
|
140
|
+
assert.deepEqual(p.shape.path,original)
|
|
141
|
+
rows.push({name,originalBottomMm:low,proposedBottomMm:0,topUnchangedMm:high,openingTopMm:2200})
|
|
142
|
+
}
|
|
143
|
+
console.log(JSON.stringify({dryroom:rows,designGap:'real profile feature coordinates are required; transform.size alone cannot express trimming'}))
|
|
144
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Independent mathematical oracles for r3, NOT tests of the prototype kernel.
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {test} from 'node:test'
|
|
4
|
+
const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-8,`${a} != ${b}`)
|
|
5
|
+
const mv=(r,p)=>r.map(row=>row.reduce((sum,x,i)=>sum+x*p[i],0))
|
|
6
|
+
const plus=(a,b)=>a.map((v,i)=>v+b[i])
|
|
7
|
+
const transpose=r=>r.map((_,i)=>r.map(row=>row[i]))
|
|
8
|
+
const compose=(a,b)=>({r:a.r.map(row=>transpose(b.r).map(col=>row.reduce((s,x,i)=>s+x*col[i],0))),t:plus(mv(a.r,b.t),a.t)})
|
|
9
|
+
const inverse=a=>{const r=transpose(a.r);return {r,t:mv(r,a.t.map(v=>-v))}}
|
|
10
|
+
const transform=(angle,t)=>({r:[[Math.cos(angle),-Math.sin(angle),0],[Math.sin(angle),Math.cos(angle),0],[0,0,1]],t})
|
|
11
|
+
const same=(a,b)=>{a.r.flat().forEach((x,i)=>near(x,b.r.flat()[i]));a.t.forEach((x,i)=>near(x,b.t[i]))}
|
|
12
|
+
|
|
13
|
+
test('attachment W=A O J inverse(B): shared full feature pose survives changing self feature',()=>{
|
|
14
|
+
let cases=0
|
|
15
|
+
for(const size of [10,100,1000])for(const angle of [-Math.PI,-.4,0,.7,Math.PI])for(const q of [-1,0,1]) {
|
|
16
|
+
const A=transform(angle,[100,20,-40]),O=transform(.25,[2,3,4]),J=transform(q,[0,q*50,0]),B=transform(-.8,[size/2,0,30])
|
|
17
|
+
const target=compose(compose(A,O),J),W=compose(target,inverse(B))
|
|
18
|
+
same(compose(W,B),target)
|
|
19
|
+
// Forgetting the inverse of the self feature is not an equivalent definition.
|
|
20
|
+
const wrong=compose(target,B)
|
|
21
|
+
assert.ok(Math.hypot(...wrong.t.map((v,i)=>v-target.t[i]))>1)
|
|
22
|
+
cases++
|
|
23
|
+
}
|
|
24
|
+
assert.equal(cases,45)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function fill(L,a,b,e,p,alpha) {
|
|
28
|
+
assert.ok(p>0&&e>0&&a>=0&&b>=0&&alpha>=0&&alpha<=1)
|
|
29
|
+
const usable=L-a-b,n=usable<e?0:1+Math.floor((usable-e)/p)
|
|
30
|
+
const occupied=n?e+(n-1)*p:0,slack=usable-occupied
|
|
31
|
+
return {n,positions:Array.from({length:n},(_,i)=>a+e/2+alpha*slack+i*p)}
|
|
32
|
+
}
|
|
33
|
+
test('repetition includes item extent and margins: 3000 exact-integer-boundary cases',()=>{
|
|
34
|
+
for(let k=0;k<1000;k++)for(const alpha of [0,.5,1]) {
|
|
35
|
+
const L=k+1,a=k%17,b=k%13,e=1+k%59,p=1+k%41
|
|
36
|
+
const result=fill(L,a,b,e,p,alpha),enumerated=[]
|
|
37
|
+
// Independent constructive oracle: add items from the low end until the next cannot fit.
|
|
38
|
+
for(let i=0;a+e+i*p<=L-b;i++)enumerated.push(i)
|
|
39
|
+
assert.equal(result.n,enumerated.length)
|
|
40
|
+
if(result.n) {
|
|
41
|
+
assert.ok(result.positions[0]-e/2>=a-1e-9)
|
|
42
|
+
assert.ok(result.positions.at(-1)+e/2<=L-b+1e-9)
|
|
43
|
+
assert.ok(e+result.n*p>L-a-b)
|
|
44
|
+
for(let i=1;i<result.n;i++)near(result.positions[i]-result.positions[i-1],p)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
test('old floor(L/pitch) disagrees with valid packing in both directions',()=>{
|
|
49
|
+
assert.equal(fill(100,0,0,10,60,0).n,2)
|
|
50
|
+
assert.equal(Math.floor(100/60),1)
|
|
51
|
+
assert.equal(fill(100,20,20,50,40,0).n,1)
|
|
52
|
+
assert.equal(Math.floor(100/40),2)
|
|
53
|
+
assert.equal(fill(100,0,0,100,150,0).n,1)
|
|
54
|
+
assert.equal(fill(99,0,0,100,150,0).n,0)
|
|
55
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {compile,ContractError} from './v3-typed-plan.mjs'
|
|
4
|
+
const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-8,`${a} != ${b}`)
|
|
5
|
+
const equal=(a,b)=>a.forEach((v,i)=>near(v,b[i]))
|
|
6
|
+
const c=(id,value,unit='mm')=>({id,value,unit})
|
|
7
|
+
const n=(id,op,args,outputs,params)=>({id,op,args,outputs,...(params?{params}:{})})
|
|
8
|
+
const rigid=(id,from,to,translation=['zero','zero','zero'],rotation=['angle0','angle0','angle0'])=>n(id,'rigid@1',[...translation,...rotation],{pose:id},{from,to})
|
|
9
|
+
function base(){return {version:'r3-spatial-1',inputs:[],constants:[c('zero',0),c('angle0',0,'rad')],nodes:[]}}
|
|
10
|
+
function wheel(){
|
|
11
|
+
const m=base();m.inputs=[{id:'radius',unit:'mm',min:10,max:1000},{id:'floorY',unit:'mm',min:-100,max:100},{id:'heading',unit:'deg',min:-180,max:180}]
|
|
12
|
+
m.constants.push(c('axle',84),c('tilt',-90,'deg'))
|
|
13
|
+
m.nodes.push(n('wheel','cylinder-shape@1',['radius','axle'],{shape:'shape'},{frame:'wheelLocal'}),
|
|
14
|
+
n('bottom','feature@1',['shape'],{pose:'self'},{frame:'touch',name:'radialBottom'}),
|
|
15
|
+
rigid('floor','floorFrame','world',['zero','floorY','zero'],['angle0','heading','angle0']),
|
|
16
|
+
rigid('mating','touch','floorFrame',['zero','zero','zero'],['tilt','angle0','angle0']),
|
|
17
|
+
n('attachment','attach@1',['floor','mating','self'],{pose:'pose'}),
|
|
18
|
+
n('place','place@1',['shape','pose'],{placed:'placed'}),
|
|
19
|
+
n('witness','compose@1',['pose','self'],{pose:'contactWorld'}))
|
|
20
|
+
return m
|
|
21
|
+
}
|
|
22
|
+
test('AGV wheel intent: changed radius, floor height and heading preserve contact (125 cases)',()=>{
|
|
23
|
+
const plan=compile(wheel())
|
|
24
|
+
for(const radius of [50,115,162.63455967,250,500])for(const floorY of [-100,-20,0,20,100])for(const heading of [-180,-45,0,60,180]){
|
|
25
|
+
const r=plan.evaluate({radius,floorY,heading})
|
|
26
|
+
equal(r.values.contactWorld.t,[0,floorY,0]);near(r.values.pose.t[1],floorY+radius)
|
|
27
|
+
near(r.geometry[0].shape.dimensions.radius,radius)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
function lamp(){
|
|
31
|
+
const m=base();m.inputs=[{id:'height',unit:'mm',min:100,max:4000},{id:'rx',unit:'deg',min:-180,max:180},{id:'ry',unit:'deg',min:-180,max:180},{id:'rz',unit:'deg',min:-180,max:180}]
|
|
32
|
+
m.constants.push(c('width',400),c('depth',100),c('lampSize',40))
|
|
33
|
+
m.nodes.push(n('support','box-shape@1',['width','height','depth'],{shape:'supportShape'},{frame:'supportLocal'}),
|
|
34
|
+
n('supportFeature','feature@1',['supportShape'],{pose:'top'},{frame:'supportTop',name:'top'}),
|
|
35
|
+
rigid('supportPose','supportLocal','world',['zero','zero','zero'],['rx','ry','rz']),
|
|
36
|
+
n('worldTop','compose@1',['supportPose','top'],{pose:'worldTop'}),
|
|
37
|
+
n('lamp','box-shape@1',['lampSize','lampSize','lampSize'],{shape:'lampShape'},{frame:'lampLocal'}),
|
|
38
|
+
n('lampFeature','feature@1',['lampShape'],{pose:'bottom'},{frame:'lampBottom',name:'bottom'}),
|
|
39
|
+
rigid('offset','lampBottom','supportTop'),
|
|
40
|
+
n('attachment','attach@1',['worldTop','offset','bottom'],{pose:'lampPose'}),
|
|
41
|
+
n('lampWitness','compose@1',['lampPose','bottom'],{pose:'lampContact'}),
|
|
42
|
+
n('placedSupport','place@1',['supportShape','supportPose'],{placed:'supportPlaced'}),
|
|
43
|
+
n('placedLamp','place@1',['lampShape','lampPose'],{placed:'lampPlaced'}))
|
|
44
|
+
return m
|
|
45
|
+
}
|
|
46
|
+
test('support/lamp attachment: 4 heights × 27 general 3-axis orientations',()=>{
|
|
47
|
+
const p=compile(lamp())
|
|
48
|
+
for(const height of [100,500,1500,3000])for(const rx of [-65,0,80])for(const ry of [-35,0,90])for(const rz of [-40,0,160]){
|
|
49
|
+
const r=p.evaluate({height,rx,ry,rz}),a=r.values.lampContact,b=r.values.worldTop
|
|
50
|
+
equal(a.t,b.t);equal(a.r.flat(),b.r.flat());assert.equal(r.geometry.length,2)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
test('two-link articulation: same compiler resolves dimensions, features and moving attachment',()=>{
|
|
54
|
+
const m=lamp()
|
|
55
|
+
// Replace static offset with a hinge at the shared interface; no special robot engine.
|
|
56
|
+
m.nodes.find(n=>n.id==='offset').args[3]='joint'
|
|
57
|
+
m.inputs.push({id:'joint',unit:'deg',min:-150,max:150})
|
|
58
|
+
const p=compile(m)
|
|
59
|
+
for(const height of [100,1000,3000])for(const joint of [-150,-60,0,60,150]){
|
|
60
|
+
const r=p.evaluate({height,rx:30,ry:45,rz:70,joint})
|
|
61
|
+
equal(r.values.lampContact.t,r.values.worldTop.t)
|
|
62
|
+
near(r.values.lampPose.r[0].reduce((s,x)=>s+x*x,0),1)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
test('world-point distance may drive another shape length in the same graph',()=>{
|
|
66
|
+
const m=base();m.constants.push(c('x',30),c('y',40),c('r',5))
|
|
67
|
+
m.nodes.push(n('a','point@1',['zero','zero','zero'],{point:'a'},{frame:'world'}),
|
|
68
|
+
n('b','point@1',['x','y','zero'],{point:'b'},{frame:'local'}),rigid('map','local','world'),
|
|
69
|
+
n('mapped','map-point@1',['map','b'],{point:'worldB'}),n('distance','distance@1',['a','worldB'],{length:'len'}),
|
|
70
|
+
n('beam','cylinder-shape@1',['r','len'],{shape:'shape'},{frame:'beamLocal'}))
|
|
71
|
+
near(compile(m).evaluate({}).values.shape.dimensions.length,50)
|
|
72
|
+
m.nodes.find(n=>n.id==='distance').args[1]='b'
|
|
73
|
+
assert.throws(()=>compile(m),e=>e.code==='FRAME_TYPE')
|
|
74
|
+
})
|
|
75
|
+
test('wrong transform order, wrong self feature frame and scalar/spatial misuse reject',()=>{
|
|
76
|
+
for(const mutate of [
|
|
77
|
+
m=>{m.nodes.find(n=>n.id==='witness').args.reverse()},
|
|
78
|
+
m=>{m.nodes.find(n=>n.id==='mating').params.from='different'},
|
|
79
|
+
m=>{m.nodes.push(n('bad','add@1',['pose','pose'],{value:'bad'}))},
|
|
80
|
+
m=>{m.nodes.find(n=>n.id==='place').args[1]='floor'}
|
|
81
|
+
]){
|
|
82
|
+
const m=wheel();mutate(m);assert.throws(()=>compile(m),e=>e instanceof ContractError&&['FRAME_TYPE','TYPE'].includes(e.code))
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
test('cross geometry/feature/placement cycle is diagnosed as one graph',()=>{
|
|
86
|
+
const m=wheel()
|
|
87
|
+
m.nodes.push(n('a','point@1',['zero','zero','zero'],{point:'origin'},{frame:'wheelLocal'}),
|
|
88
|
+
n('mapped','map-point@1',['pose','origin'],{point:'worldOrigin'}),
|
|
89
|
+
n('b','point@1',['zero','zero','zero'],{point:'worldZero'},{frame:'world'}),
|
|
90
|
+
n('distance','distance@1',['worldOrigin','worldZero'],{length:'derivedRadius'}))
|
|
91
|
+
m.nodes.find(n=>n.id==='wheel').args[0]='derivedRadius'
|
|
92
|
+
assert.throws(()=>compile(m),e=>e.code==='CYCLE'&&e.message.includes('wheel'))
|
|
93
|
+
})
|
|
94
|
+
test('missing feature and declaration reorder; unsuccessful evaluation leaves no stale geometry',()=>{
|
|
95
|
+
const m=lamp(),args={height:500,rx:30,ry:70,rz:-20},p=compile(m),r=p.evaluate(args)
|
|
96
|
+
m.nodes.reverse();const reversed=compile(m).evaluate(args)
|
|
97
|
+
equal(r.values.lampPose.t,reversed.values.lampPose.t)
|
|
98
|
+
assert.throws(()=>p.evaluate({...args,height:0}),e=>e.code==='INPUT_RANGE')
|
|
99
|
+
assert.deepEqual(p.evaluate(args),r)
|
|
100
|
+
m.nodes.find(n=>n.id==='lampFeature').params.name='opening'
|
|
101
|
+
assert.throws(()=>compile(m),e=>e.code==='UNKNOWN_FEATURE')
|
|
102
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import {test} from 'node:test'
|
|
3
|
+
import {compile,ContractError} from './v3-typed-plan.mjs'
|
|
4
|
+
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`)
|
|
5
|
+
const c=(id,value,unit='mm')=>({id,value,unit})
|
|
6
|
+
function model(){return {version:'r3-scalar-1',inputs:[{id:'width',unit:'cm',min:1,max:1000}],constants:[c('zero',0),c('margin',10),c('radius',48)],nodes:[
|
|
7
|
+
{id:'span',op:'between@1',args:['zero','width','margin','margin'],outputs:{length:'length',center:'center'}},
|
|
8
|
+
{id:'roller',op:'cylinder@1',args:['radius','length'],outputs:{start:'start',end:'end',radialMin:'rMin',radialMax:'rMax'}}]}}
|
|
9
|
+
const rejects=(fn,code)=>assert.throws(fn,e=>e instanceof ContractError&&e.code===code)
|
|
10
|
+
|
|
11
|
+
test('length conversion, multi-output owner and shared provider feature dimensions: 100 widths',()=>{
|
|
12
|
+
const p=compile(model())
|
|
13
|
+
for(let width=3;width<103;width++){
|
|
14
|
+
const r=p.evaluate({width}),g=r.geometry[0]
|
|
15
|
+
near(g.dimensions.length,width*10-20);near(r.values.center,width*5)
|
|
16
|
+
near(r.values.center+r.values.start,10);near(r.values.center+r.values.end,width*10-10)
|
|
17
|
+
near(r.values.rMax,g.dimensions.radius)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
test('dimension error rejected at compile, geometric infeasibility at evaluation',()=>{
|
|
21
|
+
const m=model();m.inputs[0].unit='deg';rejects(()=>compile(m),'TYPE')
|
|
22
|
+
const p=compile(model());rejects(()=>p.evaluate({width:1}),'NO_SPACE');rejects(()=>p.evaluate({width:2}),'NO_SPACE')
|
|
23
|
+
rejects(()=>p.evaluate({width:0}),'INPUT_RANGE')
|
|
24
|
+
})
|
|
25
|
+
test('multiply/divide compose dimensions and sin requires angle, not an untyped ratio',()=>{
|
|
26
|
+
const m={version:'r3-scalar-1',inputs:[],constants:[c('w',20),c('h',30),c('a',90,'deg')],nodes:[
|
|
27
|
+
{id:'area',op:'mul@1',args:['w','h'],outputs:{value:'areaValue'}},
|
|
28
|
+
{id:'recover',op:'div@1',args:['areaValue','w'],outputs:{value:'height'}},
|
|
29
|
+
{id:'trig',op:'sin@1',args:['a'],outputs:{value:'sine'}}]}
|
|
30
|
+
const p=compile(m),r=p.evaluate({});assert.deepEqual(p.outputTypes.areaValue,[2,0,0]);assert.deepEqual(p.outputTypes.height,[1,0,0]);near(r.values.height,30);near(r.values.sine,1)
|
|
31
|
+
m.constants[2].unit='ratio';rejects(()=>compile(m),'TYPE')
|
|
32
|
+
})
|
|
33
|
+
test('state-dependent radius uses percent conversion; negative radius is not clamped',()=>{
|
|
34
|
+
const m={version:'r3-scalar-1',inputs:[{id:'fed',unit:'percent',min:0,max:100}],constants:[c('one',1,'ratio'),c('slope',-.68,'ratio'),c('baseRadius',350),c('length',1000)],nodes:[
|
|
35
|
+
{id:'shrink',op:'mul@1',args:['fed','slope'],outputs:{value:'change'}},
|
|
36
|
+
{id:'factor',op:'add@1',args:['one','change'],outputs:{value:'factorValue'}},
|
|
37
|
+
{id:'radius',op:'mul@1',args:['baseRadius','factorValue'],outputs:{value:'r'}},
|
|
38
|
+
{id:'roll',op:'cylinder@1',args:['r','length'],outputs:{start:'start',end:'end',radialMin:'rMin',radialMax:'rMax'}}]}
|
|
39
|
+
near(compile(m).evaluate({fed:100}).geometry[0].dimensions.radius,112)
|
|
40
|
+
m.constants[1].value=-2;rejects(()=>compile(m).evaluate({fed:100}),'GEOMETRY_DOMAIN')
|
|
41
|
+
})
|
|
42
|
+
test('duplicate output and inter-operation cycles do not depend on insertion order',()=>{
|
|
43
|
+
let m=model();m.nodes[1].outputs.start='length';rejects(()=>compile(m),'DUPLICATE_WRITER')
|
|
44
|
+
m=model();m.nodes[0].args[1]='end';rejects(()=>compile(m),'CYCLE')
|
|
45
|
+
m.nodes.reverse();rejects(()=>compile(m),'CYCLE')
|
|
46
|
+
m=model();m.nodes[0].args[0]='unknown';rejects(()=>compile(m),'UNKNOWN_REFERENCE')
|
|
47
|
+
})
|
|
48
|
+
test('schema is strict and operator version is explicit',()=>{
|
|
49
|
+
let m=model();m.nodes[0].op='between@2';rejects(()=>compile(m),'OPERATOR')
|
|
50
|
+
m=model();m.nodes[0].priority=1;rejects(()=>compile(m),'SCHEMA')
|
|
51
|
+
m=model();delete m.nodes[0].outputs.center;rejects(()=>compile(m),'SCHEMA')
|
|
52
|
+
m=model();m.nodes[0].outputs.unused='unused';rejects(()=>compile(m),'SCHEMA')
|
|
53
|
+
})
|
|
54
|
+
test('zero division, missing inputs, unknown inputs and nonfinite values reject',()=>{
|
|
55
|
+
const m=model();m.nodes.push({id:'division',op:'div@1',args:['width','zero'],outputs:{value:'quotient'}})
|
|
56
|
+
rejects(()=>compile(m).evaluate({width:60}),'DOMAIN')
|
|
57
|
+
const p=compile(model());rejects(()=>p.evaluate({}),'SCHEMA');rejects(()=>p.evaluate({width:50,extra:3}),'SCHEMA');rejects(()=>p.evaluate({width:NaN}),'NON_FINITE')
|
|
58
|
+
})
|
|
59
|
+
test('compiled snapshot is independent of authoring mutation and definition ordering',()=>{
|
|
60
|
+
const m=model(),p=compile(m),before=p.evaluate({width:60})
|
|
61
|
+
m.nodes.reverse();assert.deepEqual(compile(m).evaluate({width:60}),before)
|
|
62
|
+
m.constants.find(c=>c.id==='radius').value=100
|
|
63
|
+
assert.deepEqual(p.evaluate({width:60}),before)
|
|
64
|
+
rejects(()=>p.evaluate({width:1}),'NO_SPACE');assert.deepEqual(p.evaluate({width:60}),before)
|
|
65
|
+
})
|
|
66
|
+
test('box output faces and geometry share dimensions',()=>{
|
|
67
|
+
const m={version:'r3-scalar-1',inputs:[],constants:[c('w',2,'m'),c('h',30,'cm'),c('d',80)],nodes:[{id:'box',op:'box@1',args:['w','h','d'],outputs:{xMin:'xl',xMax:'xh',yMin:'yl',yMax:'yh',zMin:'zl',zMax:'zh'}}]}
|
|
68
|
+
const r=compile(m).evaluate({}),d=r.geometry[0].dimensions
|
|
69
|
+
near(r.values.xh-r.values.xl,d.width);near(r.values.yh-r.values.yl,d.height);near(r.values.zh-r.values.zl,d.depth)
|
|
70
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import {test} from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import {compile} from './v3-typed-plan.mjs'
|
|
5
|
+
import {meshOf,meshAudit} from './v3-dryroom-mesh.mjs'
|
|
6
|
+
const seeds=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url)))
|
|
7
|
+
const c=(id,value,unit='mm')=>({id,value,unit})
|
|
8
|
+
const n=(id,op,args,outputs,params)=>({id,op,args,outputs,...(params?{params}:{})})
|
|
9
|
+
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-6,`${a} != ${b}`)
|
|
10
|
+
export function repeatModel(kind){
|
|
11
|
+
const type={tray:'FORMATION_TRAY',charger:'FORMATION_CHARGER',conveyor:'CONVEYOR'}[kind]
|
|
12
|
+
const s=seeds.find(s=>s.type===type).source,primary=s.parts.find(p=>p.name===({tray:'cell',charger:'shelf',conveyor:'roller'}[kind]))
|
|
13
|
+
const secondary=kind==='conveyor'?null:s.parts.find(p=>p.name===(kind==='tray'?'contact':'channel-lamp'))
|
|
14
|
+
const axis=kind==='charger'?'y':'x',extent=kind==='charger'?60:primary.transform.size.x
|
|
15
|
+
const margin=kind==='tray'?30:kind==='charger'?160:150
|
|
16
|
+
const m={version:'r3-spatial-1',inputs:[{id:'span',unit:'mm',min:0,max:10000},{id:'angle',unit:'deg',min:-180,max:180,role:'state'}],constants:[c('zero',0),c('a0',0,'rad'),c('quarter',90,'deg'),c('negHalf',-.5,'ratio'),c('margin',margin),c('extent',extent),c('pitch',primary.repeat.pitch),c('align',0,'ratio'),c('limit',100,'count'),c('eight',8,'count')],nodes:[]}
|
|
17
|
+
const rigid=(id,from,to,xyz,rot=['a0','a0','a0'])=>n(id,'rigid@1',[...xyz,...rot],{pose:id},{from,to})
|
|
18
|
+
m.nodes.push(n('half','mul@1',['span','negHalf'],{value:'left'}),rigid('root','item','world',axis==='x'?['left','zero','zero']:['zero','zero','zero']))
|
|
19
|
+
m.nodes.push(kind==='tray'?n('layout','fixed-count@1',['eight','span','margin','margin','extent','align','limit'],{layout:'layout'}):n('layout','fit-pitch@1',['span','margin','margin','extent','pitch','align','limit'],{layout:'layout'}))
|
|
20
|
+
const members=[]
|
|
21
|
+
for(const [index,p] of [primary,secondary].filter(Boolean).entries()){
|
|
22
|
+
const label=index?'secondary':'primary',d=p.transform.size,t=p.transform.position
|
|
23
|
+
for(const a of ['x','y','z'])m.constants.push(c(`${label}${a}`,t[a]-(a===axis?primary.transform.position[a]:0)))
|
|
24
|
+
let shape
|
|
25
|
+
if(kind==='conveyor'){
|
|
26
|
+
m.constants.push(c('radius',d.x/2),c('length',d.y))
|
|
27
|
+
shape=n(label,'cylinder-shape@1',['radius','length'],{shape:`${label}Shape`},{frame:label})
|
|
28
|
+
// Rx(90) lays the cylinder along Z; its local Y rotation follows afterwards.
|
|
29
|
+
} else {
|
|
30
|
+
for(const [key,val] of Object.entries(d))m.constants.push(c(`${label}Size${key}`,val))
|
|
31
|
+
shape=n(label,'box-shape@1',['x','y','z'].map(a=>`${label}Size${a}`),{shape:`${label}Shape`},{frame:label})
|
|
32
|
+
}
|
|
33
|
+
m.nodes.push(shape,rigid(`${label}Pose`,label,'item',['x','y','z'].map(a=>`${label}${a}`),kind==='conveyor'?['quarter','angle','a0']:['a0','a0','a0']),n(`${label}Member`,'member@1',[`${label}Shape`,`${label}Pose`],{placed:`${label}Member`}))
|
|
34
|
+
members.push(`${label}Member`)
|
|
35
|
+
}
|
|
36
|
+
m.nodes.push(n('assembly','assembly@1',members,{assembly:'assembly'}),n('repeat','repeat@1',['layout','assembly','root'],{collection:'collection'},{axis}))
|
|
37
|
+
return m
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('three actual-seed-derived assemblies: combined span and spin, no template duplicates',()=>{
|
|
41
|
+
let states=0
|
|
42
|
+
for(const [kind,spans] of [['tray',[500,600,1200]],['charger',[1000,2800,5600]],['conveyor',[1200,2400,4800]]]){
|
|
43
|
+
const p=compile(repeatModel(kind))
|
|
44
|
+
for(const span of spans)for(const angle of [-180,-90,0,90,180]){
|
|
45
|
+
const r=p.evaluate({span,angle}),count=r.values.layout.count
|
|
46
|
+
assert.equal(r.geometry.length,count*(kind==='conveyor'?1:2))
|
|
47
|
+
for(let i=0;i<count;i++){
|
|
48
|
+
const a=r.geometry.find(g=>g.index===i&&g.key==='primaryMember')
|
|
49
|
+
const axis=kind==='charger'?1:0,center=r.values.layout.centers[i]
|
|
50
|
+
near(a.pose.t[axis],center+(axis===0?-span/2:0))
|
|
51
|
+
const extent=kind==='conveyor'?96:kind==='tray'?52:60,margin=kind==='conveyor'?150:kind==='tray'?30:160
|
|
52
|
+
assert.ok(center-extent/2>=margin-1e-6&¢er+extent/2<=span-margin+1e-6)
|
|
53
|
+
if(kind==='conveyor')near(a.pose.t[1],500)
|
|
54
|
+
else {
|
|
55
|
+
const b=r.geometry.find(g=>g.index===i&&g.key==='secondaryMember')
|
|
56
|
+
near(b.pose.t[axis]-a.pose.t[axis],0)
|
|
57
|
+
if(kind==='tray')near(b.pose.t[1]-a.pose.t[1],90)
|
|
58
|
+
else near(b.pose.t[2]-a.pose.t[2],295)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
states++
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
assert.equal(states,45)
|
|
65
|
+
})
|
|
66
|
+
test('extent-aware threshold, empty result, limit and fixed-count insufficiency',()=>{
|
|
67
|
+
const p=compile(repeatModel('conveyor'))
|
|
68
|
+
for(const [span,want] of [[395,0],[396,1],[545.99,1],[546,2],[546.01,2]])assert.equal(p.evaluate({span,angle:0}).geometry.length,want)
|
|
69
|
+
const m=repeatModel('conveyor');m.constants.find(c=>c.id==='limit').value=1
|
|
70
|
+
assert.throws(()=>compile(m).evaluate({span:546,angle:0}),e=>e.code==='RESOURCE_LIMIT')
|
|
71
|
+
assert.throws(()=>compile(repeatModel('tray')).evaluate({span:400,angle:0}),e=>e.code==='NO_SPACE')
|
|
72
|
+
})
|
|
73
|
+
test('stable keys with grow/shrink/regrow and explicit dangling reference',()=>{
|
|
74
|
+
const m=repeatModel('conveyor'),p=compile(m),small=p.evaluate({span:545.99,angle:0}),big=p.evaluate({span:546,angle:0})
|
|
75
|
+
assert.equal(small.geometry[0].id,big.geometry[0].id)
|
|
76
|
+
assert.deepEqual(p.evaluate({span:546,angle:0}),big)
|
|
77
|
+
m.constants.push(c('selected',1,'count'));m.nodes.push(n('select','select-item@1',['collection','selected'],{placed:'selectedItem'},{member:'primaryMember'}))
|
|
78
|
+
const withRef=compile(m);withRef.evaluate({span:546,angle:0})
|
|
79
|
+
assert.throws(()=>withRef.evaluate({span:545.99,angle:0}),e=>e.code==='TARGET_ABSENT')
|
|
80
|
+
})
|
|
81
|
+
test('topology state dependency, count type, frame mismatch and cycles reject',()=>{
|
|
82
|
+
let m=repeatModel('tray');m.constants.find(c=>c.id==='eight').unit='ratio';assert.throws(()=>compile(m),e=>e.code==='TYPE')
|
|
83
|
+
m=repeatModel('tray');m.constants.find(c=>c.id==='eight').value=8.5;assert.throws(()=>compile(m),e=>e.code==='COUNT')
|
|
84
|
+
m=repeatModel('charger');m.inputs[0].role='state';assert.throws(()=>compile(m),e=>e.code==='TOPOLOGY_STATE')
|
|
85
|
+
m=repeatModel('conveyor');m.nodes.find(n=>n.id==='root').params.from='wrong';assert.throws(()=>compile(m),e=>e.code==='FRAME_TYPE')
|
|
86
|
+
m=repeatModel('conveyor');m.nodes.find(n=>n.id==='layout').args[0]='collection';assert.throws(()=>compile(m),e=>e.code==='CYCLE')
|
|
87
|
+
})
|
|
88
|
+
test('all generated boxes and cylinders are closed oriented meshes; layout snapshots repeat exactly',()=>{
|
|
89
|
+
let meshes=0
|
|
90
|
+
for(const [kind,span] of [['tray',600],['charger',2800],['conveyor',2400]]){
|
|
91
|
+
const p=compile(repeatModel(kind)),r=p.evaluate({span,angle:0})
|
|
92
|
+
for(const placed of r.geometry){const g=meshOf(placed),a=meshAudit(g);assert.equal(a.badEdges,0);assert.equal(a.euler,2);assert.ok(a.volume>0&&a.minArea>0);g.dispose();meshes++}
|
|
93
|
+
assert.deepEqual(p.evaluate({span,angle:0}),r)
|
|
94
|
+
}
|
|
95
|
+
console.log({meshes})
|
|
96
|
+
})
|