@hatiolab/figure-model 0.1.30 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/release-contract.d.ts +2 -2
  7. package/dist/release-contract.d.ts.map +1 -1
  8. package/dist/release-contract.js +45 -3
  9. package/dist/release-contract.js.map +1 -1
  10. package/dist/types.d.ts +15 -4
  11. package/dist/types.d.ts.map +1 -1
  12. package/dist/types.js +15 -4
  13. package/dist/types.js.map +1 -1
  14. package/dist/v3-asset-types.d.ts +116 -0
  15. package/dist/v3-asset-types.d.ts.map +1 -0
  16. package/dist/v3-asset-types.js +2 -0
  17. package/dist/v3-asset-types.js.map +1 -0
  18. package/dist/v3-asset.d.ts +8 -0
  19. package/dist/v3-asset.d.ts.map +1 -0
  20. package/dist/v3-asset.js +135 -0
  21. package/dist/v3-asset.js.map +1 -0
  22. package/dist/v3-authoring-session.d.ts +119 -0
  23. package/dist/v3-authoring-session.d.ts.map +1 -0
  24. package/dist/v3-authoring-session.js +132 -0
  25. package/dist/v3-authoring-session.js.map +1 -0
  26. package/dist/v3-capabilities.d.ts +3 -0
  27. package/dist/v3-capabilities.d.ts.map +1 -0
  28. package/dist/v3-capabilities.js +221 -0
  29. package/dist/v3-capabilities.js.map +1 -0
  30. package/dist/v3-contract.d.ts +7 -0
  31. package/dist/v3-contract.d.ts.map +1 -0
  32. package/dist/v3-contract.js +12 -0
  33. package/dist/v3-contract.js.map +1 -0
  34. package/dist/v3-create.d.ts +25 -0
  35. package/dist/v3-create.d.ts.map +1 -0
  36. package/dist/v3-create.js +50 -0
  37. package/dist/v3-create.js.map +1 -0
  38. package/dist/v3-document.d.ts +5 -0
  39. package/dist/v3-document.d.ts.map +1 -0
  40. package/dist/v3-document.js +31 -0
  41. package/dist/v3-document.js.map +1 -0
  42. package/dist/v3-editor-workspace.d.ts +334 -0
  43. package/dist/v3-editor-workspace.d.ts.map +1 -0
  44. package/dist/v3-editor-workspace.js +130 -0
  45. package/dist/v3-editor-workspace.js.map +1 -0
  46. package/dist/v3-graph-types.d.ts +91 -0
  47. package/dist/v3-graph-types.d.ts.map +1 -0
  48. package/dist/v3-graph-types.js +2 -0
  49. package/dist/v3-graph-types.js.map +1 -0
  50. package/dist/v3-graph.d.ts +5 -0
  51. package/dist/v3-graph.d.ts.map +1 -0
  52. package/dist/v3-graph.js +631 -0
  53. package/dist/v3-graph.js.map +1 -0
  54. package/dist/v3-json.d.ts +13 -0
  55. package/dist/v3-json.d.ts.map +1 -0
  56. package/dist/v3-json.js +94 -0
  57. package/dist/v3-json.js.map +1 -0
  58. package/dist/v3-part-edit.d.ts +12 -0
  59. package/dist/v3-part-edit.d.ts.map +1 -0
  60. package/dist/v3-part-edit.js +53 -0
  61. package/dist/v3-part-edit.js.map +1 -0
  62. package/docs/assembly-constraints-v3.md +141 -0
  63. package/docs/prototypes/README.md +73 -0
  64. package/docs/prototypes/render-dryroom.py +60 -0
  65. package/docs/prototypes/render-full-dryroom.mjs +86 -0
  66. package/docs/prototypes/shapeassembly-probe-results.json +325 -0
  67. package/docs/prototypes/shapeassembly-probe.py +84 -0
  68. package/docs/prototypes/v3-asset-samples.mjs +15 -0
  69. package/docs/prototypes/v3-asset-store.mjs +52 -0
  70. package/docs/prototypes/v3-asset-store.test.mjs +59 -0
  71. package/docs/prototypes/v3-asset.mjs +1 -0
  72. package/docs/prototypes/v3-asset.schema.json +44 -0
  73. package/docs/prototypes/v3-asset.test.mjs +68 -0
  74. package/docs/prototypes/v3-authoring-session.mjs +1 -0
  75. package/docs/prototypes/v3-authoring-session.test.mjs +65 -0
  76. package/docs/prototypes/v3-capability-plan.mjs +1 -0
  77. package/docs/prototypes/v3-capability-plan.test.mjs +56 -0
  78. package/docs/prototypes/v3-capability-samples.mjs +23 -0
  79. package/docs/prototypes/v3-document.mjs +2 -0
  80. package/docs/prototypes/v3-document.schema.json +66 -0
  81. package/docs/prototypes/v3-document.test.mjs +55 -0
  82. package/docs/prototypes/v3-dryroom-mesh.mjs +60 -0
  83. package/docs/prototypes/v3-dryroom-mesh.test.mjs +30 -0
  84. package/docs/prototypes/v3-editor-browser.mjs +97 -0
  85. package/docs/prototypes/v3-editor-workspace.mjs +1 -0
  86. package/docs/prototypes/v3-editor-workspace.test.mjs +35 -0
  87. package/docs/prototypes/v3-feature-pattern.test.mjs +131 -0
  88. package/docs/prototypes/v3-full-conveyor.mjs +60 -0
  89. package/docs/prototypes/v3-full-conveyor.test.mjs +61 -0
  90. package/docs/prototypes/v3-full-dryroom.mjs +67 -0
  91. package/docs/prototypes/v3-full-dryroom.test.mjs +54 -0
  92. package/docs/prototypes/v3-full-forklift.mjs +40 -0
  93. package/docs/prototypes/v3-full-forklift.test.mjs +37 -0
  94. package/docs/prototypes/v3-full-robot.mjs +35 -0
  95. package/docs/prototypes/v3-full-robot.test.mjs +68 -0
  96. package/docs/prototypes/v3-kernel.mjs +147 -0
  97. package/docs/prototypes/v3-kernel.test.mjs +144 -0
  98. package/docs/prototypes/v3-layout.test.mjs +281 -0
  99. package/docs/prototypes/v3-linear-comparison.py +183 -0
  100. package/docs/prototypes/v3-more-samples.test.mjs +182 -0
  101. package/docs/prototypes/v3-payload-collision.mjs +54 -0
  102. package/docs/prototypes/v3-payload-collision.test.mjs +68 -0
  103. package/docs/prototypes/v3-payload-fit.mjs +58 -0
  104. package/docs/prototypes/v3-payload-fit.test.mjs +59 -0
  105. package/docs/prototypes/v3-repeated-binding.mjs +13 -0
  106. package/docs/prototypes/v3-repeated-binding.test.mjs +46 -0
  107. package/docs/prototypes/v3-runtime-browser.mjs +76 -0
  108. package/docs/prototypes/v3-samples.test.mjs +144 -0
  109. package/docs/prototypes/v3-semantics.test.mjs +55 -0
  110. package/docs/prototypes/v3-spatial-plan.test.mjs +102 -0
  111. package/docs/prototypes/v3-typed-plan.mjs +3 -0
  112. package/docs/prototypes/v3-typed-plan.test.mjs +70 -0
  113. package/docs/prototypes/v3-typed-repeat.test.mjs +96 -0
  114. package/docs/v3-asset-persistence.md +66 -0
  115. package/docs/v3-authoring-transactions.md +57 -0
  116. package/docs/v3-capability-bindings.md +109 -0
  117. package/docs/v3-common-kernel-experiment.md +116 -0
  118. package/docs/v3-core-semantics.md +303 -0
  119. package/docs/v3-cutover.md +27 -0
  120. package/docs/v3-design.md +388 -0
  121. package/docs/v3-document-contract.md +55 -0
  122. package/docs/v3-dryroom-profile-validation.md +87 -0
  123. package/docs/v3-editor-integration.md +59 -0
  124. package/docs/v3-full-conveyor-validation.md +69 -0
  125. package/docs/v3-full-dryroom-validation.md +55 -0
  126. package/docs/v3-full-forklift-validation.md +41 -0
  127. package/docs/v3-full-robot-validation.md +64 -0
  128. package/docs/v3-layout-system.md +318 -0
  129. package/docs/v3-minimal-model.md +255 -0
  130. package/docs/v3-operator-contracts.md +148 -0
  131. package/docs/v3-payload-collision-validation.md +42 -0
  132. package/docs/v3-payload-contact-validation.md +56 -0
  133. package/docs/v3-repeated-binding-validation.md +42 -0
  134. package/docs/v3-research-review.md +160 -0
  135. package/docs/v3-runtime-extraction.md +107 -0
  136. package/docs/v3-sample-validation-expanded.md +106 -0
  137. package/docs/v3-sample-validation.md +97 -0
  138. package/docs/v3-shapeassembly-reproduction.md +99 -0
  139. package/docs/v3-spatial-contracts.md +84 -0
  140. package/docs/v3-status.md +41 -0
  141. package/docs/v3-storage-integration.md +58 -0
  142. package/docs/v3-typed-repeat-contracts.md +79 -0
  143. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Experimental V3 asset envelope; not a complete V2 migration contract",
4
+ "type": "object", "additionalProperties": false,
5
+ "required": ["version", "name", "document", "designInputs", "stateDefaults", "palette", "appearance"],
6
+ "properties": {
7
+ "version": {"const": "figure-v3-asset-experiment-1"},
8
+ "name": {"type": "string", "minLength": 1},
9
+ "document": {"$ref": "v3-document.schema.json"},
10
+ "designInputs": {"type": "object", "additionalProperties": {"type": "number"}},
11
+ "stateDefaults": {"type": "object", "additionalProperties": {"type": "number"}},
12
+ "palette": {"type": "object", "additionalProperties": {"type": "string", "pattern": "^#[0-9a-fA-F]{6}$"}},
13
+ "appearance": {"type": "array", "items": {"$ref": "#/definitions/appearance"}}
14
+ },
15
+ "definitions": {
16
+ "appearance": {
17
+ "type": "object", "additionalProperties": false, "required": ["target", "material"],
18
+ "properties": {
19
+ "target": {"type": "string", "minLength": 1},
20
+ "segments": {"enum": [8,12,16,24,32,48]},
21
+ "legacyCapability": {
22
+ "type": "object", "additionalProperties": false, "required": ["roles", "accepts", "capacity"],
23
+ "properties": {
24
+ "roles": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
25
+ "accepts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
26
+ "capacity": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}
27
+ }
28
+ },
29
+ "material": {
30
+ "type": "object", "additionalProperties": false, "required": ["token"],
31
+ "properties": {
32
+ "token": {"type": "string", "minLength": 1},
33
+ "preset": {"enum": ["default", "metal", "glass", "plastic", "wood", "ceramic", "rubber"]},
34
+ "transparent": {"type": "boolean"},
35
+ "emissive": {
36
+ "type": "object", "additionalProperties": false, "required": ["token", "intensity", "on"],
37
+ "properties": {"token": {"type": "string", "minLength": 1}, "intensity": {"type": "number", "minimum": 0, "maximum": 5}, "on": {"type": "boolean"}}
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,68 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {compileAsset,serializeAsset,parseAsset,captureAuthoredInputs} from './v3-asset.mjs'
4
+ import {sampleAsset} from './v3-asset-samples.mjs'
5
+ import {createAuthoringSession} from './v3-authoring-session.mjs'
6
+ import * as dryroom from './v3-full-dryroom.mjs'
7
+ import * as conveyor from './v3-full-conveyor.mjs'
8
+ import * as robot from './v3-full-robot.mjs'
9
+ import * as forklift from './v3-full-forklift.mjs'
10
+ import fs from 'node:fs/promises'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import Ajv from '../../../things-factory/node_modules/ajv/lib/ajv.js'
14
+ const samples={dryroom,conveyor,robot,forklift}
15
+ test('four complete sample assets round-trip: geometry, bindings, materials, quality and palette',()=>{
16
+ for(const [kind,sample] of Object.entries(samples)){
17
+ const asset=sampleAsset(kind),text=serializeAsset(asset),restored=parseAsset(text),a=compileAsset(asset).evaluate(),b=compileAsset(restored).evaluate()
18
+ assert.deepEqual(restored,asset);assert.deepEqual(a,b)
19
+ const expected=sample.evaluateFull()
20
+ for(const g of b.geometry){const source=expected.find(x=>x.id===g.id);assert.ok(source);assert.deepEqual(g.shape,source.shape);assert.deepEqual(g.pose,source.pose);assert.deepEqual(g.material,source.material);if(g.shape.provider==='cylinder')assert.equal(g.segments,source.segments??sample.seed.parts.find(p=>p.name===g.id)?.segments??16)}
21
+ console.log({asset:kind,bytes:Buffer.byteLength(text),meshes:b.geometry.length})
22
+ }
23
+ const r=compileAsset(parseAsset(serializeAsset(sampleAsset('robot')))).evaluate()
24
+ assert.deepEqual(r.geometry.find(g=>g.id==='tool-seat').legacyCapability,robot.seed.parts.find(p=>p.name==='tool-seat').capability)
25
+ })
26
+ test('save edited design but reset transient motion unless explicitly captured as defaults',()=>{
27
+ const asset=sampleAsset('forklift'),session=createAuthoringSession(asset.document,{...asset.designInputs,...asset.stateDefaults})
28
+ session.apply({origin:'ai',expectedRevision:0,changes:[{kind:'set-design',input:'forkLength',unit:'mm',value:1275},{kind:'set-state',input:'lift',unit:'mm',value:1000}]})
29
+ const saved=captureAuthoredInputs(asset,session.read().inputs),r=compileAsset(parseAsset(serializeAsset(saved))).evaluate()
30
+ assert.equal(r.inputs.forkLength,1275);assert.equal(r.inputs.lift,0)
31
+ assert.equal(r.bindings[0].pose.t[1],57)
32
+ const captured=captureAuthoredInputs(asset,session.read().inputs,{captureStateDefaults:true})
33
+ assert.equal(compileAsset(parseAsset(serializeAsset(captured))).evaluate().bindings[0].pose.t[1],1057)
34
+ assert.equal(asset.designInputs.forkLength,850);assert.equal(asset.stateDefaults.lift,0)
35
+ })
36
+ test('saved repeat appearance applies to every new occurrence after dimension edit',()=>{
37
+ const a=sampleAsset('conveyor');a.designInputs.length=4800
38
+ const r=compileAsset(parseAsset(serializeAsset(a))).evaluate({angle:37}),rollers=r.geometry.filter(g=>g.key)
39
+ assert.equal(rollers.length,30);assert.ok(rollers.every(g=>g.segments===12&&g.material.preset==='metal'))
40
+ })
41
+ test('unknown or missing appearance, unresolved palette, invalid quality and mismatched inputs reject',()=>{
42
+ const edits=[a=>a.appearance.pop(),a=>a.appearance.push(a.appearance[0]),a=>a.appearance[0].target='missing',a=>delete a.palette['palette.primary'],a=>a.appearance.find(x=>x.segments).segments=13,a=>a.designInputs.lift=1,a=>delete a.stateDefaults.lift,a=>a.stateDefaults.lift=2001,a=>a.appearance[0].material.extra=true]
43
+ for(const edit of edits){const a=sampleAsset('forklift');edit(a);assert.throws(()=>serializeAsset(a))}
44
+ })
45
+ test('runtime cannot override design or mutate saved defaults through returned data',()=>{
46
+ const asset=sampleAsset('forklift'),p=compileAsset(asset),first=p.evaluate()
47
+ assert.throws(()=>p.evaluate({forkLength:1700}))
48
+ first.palette['palette.primary']='#000000';first.geometry[0].material.token='invalid';asset.designInputs.forkLength=0
49
+ assert.equal(p.evaluate().inputs.forkLength,850);assert.equal(p.evaluate().palette['palette.primary'],'#154473')
50
+ })
51
+ test('asset parser retains duplicate-key and invalid JSON protections',()=>{
52
+ const text=serializeAsset(sampleAsset('robot'))
53
+ assert.throws(()=>parseAsset('{"version":"fake",'+text.slice(1)),e=>e.code==='JSON_DUPLICATE_KEY')
54
+ assert.throws(()=>parseAsset(text.slice(0,-1)),e=>e.code==='JSON_SYNTAX')
55
+ const a=sampleAsset('forklift');a.stateDefaults.lift=NaN;assert.throws(()=>serializeAsset(a),e=>e.code==='JSON_VALUE')
56
+ })
57
+ test('schema validation and real temporary JSON file round-trip, without seed lookups on reload',async()=>{
58
+ const ajv=new Ajv({allErrors:true});ajv.addSchema(JSON.parse(await fs.readFile(new URL('./v3-document.schema.json',import.meta.url))),'v3-document.schema.json')
59
+ const validate=ajv.compile(JSON.parse(await fs.readFile(new URL('./v3-asset.schema.json',import.meta.url))))
60
+ const directory=await fs.mkdtemp(path.join(os.tmpdir(),'figure-v3-asset-'))
61
+ try{
62
+ for(const kind of Object.keys(samples)){
63
+ const asset=sampleAsset(kind);assert.ok(validate(asset),JSON.stringify(validate.errors))
64
+ const file=path.join(directory,kind+'.json')
65
+ try{await fs.writeFile(file,serializeAsset(asset),'utf8');const loaded=parseAsset(await fs.readFile(file,'utf8'));assert.deepEqual(compileAsset(loaded).evaluate(),compileAsset(asset).evaluate())}finally{await fs.unlink(file).catch(()=>{})}
66
+ }
67
+ }finally{await fs.rmdir(directory)}
68
+ })
@@ -0,0 +1 @@
1
+ export {createV3AuthoringSession as createAuthoringSession} from '../../src/v3-authoring-session.ts'
@@ -0,0 +1,65 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {createAuthoringSession} from './v3-authoring-session.mjs'
4
+ import {repeatedBinding} from './v3-repeated-binding.mjs'
5
+ import {forkliftBindings} from './v3-capability-samples.mjs'
6
+ import {defaults as conveyorDefaults} from './v3-full-conveyor.mjs'
7
+ import {defaults as forkliftDefaults} from './v3-full-forklift.mjs'
8
+ const doc=s=>({version:'figure-v3-prototype-1',model:s.model,capabilities:s.declaration})
9
+ const edit=(input,value,unit='mm',kind='set-design')=>({kind,input,value,unit})
10
+ const request=(changes,expectedRevision=0,origin='ui')=>({origin,expectedRevision,changes})
11
+ const session=()=>createAuthoringSession(doc(repeatedBinding()),{...conveyorDefaults,selectedIndex:13})
12
+ test('UI and AI use identical transactions and get identical candidate geometry',()=>{
13
+ const ui=session(),ai=session(),changes=[edit('length',3000)]
14
+ const a=ui.apply(request(changes,0,'ui')),b=ai.apply(request(changes,0,'ai'))
15
+ assert.deepEqual(a.evaluation,b.evaluation);assert.deepEqual(a.inputs,b.inputs);assert.deepEqual(a.report.effects,b.report.effects)
16
+ assert.equal(a.report.effects.geometry.added.length,4);assert.equal(a.revision,1)
17
+ assert.equal(a.report.validation.collision,'unverified')
18
+ })
19
+ test('preview is detached and read-only; apply reevaluates the request instead of trusting preview result',()=>{
20
+ const s=session(),before=s.read(),q=request([edit('length',3000)]),preview=s.preview(q)
21
+ assert.deepEqual(s.read(),before);preview.candidate.inputs.length=1;preview.candidate.evaluation.geometry.length=0
22
+ const result=s.apply(q);assert.equal(result.inputs.length,3000);assert.ok(result.evaluation.geometry.length>0)
23
+ })
24
+ test('broken repeated reference rolls back everything; a valid batch retargets explicitly',()=>{
25
+ const s=session(),before=s.read()
26
+ assert.throws(()=>s.apply(request([edit('length',900)])),e=>e.code==='TARGET_ABSENT');assert.deepEqual(s.read(),before)
27
+ const r=s.apply(request([edit('length',900),edit('selectedIndex',0,'count')]))
28
+ assert.equal(r.revision,1);assert.equal(r.inputs.selectedIndex,0);assert.equal(r.report.effects.geometry.removed.length,10)
29
+ assert.notEqual(r.evaluation.bindings[0].targetGeometryId,before.evaluation.bindings[0].targetGeometryId)
30
+ })
31
+ test('stale AI proposal cannot overwrite a UI edit, including after undo (no revision reuse)',()=>{
32
+ const s=session(),old=request([edit('length',3000)],0,'ai');s.preview(old)
33
+ s.apply(request([edit('length',2550)]));assert.throws(()=>s.apply(old),e=>e.code==='REVISION_CONFLICT')
34
+ const restored=s.undo({origin:'ui',expectedRevision:1});assert.equal(restored.inputs.length,2400);assert.equal(restored.revision,2)
35
+ assert.throws(()=>s.apply(old),e=>e.code==='REVISION_CONFLICT')
36
+ })
37
+ test('undo/redo restore geometry and binding; failed edits and no-ops preserve redo',()=>{
38
+ const s=session(),base=s.read(),changed=s.apply(request([edit('length',3000)]))
39
+ const back=s.undo({origin:'ai',expectedRevision:1});assert.deepEqual(back.evaluation,base.evaluation)
40
+ assert.throws(()=>s.apply(request([edit('length',900)],2)),e=>e.code==='TARGET_ABSENT')
41
+ const noop=s.apply(request([edit('length',2400)],2));assert.equal(noop.revision,2);assert.equal(noop.canRedo,true)
42
+ const redone=s.redo({origin:'ui',expectedRevision:2});assert.deepEqual(redone.evaluation,changed.evaluation)
43
+ s.undo({origin:'ui',expectedRevision:3});s.apply(request([edit('length',2550)],4));assert.equal(s.read().canRedo,false)
44
+ })
45
+ test('role, unit, duplicate, range and unknown input errors cannot partially edit',()=>{
46
+ const s=session(),before=s.read()
47
+ for(const changes of [[edit('angle',37,'deg')],[edit('length',3,'m')],[edit('length',3000),edit('length',2400)],[edit('width',0)],[edit('missing',1)],[edit('length',3000),edit('width',NaN)]]){
48
+ assert.throws(()=>s.apply(request(changes)));assert.deepEqual(s.read(),before)
49
+ }
50
+ })
51
+ test('simultaneous forklift design/state edit moves bindings once and survives undo',()=>{
52
+ const s=createAuthoringSession(doc(forkliftBindings()),forkliftDefaults),before=s.read()
53
+ const r=s.apply(request([edit('forkLength',1275),edit('lift',1000,'mm','set-state'),edit('reach',200,'mm','set-state')],0,'ai'))
54
+ assert.deepEqual(r.evaluation.bindings[0].pose.t,[-260,1057,1197.5]);assert.equal(r.revision,1)
55
+ assert.deepEqual(s.undo({origin:'ui',expectedRevision:1}).evaluation,before.evaluation)
56
+ })
57
+ test('bounded history and externally mutated snapshots cannot corrupt future edits',()=>{
58
+ const s=session()
59
+ for(let i=0;i<55;i++)s.apply(request([edit('length',i%2?2400:2550)],i))
60
+ let view=s.read();view.inputs.length=0;view.evaluation.geometry.length=0
61
+ assert.ok(s.read().evaluation.geometry.length>0)
62
+ for(let i=0;i<50;i++)s.undo({origin:'ui',expectedRevision:55+i})
63
+ assert.equal(s.read().canUndo,false);assert.equal(s.read().canRedo,true)
64
+ assert.throws(()=>s.undo({origin:'ui',expectedRevision:105}),e=>e.code==='NOTHING_TO_UNDO')
65
+ })
@@ -0,0 +1 @@
1
+ export {compileV3Capabilities as compileCapabilities} from '../../src/v3-capabilities.ts'
@@ -0,0 +1,56 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {compileCapabilities} from './v3-capability-plan.mjs'
4
+ import {robotBinding,forkliftBindings} from './v3-capability-samples.mjs'
5
+ import {defaults as robotDefaults} from './v3-full-robot.mjs'
6
+ import {defaults as forkDefaults} from './v3-full-forklift.mjs'
7
+ import {compile} from './v3-typed-plan.mjs'
8
+ const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7)
9
+ const run=s=>compileCapabilities(s.model,s.declaration)
10
+ test('robot grip follows changing links and six joints without creating or hiding solids',()=>{
11
+ const s=robotBinding(),p=run(s),core=compile(s.model);let cases=0
12
+ for(const upperLength of [210,420,840])for(const foreLength of [190,380,760])for(const angle of [-60,0,60])for(const gripper of [0,50,100]){
13
+ const args={...robotDefaults,upperLength,foreLength,gripper,'base-pan':angle,'shoulder-lift':angle,'elbow-flex':angle,'wrist-pitch':angle,'wrist-yaw':angle,'tool-roll':angle}
14
+ const result=p.evaluate(args),base=core.evaluate(args)
15
+ assert.deepEqual(result.geometry,base.geometry);assert.equal(result.geometry.length,10)
16
+ assert.ok(result.geometry.some(g=>g.id==='tool-seat'))
17
+ assert.deepEqual(result.bindings[0].pose,base.values.flangeWorldTop)
18
+ for(const key of ['spatial','collision','safety'])assert.equal(result.bindings[0].checks[key].status,'unverified')
19
+ cases++
20
+ }
21
+ console.log({robotBindingStates:cases})
22
+ })
23
+ test('both forklift seats follow actual top faces through lift, reach and fork length exactly once',()=>{
24
+ const s=forkliftBindings(),p=run(s);let cases=0
25
+ for(const forkLength of [425,850,1700])for(const lift of [0,1000,2000])for(const reach of [0,200,400]){
26
+ const r=p.evaluate({...forkDefaults,forkLength,lift,reach});assert.equal(r.geometry.length,20)
27
+ for(const b of r.bindings){near(b.pose.t[0],-260);near(b.pose.t[1],(b.id==='load-low'?57:457)+lift);near(b.pose.t[2],360+forkLength/2+reach);assert.equal(b.contacts.length,2);near(b.contacts[1].pose.t[0],260);assert.equal(b.region,null)}
28
+ cases++
29
+ }
30
+ console.log({forkliftBindingStates:cases})
31
+ })
32
+ test('eligibility is separate from fit/collision/safety, no occupancy mutation or reservation',()=>{
33
+ const p=run(robotBinding())
34
+ for(const [request,status] of [[undefined,'unverified'],[{type:'BOX',occupied:0},'satisfied'],[{type:'PALLET',occupied:0},'failed'],[{type:'BOX',occupied:1},'failed']]){
35
+ const requests=request?{'tool-grip':request}:{},before=structuredClone(requests),r=p.evaluate(robotDefaults,requests)
36
+ assert.equal(r.bindings[0].checks.eligibility.status,status);assert.equal(r.bindings[0].checks.collision.status,'unverified');assert.deepEqual(requests,before)
37
+ }
38
+ assert.throws(()=>p.evaluate(robotDefaults,{'tool-grip':{type:'BOX',occupied:-1}}),e=>e.code==='OCCUPANCY')
39
+ assert.throws(()=>p.evaluate(robotDefaults,{unknown:{type:'BOX',occupied:0}}),e=>e.code==='SCHEMA')
40
+ })
41
+ test('missing references, wrong frame, wrong units, duplicate ids and ambiguous roles fail closed',()=>{
42
+ const checks=[['TARGET_ABSENT',b=>b.pose='deleted'],['FRAME_TYPE',b=>b.frame='wrong'],['ROLE',b=>b.role='slot'],['CAPACITY',b=>b.capacity=1.5],['TYPE',b=>b.region={kind:'centered-box',dimensions:['gripper','upperLength','foreLength']}],['SCHEMA',b=>b.region=null],['SCHEMA',b=>b.hidden=true]]
43
+ for(const [code,edit] of checks){const s=robotBinding();edit(s.declaration.bindings[0]);assert.throws(()=>run(s),e=>e.code===code)}
44
+ const s=robotBinding();s.declaration.bindings.push(structuredClone(s.declaration.bindings[0]));assert.throws(()=>run(s),e=>e.code==='DUPLICATE_BINDING')
45
+ })
46
+ test('region values have canonical mm, are not solid meshes, reject zero and recover atomically',()=>{
47
+ const s=robotBinding();s.model.inputs.push({id:'clearance',unit:'cm',min:0,max:10});s.declaration.bindings[0].region={kind:'centered-box',dimensions:['upperLength','foreLength','clearance']}
48
+ const p=run(s),args={...robotDefaults,clearance:2},first=p.evaluate(args)
49
+ assert.deepEqual(first.bindings[0].region.dimensions,[420,380,20]);assert.equal(first.geometry.length,10);assert.equal(first.bindings[0].checks.spatial.status,'unverified')
50
+ assert.throws(()=>p.evaluate({...args,clearance:0}),e=>e.code==='REGION_DOMAIN');assert.deepEqual(p.evaluate(args),first)
51
+ })
52
+ test('compiled declaration and returned results are isolated from external mutation',()=>{
53
+ const s=robotBinding(),p=run(s),a=p.evaluate(robotDefaults),snapshot=structuredClone(a)
54
+ s.declaration.bindings[0].pose='deleted';s.model.nodes.length=0;a.bindings[0].pose.t[0]=999;a.geometry.length=0
55
+ assert.deepEqual(p.evaluate(robotDefaults),snapshot)
56
+ })
@@ -0,0 +1,23 @@
1
+ import {fullModel as robotModel} from './v3-full-robot.mjs'
2
+ import {fullModel as forkliftModel} from './v3-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
+ }
@@ -0,0 +1,2 @@
1
+ export {compileV3Document as compileDocument,parseV3Document as parseDocument,serializeV3Document as serializeDocument} from '../../src/v3-document.ts'
2
+ export {assertV3Json as jsonOnly,parseV3Json as parseStrictJson,V3_JSON_MAX_BYTES as MAX_BYTES,v3Utf8ByteLength} from '../../src/v3-json.ts'
@@ -0,0 +1,66 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Experimental V3 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-v3-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
+ }
@@ -0,0 +1,55 @@
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 './v3-document.mjs'
6
+ import {compileCapabilities} from './v3-capability-plan.mjs'
7
+ import {forkliftBindings,robotBinding} from './v3-capability-samples.mjs'
8
+ import {defaults as forkDefaults} from './v3-full-forklift.mjs'
9
+ import {defaults as robotDefaults} from './v3-full-robot.mjs'
10
+ const envelope=s=>({version:'figure-v3-prototype-1',model:s.model,capabilities:s.declaration})
11
+ const schema=JSON.parse(fs.readFileSync(new URL('./v3-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
+ })
@@ -0,0 +1,60 @@
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 './v3-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
+ }
@@ -0,0 +1,30 @@
1
+ import {test} from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {evaluatePortal,meshOf,meshAudit,rayHit,seed} from './v3-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
+ })
@@ -0,0 +1,97 @@
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 './v3-editor-workspace.mjs'
10
+ import {sampleAsset} from './v3-asset-samples.mjs'
11
+ import {serializeAsset} from './v3-asset.mjs'
12
+ import {createV3Asset} from '../../src/v3-create.ts'
13
+ import {createAssetStore} from './v3-asset-store.mjs'
14
+ import {geometryOf as ordinary} from './v3-full-conveyor.mjs'
15
+ import {geometryOf as portal} from './v3-full-dryroom.mjs'
16
+ const directory=await mkdtemp(join(tmpdir(),'v3-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>V3 편집 연결 검증</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>V3 편집 연결 검증</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(createV3Asset('새 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-v3-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-v3-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-v3-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-v3-editor.png'},null,2))
97
+ }finally{await browser?.close();await new Promise(r=>server.close(r));await rm(directory,{recursive:true,force:true})}
@@ -0,0 +1 @@
1
+ export {createV3EditorWorkspace as createEditorWorkspace} from '../../src/v3-editor-workspace.ts'