@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,58 +0,0 @@
|
|
|
1
|
-
// Bounded geometric inspection, not a load-bearing or collision-safety certificate.
|
|
2
|
-
import {compileCapabilities} from './capability-plan.mjs'
|
|
3
|
-
import {ContractError} from './typed-plan.mjs'
|
|
4
|
-
const fail=(code,message)=>{throw new ContractError(code,'payload',message)}
|
|
5
|
-
const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
|
|
6
|
-
const mv=(r,v)=>r.map(row=>dot(row,v))
|
|
7
|
-
const local=(pose,p)=>[0,1,2].map(i=>pose.r.reduce((s,row,j)=>s+row[i]*(p[j]-pose.t[j]),0))
|
|
8
|
-
const world=(pose,p)=>mv(pose.r,p).map((v,i)=>v+pose.t[i])
|
|
9
|
-
function clip(poly,axis,bound,sign){
|
|
10
|
-
const out=[]
|
|
11
|
-
for(let i=0;i<poly.length;i++){
|
|
12
|
-
const a=poly[i],b=poly[(i+1)%poly.length],da=sign*(a[axis]-bound),db=sign*(b[axis]-bound),inside=da<=0,next=db<=0
|
|
13
|
-
if(inside)out.push(a)
|
|
14
|
-
if(inside!==next){const t=da/(da-db);out.push(a.map((v,j)=>v+t*(b[j]-v)))}
|
|
15
|
-
}
|
|
16
|
-
return out
|
|
17
|
-
}
|
|
18
|
-
function overlapArea(poly,width,depth){
|
|
19
|
-
for(const [axis,bound,sign] of [[0,width/2,1],[0,-width/2,-1],[1,depth/2,1],[1,-depth/2,-1]])poly=clip(poly,axis,bound,sign)
|
|
20
|
-
return Math.abs(poly.reduce((sum,p,i)=>{const q=poly[(i+1)%poly.length];return sum+p[0]*q[1]-q[0]*p[1]},0))/2
|
|
21
|
-
}
|
|
22
|
-
export function compilePayloadInspection(model,declaration){
|
|
23
|
-
const plan=compileCapabilities(model,declaration)
|
|
24
|
-
return {evaluate(input,payload){
|
|
25
|
-
const keys=['bindingId','type','occupied','dimensions','center','yawDeg']
|
|
26
|
-
if(!payload||typeof payload!=='object'||Array.isArray(payload)||Object.keys(payload).length!==keys.length||keys.some(k=>!Object.hasOwn(payload,k)))fail('SCHEMA','expected bindingId,type,occupied,dimensions,center,yawDeg')
|
|
27
|
-
if(typeof payload.bindingId!=='string')fail('SCHEMA','bindingId required')
|
|
28
|
-
for(const key of ['dimensions','center'])if(!Array.isArray(payload[key])||payload[key].length!==3||payload[key].some(v=>typeof v!=='number'||!Number.isFinite(v)))fail('PAYLOAD_DOMAIN','finite 3D vectors required')
|
|
29
|
-
if(payload.dimensions.some(v=>v<=0)||!Number.isFinite(payload.yawDeg))fail('PAYLOAD_DOMAIN','positive dimensions and finite yaw required')
|
|
30
|
-
// Explicit supported numeric envelope. Never silently clamp a larger asset.
|
|
31
|
-
if([...payload.dimensions,...payload.center].some(v=>Math.abs(v)>1e6)||Math.abs(payload.yawDeg)>360)fail('PAYLOAD_LIMIT','experimental envelope: 1e6 mm and yaw ±360 degrees')
|
|
32
|
-
const evaluation=plan.evaluate(input,{[payload.bindingId]:{type:payload.type,occupied:payload.occupied}})
|
|
33
|
-
const binding=evaluation.bindings.find(b=>b.id===payload.bindingId)
|
|
34
|
-
if(!binding)fail('TARGET_ABSENT',payload.bindingId)
|
|
35
|
-
const [w,h,d]=payload.dimensions,a=payload.yawDeg*Math.PI/180,c=Math.cos(a),s=Math.sin(a)
|
|
36
|
-
const rotate=([x,y,z])=>[c*x+s*z,y,-s*x+c*z].map((v,i)=>v+payload.center[i])
|
|
37
|
-
const bottom=[[-w/2,-h/2,-d/2],[w/2,-h/2,-d/2],[w/2,-h/2,d/2],[-w/2,-h/2,d/2]].map(rotate)
|
|
38
|
-
const corners=[...bottom,...bottom.map(v=>[v[0],v[1]+h,v[2]])]
|
|
39
|
-
const distanceToleranceMm=1e-6,areaToleranceMm2=1e-6
|
|
40
|
-
let containment={status:'unverified',reason:'NO_REGION'}
|
|
41
|
-
if(binding.region){const excess=Math.max(...corners.flatMap(p=>p.map((v,i)=>Math.abs(v)-binding.region.dimensions[i]/2)));containment={status:excess<=distanceToleranceMm?'satisfied':'failed',reason:excess<=distanceToleranceMm?'BOX_CONTAINED':'OUTSIDE_REGION',excessMm:Math.max(0,excess)}}
|
|
42
|
-
const up=mv(binding.pose.r,[0,1,0]),bottomWorld=bottom.map(p=>world(binding.pose,p))
|
|
43
|
-
const contacts=binding.contacts.map(surface=>{
|
|
44
|
-
const base={geometryId:surface.geometryId,feature:surface.feature}
|
|
45
|
-
if(binding.role!=='seat'||surface.provider!=='box'||surface.face!=='top')return {...base,status:'unverified',reason:'UNSUPPORTED_CONTACT_PAIR'}
|
|
46
|
-
if(Math.abs(dot(up,surface.normal)-1)>1e-9)return {...base,status:'unverified',reason:'NONPARALLEL_FACES'}
|
|
47
|
-
const geometry=evaluation.geometry.find(g=>g.id===surface.geometryId)
|
|
48
|
-
if(!geometry)fail('TARGET_ABSENT',surface.geometryId)
|
|
49
|
-
const points=bottomWorld.map(p=>local(surface.pose,p)),dim=geometry.shape.dimensions
|
|
50
|
-
const overlap=overlapArea(points.map(p=>[p[0],p[2]]),dim.width,dim.depth)
|
|
51
|
-
const minGap=Math.min(...points.map(p=>p[1])),maxGap=Math.max(...points.map(p=>p[1]))
|
|
52
|
-
const reason=overlap<=areaToleranceMm2?'NO_POSITIVE_CONTACT_AREA':minGap < -distanceToleranceMm?'PENETRATES_SUPPORT_PLANE':maxGap>distanceToleranceMm?'ABOVE_SUPPORT':'PLANAR_CONTACT'
|
|
53
|
-
return {...base,status:reason==='PLANAR_CONTACT'?'satisfied':'failed',reason,overlapAreaMm2:overlap,minGapMm:minGap,maxGapMm:maxGap}
|
|
54
|
-
})
|
|
55
|
-
const contactStatus=!contacts.length?'unverified':contacts.some(c=>c.status==='failed')?'failed':contacts.some(c=>c.status==='unverified')?'unverified':'satisfied'
|
|
56
|
-
return {evaluation,inspection:{bindingId:binding.id,method:'box-payload/box-top-v1',distanceToleranceMm,areaToleranceMm2,containment,contact:{status:contactStatus,surfaces:contacts},collision:{status:'unverified',reason:'GLOBAL_COLLISION_NOT_CHECKED'},stability:{status:'unverified',reason:'CENTER_OF_MASS_AND_FORCES_NOT_CHECKED'},safety:{status:'unverified',reason:'NOT_CHECKED'}}}
|
|
57
|
-
}}
|
|
58
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {compilePayloadInspection} from './payload-fit.mjs'
|
|
4
|
-
import {forkliftBindings,robotBinding} from './capability-samples.mjs'
|
|
5
|
-
import {defaults} from './full-forklift.mjs'
|
|
6
|
-
import {defaults as robotDefaults} from './full-robot.mjs'
|
|
7
|
-
const payload={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[620,100,850],center:[260,50,0],yawDeg:0}
|
|
8
|
-
const plan=s=>compilePayloadInspection(s.model,s.declaration)
|
|
9
|
-
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-5,`${a} != ${b}`)
|
|
10
|
-
test('both physical fork surfaces support the payload through size/lift/reach/yaw states',()=>{
|
|
11
|
-
const p=plan(forkliftBindings());let states=0
|
|
12
|
-
for(const forkLength of [425,850,1700])for(const lift of [0,1000,2000])for(const reach of [0,200,400])for(const yawDeg of [-30,0,30]){
|
|
13
|
-
const q={...payload,dimensions:[620,100,forkLength],yawDeg},r=p.evaluate({...defaults,forkLength,lift,reach},q)
|
|
14
|
-
assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.inspection.containment.status,'unverified')
|
|
15
|
-
assert.equal(r.evaluation.geometry.length,20)
|
|
16
|
-
for(const c of r.inspection.contact.surfaces){near(c.minGapMm,0);near(c.maxGapMm,0);assert.ok(c.overlapAreaMm2>0);if(!yawDeg)near(c.overlapAreaMm2,90*forkLength)}
|
|
17
|
-
states++
|
|
18
|
-
}
|
|
19
|
-
console.log({payloadContactStates:states})
|
|
20
|
-
})
|
|
21
|
-
test('floating, penetration, missing one fork and zero-area edge touch fail explicitly',()=>{
|
|
22
|
-
const p=plan(forkliftBindings())
|
|
23
|
-
for(const [center,reason] of [[[260,51,0],'ABOVE_SUPPORT'],[[260,49,0],'PENETRATES_SUPPORT_PLANE'],[[0,50,0],'NO_POSITIVE_CONTACT_AREA'],[[-355,50,0],'NO_POSITIVE_CONTACT_AREA']]){
|
|
24
|
-
const r=p.evaluate(defaults,{...payload,center}).inspection
|
|
25
|
-
assert.equal(r.contact.status,'failed');assert.ok(r.contact.surfaces.some(c=>c.reason===reason))
|
|
26
|
-
if(center[0]===-355)assert.equal(r.contact.surfaces[0].overlapAreaMm2,0)
|
|
27
|
-
}
|
|
28
|
-
// Narrow object fits between the forks: no support, even though a broad region could contain it.
|
|
29
|
-
const r=p.evaluate(defaults,{...payload,dimensions:[100,100,850]}).inspection;assert.ok(r.contact.surfaces.every(c=>c.reason==='NO_POSITIVE_CONTACT_AREA'))
|
|
30
|
-
})
|
|
31
|
-
test('containment checks all rotated box corners and does not imply support',()=>{
|
|
32
|
-
const s=forkliftBindings();for(const [id,value] of [['regionW',1200],['regionH',400],['regionD',900]])s.model.constants.push({id,value,unit:'mm'})
|
|
33
|
-
s.declaration.bindings[0].region={kind:'centered-box',dimensions:['regionW','regionH','regionD']}
|
|
34
|
-
const p=plan(s),ok=p.evaluate(defaults,payload).inspection;assert.equal(ok.containment.status,'satisfied')
|
|
35
|
-
const outside=p.evaluate(defaults,{...payload,yawDeg:45}).inspection;assert.equal(outside.containment.status,'failed')
|
|
36
|
-
const floating=p.evaluate(defaults,{...payload,center:[260,60,0]}).inspection;assert.equal(floating.containment.status,'satisfied');assert.equal(floating.contact.status,'failed')
|
|
37
|
-
})
|
|
38
|
-
test('contact result invariant under shared rigid rotation; not a gravity stability proof',()=>{
|
|
39
|
-
const original=plan(forkliftBindings()).evaluate(defaults,payload).inspection
|
|
40
|
-
for(const angle of [-65,30,90]){
|
|
41
|
-
const s=forkliftBindings();s.model.constants.push({id:'tilt',value:angle,unit:'deg'});s.model.nodes.find(n=>n.id==='mastPose').args[5]='tilt'
|
|
42
|
-
const r=plan(s).evaluate({...defaults,lift:700,reach:120},payload).inspection
|
|
43
|
-
assert.equal(r.contact.status,'satisfied');r.contact.surfaces.forEach((c,i)=>near(c.overlapAreaMm2,original.contact.surfaces[i].overlapAreaMm2));assert.equal(r.stability.status,'unverified')
|
|
44
|
-
}
|
|
45
|
-
})
|
|
46
|
-
test('contact never certifies global collision, eligibility or safety',()=>{
|
|
47
|
-
const p=plan(forkliftBindings())
|
|
48
|
-
// Height 500 crosses the upper fork level. Bottom contact remains correct, not globally safe.
|
|
49
|
-
const r=p.evaluate(defaults,{...payload,type:'NOT_ACCEPTED',occupied:1,dimensions:[620,500,850],center:[260,250,0]})
|
|
50
|
-
assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.evaluation.bindings[0].checks.eligibility.status,'failed')
|
|
51
|
-
assert.equal(r.inspection.collision.status,'unverified');assert.equal(r.inspection.safety.status,'unverified')
|
|
52
|
-
const robot=plan(robotBinding()).evaluate(robotDefaults,{...payload,bindingId:'tool-grip',type:'BOX'})
|
|
53
|
-
assert.equal(robot.inspection.contact.status,'unverified')
|
|
54
|
-
})
|
|
55
|
-
test('invalid payloads reject and subsequent evaluations recover without model mutation',()=>{
|
|
56
|
-
const s=forkliftBindings(),snapshot=structuredClone(s),p=plan(s),baseline=p.evaluate(defaults,payload)
|
|
57
|
-
for(const change of [{dimensions:[0,1,1]},{center:[NaN,0,0]},{yawDeg:Infinity},{dimensions:[1e7,1,1]},{extra:1}])assert.throws(()=>p.evaluate(defaults,{...payload,...change}))
|
|
58
|
-
assert.deepEqual(p.evaluate(defaults,payload),baseline);assert.deepEqual(s,snapshot)
|
|
59
|
-
})
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"""Software orthographic rendering of the actual generated triangle meshes.
|
|
2
|
-
Input: JSON from dryroom-mesh.mjs on stdin. Requires Pillow, no product changes.
|
|
3
|
-
Output path must be explicitly supplied. Not a screenshot of the product renderer.
|
|
4
|
-
"""
|
|
5
|
-
import json,sys,math
|
|
6
|
-
import numpy as np
|
|
7
|
-
from PIL import Image,ImageDraw,ImageFont
|
|
8
|
-
data=json.load(sys.stdin)
|
|
9
|
-
im=Image.new('RGB',(1440,1000),'#101923');draw=ImageDraw.Draw(im)
|
|
10
|
-
font=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',22)
|
|
11
|
-
small=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',17)
|
|
12
|
-
title=ImageFont.truetype('/System/Library/Fonts/Helvetica.ttc',29)
|
|
13
|
-
draw.text((30,20),'DRY ROOM / actual seed portal mesh',font=title,fill='white')
|
|
14
|
-
draw.text((30,62),'Orthographic mesh render, not the application viewport. Original asset unchanged.',font=small,fill='#a9bdcc')
|
|
15
|
-
for col,panel in enumerate(data['panels']):
|
|
16
|
-
x0=25+col*720
|
|
17
|
-
draw.text((x0,108),'BEFORE | bottom -60 mm' if col==0 else 'AFTER | bottom 0 mm',font=font,fill='#ffd189' if col==0 else '#85dfc7')
|
|
18
|
-
for zoom in [False,True]:
|
|
19
|
-
y0=155 if not zoom else 715
|
|
20
|
-
w,h=675,520 if not zoom else 215
|
|
21
|
-
canvas=Image.new('RGB',(w,h),'#192733');d=ImageDraw.Draw(canvas)
|
|
22
|
-
scale=.151 if not zoom else .235
|
|
23
|
-
def project(v):
|
|
24
|
-
x,y,z=v;z-=3050
|
|
25
|
-
return (w/2+(x+.40*z)*scale, (h-50 if not zoom else h-70)-y*scale+.16*z*scale)
|
|
26
|
-
pixels=np.full((h,w,3),(25,39,51),dtype=np.uint8)
|
|
27
|
-
zbuffer=np.full((h,w),-np.inf)
|
|
28
|
-
for part in panel['parts']:
|
|
29
|
-
for tri in part['triangles']:
|
|
30
|
-
a,b,c=tri
|
|
31
|
-
u=[b[i]-a[i] for i in range(3)];v=[c[i]-a[i] for i in range(3)]
|
|
32
|
-
normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]
|
|
33
|
-
norm=math.sqrt(sum(x*x for x in normal))
|
|
34
|
-
illumination=.58+.42*abs(sum(x*y for x,y in zip(normal,[.3,.4,.866]))/norm)
|
|
35
|
-
base=(51,184,176) if part['id']=='placedPortal' else (245,185,60)
|
|
36
|
-
color=tuple(int(x*illumination) for x in base)
|
|
37
|
-
poly=[project(v) for v in tri]
|
|
38
|
-
(ax,ay),(bx,by),(cx,cy)=poly
|
|
39
|
-
den=(by-cy)*(ax-cx)+(cx-bx)*(ay-cy)
|
|
40
|
-
if abs(den)<1e-9:continue
|
|
41
|
-
loX=max(0,math.floor(min(v[0] for v in poly)));hiX=min(w-1,math.ceil(max(v[0] for v in poly)))
|
|
42
|
-
loY=max(0,math.floor(min(v[1] for v in poly)));hiY=min(h-1,math.ceil(max(v[1] for v in poly)))
|
|
43
|
-
if loX>hiX or loY>hiY:continue
|
|
44
|
-
yy,xx=np.mgrid[loY:hiY+1,loX:hiX+1];xx=xx+.5;yy=yy+.5
|
|
45
|
-
wa=((by-cy)*(xx-cx)+(cx-bx)*(yy-cy))/den
|
|
46
|
-
wb=((cy-ay)*(xx-cx)+(ax-cx)*(yy-cy))/den;wc=1-wa-wb
|
|
47
|
-
depths=[v[2]-.40*v[0]+.16*v[1] for v in tri]
|
|
48
|
-
zz=wa*depths[0]+wb*depths[1]+wc*depths[2]
|
|
49
|
-
region=zbuffer[loY:hiY+1,loX:hiX+1]
|
|
50
|
-
mask=(wa>=-1e-8)&(wb>=-1e-8)&(wc>=-1e-8)&(zz>region)
|
|
51
|
-
region[mask]=zz[mask];pixels[loY:hiY+1,loX:hiX+1][mask]=color
|
|
52
|
-
canvas=Image.fromarray(pixels);d=ImageDraw.Draw(canvas)
|
|
53
|
-
# Reference line on front face plane; no opaque floor hiding the original protrusion.
|
|
54
|
-
a=project([-2000,0,3200]);b=project([2000,0,3200]);d.line([a,b],fill='#ff6b73',width=3)
|
|
55
|
-
d.text((12,h-28),'Y = 0 / floor reference',font=small,fill='#ff9ba1')
|
|
56
|
-
if zoom:d.text((12,10),'BOTTOM DETAIL',font=small,fill='white')
|
|
57
|
-
im.paste(canvas,(x0,y0))
|
|
58
|
-
draw.text((x0,682),'Top 2900 mm / opening top 2200 mm / lamp unchanged',font=small,fill='#a9bdcc')
|
|
59
|
-
draw.text((30,954),'Mesh checks: closed, oriented edges; 28 triangles per portal. Both inlet and outlet tested.',font=small,fill='#a9bdcc')
|
|
60
|
-
im.save(sys.argv[1])
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
// Isolated browser experiment; never connects to the product server or edits assets.
|
|
2
|
-
import {createServer} from 'node:http'
|
|
3
|
-
import {readFile} from 'node:fs/promises'
|
|
4
|
-
import {chromium} from '/Users/super/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs'
|
|
5
|
-
import {seed,defaults,evaluateFull,geometryOf,sourceGeometry} from './full-dryroom.mjs'
|
|
6
|
-
import * as conveyor from './full-conveyor.mjs'
|
|
7
|
-
import * as robot from './full-robot.mjs'
|
|
8
|
-
import * as forklift from './full-forklift.mjs'
|
|
9
|
-
import {forkliftBindings} from './capability-samples.mjs'
|
|
10
|
-
import {compilePayloadInspection} from './payload-fit.mjs'
|
|
11
|
-
import {compileCollisionInspection} from './payload-collision.mjs'
|
|
12
|
-
const isConveyor=process.argv.includes('--conveyor')
|
|
13
|
-
const isRobot=process.argv.includes('--robot')
|
|
14
|
-
const isCollision=process.argv.includes('--collision')
|
|
15
|
-
const isPayload=process.argv.includes('--payload')||isCollision
|
|
16
|
-
const isForklift=process.argv.includes('--forklift')||isPayload
|
|
17
|
-
const pack=(items)=>items.map(x=>({id:x.id,material:x.material,geometry:x.geometry.toJSON()}))
|
|
18
|
-
const fromFigure=args=>pack(evaluateFull(args).map(x=>({...x,geometry:geometryOf(x)})))
|
|
19
|
-
const robotPose={...robot.defaults,'base-pan':30,'shoulder-lift':-35,'elbow-flex':65,'wrist-pitch':-25,'wrist-yaw':40,'tool-roll':20,gripper:50}
|
|
20
|
-
function payloadVariants(){
|
|
21
|
-
const sample=forkliftBindings(),p=(isCollision?compileCollisionInspection:compilePayloadInspection)(sample.model,sample.declaration)
|
|
22
|
-
const scenarios=isCollision?[['Clear placement',[260,50,75],620,100,700],['Upper forks penetrated',[260,250,75],620,500,700],['Floating',[260,200,75],620,100,700],['Curved body: unresolved',[260,343,-1485],50,50,50]]:[['Contact',[260,50,0],620],['Floating 150mm',[260,200,0],620],['Penetrates 20mm',[260,30,0],620],['Between forks: no contact',[260,50,0],100]]
|
|
23
|
-
return scenarios.map(([title,center,width,height=100,depth=850])=>{
|
|
24
|
-
const q={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[width,height,depth],center,yawDeg:0},r=p.evaluate(forklift.defaults,q)
|
|
25
|
-
const b=r.evaluation.bindings[0],t=b.pose.r.map((row,j)=>b.pose.t[j]+row.reduce((v,x,i)=>v+x*center[i],0))
|
|
26
|
-
const placed={shape:{provider:'box',dimensions:{width,height,depth}},pose:{r:b.pose.r,t}}
|
|
27
|
-
return {title:title+' · contact '+r.inspection.contact.status+(isCollision?' / collision '+r.inspection.collision.status:''),parts:pack([...forklift.evaluateFull().map(x=>({...x,material:isCollision&&r.inspection.collision.pairs.some(p=>p.geometryId===x.id&&p.status==='failed')?{token:'palette.status-danger'}:x.material,geometry:forklift.geometryOf(x)})),{id:'payload',material:{token:'palette.accent'},geometry:forklift.geometryOf(placed)}])}
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
const variants=isPayload?payloadVariants():isForklift?[
|
|
31
|
-
{title:'Source seed · rest pose',parts:pack(forklift.seed.parts.map(p=>({id:p.name,material:p.material,geometry:forklift.sourceGeometry(p)})))},
|
|
32
|
-
...[
|
|
33
|
-
['Figure · same rest pose',forklift.defaults],
|
|
34
|
-
['Figure · lift 1000 / reach 200 / drive 37',{...forklift.defaults,lift:1000,reach:200,drive:37}],
|
|
35
|
-
['Figure · longer forks / lift 2000 / reach 400',{forkLength:1275,lift:2000,reach:400,drive:73}]
|
|
36
|
-
].map(([title,args])=>({title,parts:pack(forklift.evaluateFull(args).map(x=>({...x,geometry:forklift.geometryOf(x)})))}))
|
|
37
|
-
]:isRobot?[
|
|
38
|
-
{title:'Source fixture · rest pose',parts:pack(robot.seed.parts.map(p=>({id:p.name,material:p.material??{token:'palette.neutral'},geometry:robot.sourceGeometry(p)})))},
|
|
39
|
-
...[
|
|
40
|
-
['Figure · same rest pose',robot.defaults],
|
|
41
|
-
['Figure · six joints + gripper',robotPose],
|
|
42
|
-
['Figure · changed link lengths + same joints',{...robotPose,upperLength:630,foreLength:570}]
|
|
43
|
-
].map(([title,args])=>({title,parts:pack(robot.evaluateFull(args).map(x=>({...x,geometry:robot.geometryOf(x)})))}))
|
|
44
|
-
]:isConveyor?[
|
|
45
|
-
{title:'Source seed · overlapping end rollers',parts:pack(conveyor.sourceItems())},
|
|
46
|
-
...[
|
|
47
|
-
['Figure · reserved end roller space',conveyor.defaults],
|
|
48
|
-
['Figure · roller rotation 37 degrees',{...conveyor.defaults,angle:37}],
|
|
49
|
-
['Figure · resized assembly',{length:3000,width:900,rollerHeight:700,angle:37}]
|
|
50
|
-
].map(([title,args])=>({title,parts:pack(conveyor.evaluateFull(args).map(x=>({...x,geometry:conveyor.geometryOf(x)})))}))
|
|
51
|
-
]:[
|
|
52
|
-
{title:'Source seed · six parts',parts:pack(seed.parts.map(p=>({id:p.name,material:p.material,geometry:sourceGeometry(p)})))},
|
|
53
|
-
{title:'Figure · same dimensions',parts:fromFigure(defaults)},
|
|
54
|
-
{title:'Figure · portal bottom trimmed to floor',parts:fromFigure({...defaults,bottom:0})},
|
|
55
|
-
{title:'Figure · resized + lamps raised',parts:fromFigure({...defaults,width:4800,height:3600,depth:7200,bottom:0,lampLift:100})}
|
|
56
|
-
]
|
|
57
|
-
const html=`<!doctype html><meta charset="utf-8"><style>body{margin:0;background:#e9edf2;font:16px sans-serif;color:#17283b}main{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px}section{background:white;border-radius:8px;overflow:hidden}h3{margin:12px;font-size:16px}canvas{display:block;width:100%}p{margin:12px;font-size:12px}</style><main></main><script type="module">
|
|
58
|
-
import * as T from '/three.module.js';
|
|
59
|
-
const variants=${JSON.stringify(variants)};
|
|
60
|
-
const palette={'palette.primary':'#154473','palette.accent':'#f79c40','palette.neutral':'#96989b','palette.dark':'#27292c','palette.light':'#d6d7d9','palette.surface':'#56585b','palette.status-danger':'#f61b30','palette.status-warning':'#e68d1a','palette.status-success':'#1cb648','palette.status-info':'#158bde'};
|
|
61
|
-
window.results=[];window.pixels=[];
|
|
62
|
-
for(const v of variants){
|
|
63
|
-
const section=document.createElement('section');section.innerHTML='<h3>'+v.title+'</h3>';document.querySelector('main').append(section);
|
|
64
|
-
const renderer=new T.WebGLRenderer({antialias:true,preserveDrawingBuffer:true});renderer.setSize(640,420);renderer.setClearColor('#f7f9fb');section.append(renderer.domElement);
|
|
65
|
-
const scene=new T.Scene();scene.add(new T.HemisphereLight(0xffffff,0x687381,2));const light=new T.DirectionalLight(0xffffff,3);light.position.set(5000,9000,9000);scene.add(light);
|
|
66
|
-
// Metal needs reflected illumination; a hemisphere light alone leaves it nearly black.
|
|
67
|
-
const envCanvas=document.createElement('canvas');envCanvas.width=512;envCanvas.height=256;const ctx=envCanvas.getContext('2d');const grad=ctx.createLinearGradient(0,0,0,256);grad.addColorStop(0,'#ffffff');grad.addColorStop(.5,'#b8c4d4');grad.addColorStop(1,'#4e5c6b');ctx.fillStyle=grad;ctx.fillRect(0,0,512,256);ctx.fillStyle='#ffffff';ctx.fillRect(70,30,90,80);const envTex=new T.CanvasTexture(envCanvas);envTex.colorSpace=T.SRGBColorSpace;const pmrem=new T.PMREMGenerator(renderer);scene.environment=pmrem.fromEquirectangular(envTex).texture;
|
|
68
|
-
for(const p of v.parts){const m=p.material,e=m.emissive;const color=palette[e?.on?e.token:m.token]??'#96989b';const mat=new T.MeshStandardMaterial({color,metalness:m.preset==='metal'?1:0,roughness:m.preset==='rubber'?.9:m.preset==='metal'?.3:.4,flatShading:true,transparent:!!m.transparent,emissive:e?.on?color:0,emissiveIntensity:e?.on?e.intensity:0});scene.add(new T.Mesh(new T.BufferGeometryLoader().parse(p.geometry),mat));}
|
|
69
|
-
const camera=new T.PerspectiveCamera(38,640/420,1,50000);camera.position.set(...${JSON.stringify(isForklift?[5200,4200,6200]:isRobot?[2300,1900,2900]:isConveyor?[3000,2400,3300]:[10000,8000,13500])});camera.lookAt(0,${isForklift?1250:isRobot?750:isConveyor?350:1300},0);renderer.render(scene,camera);
|
|
70
|
-
const gl=renderer.getContext(),px=new Uint8Array(640*420*4);gl.readPixels(0,0,640,420,gl.RGBA,gl.UNSIGNED_BYTE,px);window.pixels.push(px);
|
|
71
|
-
window.results.push({title:v.title,calls:renderer.info.render.calls,triangles:renderer.info.render.triangles});
|
|
72
|
-
section.insertAdjacentHTML('beforeend','<p>'+renderer.info.render.calls+' draw calls / '+renderer.info.render.triangles+' triangles · identical camera</p>');
|
|
73
|
-
}
|
|
74
|
-
let different=0,max=0;for(let i=0;i<pixels[0].length;i++){const d=Math.abs(pixels[0][i]-pixels[1][i]);if(d)different++;max=Math.max(max,d)}window.comparison={differentChannels:different,maxChannelDifference:max};window.ready=true;
|
|
75
|
-
</script>`
|
|
76
|
-
const server=createServer(async(req,res)=>{try{if(req.url==='/'){res.setHeader('Content-Type','text/html');res.end(html)}else if(['/three.module.js','/three.core.js'].includes(req.url)){res.setHeader('Content-Type','text/javascript');res.end(await readFile(new URL('../../../things-scene/node_modules/three/build'+req.url,import.meta.url)))}else{res.statusCode=404;res.end()}}catch(e){res.statusCode=500;res.end(String(e))}})
|
|
77
|
-
await new Promise(r=>server.listen(0,'127.0.0.1',r));let browser
|
|
78
|
-
try{
|
|
79
|
-
browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
|
80
|
-
const page=await browser.newPage({viewport:{width:1328,height:990},deviceScaleFactor:1});const errors=[];page.on('pageerror',e=>errors.push(String(e)));
|
|
81
|
-
await page.goto('http://127.0.0.1:'+server.address().port);await page.waitForFunction(()=>window.ready);
|
|
82
|
-
const screenshot='/private/tmp/figure-full-'+(isCollision?'collision':isPayload?'payload':isForklift?'forklift':isRobot?'robot':isConveyor?'conveyor':'dryroom')+'.png';
|
|
83
|
-
await page.screenshot({path:screenshot,fullPage:true});
|
|
84
|
-
console.log(JSON.stringify({...await page.evaluate(()=>({results:window.results,comparison:window.comparison})),errors,screenshot},null,2));
|
|
85
|
-
if(errors.length)process.exitCode=1;
|
|
86
|
-
}finally{await browser?.close();server.close()}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import {fullModel} from './full-conveyor.mjs'
|
|
2
|
-
export function repeatedBinding(){
|
|
3
|
-
const model=fullModel()
|
|
4
|
-
model.inputs.push({id:'selectedIndex',unit:'count',min:0,max:100,role:'design'})
|
|
5
|
-
model.nodes.push(
|
|
6
|
-
{id:'selectedRoller',op:'select-item@1',args:['rollers','selectedIndex'],outputs:{placed:'selectedRoller'},params:{member:'rollerPlaced'}},
|
|
7
|
-
{id:'rollerEnd',op:'feature@1',args:['rollerShape'],outputs:{pose:'rollerEnd'},params:{frame:'rollerEndFrame',name:'end'}}
|
|
8
|
-
)
|
|
9
|
-
const target={placement:'selectedRoller',feature:'rollerEnd'}
|
|
10
|
-
// Illustrative binding for reference testing, not an asserted industrial roller use case.
|
|
11
|
-
const declaration={version:'r3-capability-3',assetFrame:'world',bindings:[{id:'selected-end',label:'선택한 롤러 끝면',role:'grip',frame:'rollerEndFrame',pose:target,contacts:[{...target}],accepts:['TEST_FIXTURE'],capacity:1}]}
|
|
12
|
-
return {model,declaration}
|
|
13
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import {test} from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {repeatedBinding} from './repeated-binding.mjs'
|
|
4
|
-
import {compileCapabilities} from './capability-plan.mjs'
|
|
5
|
-
import {compileDocument,parseDocument,serializeDocument} from './document.mjs'
|
|
6
|
-
import {defaults} from './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-prototype-1',model:s.model,capabilities:s.declaration}
|
|
40
|
-
const validate=new Ajv().compile(JSON.parse(fs.readFileSync(new URL('./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
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
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 './asset-samples.mjs'
|
|
5
|
-
import {compileFigureGraph} from '../../src/graph.ts'
|
|
6
|
-
import {compileFigureAsset} from '../../src/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/contract.js',import.meta.url),'utf8'))
|
|
10
|
-
const source=await readFile(new URL('../../dist/json.js',import.meta.url),'utf8')
|
|
11
|
-
assert.ok(source.includes('./contract.js'))
|
|
12
|
-
const entry=url(source.replace('./contract.js',contract))
|
|
13
|
-
const graphSource=await readFile(new URL('../../dist/graph.js',import.meta.url),'utf8')
|
|
14
|
-
const graphEntry=url(graphSource.replace('./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,/^[a-z-]+\.js$/)
|
|
19
|
-
let code=await readFile(new URL('../../dist/'+name,import.meta.url),'utf8')
|
|
20
|
-
for(const match of [...code.matchAll(/from ['"]\.\/([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('asset.js')
|
|
26
|
-
const workspaceEntry=await moduleUrl('editor-workspace.js')
|
|
27
|
-
const createEntry=await moduleUrl('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.parseFigureJson('{"name":"드라이룸 😀"}')
|
|
34
|
-
let duplicate
|
|
35
|
-
try{module.parseFigureJson('{"a":1,"\\u0061":2}')}catch(e){duplicate=e.code}
|
|
36
|
-
return {buffer:typeof globalThis.Buffer,process:typeof globalThis.process,value,bytes:module.figureUtf8ByteLength('드라이룸 😀'),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=compileFigureGraph(model).evaluate(inputs)
|
|
43
|
-
const actual=await page.evaluate(async({entry,model,inputs})=>(await import(entry)).compileFigureGraph(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.compileFigureAsset(m.parseFigureAsset(text)).evaluate()},{entry:assetEntry,text:JSON.stringify(asset)})
|
|
48
|
-
assert.deepEqual(JSON.parse(JSON.stringify(restored)),JSON.parse(JSON.stringify(compileFigureAsset(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)).createFigureEditorWorkspace(),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)).createFigureEditorWorkspace()
|
|
67
|
-
const empty=c.createFigureAsset('New Figure'),emptyCount=a.compileFigureAsset(empty).evaluate().geometry.length
|
|
68
|
-
const model=c.addFigureBox(empty,{id:'gate',size:{x:800,y:2000,z:100},position:{x:0,y:1000,z:0}})
|
|
69
|
-
const opened=w.open(a.serializeFigureAsset(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.compileFigureAsset(a.parseFigureAsset(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({newFigureAuthoring:authored}))
|
|
76
|
-
}finally{await browser.close()}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Read-only design probes against checked-in product samples. NOT a 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
|
-
})
|