@hatiolab/figure-model 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/release-contract.d.ts +2 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +45 -3
- package/dist/release-contract.js.map +1 -1
- package/dist/types.d.ts +16 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +15 -4
- package/dist/types.js.map +1 -1
- package/dist/v3-asset-types.d.ts +116 -0
- package/dist/v3-asset-types.d.ts.map +1 -0
- package/dist/v3-asset-types.js +2 -0
- package/dist/v3-asset-types.js.map +1 -0
- package/dist/v3-asset.d.ts +8 -0
- package/dist/v3-asset.d.ts.map +1 -0
- package/dist/v3-asset.js +135 -0
- package/dist/v3-asset.js.map +1 -0
- package/dist/v3-authoring-session.d.ts +119 -0
- package/dist/v3-authoring-session.d.ts.map +1 -0
- package/dist/v3-authoring-session.js +132 -0
- package/dist/v3-authoring-session.js.map +1 -0
- package/dist/v3-capabilities.d.ts +3 -0
- package/dist/v3-capabilities.d.ts.map +1 -0
- package/dist/v3-capabilities.js +221 -0
- package/dist/v3-capabilities.js.map +1 -0
- package/dist/v3-contract.d.ts +7 -0
- package/dist/v3-contract.d.ts.map +1 -0
- package/dist/v3-contract.js +12 -0
- package/dist/v3-contract.js.map +1 -0
- package/dist/v3-create.d.ts +25 -0
- package/dist/v3-create.d.ts.map +1 -0
- package/dist/v3-create.js +50 -0
- package/dist/v3-create.js.map +1 -0
- package/dist/v3-document.d.ts +5 -0
- package/dist/v3-document.d.ts.map +1 -0
- package/dist/v3-document.js +31 -0
- package/dist/v3-document.js.map +1 -0
- package/dist/v3-editor-workspace.d.ts +334 -0
- package/dist/v3-editor-workspace.d.ts.map +1 -0
- package/dist/v3-editor-workspace.js +130 -0
- package/dist/v3-editor-workspace.js.map +1 -0
- package/dist/v3-graph-types.d.ts +91 -0
- package/dist/v3-graph-types.d.ts.map +1 -0
- package/dist/v3-graph-types.js +2 -0
- package/dist/v3-graph-types.js.map +1 -0
- package/dist/v3-graph.d.ts +5 -0
- package/dist/v3-graph.d.ts.map +1 -0
- package/dist/v3-graph.js +631 -0
- package/dist/v3-graph.js.map +1 -0
- package/dist/v3-json.d.ts +13 -0
- package/dist/v3-json.d.ts.map +1 -0
- package/dist/v3-json.js +94 -0
- package/dist/v3-json.js.map +1 -0
- package/dist/v3-part-edit.d.ts +12 -0
- package/dist/v3-part-edit.d.ts.map +1 -0
- package/dist/v3-part-edit.js +53 -0
- package/dist/v3-part-edit.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +12 -11
- package/dist/validate.js.map +1 -1
- package/docs/assembly-constraints-v3.md +141 -0
- package/docs/prototypes/README.md +73 -0
- package/docs/prototypes/render-dryroom.py +60 -0
- package/docs/prototypes/render-full-dryroom.mjs +86 -0
- package/docs/prototypes/shapeassembly-probe-results.json +325 -0
- package/docs/prototypes/shapeassembly-probe.py +84 -0
- package/docs/prototypes/v3-asset-samples.mjs +15 -0
- package/docs/prototypes/v3-asset-store.mjs +52 -0
- package/docs/prototypes/v3-asset-store.test.mjs +59 -0
- package/docs/prototypes/v3-asset.mjs +1 -0
- package/docs/prototypes/v3-asset.schema.json +44 -0
- package/docs/prototypes/v3-asset.test.mjs +68 -0
- package/docs/prototypes/v3-authoring-session.mjs +1 -0
- package/docs/prototypes/v3-authoring-session.test.mjs +65 -0
- package/docs/prototypes/v3-capability-plan.mjs +1 -0
- package/docs/prototypes/v3-capability-plan.test.mjs +56 -0
- package/docs/prototypes/v3-capability-samples.mjs +23 -0
- package/docs/prototypes/v3-document.mjs +2 -0
- package/docs/prototypes/v3-document.schema.json +66 -0
- package/docs/prototypes/v3-document.test.mjs +55 -0
- package/docs/prototypes/v3-dryroom-mesh.mjs +60 -0
- package/docs/prototypes/v3-dryroom-mesh.test.mjs +30 -0
- package/docs/prototypes/v3-editor-browser.mjs +97 -0
- package/docs/prototypes/v3-editor-workspace.mjs +1 -0
- package/docs/prototypes/v3-editor-workspace.test.mjs +35 -0
- package/docs/prototypes/v3-feature-pattern.test.mjs +131 -0
- package/docs/prototypes/v3-full-conveyor.mjs +60 -0
- package/docs/prototypes/v3-full-conveyor.test.mjs +61 -0
- package/docs/prototypes/v3-full-dryroom.mjs +67 -0
- package/docs/prototypes/v3-full-dryroom.test.mjs +54 -0
- package/docs/prototypes/v3-full-forklift.mjs +40 -0
- package/docs/prototypes/v3-full-forklift.test.mjs +37 -0
- package/docs/prototypes/v3-full-robot.mjs +35 -0
- package/docs/prototypes/v3-full-robot.test.mjs +68 -0
- package/docs/prototypes/v3-kernel.mjs +147 -0
- package/docs/prototypes/v3-kernel.test.mjs +144 -0
- package/docs/prototypes/v3-layout.test.mjs +281 -0
- package/docs/prototypes/v3-linear-comparison.py +183 -0
- package/docs/prototypes/v3-more-samples.test.mjs +182 -0
- package/docs/prototypes/v3-payload-collision.mjs +54 -0
- package/docs/prototypes/v3-payload-collision.test.mjs +68 -0
- package/docs/prototypes/v3-payload-fit.mjs +58 -0
- package/docs/prototypes/v3-payload-fit.test.mjs +59 -0
- package/docs/prototypes/v3-repeated-binding.mjs +13 -0
- package/docs/prototypes/v3-repeated-binding.test.mjs +46 -0
- package/docs/prototypes/v3-runtime-browser.mjs +76 -0
- package/docs/prototypes/v3-samples.test.mjs +144 -0
- package/docs/prototypes/v3-semantics.test.mjs +55 -0
- package/docs/prototypes/v3-spatial-plan.test.mjs +102 -0
- package/docs/prototypes/v3-typed-plan.mjs +3 -0
- package/docs/prototypes/v3-typed-plan.test.mjs +70 -0
- package/docs/prototypes/v3-typed-repeat.test.mjs +96 -0
- package/docs/v3-asset-persistence.md +66 -0
- package/docs/v3-authoring-transactions.md +57 -0
- package/docs/v3-capability-bindings.md +109 -0
- package/docs/v3-common-kernel-experiment.md +116 -0
- package/docs/v3-core-semantics.md +303 -0
- package/docs/v3-cutover.md +27 -0
- package/docs/v3-design.md +388 -0
- package/docs/v3-document-contract.md +55 -0
- package/docs/v3-dryroom-profile-validation.md +87 -0
- package/docs/v3-editor-integration.md +59 -0
- package/docs/v3-full-conveyor-validation.md +69 -0
- package/docs/v3-full-dryroom-validation.md +55 -0
- package/docs/v3-full-forklift-validation.md +41 -0
- package/docs/v3-full-robot-validation.md +64 -0
- package/docs/v3-layout-system.md +318 -0
- package/docs/v3-minimal-model.md +255 -0
- package/docs/v3-operator-contracts.md +148 -0
- package/docs/v3-payload-collision-validation.md +42 -0
- package/docs/v3-payload-contact-validation.md +56 -0
- package/docs/v3-repeated-binding-validation.md +42 -0
- package/docs/v3-research-review.md +160 -0
- package/docs/v3-runtime-extraction.md +107 -0
- package/docs/v3-sample-validation-expanded.md +106 -0
- package/docs/v3-sample-validation.md +97 -0
- package/docs/v3-shapeassembly-reproduction.md +99 -0
- package/docs/v3-spatial-contracts.md +84 -0
- package/docs/v3-status.md +41 -0
- package/docs/v3-storage-integration.md +58 -0
- package/docs/v3-typed-repeat-contracts.md +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {createEditorWorkspace} from './v3-editor-workspace.mjs'
|
|
4
|
+
import {sampleAsset} from './v3-asset-samples.mjs'
|
|
5
|
+
import {serializeAsset} from './v3-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
|
+
})
|