@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +8 -4
  3. package/dist/asset-types.d.ts +8 -13
  4. package/dist/asset-types.d.ts.map +1 -1
  5. package/dist/asset-types.js.map +1 -1
  6. package/dist/asset.js +4 -4
  7. package/dist/asset.js.map +1 -1
  8. package/dist/authoring-actions.js +18 -5
  9. package/dist/authoring-actions.js.map +1 -1
  10. package/dist/authoring-types.d.ts +6 -11
  11. package/dist/authoring-types.d.ts.map +1 -1
  12. package/dist/authoring-types.js.map +1 -1
  13. package/dist/editor-workspace.d.ts +1 -1
  14. package/dist/editor-workspace.d.ts.map +1 -1
  15. package/dist/editor-workspace.js +13 -6
  16. package/dist/editor-workspace.js.map +1 -1
  17. package/dist/extensions.d.ts +15 -0
  18. package/dist/extensions.d.ts.map +1 -0
  19. package/dist/extensions.js +63 -0
  20. package/dist/extensions.js.map +1 -0
  21. package/dist/gate.d.ts +1 -1
  22. package/dist/gate.d.ts.map +1 -1
  23. package/dist/gate.js +28 -33
  24. package/dist/gate.js.map +1 -1
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/input-preview.d.ts +48 -0
  30. package/dist/input-preview.d.ts.map +1 -0
  31. package/dist/{authoring-session.js → input-preview.js} +7 -63
  32. package/dist/input-preview.js.map +1 -0
  33. package/dist/kernel-version.d.ts +1 -1
  34. package/dist/kernel-version.js +1 -1
  35. package/dist/names.d.ts.map +1 -1
  36. package/dist/names.js +5 -1
  37. package/dist/names.js.map +1 -1
  38. package/dist/package.d.ts.map +1 -1
  39. package/dist/package.js +18 -15
  40. package/dist/package.js.map +1 -1
  41. package/dist/proposal.d.ts +5 -1
  42. package/dist/proposal.d.ts.map +1 -1
  43. package/dist/proposal.js +18 -5
  44. package/dist/proposal.js.map +1 -1
  45. package/dist/release-contract.d.ts +1 -1
  46. package/dist/release-contract.d.ts.map +1 -1
  47. package/dist/release-contract.js +13 -24
  48. package/dist/release-contract.js.map +1 -1
  49. package/dist/shape-edit.d.ts +1 -1
  50. package/dist/shape-edit.d.ts.map +1 -1
  51. package/dist/size-edit.d.ts +1 -1
  52. package/dist/size-edit.d.ts.map +1 -1
  53. package/docs/asset-persistence.md +6 -3
  54. package/docs/authoring-transactions.md +21 -19
  55. package/docs/capability-bindings.md +6 -3
  56. package/docs/common-kernel-experiment.md +3 -0
  57. package/docs/document-contract.md +6 -3
  58. package/docs/dryroom-profile-validation.md +3 -0
  59. package/docs/editor-integration.md +5 -2
  60. package/docs/full-conveyor-validation.md +5 -2
  61. package/docs/full-dryroom-validation.md +6 -3
  62. package/docs/full-forklift-validation.md +5 -2
  63. package/docs/full-robot-validation.md +5 -2
  64. package/docs/payload-collision-validation.md +5 -2
  65. package/docs/payload-contact-validation.md +5 -2
  66. package/docs/prototypes/README.md +27 -61
  67. package/docs/repeated-binding-validation.md +6 -3
  68. package/docs/review-2026-09-22.md +3 -0
  69. package/docs/runtime-extraction.md +9 -2
  70. package/docs/sample-validation-expanded.md +3 -0
  71. package/docs/sample-validation.md +3 -0
  72. package/docs/shape-contract.md +7 -5
  73. package/docs/spatial-contracts.md +3 -0
  74. package/docs/status.md +2 -1
  75. package/docs/storage-integration.md +6 -3
  76. package/docs/typed-repeat-contracts.md +3 -0
  77. package/package.json +9 -9
  78. package/dist/authoring-session.d.ts +0 -121
  79. package/dist/authoring-session.d.ts.map +0 -1
  80. package/dist/authoring-session.js.map +0 -1
  81. package/dist/carrier.d.ts +0 -23
  82. package/dist/carrier.d.ts.map +0 -1
  83. package/dist/carrier.js +0 -65
  84. package/dist/carrier.js.map +0 -1
  85. package/docs/prototypes/asset-samples.mjs +0 -15
  86. package/docs/prototypes/asset-store.mjs +0 -52
  87. package/docs/prototypes/asset-store.test.mjs +0 -59
  88. package/docs/prototypes/asset.mjs +0 -1
  89. package/docs/prototypes/asset.schema.json +0 -36
  90. package/docs/prototypes/asset.test.mjs +0 -68
  91. package/docs/prototypes/authoring-session.mjs +0 -1
  92. package/docs/prototypes/authoring-session.test.mjs +0 -65
  93. package/docs/prototypes/capability-plan.mjs +0 -1
  94. package/docs/prototypes/capability-plan.test.mjs +0 -56
  95. package/docs/prototypes/capability-samples.mjs +0 -23
  96. package/docs/prototypes/document.mjs +0 -2
  97. package/docs/prototypes/document.schema.json +0 -66
  98. package/docs/prototypes/document.test.mjs +0 -55
  99. package/docs/prototypes/dryroom-mesh.mjs +0 -60
  100. package/docs/prototypes/dryroom-mesh.test.mjs +0 -30
  101. package/docs/prototypes/editor-browser.mjs +0 -97
  102. package/docs/prototypes/editor-workspace.mjs +0 -1
  103. package/docs/prototypes/editor-workspace.test.mjs +0 -35
  104. package/docs/prototypes/feature-pattern.test.mjs +0 -131
  105. package/docs/prototypes/full-conveyor.mjs +0 -60
  106. package/docs/prototypes/full-conveyor.test.mjs +0 -61
  107. package/docs/prototypes/full-dryroom.mjs +0 -67
  108. package/docs/prototypes/full-dryroom.test.mjs +0 -54
  109. package/docs/prototypes/full-forklift.mjs +0 -40
  110. package/docs/prototypes/full-forklift.test.mjs +0 -37
  111. package/docs/prototypes/full-robot.mjs +0 -35
  112. package/docs/prototypes/full-robot.test.mjs +0 -68
  113. package/docs/prototypes/kernel.mjs +0 -147
  114. package/docs/prototypes/kernel.test.mjs +0 -144
  115. package/docs/prototypes/more-samples.test.mjs +0 -182
  116. package/docs/prototypes/payload-collision.mjs +0 -54
  117. package/docs/prototypes/payload-collision.test.mjs +0 -68
  118. package/docs/prototypes/payload-fit.mjs +0 -58
  119. package/docs/prototypes/payload-fit.test.mjs +0 -59
  120. package/docs/prototypes/render-dryroom.py +0 -60
  121. package/docs/prototypes/render-full-dryroom.mjs +0 -86
  122. package/docs/prototypes/repeated-binding.mjs +0 -13
  123. package/docs/prototypes/repeated-binding.test.mjs +0 -46
  124. package/docs/prototypes/runtime-browser.mjs +0 -76
  125. package/docs/prototypes/samples.test.mjs +0 -144
  126. package/docs/prototypes/spatial-plan.test.mjs +0 -102
  127. 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
- })