@hatiolab/figure-model 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) 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/data-interface.d.ts.map +1 -1
  14. package/dist/data-interface.js +8 -4
  15. package/dist/data-interface.js.map +1 -1
  16. package/dist/editor-workspace.d.ts +1 -1
  17. package/dist/editor-workspace.d.ts.map +1 -1
  18. package/dist/editor-workspace.js +2 -5
  19. package/dist/editor-workspace.js.map +1 -1
  20. package/dist/extensions.d.ts +15 -0
  21. package/dist/extensions.d.ts.map +1 -0
  22. package/dist/extensions.js +63 -0
  23. package/dist/extensions.js.map +1 -0
  24. package/dist/gate.d.ts +1 -1
  25. package/dist/gate.d.ts.map +1 -1
  26. package/dist/gate.js +28 -33
  27. package/dist/gate.js.map +1 -1
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/input-preview.d.ts +48 -0
  33. package/dist/input-preview.d.ts.map +1 -0
  34. package/dist/{authoring-session.js → input-preview.js} +7 -63
  35. package/dist/input-preview.js.map +1 -0
  36. package/dist/kernel-version.d.ts +1 -1
  37. package/dist/kernel-version.js +1 -1
  38. package/dist/names.d.ts.map +1 -1
  39. package/dist/names.js +5 -1
  40. package/dist/names.js.map +1 -1
  41. package/dist/package.d.ts.map +1 -1
  42. package/dist/package.js +18 -15
  43. package/dist/package.js.map +1 -1
  44. package/dist/proposal.d.ts +5 -1
  45. package/dist/proposal.d.ts.map +1 -1
  46. package/dist/proposal.js +18 -5
  47. package/dist/proposal.js.map +1 -1
  48. package/dist/release-contract.d.ts +1 -1
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +13 -24
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/shape-edit.d.ts +1 -1
  53. package/dist/shape-edit.d.ts.map +1 -1
  54. package/dist/size-edit.d.ts +1 -1
  55. package/dist/size-edit.d.ts.map +1 -1
  56. package/docs/asset-persistence.md +6 -3
  57. package/docs/authoring-transactions.md +21 -19
  58. package/docs/capability-bindings.md +6 -3
  59. package/docs/common-kernel-experiment.md +3 -0
  60. package/docs/document-contract.md +6 -3
  61. package/docs/dryroom-profile-validation.md +3 -0
  62. package/docs/editor-integration.md +5 -2
  63. package/docs/full-conveyor-validation.md +5 -2
  64. package/docs/full-dryroom-validation.md +6 -3
  65. package/docs/full-forklift-validation.md +5 -2
  66. package/docs/full-robot-validation.md +5 -2
  67. package/docs/payload-collision-validation.md +5 -2
  68. package/docs/payload-contact-validation.md +5 -2
  69. package/docs/prototypes/README.md +27 -61
  70. package/docs/repeated-binding-validation.md +6 -3
  71. package/docs/review-2026-09-22.md +3 -0
  72. package/docs/runtime-extraction.md +9 -2
  73. package/docs/sample-validation-expanded.md +3 -0
  74. package/docs/sample-validation.md +3 -0
  75. package/docs/shape-contract.md +7 -5
  76. package/docs/spatial-contracts.md +3 -0
  77. package/docs/status.md +2 -1
  78. package/docs/storage-integration.md +6 -3
  79. package/docs/typed-repeat-contracts.md +3 -0
  80. package/package.json +9 -9
  81. package/dist/authoring-session.d.ts +0 -121
  82. package/dist/authoring-session.d.ts.map +0 -1
  83. package/dist/authoring-session.js.map +0 -1
  84. package/dist/carrier.d.ts +0 -23
  85. package/dist/carrier.d.ts.map +0 -1
  86. package/dist/carrier.js +0 -65
  87. package/dist/carrier.js.map +0 -1
  88. package/docs/prototypes/asset-samples.mjs +0 -15
  89. package/docs/prototypes/asset-store.mjs +0 -52
  90. package/docs/prototypes/asset-store.test.mjs +0 -59
  91. package/docs/prototypes/asset.mjs +0 -1
  92. package/docs/prototypes/asset.schema.json +0 -36
  93. package/docs/prototypes/asset.test.mjs +0 -68
  94. package/docs/prototypes/authoring-session.mjs +0 -1
  95. package/docs/prototypes/authoring-session.test.mjs +0 -65
  96. package/docs/prototypes/capability-plan.mjs +0 -1
  97. package/docs/prototypes/capability-plan.test.mjs +0 -56
  98. package/docs/prototypes/capability-samples.mjs +0 -23
  99. package/docs/prototypes/document.mjs +0 -2
  100. package/docs/prototypes/document.schema.json +0 -66
  101. package/docs/prototypes/document.test.mjs +0 -55
  102. package/docs/prototypes/dryroom-mesh.mjs +0 -60
  103. package/docs/prototypes/dryroom-mesh.test.mjs +0 -30
  104. package/docs/prototypes/editor-browser.mjs +0 -97
  105. package/docs/prototypes/editor-workspace.mjs +0 -1
  106. package/docs/prototypes/editor-workspace.test.mjs +0 -35
  107. package/docs/prototypes/feature-pattern.test.mjs +0 -131
  108. package/docs/prototypes/full-conveyor.mjs +0 -60
  109. package/docs/prototypes/full-conveyor.test.mjs +0 -61
  110. package/docs/prototypes/full-dryroom.mjs +0 -67
  111. package/docs/prototypes/full-dryroom.test.mjs +0 -54
  112. package/docs/prototypes/full-forklift.mjs +0 -40
  113. package/docs/prototypes/full-forklift.test.mjs +0 -37
  114. package/docs/prototypes/full-robot.mjs +0 -35
  115. package/docs/prototypes/full-robot.test.mjs +0 -68
  116. package/docs/prototypes/kernel.mjs +0 -147
  117. package/docs/prototypes/kernel.test.mjs +0 -144
  118. package/docs/prototypes/more-samples.test.mjs +0 -182
  119. package/docs/prototypes/payload-collision.mjs +0 -54
  120. package/docs/prototypes/payload-collision.test.mjs +0 -68
  121. package/docs/prototypes/payload-fit.mjs +0 -58
  122. package/docs/prototypes/payload-fit.test.mjs +0 -59
  123. package/docs/prototypes/render-dryroom.py +0 -60
  124. package/docs/prototypes/render-full-dryroom.mjs +0 -86
  125. package/docs/prototypes/repeated-binding.mjs +0 -13
  126. package/docs/prototypes/repeated-binding.test.mjs +0 -46
  127. package/docs/prototypes/runtime-browser.mjs +0 -76
  128. package/docs/prototypes/samples.test.mjs +0 -144
  129. package/docs/prototypes/spatial-plan.test.mjs +0 -102
  130. package/docs/prototypes/typed-repeat.test.mjs +0 -96
@@ -1,23 +0,0 @@
1
- import {fullModel as robotModel} from './full-robot.mjs'
2
- import {fullModel as forkliftModel} from './full-forklift.mjs'
3
- export function robotBinding(){
4
- const model=robotModel()
5
- // Explicit experimental grip reference. Original tool-seat solid is NOT removed.
6
- const declaration={version:'r3-capability-3',assetFrame:'world',bindings:[{id:'tool-grip',label:'툴 파지 기준',role:'grip',frame:'flangetop',pose:'flangeWorldTop',accepts:['BOX','WORKPIECE'],capacity:1}]}
7
- return {model,declaration}
8
- }
9
- export function forkliftBindings(){
10
- const model=forkliftModel(),bindings=[]
11
- const n=(id,op,args,outputs,params)=>model.nodes.push({id,op,args,outputs,...(params?{params}:{})})
12
- for(const level of ['low','high']){
13
- const frames=[]
14
- for(const side of ['left','right']){
15
- const id=`fork-${level}-${side}`
16
- n(id+'Top','feature@1',[id+'Shape'],{pose:id+'Top'},{frame:id+'Top',name:'top'})
17
- n(id+'WorldTop','compose@1',[id+'PartPose',id+'Top'],{pose:id+'WorldTop'});frames.push(id+'WorldTop')
18
- }
19
- // Binding at the left support face, not an invented solid spanning the fork gap.
20
- bindings.push({id:`load-${level}`,label:level==='low'?'하단 포크 받침 기준':'상단 포크 받침 기준',role:'seat',frame:`fork-${level}-leftTop`,pose:frames[0],contacts:['left','right'].map(side=>({placement:`fork-${level}-${side}Placed`,feature:`fork-${level}-${side}Top`})),accepts:['PALLET'],capacity:1})
21
- }
22
- return {model,declaration:{version:'r3-capability-3',assetFrame:'world',bindings}}
23
- }
@@ -1,2 +0,0 @@
1
- export {compileFigureDocument as compileDocument,parseFigureDocument as parseDocument,serializeFigureDocument as serializeDocument} from '../../src/document.ts'
2
- export {assertFigureJson as jsonOnly,parseFigureJson as parseStrictJson,FIGURE_JSON_MAX_BYTES as MAX_BYTES,figureUtf8ByteLength} from '../../src/json.ts'
@@ -1,66 +0,0 @@
1
- {
2
- "$schema": "http://json-schema.org/draft-07/schema#",
3
- "title": "Experimental Figure graph and capability document (not the complete Figure asset format)",
4
- "type": "object",
5
- "additionalProperties": false,
6
- "required": ["version", "model", "capabilities"],
7
- "properties": {
8
- "version": {"const": "figure-prototype-1"},
9
- "model": {"$ref": "#/$defs/model"},
10
- "capabilities": {"$ref": "#/$defs/capabilities"}
11
- },
12
- "$defs": {
13
- "text": {"type": "string", "minLength": 1},
14
- "ref": {"type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9_.-]*$"},
15
- "unit": {"enum": ["mm", "cm", "m", "rad", "deg", "s", "ratio", "percent", "count"]},
16
- "input": {
17
- "type": "object", "additionalProperties": false, "required": ["id", "unit", "min", "max"],
18
- "properties": {"id": {"$ref": "#/$defs/ref"}, "unit": {"$ref": "#/$defs/unit"}, "min": {"type": "number"}, "max": {"type": "number"}, "role": {"enum": ["design", "state"]}}
19
- },
20
- "constant": {
21
- "type": "object", "additionalProperties": false, "required": ["id", "unit", "value"],
22
- "properties": {"id": {"$ref": "#/$defs/ref"}, "unit": {"$ref": "#/$defs/unit"}, "value": {"type": "number"}}
23
- },
24
- "node": {
25
- "type": "object", "additionalProperties": false, "required": ["id", "op", "args", "outputs"],
26
- "properties": {
27
- "id": {"$ref": "#/$defs/ref"}, "op": {"type": "string", "pattern": "^[a-z-]+@[1-9][0-9]*$"},
28
- "args": {"type": "array", "items": {"$ref": "#/$defs/ref"}},
29
- "outputs": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/$defs/ref"}},
30
- "params": {"type": "object", "additionalProperties": {"$ref": "#/$defs/ref"}}
31
- }
32
- },
33
- "model": {
34
- "type": "object", "additionalProperties": false, "required": ["version", "inputs", "constants", "nodes"],
35
- "properties": {
36
- "version": {"enum": ["r3-scalar-1", "r3-spatial-1"]},
37
- "inputs": {"type": "array", "items": {"$ref": "#/$defs/input"}},
38
- "constants": {"type": "array", "items": {"$ref": "#/$defs/constant"}},
39
- "nodes": {"type": "array", "items": {"$ref": "#/$defs/node"}}
40
- }
41
- },
42
- "contact": {
43
- "type": "object", "additionalProperties": false, "required": ["placement", "feature"],
44
- "properties": {"placement": {"$ref": "#/$defs/ref"}, "feature": {"$ref": "#/$defs/ref"}}
45
- },
46
- "binding": {
47
- "type": "object", "additionalProperties": false,
48
- "required": ["id", "label", "role", "frame", "pose", "accepts", "capacity"],
49
- "properties": {
50
- "id": {"$ref": "#/$defs/text"}, "label": {"$ref": "#/$defs/text"}, "role": {"enum": ["seat", "grip"]},
51
- "frame": {"$ref": "#/$defs/text"}, "pose": {"oneOf": [{"$ref": "#/$defs/ref"}, {"$ref": "#/$defs/contact"}]},
52
- "accepts": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/text"}},
53
- "capacity": {"type": "integer", "minimum": 1, "maximum": 9007199254740991},
54
- "contacts": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/contact"}},
55
- "region": {
56
- "type": "object", "additionalProperties": false, "required": ["kind", "dimensions"],
57
- "properties": {"kind": {"const": "centered-box"}, "dimensions": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"$ref": "#/$defs/ref"}}}
58
- }
59
- }
60
- },
61
- "capabilities": {
62
- "type": "object", "additionalProperties": false, "required": ["version", "assetFrame", "bindings"],
63
- "properties": {"version": {"const": "r3-capability-3"}, "assetFrame": {"$ref": "#/$defs/text"}, "bindings": {"type": "array", "items": {"$ref": "#/$defs/binding"}}}
64
- }
65
- }
66
- }
@@ -1,55 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import fs from 'node:fs'
4
- import Ajv from '../../../things-factory/node_modules/ajv/lib/ajv.js'
5
- import {compileDocument,serializeDocument,parseDocument,MAX_BYTES} from './document.mjs'
6
- import {compileCapabilities} from './capability-plan.mjs'
7
- import {forkliftBindings,robotBinding} from './capability-samples.mjs'
8
- import {defaults as forkDefaults} from './full-forklift.mjs'
9
- import {defaults as robotDefaults} from './full-robot.mjs'
10
- const envelope=s=>({version:'figure-prototype-1',model:s.model,capabilities:s.declaration})
11
- const schema=JSON.parse(fs.readFileSync(new URL('./document.schema.json',import.meta.url)))
12
- const validate=new Ajv({allErrors:true}).compile(schema)
13
- test('JSON Schema and semantic compiler: serialize/parse/recompile both complete models',()=>{
14
- for(const [sample,args] of [[forkliftBindings(),{...forkDefaults,lift:1000,reach:200}],[robotBinding(),{...robotDefaults,'shoulder-lift':40}]]){
15
- const doc=envelope(sample);assert.ok(validate(doc),JSON.stringify(validate.errors))
16
- const text=serializeDocument(doc),restored=parseDocument(text);assert.deepEqual(restored,doc)
17
- assert.deepEqual(compileDocument(restored).evaluate(args),compileDocument(doc).evaluate(args))
18
- }
19
- const d=envelope(forkliftBindings());d.capabilities.bindings[0].contacts[0].forged=true;assert.equal(validate(d),false);assert.throws(()=>compileDocument(d))
20
- })
21
- test('surface provenance rejects arbitrary transform, foreign shape, curved feature and duplicate contact',()=>{
22
- for(const [code,edit] of [
23
- ['SURFACE_PROVENANCE',s=>s.declaration.bindings[0].contacts[0].feature='mastPose'],
24
- ['SURFACE_OWNER',s=>s.declaration.bindings[0].contacts[0].feature='fork-high-leftTop'],
25
- ['DUPLICATE_CONTACT',s=>s.declaration.bindings[0].contacts.push(structuredClone(s.declaration.bindings[0].contacts[0]))],
26
- ['TARGET_ABSENT',s=>s.declaration.bindings[0].contacts[0].placement='deleted']
27
- ]){const s=forkliftBindings();edit(s);assert.throws(()=>compileCapabilities(s.model,s.declaration),e=>e.code===code)}
28
- const s=robotBinding();s.model.nodes.push({id:'curved',op:'feature@1',args:['pedestalShape'],outputs:{pose:'curved'},params:{frame:'curvedFrame',name:'radialBottom'}})
29
- s.declaration.bindings[0].contacts=[{placement:'pedestalPlaced',feature:'curved'}]
30
- assert.throws(()=>compileCapabilities(s.model,s.declaration),e=>e.code==='SURFACE_UNSUPPORTED')
31
- })
32
- test('surface normal follows actual placement rotation, not provider feature frame axes',()=>{
33
- const s=robotBinding();s.declaration.bindings[0].contacts=[{placement:'upper-armPlaced',feature:'upper-armtop'}]
34
- const p=compileCapabilities(s.model,s.declaration)
35
- for(const angle of [-90,0,90]){
36
- const r=p.evaluate({...robotDefaults,'shoulder-lift':angle}),surface=r.bindings[0].contacts[0]
37
- assert.equal(surface.geometryId,'upper-arm');assert.equal(surface.provenance,'verified-planar-surface')
38
- const want=[0,Math.cos(angle*Math.PI/180),Math.sin(angle*Math.PI/180)]
39
- want.forEach((v,i)=>assert.ok(Math.abs(surface.normal[i]-v)<1e-8))
40
- assert.equal(r.bindings[0].checks.spatial.status,'unverified')
41
- }
42
- })
43
- test('imports reject unknown versions, truncation, duplicate keys, oversized documents',()=>{
44
- const d=envelope(forkliftBindings()),text=serializeDocument(d)
45
- assert.throws(()=>parseDocument(text.slice(0,-1)),e=>e.code==='JSON_SYNTAX')
46
- assert.throws(()=>parseDocument('{"version":0,'+text.slice(1)),e=>e.code==='JSON_DUPLICATE_KEY')
47
- assert.throws(()=>parseDocument('{"x":1,"\\u0078":2}'),e=>e.code==='JSON_DUPLICATE_KEY')
48
- assert.throws(()=>parseDocument(' '.repeat(MAX_BYTES+1)),e=>e.code==='DOCUMENT_LIMIT')
49
- d.capabilities.version='r3-capability-1';assert.throws(()=>serializeDocument(d),e=>e.code==='VERSION')
50
- })
51
- test('serialization rejects lossy JS values rather than silently dropping or converting them',()=>{
52
- for(const value of [undefined,NaN,Infinity,()=>1,new Date()]){const d=envelope(robotBinding());d.model.constants[0].value=value;assert.throws(()=>serializeDocument(d),e=>e.code==='JSON_VALUE')}
53
- const d=envelope(robotBinding());d.model.nodes.push(d);assert.throws(()=>serializeDocument(d),e=>e.code==='JSON_VALUE')
54
- const sparse=envelope(robotBinding());delete sparse.model.nodes[0];assert.throws(()=>serializeDocument(sparse),e=>e.code==='JSON_VALUE')
55
- })
@@ -1,60 +0,0 @@
1
- // Isolated mesh experiment; reuses installed Three.js, never changes seed assets.
2
- import fs from 'node:fs'
3
- import {fileURLToPath} from 'node:url'
4
- import * as THREE from '../../../things-scene/node_modules/three/build/three.module.js'
5
- import {compile} from './typed-plan.mjs'
6
- export const seed=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url))).find(s=>s.type==='DRY_ROOM').source
7
- const c=(id,value,unit='mm')=>({id,value,unit})
8
- const n=(id,op,args,outputs,params)=>({id,op,args,outputs,...(params?{params}:{})})
9
- const rigid=(id,from,to,xyz,rot=['a0','a0','a0'])=>n(id,'rigid@1',[...xyz,...rot],{pose:id},{from,to})
10
- export function evaluatePortal(name,worldBottom){
11
- const p=seed.parts.find(p=>p.name===name),lamp=seed.parts.find(p=>p.name===name.replace('portal','lamp')),path=p.shape.path
12
- if(p.transform.rotation.x!==-90)throw Error('adapter requires source -90 X orientation')
13
- const t=p.transform.position,lt=lamp.transform.position
14
- // Preserve the original non-centred XY profile. Original +90 extrusion and -90 placement cancel.
15
- const m={version:'r3-spatial-1',inputs:[{id:'bottom',unit:'mm',min:-2000,max:path[2].y}],constants:[
16
- c('left',path[0].x),c('right',path[5].x),c('top',path[6].y),c('il',path[1].x),c('ir',path[3].x),c('it',path[2].y),c('depth',p.transform.size.y),
17
- c('x',t.x),c('y',t.y),c('z',t.z),c('zero',0),c('a0',0,'rad'),c('turn',90,'deg'),
18
- c('lampRadius',lamp.transform.size.x/2),c('lampLength',lamp.transform.size.y),
19
- c('lampX',lt.x-t.x),c('lampY',lt.y-t.y-path[6].y),c('lampZ',lt.z-t.z-p.transform.size.y/2)
20
- ],nodes:[
21
- n('profile','portal-profile@1',['left','right','bottom','top','il','ir','it','depth'],{shape:'shape'},{frame:'portal'}),
22
- rigid('pose','portal','world',['x','y','z']),
23
- n('surface','feature@1',['shape'],{pose:'surface'},{frame:'topFront',name:'frontTop'}),
24
- n('worldSurface','compose@1',['pose','surface'],{pose:'worldSurface'}),
25
- rigid('lampOffset','lampLocal','topFront',['lampX','lampY','lampZ'],['turn','a0','a0']),
26
- n('lampPose','compose@1',['worldSurface','lampOffset'],{pose:'lampPose'}),
27
- n('lampShape','cylinder-shape@1',['lampRadius','lampLength'],{shape:'lampShape'},{frame:'lampLocal'}),
28
- n('placedPortal','place@1',['shape','pose'],{placed:'placedPortal'}),n('placedLamp','place@1',['lampShape','lampPose'],{placed:'placedLamp'})]}
29
- return compile(m).evaluate({bottom:worldBottom-t.y})
30
- }
31
- export function meshOf(placed){
32
- const {shape,pose}=placed
33
- let g
34
- if(shape.provider==='portal'){
35
- const p=shape.profile,s=new THREE.Shape();s.moveTo(p[0].x,p[0].y);p.slice(1).forEach(v=>s.lineTo(v.x,v.y));s.closePath()
36
- g=new THREE.ExtrudeGeometry(s,{depth:shape.dimensions.depth,steps:1,bevelEnabled:false});g.translate(0,0,-shape.dimensions.depth/2)
37
- } else if(shape.provider==='box')g=new THREE.BoxGeometry(shape.dimensions.width,shape.dimensions.height,shape.dimensions.depth)
38
- else g=new THREE.CylinderGeometry(shape.dimensions.radius,shape.dimensions.radius,shape.dimensions.length,16)
39
- const r=pose.r,t=pose.t,mat=new THREE.Matrix4().set(...r[0],t[0],...r[1],t[1],...r[2],t[2],0,0,0,1)
40
- g.applyMatrix4(mat);return g.index?g.toNonIndexed():g
41
- }
42
- export function triangles(g){const p=g.attributes.position;return Array.from({length:p.count/3},(_,i)=>[0,1,2].map(j=>[p.getX(i*3+j),p.getY(i*3+j),p.getZ(i*3+j)]))}
43
- export function meshAudit(g){
44
- const edges=new Map(),vertices=new Set();let volume=0,minArea=Infinity
45
- for(const tri of triangles(g)){
46
- const [a,b,c]=tri.map(v=>new THREE.Vector3(...v)),normal=b.clone().sub(a).cross(c.clone().sub(a))
47
- minArea=Math.min(minArea,normal.length()/2);volume+=a.dot(b.clone().cross(c))/6
48
- const ids=tri.map(v=>v.map(x=>Math.round(x*1e6)).join(','));ids.forEach(k=>vertices.add(k))
49
- for(let i=0;i<3;i++){const a=ids[i],b=ids[(i+1)%3],key=[a,b].sort().join('|'),entry=edges.get(key)??{count:0,balance:0};entry.count++;entry.balance+=a<b?1:-1;edges.set(key,entry)}
50
- }
51
- return {faces:g.attributes.position.count/3,vertices:vertices.size,edges:edges.size,euler:vertices.size-edges.size+g.attributes.position.count/3,volume,minArea,badEdges:[...edges.values()].filter(e=>e.count!==2||e.balance!==0).length}
52
- }
53
- export function rayHit(g,x,y){
54
- const m=new THREE.Mesh(g,new THREE.MeshBasicMaterial({side:THREE.DoubleSide}));m.updateMatrixWorld()
55
- return new THREE.Raycaster(new THREE.Vector3(x,y,10000),new THREE.Vector3(0,0,-1)).intersectObject(m).length>0
56
- }
57
- if(process.argv[1]===fileURLToPath(import.meta.url)){
58
- const panels=[-60,0].map(bottom=>{const result=evaluatePortal('portal-in',bottom);return {bottom,parts:result.geometry.map(g=>({id:g.id,triangles:triangles(meshOf(g))}))}})
59
- console.log(JSON.stringify({threeRevision:THREE.REVISION,panels}))
60
- }
@@ -1,30 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {evaluatePortal,meshOf,meshAudit,rayHit,seed} from './dryroom-mesh.mjs'
4
- const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-5,`${a} != ${b}`)
5
- test('both actual portal profiles: baseline matches seed, bottom trim preserves top/opening/lamp',()=>{
6
- for(const name of ['portal-in','portal-out']){
7
- const before=evaluatePortal(name,-60),after=evaluatePortal(name,0)
8
- assert.deepEqual(before.values.shape.profile.map(({x,y})=>({x,y})),seed.parts.find(p=>p.name===name).shape.path)
9
- assert.deepEqual(before.values.lampPose,after.values.lampPose)
10
- for(const key of ['top','openingLeft','openingRight','openingTop','depth'])assert.equal(before.values.shape.dimensions[key],after.values.shape.dimensions[key])
11
- for(const [out,bottom] of [[before,-60],[after,0]]){
12
- const g=meshOf(out.geometry[0]);g.computeBoundingBox();near(g.boundingBox.min.y,bottom);near(g.boundingBox.max.y,2900)
13
- const a=meshAudit(g),d=out.values.shape.dimensions
14
- assert.equal(a.badEdges,0);assert.equal(a.euler,2);assert.ok(a.minArea>0)
15
- const area=(d.right-d.left)*(d.top-d.bottom)-(d.openingRight-d.openingLeft)*(d.openingTop-d.bottom)
16
- near(a.volume,area*d.depth)
17
- assert.equal(rayHit(g,0,1000),false);assert.equal(rayHit(g,0,2500),true);assert.equal(rayHit(g,1000,1000),true)
18
- assert.equal(rayHit(g,1000,-30),bottom===-60)
19
- console.log(JSON.stringify({name,bottom,...a}));g.dispose()
20
- }
21
- near(meshAudit(meshOf(before.geometry[0])).volume-meshAudit(meshOf(after.geometry[0])).volume,21600000)
22
- }
23
- })
24
- test('boundary edit rejects disappearing opening instead of silently changing topology',()=>{
25
- assert.throws(()=>evaluatePortal('portal-in',2200),e=>e.code==='GEOMETRY_DOMAIN')
26
- for(const bottom of [-60,0,100,1000,2199]){
27
- const r=evaluatePortal('portal-in',bottom),g=meshOf(r.geometry[0]),a=meshAudit(g)
28
- assert.equal(a.badEdges,0);assert.equal(a.euler,2);assert.ok(a.volume>0);g.dispose()
29
- }
30
- })
@@ -1,97 +0,0 @@
1
- // Isolated UI integration harness. No product server, DB or saved assets are touched.
2
- import {createServer} from 'node:http'
3
- import {readFile,mkdtemp,rm} from 'node:fs/promises'
4
- import {tmpdir} from 'node:os'
5
- import {join} from 'node:path'
6
- import {randomUUID} from 'node:crypto'
7
- import {chromium} from '/Users/super/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs'
8
- import assert from 'node:assert/strict'
9
- import {createEditorWorkspace} from './editor-workspace.mjs'
10
- import {sampleAsset} from './asset-samples.mjs'
11
- import {serializeAsset} from './asset.mjs'
12
- import {createFigureAsset} from '../../src/create.ts'
13
- import {createAssetStore} from './asset-store.mjs'
14
- import {geometryOf as ordinary} from './full-conveyor.mjs'
15
- import {geometryOf as portal} from './full-dryroom.mjs'
16
- const directory=await mkdtemp(join(tmpdir(),'editor-store-'))
17
- const store=createAssetStore(directory),workspace=createEditorWorkspace();let saved=null,storage=null
18
- const view=()=>{const v=workspace.read();return {...v,render:v.geometry.map(g=>{const mesh=(g.shape.provider==='portal'?portal:ordinary)(g);const json=mesh.toJSON();mesh.dispose();return {id:g.id,geometry:json,material:g.material}})}}
19
- const html=`<!doctype html><meta charset="utf-8"><title>Figure 편집 연결 검증</title>
20
- <style>body{margin:0;font:14px system-ui;color:#263543;background:#eef2f6}header{padding:16px;display:flex;gap:10px;align-items:center;background:white;border-bottom:1px solid #ccd5dd}main{display:grid;grid-template-columns:1fr 340px;height:650px}aside{padding:18px;background:white;overflow:auto}h2{font-size:15px;margin:22px 0 12px}label{display:grid;grid-template-columns:110px 1fr 46px;align-items:center;gap:8px;margin:8px 0}input,button,select{height:36px;box-sizing:border-box;border:1px solid #bdc9d2;border-radius:5px;background:white;padding:5px 8px}input{min-width:0;width:100%}button{cursor:pointer}#canvas{overflow:hidden}#status{padding:12px}#error{color:#a12626;white-space:pre-wrap}small{color:#526574}</style>
21
- <header><strong>Figure 편집 연결 검증</strong><button id="new">새 Figure</button><button id="add">상자 추가</button><select id="model"><option value="forklift">포크리프트</option><option value="robot">로봇암</option><option value="conveyor">컨베이어</option><option value="dryroom">드라이룸</option></select><button id="open">열기</button><button id="undo">되돌리기</button><button id="redo">다시 실행</button><button id="save">검증 저장</button><button id="reload">저장본 열기</button><button id="close">빈 화면</button></header>
22
- <main><section><div id="canvas"></div><div id="status"></div></section><aside><small>제품 화면이 아닌 격리된 실험 UI · 임시 파일에 저장</small><div id="fields"></div><p id="error" role="alert"></p></aside></main>
23
- <script type="module">
24
- import * as T from '/three.module.js';
25
- const host=document.querySelector('#canvas'),renderer=new T.WebGLRenderer({antialias:true});renderer.setSize(830,590);renderer.setClearColor('#edf2f6');host.append(renderer.domElement);
26
- const scene=new T.Scene(),group=new T.Group();scene.add(group,new T.HemisphereLight(0xffffff,0x738295,3));const light=new T.DirectionalLight(0xffffff,3);light.position.set(3000,5000,6000);scene.add(light);
27
- const camera=new T.PerspectiveCamera(38,830/590,1,50000);let current;
28
- const labels={forkLength:'포크 길이',lift:'포크 높이',reach:'내밈',drive:'바퀴 회전',upperLength:'상완 길이',foreLength:'전완 길이',length:'길이',width:'폭',rollerHeight:'롤러 높이',angle:'롤러 회전',gripper:'그리퍼'};
29
- function fieldLabel(id){const m=/^(.+)\\.(size|position|rotation)\\.([xyz])$/.exec(id);return labels[id]||(m?({size:'크기',position:'위치',rotation:'회전'}[m[2]]+' '+m[3].toUpperCase()):id)}
30
- function show(v){current=v;window.lastView=v;document.querySelector('#fields').replaceChildren();
31
- for(const category of ['design','state']){const fields=v.fields.filter(f=>(f.role==='state'?'state':'design')===category);if(!fields.length)continue;const h=document.createElement('h2');h.textContent=category==='design'?'설계':'동작 미리보기';document.querySelector('#fields').append(h);
32
- for(const f of fields){const label=document.createElement('label'),name=document.createElement('span'),input=document.createElement('input'),button=document.createElement('button');name.textContent=fieldLabel(f.id)+' ('+f.unit+')';input.type='number';input.id=f.id;input.value=f.value;input.min=f.min;input.max=f.max;input.setAttribute('aria-label',fieldLabel(f.id));button.textContent='적용';button.dataset.input=f.id;button.onclick=()=>run('apply',{request:{context:current.context,origin:'ui',expectedRevision:current.revision,changes:[{kind:category==='state'?'set-state':'set-design',input:f.id,unit:f.unit,value:Number(input.value)}]}});label.append(name,input,button);document.querySelector('#fields').append(label)}
33
- }
34
- for(const child of [...group.children]){group.remove(child);child.geometry.dispose();child.material.dispose()}
35
- for(const item of v.render){const mat=new T.MeshStandardMaterial({color:v.palette[item.material.token]||'#96989b',roughness:.5});group.add(new T.Mesh(new T.BufferGeometryLoader().parse(item.geometry),mat))}
36
- if(group.children.length){const box=new T.Box3().setFromObject(group),size=box.getSize(new T.Vector3()),center=box.getCenter(new T.Vector3()),radius=Math.max(size.x,size.y,size.z);camera.position.copy(center).add(new T.Vector3(radius*1.1,radius*.85,radius*1.5));camera.lookAt(center)}
37
- renderer.render(scene,camera);window.meshCount=group.children.length;
38
- document.querySelector('#status').textContent=(v.name||'빈 상태')+' · 파트 '+group.children.length+' · revision '+v.revision+' · '+(v.dirty?'저장하지 않은 설계 변경':'저장 기준과 일치');
39
- }
40
- async function run(action,args={}){document.querySelector('#error').textContent='';try{const r=await fetch('/api',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({action,...args})});const body=await r.json();if(!r.ok)throw Error(body.error);show(body.view)}catch(e){document.querySelector('#error').textContent=e.message}}
41
- document.querySelector('#open').onclick=()=>run('open',{kind:document.querySelector('#model').value});document.querySelector('#undo').onclick=()=>run('undo',{context:current.context,revision:current.revision});document.querySelector('#save').onclick=()=>run('save',{context:current.context});document.querySelector('#reload').onclick=()=>run('reload');document.querySelector('#close').onclick=()=>run('close');
42
- document.querySelector('#new').onclick=()=>run('new');document.querySelector('#add').onclick=()=>run('add',{context:current.context,revision:current.revision});document.querySelector('#redo').onclick=()=>run('redo',{context:current.context,revision:current.revision});
43
- await run('open',{kind:'forklift'});window.ready=true;
44
- </script>`
45
- const server=createServer(async(req,res)=>{try{
46
- if(req.url==='/'){res.setHeader('Content-Type','text/html');res.end(html);return}
47
- 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)));return}
48
- if(req.url!=='/api'||req.method!=='POST'){res.statusCode=404;res.end();return}
49
- let body='';for await(const chunk of req){body+=chunk;if(body.length>20000)throw Error('request too large')}
50
- const q=JSON.parse(body)
51
- if(q.action==='open'){workspace.open(serializeAsset(sampleAsset(q.kind)));storage={scope:'isolated-browser',id:randomUUID(),expectedRevision:null}}
52
- else if(q.action==='new'){workspace.open(serializeAsset(createFigureAsset('새 Figure')));storage={scope:'isolated-browser',id:randomUUID(),expectedRevision:null}}
53
- else if(q.action==='add'){const id='box'+(workspace.read().revision+1);workspace.addBox({context:q.context,origin:'ui',expectedRevision:q.revision,box:{id,size:{x:800,y:1000,z:600},position:{x:0,y:500,z:0}}})}
54
- else if(q.action==='redo')workspace.redo({context:q.context,origin:'ui',expectedRevision:q.revision})
55
- else if(q.action==='apply')workspace.apply(q.request)
56
- else if(q.action==='undo')workspace.undo({context:q.context,origin:'ui',expectedRevision:q.revision})
57
- else if(q.action==='save'){
58
- const snapshot=workspace.export({context:q.context}),target={...storage}
59
- const record=await store.save({...target,text:snapshot.text})
60
- saved={scope:record.scope,id:record.id,expectedRevision:record.revision}
61
- if(workspace.read().context===snapshot.context)storage={...saved}
62
- workspace.acknowledgeSaved(snapshot)
63
- }
64
- else if(q.action==='reload'){if(!saved)throw Error('no saved snapshot');const record=await createAssetStore(directory).read(saved);workspace.open(record.text);storage={...saved,expectedRevision:record.revision}}
65
- else if(q.action==='close'){workspace.close();storage=null}
66
- else throw Error('unknown action')
67
- res.setHeader('Content-Type','application/json');res.end(JSON.stringify({view:view()}))
68
- }catch(e){res.statusCode=['CONFLICT','CONTEXT_CONFLICT','REVISION_CONFLICT'].includes(e.code)?409:400;res.setHeader('Content-Type','application/json');res.end(JSON.stringify({error:e.message,code:e.code}))}})
69
- await new Promise(r=>server.listen(0,'127.0.0.1',r));let browser
70
- try{
71
- browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage({viewport:{width:1180,height:740}}),errors=[];page.on('pageerror',e=>errors.push(String(e)))
72
- await page.goto('http://127.0.0.1:'+server.address().port);await page.waitForFunction(()=>window.ready&&window.meshCount===20)
73
- const old=await page.evaluate(()=>({context:lastView.context,revision:lastView.revision}))
74
- await page.locator('#forkLength').fill('1275');await page.locator('button[data-input="forkLength"]').click();await page.waitForFunction(()=>lastView.revision===1)
75
- assert.equal(await page.evaluate(()=>lastView.geometry.find(g=>g.id==='fork-low-left').shape.dimensions.depth),1275)
76
- await page.locator('#save').click();await page.waitForFunction(()=>!lastView.dirty)
77
- await page.screenshot({path:'/private/tmp/figure-editor.png',fullPage:true})
78
- await page.locator('#model').selectOption('robot');await page.locator('#open').click();await page.waitForFunction(()=>window.meshCount===10)
79
- const stale=await page.evaluate(async old=>{const r=await fetch('/api',{method:'POST',body:JSON.stringify({action:'apply',request:{context:old.context,expectedRevision:0,origin:'ai',changes:[{kind:'set-design',input:'forkLength',unit:'mm',value:1500}]}})});return r.json()},old);assert.match(stale.error,/CONTEXT_CONFLICT/)
80
- await page.locator('#reload').click();await page.waitForFunction(()=>window.meshCount===20);assert.equal(await page.locator('#forkLength').inputValue(),'1275')
81
- await page.locator('#close').click();await page.waitForFunction(()=>window.meshCount===0);assert.equal(await page.locator('#fields input').count(),0)
82
- await page.screenshot({path:'/private/tmp/figure-editor-empty.png',fullPage:true})
83
- await page.locator('#new').click();await page.waitForFunction(()=>lastView.name==='새 Figure'&&meshCount===0)
84
- await page.locator('#add').click();await page.waitForFunction(()=>meshCount===1&&lastView.dirty)
85
- await page.locator('[id="box1.size.y"]').fill('1500');await page.locator('button[data-input="box1.size.y"]').click();await page.waitForFunction(()=>lastView.revision===2)
86
- await page.locator('#undo').click();await page.waitForFunction(()=>lastView.revision===3)
87
- assert.equal(await page.locator('[id="box1.size.y"]').inputValue(),'1000')
88
- await page.locator('#undo').click();await page.waitForFunction(()=>meshCount===0&&lastView.revision===4)
89
- await page.locator('#redo').click();await page.waitForFunction(()=>meshCount===1&&lastView.revision===5)
90
- await page.locator('#redo').click();await page.waitForFunction(()=>lastView.revision===6)
91
- await page.locator('#save').click();await page.waitForFunction(()=>!lastView.dirty)
92
- await page.locator('#reload').click();await page.waitForFunction(()=>lastView.revision===0)
93
- assert.equal(await page.locator('[id="box1.size.y"]').inputValue(),'1500')
94
- await page.screenshot({path:'/private/tmp/figure-new-authoring.png',fullPage:true})
95
- console.log('new empty asset -> add box -> edit -> undo edit -> undo creation -> redo both -> disk save/reload: passed')
96
- assert.deepEqual(errors,[]);console.log(JSON.stringify({checks:['open asset','edit changes actual rendered geometry','save through HTTP to disk','switch 20 meshes to 10 without leftovers','reject stale AI context','reload edited design from independent disk store handle','close clears meshes and fields'],pageErrors:errors,screenshot:'/private/tmp/figure-editor.png'},null,2))
97
- }finally{await browser?.close();await new Promise(r=>server.close(r));await rm(directory,{recursive:true,force:true})}
@@ -1 +0,0 @@
1
- export {createFigureEditorWorkspace as createEditorWorkspace} from '../../src/editor-workspace.ts'
@@ -1,35 +0,0 @@
1
- import {test} from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {createEditorWorkspace} from './editor-workspace.mjs'
4
- import {sampleAsset} from './asset-samples.mjs'
5
- import {serializeAsset} from './asset.mjs'
6
- const text=kind=>serializeAsset(sampleAsset(kind))
7
- const edit=(view,id,value,kind='set-design')=>({context:view.context,origin:'ui',expectedRevision:view.revision,changes:[{kind,input:id,value,unit:'mm'}]})
8
- test('saved asset opens with fields/render appearance; edits and undo share the same session',()=>{
9
- const w=createEditorWorkspace(),v=w.open(text('forklift')),changed=w.apply(edit(v,'forkLength',1275))
10
- assert.equal(changed.fields.find(f=>f.id==='forkLength').value,1275);assert.equal(changed.geometry.find(g=>g.id==='fork-low-left').shape.dimensions.depth,1275);assert.equal(changed.dirty,true)
11
- assert.deepEqual(w.undo({context:v.context,origin:'ui',expectedRevision:1}).geometry,v.geometry)
12
- })
13
- test('model switch and empty close do not retain canvas, binding, selection fields or undo',()=>{
14
- const w=createEditorWorkspace(),old=w.open(text('forklift')),next=w.open(text('robot'))
15
- assert.equal(next.geometry.length,10);assert.ok(next.geometry.every(g=>!g.id.startsWith('fork-')));assert.equal(next.revision,0);assert.equal(next.canUndo,false)
16
- assert.throws(()=>w.apply({...edit(old,'forkLength',1275),origin:'ai'}),e=>e.code==='CONTEXT_CONFLICT')
17
- const empty=w.close();assert.equal(empty.geometry.length,0);assert.equal(empty.fields.length,0);assert.equal(empty.bindings.length,0)
18
- assert.throws(()=>w.preview(edit(next,'upperLength',630)),e=>e.code==='CONTEXT_CONFLICT')
19
- })
20
- test('unsaved design asks at departure; state-only preview does not; invalid open is atomic',()=>{
21
- const w=createEditorWorkspace(),v=w.open(text('forklift'));w.apply(edit(v,'lift',1000,'set-state'))
22
- assert.equal(w.read().dirty,false);const robot=w.open(text('robot'));w.apply(edit(robot,'upperLength',630))
23
- const before=w.read();assert.throws(()=>w.open(text('conveyor')),e=>e.code==='UNSAVED_DESIGN');assert.deepEqual(w.read(),before)
24
- assert.throws(()=>w.open('broken',{discardDesignChanges:true}));assert.deepEqual(w.read(),before)
25
- assert.equal(w.close({discardDesignChanges:true}).geometry.length,0)
26
- })
27
- test('export is not save; storage acknowledgement is revision/context-bound; reopen resets transient state',()=>{
28
- const w=createEditorWorkspace(),v=w.open(text('forklift')),a=w.apply(edit(v,'forkLength',1275));w.apply(edit(a,'lift',1000,'set-state'))
29
- const exported=w.export({context:v.context});assert.equal(w.read().dirty,true)
30
- w.acknowledgeSaved(exported);assert.equal(w.read().dirty,false)
31
- const opened=w.open(exported.text);assert.equal(opened.fields.find(f=>f.id==='forkLength').value,1275);assert.equal(opened.fields.find(f=>f.id==='lift').value,0)
32
- assert.throws(()=>w.acknowledgeSaved(exported),e=>e.code==='CONTEXT_CONFLICT')
33
- const stale=w.export({context:opened.context});w.apply(edit(opened,'forkLength',1500))
34
- assert.throws(()=>w.acknowledgeSaved(stale),e=>e.code==='REVISION_CONFLICT');assert.equal(w.read().dirty,true)
35
- })
@@ -1,131 +0,0 @@
1
- import fs from 'node:fs'
2
- import assert from 'node:assert/strict'
3
- import {test} from 'node:test'
4
- import {resolve,point,DesignError} from './kernel.mjs'
5
-
6
- const seeds=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url)))
7
- const src=t=>seeds.find(s=>s.type===t).source
8
- const part=(s,n)=>s.parts.find(p=>p.name===n)
9
- const add=(...args)=>({op:'add',args}),mul=(...args)=>({op:'mul',args})
10
- const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-7,`${a} != ${b}`)
11
- const same=(a,b)=>a.forEach((x,i)=>near(x,b[i]))
12
- const fid=(p,i,n)=>JSON.stringify(['pattern',p,i,'frame',n])
13
- const gid=(p,i,n)=>JSON.stringify(['pattern',p,i,'geometry',n])
14
- const instance=(r,p,i,n)=>r.instances.find(g=>g.source===gid(p,i,n))
15
- const limits=(min,max)=>({min,max})
16
-
17
- function tray() {
18
- const s=src('FORMATION_TRAY'),cell=part(s,'cell'),contact=part(s,'contact')
19
- const c=cell.transform,t=contact.transform
20
- return {inputs:{scale:limits(.5,2),angle:limits(-180,180)},
21
- frames:{assembly:{position:[mul(-3.5*cell.repeat.pitch,'scale'),c.position.y,0],orientation:{axis:[0,1,0],angle:'angle'}}},
22
- patterns:{slots:{parent:'assembly',repeat:{mode:'fixed',count:8,pitch:mul(cell.repeat.pitch,'scale'),axis:[1,0,0],limit:32},
23
- frames:{cell:{},contact:{feature:{geometry:'cell',point:'y+'},position:[0,t.position.y-c.position.y-c.size.y/2,0]}},
24
- geometry:{cell:{frame:'cell',kind:'box',dimensions:{width:mul(c.size.x,'scale'),height:c.size.y,depth:c.size.z}},contact:{frame:'contact',kind:'box',dimensions:{width:mul(t.size.x,'scale'),height:t.size.y,depth:t.size.z}}}}}}
25
- }
26
- function charger() {
27
- const s=src('FORMATION_CHARGER'),shelf=part(s,'shelf'),lamp=part(s,'channel-lamp')
28
- return {inputs:{height:limits(600,8000),depth:limits(300,1200),angle:limits(-180,180)},values:{shelfDepth:add('depth',-140)},
29
- frames:{cabinet:{orientation:{axis:[0,0,1],angle:'angle'}},start:{parent:'cabinet',position:[0,200,shelf.transform.position.z]}},
30
- patterns:{levels:{parent:'start',repeat:{mode:'pitch',span:add('height',-400),pitch:shelf.repeat.pitch,axis:[0,1,0],limit:20},
31
- frames:{shelf:{},lamp:{feature:{geometry:'shelf',point:'z+'},position:[lamp.transform.position.x,0,15]}},
32
- geometry:{shelf:{frame:'shelf',kind:'box',dimensions:{width:1400,height:40,depth:'shelfDepth'}},lamp:{frame:'lamp',kind:'box',dimensions:{width:90,height:60,depth:40}}}}}}
33
- }
34
- function conveyor() {
35
- const p=part(src('CONVEYOR'),'roller')
36
- return {inputs:{width:limits(300,15000),depth:limits(100,1200),spin:limits(-360,360)},
37
- frames:{row:{position:[add(mul(-.5,'width'),75),p.transform.position.y,0]}},
38
- patterns:{rollers:{parent:'row',repeat:{mode:'pitch',span:'width',pitch:p.repeat.pitch,axis:[1,0,0],limit:100},
39
- frames:{roller:{orientation:{axis:[1,0,0],angle:90},joint:{type:'hinge',axis:[0,1,0],value:'spin'}},end:{feature:{geometry:'roller',point:'end'}}},
40
- geometry:{roller:{frame:'roller',kind:'cylinder',dimensions:{radius:48,length:add('depth',-80)}}}}}}
41
- }
42
-
43
- test('fixed paired tray: 5 widths × 5 poses, actual cell/contact rest offset shared',()=>{
44
- const model=tray(),before=JSON.stringify(model)
45
- for(const scale of [.5,.75,1,1.5,2])for(const angle of [-90,-30,0,45,180]) {
46
- const out=resolve(model,{scale,angle});assert.equal(out.instances.length,16)
47
- for(let i=0;i<8;i++) {
48
- const cell=instance(out,'slots',i,'cell'),contact=instance(out,'slots',i,'contact')
49
- same(contact.frame.position,point(cell.frame,[0,90,0]))
50
- near(contact.frame.position[1]-contact.dimensions.height/2-(cell.frame.position[1]+cell.dimensions.height/2),-2)
51
- if(i)near(Math.hypot(...cell.frame.position.map((x,a)=>x-instance(out,'slots',i-1,'cell').frame.position[a])),68*scale)
52
- }
53
- }
54
- assert.equal(JSON.stringify(model),before)
55
- })
56
-
57
- test('feature uses changed geometry dimension once, not an independent stale attachment value',()=>{
58
- const m=tray();m.inputs.cellHeight=limits(50,300);m.patterns.slots.geometry.cell.dimensions.height='cellHeight'
59
- for(const cellHeight of [50,140,300]) {
60
- const out=resolve(m,{scale:1,angle:0,cellHeight})
61
- for(let i=0;i<8;i++)near(instance(out,'slots',i,'contact').frame.position[1]-instance(out,'slots',i,'cell').frame.position[1],cellHeight/2+20)
62
- }
63
- })
64
-
65
- test('pitch paired shelves: 5 count boundaries × 3 depths × 3 poses',()=>{
66
- const m=charger()
67
- for(const height of [2799.99,2800,2800.01,3200,5600])for(const depth of [300,700,1200])for(const angle of [-30,0,90]) {
68
- const out=resolve(m,{height,depth,angle}),count=Math.floor((height-400)/400)
69
- assert.equal(out.instances.length,count*2)
70
- for(let i=0;i<count;i++) {
71
- const shelf=instance(out,'levels',i,'shelf'),lamp=instance(out,'levels',i,'lamp')
72
- same(lamp.frame.position,point(shelf.frame,[-620,0,shelf.dimensions.depth/2+15]))
73
- if(angle===0){near(shelf.frame.position[1],200+i*400);near(lamp.frame.position[2],20+(depth-140)/2+15)}
74
- }
75
- }
76
- })
77
-
78
- test('pattern conveyor: actual child hinges now spin without moving repetition centers',()=>{
79
- const m=conveyor()
80
- for(const width of [1200,2400,4800])for(const depth of [300,600,1200])for(const spin of [-180,-90,0,90,180]) {
81
- const out=resolve(m,{width,depth,spin})
82
- for(let i=0;i<out.instances.length;i++) {
83
- const g=instance(out,'rollers',i,'roller')
84
- same(g.frame.position,[-width/2+75+i*150,500,0])
85
- same(out.frames[fid('rollers',i,'end')].position,[-width/2+75+i*150,500,(depth-80)/2])
86
- }
87
- }
88
- })
89
-
90
- test('grow/shrink/regrow: retained ids and placement stable; removed references fail explicitly',()=>{
91
- const m=charger(),args={depth:700,angle:0},small=resolve(m,{...args,height:2799.99}),big=resolve(m,{...args,height:2800})
92
- assert.equal(small.instances.length,10);assert.equal(big.instances.length,12)
93
- for(const g of small.instances)assert.deepEqual(big.instances.find(h=>h.id===g.id),g)
94
- assert.deepEqual(resolve(m,{...args,height:2799.99}).instances,small.instances)
95
- assert.deepEqual(resolve(m,{...args,height:2800}).instances,big.instances)
96
- const referenced=structuredClone(m)
97
- referenced.frames.target={parent:fid('levels',5,'shelf')}
98
- resolve(referenced,{...args,height:2800})
99
- assert.throws(()=>resolve(referenced,{...args,height:2799.99}),e=>e.code==='UNKNOWN_FRAME')
100
- // This experiment rejects dangling references, it does not implement automatic rebinding.
101
- })
102
-
103
- test('feature includes shape-local rotation as well as owner motion',()=>{
104
- const m={inputs:{spin:limits(-180,180)},frames:{base:{position:[1,2,3],joint:{type:'hinge',axis:[0,0,1],value:'spin'}},tip:{feature:{geometry:'beam',point:'end'}}},
105
- geometry:{beam:{frame:'base',kind:'cylinder',dimensions:{radius:5,length:100},orientation:{axis:[1,0,0],angle:90}}}}
106
- for(const spin of [-90,0,90]) {
107
- const r=resolve(m,{spin});same(r.frames.tip.position,point(r.instances[0].frame,[0,50,0]))
108
- }
109
- })
110
-
111
- test('invalid feature cycles, unknown labels, ambiguous repeats, local escape and duplicate parents reject',()=>{
112
- const base=tray(),args={scale:1,angle:0}
113
- const cases=[]
114
- let m=structuredClone(base);m.patterns.slots.frames.cell={feature:{geometry:'cell',point:'y+'}};cases.push([m,'FRAME_CYCLE'])
115
- m=structuredClone(base);m.patterns.slots.frames.contact.feature.point='not-a-face';cases.push([m,'UNKNOWN_FEATURE'])
116
- m=structuredClone(base);m.patterns.slots.frames.contact.parent='cell';cases.push([m,'DUPLICATE_PARENT'])
117
- m=structuredClone(base);m.patterns.slots.frames.contact={parent:'outside'};cases.push([m,'PATTERN_REFERENCE'])
118
- m=structuredClone(base);m.patterns.slots.repeat.count=1.5;cases.push([m,'REPEAT_DOMAIN'])
119
- m=structuredClone(base);m.patterns.slots.geometry.cell.repeat={};cases.push([m,'NESTED_REPEAT'])
120
- for(const [model,code] of cases)assert.throws(()=>resolve(model,args),e=>e instanceof DesignError&&e.code===code)
121
- const repeated={frames:{base:{},tip:{feature:{geometry:'beam',point:'end'}}},geometry:{beam:{frame:'base',kind:'cylinder',dimensions:{radius:5,length:10},repeat:{mode:'fixed',count:2,pitch:20,axis:[1,0,0],limit:5}}}}
122
- assert.throws(()=>resolve(repeated,{}),e=>e.code==='AMBIGUOUS_FEATURE')
123
- })
124
-
125
- test('declaration order is irrelevant across geometry feature and repeated frame dependencies',()=>{
126
- const m=tray(),args={scale:1.5,angle:45},expected=resolve(m,args)
127
- const reverse=o=>Object.fromEntries(Object.entries(o).reverse())
128
- m.patterns.slots.frames=reverse(m.patterns.slots.frames);m.patterns.slots.geometry=reverse(m.patterns.slots.geometry)
129
- const sort=r=>r.instances.toSorted((a,b)=>a.id.localeCompare(b.id))
130
- assert.deepEqual(sort(resolve(m,args)),sort(expected))
131
- })
@@ -1,60 +0,0 @@
1
- import fs from 'node:fs'
2
- import {compile} from './typed-plan.mjs'
3
- import {THREE,rounded} from './full-dryroom.mjs'
4
- export const seed=JSON.parse(fs.readFileSync(new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url))).find(s=>s.type==='CONVEYOR').source
5
- export const defaults={length:2400,width:600,rollerHeight:500,angle:0}
6
- export function fullModel(){
7
- const m={version:'r3-spatial-1',inputs:[['length',900,6000],['width',400,1200],['rollerHeight',400,1200]].map(([id,min,max])=>({id,unit:'mm',min,max})).concat({id:'angle',unit:'deg',min:-360,max:360,role:'state'}),constants:[],nodes:[]}
8
- const c=(id,value,unit='mm')=>{m.constants.push({id,value,unit});return id}
9
- const n=(id,op,args,outputs,params)=>m.nodes.push({id,op,args,outputs,...(params?{params}:{})})
10
- const calc=(id,op,a,b)=>{n(id,op,[a,b],{value:id});return id}
11
- c('zero',0);c('a0',0,'deg');c('quarter',90,'deg');c('half',.5,'ratio');c('negative',-1,'ratio')
12
- const add=(id,a,v)=>calc(id,'add@1',a,c(id+'Offset',v))
13
- calc('halfLength','mul@1','length','half');calc('halfWidth','mul@1','width','half')
14
- add('endX','halfLength',-60);add('legX','halfLength',-260);add('frameZ','halfWidth',-18);add('guardZ','halfWidth',-8);add('legZ','halfWidth',-56)
15
- add('frameY','rollerHeight',-54);add('guardY','rollerHeight',52);add('deckY','rollerHeight',-110)
16
- add('legHeight','rollerHeight',-144);calc('halfLeg','mul@1','legHeight','half');add('legY','halfLeg',42)
17
- add('rollerLength','width',-80);add('endLength','width',-40);add('deckWidth','width',-100);add('footWidth','width',20)
18
- add('motorZ','halfWidth',40);add('gearZ','halfWidth',-48);add('driveY','rollerHeight',-170);add('controlY','rollerHeight',-270)
19
- const neg=id=>{const key='neg'+id;if(!m.nodes.some(n=>n.id===key))calc(key,'mul@1',id,'negative');return key}
20
- const rigid=(id,from,to,xyz,rot=['a0','a0','a0'])=>n(id,'rigid@1',[...xyz,...rot],{pose:id},{from,to})
21
- for(const p of seed.parts){
22
- const id=p.name,d=p.transform.size;let xyz=['zero','zero','zero'],dims=['x','y','z'].map(a=>c(id+a,d[a]))
23
- if(id==='deck'){dims=['length',dims[1],'deckWidth'];xyz=['zero','deckY','zero']}
24
- else if(id.startsWith('frame-')||id.startsWith('guard-')){const family=id.split('-')[0];dims[0]='length';xyz=['zero',family+'Y',id.endsWith('left')?neg(family+'Z'):family+'Z']}
25
- else if(id.startsWith('leg-')){dims[1]='legHeight';xyz=[id.includes('head')?neg('legX'):'legX','legY',id.endsWith('left')?neg('legZ'):'legZ']}
26
- else if(id.startsWith('foot-')){dims[2]='footWidth';xyz=[id.endsWith('head')?neg('legX'):'legX',c(id+'Y',24),'zero']}
27
- else if(id.startsWith('roller')){dims[1]=id==='roller'?'rollerLength':'endLength';xyz=[id==='roller'?'zero':id.endsWith('head')?neg('endX'):'endX','rollerHeight','zero']}
28
- else if(id==='motor'||id==='gearbox')xyz=[neg('endX'),'driveY',id==='motor'?'motorZ':'gearZ']
29
- else if(id==='control-box')xyz=['legX','controlY','halfWidth']
30
- else throw Error('Unmapped part '+id)
31
- const shape=id+'Shape',pose=id+'Pose'
32
- if(p.primitive==='cylinder')n(shape,'cylinder-shape@1',[c(id+'Radius',d.x/2),dims[1]],{shape},{frame:id})
33
- else if(p.primitive==='rect')n(shape,'rounded-box@1',[...dims,c(id+'Round',p.shape.round)],{shape},{frame:id})
34
- else n(shape,'box-shape@1',dims,{shape},{frame:id})
35
- rigid(pose,id,id==='roller'?'rollerItem':'world',xyz,p.primitive==='cylinder'?['quarter',id==='roller'?'angle':'a0','a0']:undefined)
36
- n(id,id==='roller'?'member@1':'place@1',[shape,pose],{placed:id+'Placed'})
37
- }
38
- // End roller center is 60mm from the edge, radius 75mm; reserve an additional 10mm gap.
39
- n('layout','fit-pitch@1',['length',c('margin',145),'margin',c('extent',96),c('pitch',150),c('align',.5,'ratio'),c('limit',100,'count')],{layout:'layout'})
40
- n('assembly','assembly@1',['rollerPlaced'],{assembly:'assembly'})
41
- rigid('repeatRoot','rollerItem','world',[neg('halfLength'),'zero','zero'])
42
- n('rollers','repeat@1',['layout','assembly','repeatRoot'],{collection:'rollers'},{axis:'x'})
43
- return m
44
- }
45
- export function evaluateFull(args=defaults){return compile(fullModel()).evaluate(args).geometry.map(g=>{const p=seed.parts.find(p=>p.name===(g.key?'roller':g.id));return {...g,sourceName:p.name,segments:p.segments,material:structuredClone(p.material)}})}
46
- export function geometryOf(item){
47
- const d=item.shape.dimensions;let g
48
- if(item.shape.provider==='box')g=new THREE.BoxGeometry(d.width,d.height,d.depth)
49
- else if(item.shape.provider==='roundedBox'){g=new THREE.ExtrudeGeometry(rounded(d.width,d.depth,d.round),{depth:d.height,steps:1,bevelEnabled:false});g.rotateX(Math.PI/2);g.translate(0,d.height/2,0)}
50
- else g=new THREE.CylinderGeometry(d.radius,d.radius,d.length,item.segments)
51
- const r=item.pose.r,t=item.pose.t;g.applyMatrix4(new THREE.Matrix4().set(...r[0],t[0],...r[1],t[1],...r[2],t[2],0,0,0,1));return g.index?g.toNonIndexed():g
52
- }
53
- export function sourceGeometry(p){
54
- const d=p.transform.size;let g
55
- if(p.primitive==='cube')g=new THREE.BoxGeometry(d.x,d.y,d.z)
56
- else if(p.primitive==='cylinder')g=new THREE.CylinderGeometry(d.x/2,d.z/2,d.y,p.segments)
57
- else{g=new THREE.ExtrudeGeometry(rounded(d.x,d.z,p.shape.round),{depth:d.y,steps:1,bevelEnabled:false});g.rotateX(Math.PI/2);g.translate(0,d.y/2,0)}
58
- const r=p.transform.rotation??{};g.rotateZ((r.z??0)*Math.PI/180);g.rotateY((r.y??0)*Math.PI/180);g.rotateX((r.x??0)*Math.PI/180);const t=p.transform.position;g.translate(t.x,t.y,t.z);return g.index?g.toNonIndexed():g
59
- }
60
- export function sourceItems(){return seed.parts.flatMap(p=>{const count=p.repeat?Math.floor(seed.base.x/p.repeat.pitch):1;return Array.from({length:count},(_,i)=>{const geometry=sourceGeometry(p);if(p.repeat)geometry.translate((i-(count-1)/2)*p.repeat.pitch,0,0);return {id:p.name+'-'+i,material:p.material,geometry}})})}