@hatiolab/figure-model 0.1.30 → 0.1.32
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 +15 -4
- 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 +61 -0
- package/dist/v3-create.d.ts.map +1 -0
- package/dist/v3-create.js +151 -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 +367 -0
- package/dist/v3-editor-workspace.d.ts.map +1 -0
- package/dist/v3-editor-workspace.js +179 -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 +61 -0
- package/dist/v3-part-edit.js.map +1 -0
- 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,183 @@
|
|
|
1
|
+
"""Independent linear design comparison; not a production evaluator.
|
|
2
|
+
Requires kiwisolver==1.4.9 in an isolated research environment.
|
|
3
|
+
Run: python docs/prototypes/v3-linear-comparison.py
|
|
4
|
+
All lengths mm. No external research implementation is copied.
|
|
5
|
+
"""
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import sys
|
|
10
|
+
import kiwisolver as kiwi
|
|
11
|
+
|
|
12
|
+
assert kiwi.__version__ == "1.4.9"
|
|
13
|
+
EPS = 1e-7
|
|
14
|
+
results = []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def close(a, b):
|
|
18
|
+
assert math.isfinite(a) and abs(a-b) <= EPS, (a, b)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dag(definitions, inputs):
|
|
22
|
+
values = dict(inputs)
|
|
23
|
+
active = []
|
|
24
|
+
assert not (set(definitions) & set(inputs)), "duplicate writer"
|
|
25
|
+
def visit(name):
|
|
26
|
+
if name in values:
|
|
27
|
+
return values[name]
|
|
28
|
+
if name in active:
|
|
29
|
+
raise ValueError("cycle: " + " -> ".join(active + [name]))
|
|
30
|
+
if name not in definitions:
|
|
31
|
+
raise ValueError("unknown: " + name)
|
|
32
|
+
active.append(name)
|
|
33
|
+
constant, terms = definitions[name]
|
|
34
|
+
value = constant + sum(coef * visit(ref) for ref, coef in terms.items())
|
|
35
|
+
assert math.isfinite(value)
|
|
36
|
+
active.pop()
|
|
37
|
+
values[name] = value
|
|
38
|
+
return value
|
|
39
|
+
for name in definitions:
|
|
40
|
+
visit(name)
|
|
41
|
+
return values
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def solve(names, make, order=None):
|
|
45
|
+
variables = {n: kiwi.Variable(n) for n in names}
|
|
46
|
+
constraints = make(variables)
|
|
47
|
+
solver = kiwi.Solver()
|
|
48
|
+
for i in (range(len(constraints)) if order is None else order):
|
|
49
|
+
solver.addConstraint(constraints[i])
|
|
50
|
+
solver.updateVariables()
|
|
51
|
+
assert all(not c.violated() for c in constraints)
|
|
52
|
+
return {n: v.value() for n,v in variables.items()}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Same physical conditions, different formulations; independent edge checks below.
|
|
56
|
+
max_difference = 0
|
|
57
|
+
for width in range(40, 10041, 100):
|
|
58
|
+
direct = dag({
|
|
59
|
+
"leaf": (-15, {"width": .5}),
|
|
60
|
+
"left": (2.5, {"width": -.25}),
|
|
61
|
+
"right": (-2.5, {"width": .25}),
|
|
62
|
+
"travel": (-5, {"width": .5})
|
|
63
|
+
}, {"width": width})
|
|
64
|
+
for reverse in [False, True]:
|
|
65
|
+
order = list(range(6))
|
|
66
|
+
if reverse:
|
|
67
|
+
order.reverse()
|
|
68
|
+
constrained = solve(["leaf","left","right","travel"], lambda v: [
|
|
69
|
+
2*v["leaf"] + 30 == width,
|
|
70
|
+
v["left"] - v["leaf"]/2 == -width/2 + 10,
|
|
71
|
+
v["right"] + v["leaf"]/2 == width/2 - 10,
|
|
72
|
+
v["left"] - v["travel"] + v["leaf"]/2 == -width/2,
|
|
73
|
+
v["leaf"] >= 1,
|
|
74
|
+
v["right"]-v["leaf"]/2 - (v["left"]+v["leaf"]/2) == 10
|
|
75
|
+
], order)
|
|
76
|
+
for key in constrained:
|
|
77
|
+
close(constrained[key], direct[key])
|
|
78
|
+
max_difference = max(max_difference, abs(constrained[key]-direct[key]))
|
|
79
|
+
close(constrained["right"]+constrained["travel"]-constrained["leaf"]/2, width/2)
|
|
80
|
+
results.append({"case":"doors_101_widths_two_orders", "solver_runs":202,
|
|
81
|
+
"max_difference_mm":max_difference})
|
|
82
|
+
|
|
83
|
+
for length in [.5, 1, 2, 1000]:
|
|
84
|
+
direct = dag({"size": (0, {"high":1,"low":-1}),
|
|
85
|
+
"center": (0, {"high":.5,"low":.5})},
|
|
86
|
+
{"low":-length/2, "high":length/2})
|
|
87
|
+
solved = solve(["size","center"], lambda v:[
|
|
88
|
+
v["center"]-v["size"]/2 == -length/2,
|
|
89
|
+
v["center"]+v["size"]/2 == length/2,
|
|
90
|
+
v["size"] >= .001
|
|
91
|
+
])
|
|
92
|
+
close(solved["size"], direct["size"])
|
|
93
|
+
close(solved["center"], direct["center"])
|
|
94
|
+
results.append({"case":"two_boundaries", "runs":4, "all_exact_within_tolerance":True})
|
|
95
|
+
|
|
96
|
+
# r1's greedy auto-track algorithm required 150 instead of the feasible 110.
|
|
97
|
+
def grid_constraints(v):
|
|
98
|
+
return [v["a"]>=10, v["b"]>=10, v["c"]>=10,
|
|
99
|
+
v["a"]+v["b"]>=100, v["b"]+v["c"]>=100,
|
|
100
|
+
v["a"]+v["b"]+v["c"]==110]
|
|
101
|
+
for order in itertools.permutations(range(6)):
|
|
102
|
+
values = solve(["a","b","c"], grid_constraints, order)
|
|
103
|
+
for name, expected in zip(["a","b","c"], [10,90,10]):
|
|
104
|
+
close(values[name], expected)
|
|
105
|
+
results.append({"case":"feasible_overlapping_spans", "orders":720, "solution_mm":[10,90,10],
|
|
106
|
+
"dag":"requires an authored allocation; inequalities alone are not its input language"})
|
|
107
|
+
|
|
108
|
+
for reverse in [False, True]:
|
|
109
|
+
try:
|
|
110
|
+
solve(["p"], lambda v:[v["p"]==0, v["p"]==100],
|
|
111
|
+
[1,0] if reverse else [0,1])
|
|
112
|
+
raise AssertionError("conflict accepted")
|
|
113
|
+
except kiwi.UnsatisfiableConstraint:
|
|
114
|
+
pass
|
|
115
|
+
results.append({"case":"contradictory_point", "orders":2, "required_conflict_rejected":True})
|
|
116
|
+
|
|
117
|
+
for width in [20, 30, 31]:
|
|
118
|
+
direct = dag({"leaf": (-15, {"width":.5})}, {"width":width})
|
|
119
|
+
assert direct["leaf"] < 1 # A domain check is necessary after DAG evaluation.
|
|
120
|
+
try:
|
|
121
|
+
solve(["leaf"], lambda v:[2*v["leaf"]+30==width, v["leaf"]>=1])
|
|
122
|
+
raise AssertionError("invalid size accepted")
|
|
123
|
+
except kiwi.UnsatisfiableConstraint:
|
|
124
|
+
pass
|
|
125
|
+
results.append({"case":"impossible_door_width", "widths_mm":[20,30,31],
|
|
126
|
+
"dag":"domain violation; no silent clamping", "kiwi":"required conflict"})
|
|
127
|
+
|
|
128
|
+
cycle = {"parent":(20,{"child":1}), "child":(0,{"parent":.5})}
|
|
129
|
+
try:
|
|
130
|
+
dag(cycle,{})
|
|
131
|
+
raise AssertionError("cycle accepted")
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
cycle_error = str(exc)
|
|
134
|
+
cyclic_solution = solve(["parent","child"],lambda v:[
|
|
135
|
+
v["parent"]==v["child"]+20, v["child"]==v["parent"]/2])
|
|
136
|
+
close(cyclic_solution["parent"],40)
|
|
137
|
+
close(cyclic_solution["child"],20)
|
|
138
|
+
results.append({"case":"solvable_cycle","dag":cycle_error,"kiwi":cyclic_solution})
|
|
139
|
+
|
|
140
|
+
# Same-strength preferences leave many optima: do not confuse a returned solution with intent.
|
|
141
|
+
tie_outcomes = []
|
|
142
|
+
for reverse in [False,True]:
|
|
143
|
+
x = kiwi.Variable("x")
|
|
144
|
+
solver = kiwi.Solver()
|
|
145
|
+
preferences = [(x==0)|"weak", (x==100)|"weak"]
|
|
146
|
+
for c in (reversed(preferences) if reverse else preferences):
|
|
147
|
+
solver.addConstraint(c)
|
|
148
|
+
solver.updateVariables()
|
|
149
|
+
assert -EPS <= x.value() <= 100+EPS
|
|
150
|
+
tie_outcomes.append(x.value())
|
|
151
|
+
results.append({"case":"equal_strength_preferences","values_mm":tie_outcomes,
|
|
152
|
+
"meaning":"any x in [0,100] has the same sum of absolute errors; no unique intended result"})
|
|
153
|
+
|
|
154
|
+
x = kiwi.Variable("x")
|
|
155
|
+
solver = kiwi.Solver()
|
|
156
|
+
solver.addConstraint(x>=0)
|
|
157
|
+
solver.addConstraint(x<=100)
|
|
158
|
+
solver.addEditVariable(x,"strong")
|
|
159
|
+
solver.suggestValue(x,120)
|
|
160
|
+
solver.updateVariables()
|
|
161
|
+
close(x.value(),100)
|
|
162
|
+
results.append({"case":"edit_suggestion_not_requirement","requested_mm":120,
|
|
163
|
+
"actual_mm":x.value(),"must_report_delta_mm":120-x.value()})
|
|
164
|
+
|
|
165
|
+
# Unique equalities under resize and return: measure edit suggestions AND original invariants.
|
|
166
|
+
w, leaf, left = [kiwi.Variable(n) for n in ["w","leaf","left"]]
|
|
167
|
+
solver = kiwi.Solver()
|
|
168
|
+
solver.addConstraint(2*leaf+30==w)
|
|
169
|
+
solver.addConstraint(left-leaf/2 == -w/2+10)
|
|
170
|
+
solver.addConstraint(leaf>=1)
|
|
171
|
+
solver.addEditVariable(w,"strong")
|
|
172
|
+
for target in [1440,2880,40,1440]:
|
|
173
|
+
solver.suggestValue(w,target)
|
|
174
|
+
solver.updateVariables()
|
|
175
|
+
close(w.value(),target)
|
|
176
|
+
close(leaf.value(),(target-30)/2)
|
|
177
|
+
close(left.value()-leaf.value()/2,-target/2+10)
|
|
178
|
+
results.append({"case":"incremental_resize_return","widths_mm":[1440,2880,40,1440],
|
|
179
|
+
"invariants_and_requested_values_checked":True})
|
|
180
|
+
|
|
181
|
+
print(json.dumps({"python":sys.version,"kiwi":kiwi.__version__,"tolerance_mm":EPS,
|
|
182
|
+
"scope":"linear scalar design experiments; not 3D geometry or user-intent proof",
|
|
183
|
+
"results":results},indent=2))
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Design probes, not a v3 implementation. No source assets are modified.
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { validate } from '../../src/validate.ts'
|
|
7
|
+
import { centredPart } from '../../src/origin.ts'
|
|
8
|
+
import { sizingPosition, sizingScale, rotatedExtent, repeatPlanOfPart, repeatOffset } from '../../src/sizing.ts'
|
|
9
|
+
|
|
10
|
+
const files = [new URL('../../../operato-application/packages/operato-figure/server/seed/figure-samples.json',import.meta.url),
|
|
11
|
+
new URL('../../src/fixtures/robot-arm-6dof.json',import.meta.url),new URL('../../src/fixtures/gantry-crane-3dof.json',import.meta.url)]
|
|
12
|
+
const all = [...JSON.parse(fs.readFileSync(files[0])).map(x=>x.source),...files.slice(1).map(f=>JSON.parse(fs.readFileSync(f)))]
|
|
13
|
+
const source = type => all.find(s=>s.type===type)
|
|
14
|
+
const part = (s,n) => { const p=s.parts.find(p=>p.name===n);assert.ok(p,n);return p }
|
|
15
|
+
const axes=['x','y','z'], unit={x:1,y:1,z:1}
|
|
16
|
+
const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-7,`${a} != ${b}`)
|
|
17
|
+
// Bounds of the declared rotated box, NOT exact polygon/rounded shape contact.
|
|
18
|
+
function bounds(s,n,scale=unit) {
|
|
19
|
+
const p=centredPart({...part(s,n),sizing:part(s,n).sizing??'scale'},s.base)
|
|
20
|
+
const at=sizingPosition(p,scale,s.base),factor=sizingScale(p,scale,s.base),extent=rotatedExtent(p)
|
|
21
|
+
return Object.fromEntries(axes.map(a=>{
|
|
22
|
+
const center=at[a]*scale[a]+(a==='y'?s.base.y*scale.y/2:0),size=extent[a]*factor[a]*scale[a]
|
|
23
|
+
return [a,{center,size,min:center-size/2,max:center+size/2}]
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
function channel(s,param,target) {
|
|
27
|
+
const p=s.parameters.find(p=>p.name===param),c=p.clip.channels.find(c=>c.target===target)
|
|
28
|
+
assert.ok(c);assert.equal(c.keys.length,2);assert.equal(c.keys[0].at,0);assert.equal(c.keys[1].at,1)
|
|
29
|
+
return {p,c}
|
|
30
|
+
}
|
|
31
|
+
function sampleChannel(c,t) {
|
|
32
|
+
return Object.fromEntries(axes.map(a=>[a,c.keys[0].value[a]*(1-t)+c.keys[1].value[a]*t]))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('16 sources: validation, finite sizing at 27 scales, baseline positions; provenance',()=>{
|
|
36
|
+
let checks=0
|
|
37
|
+
for(const s of all) {
|
|
38
|
+
assert.deepEqual(validate(s).errors,[],s.type)
|
|
39
|
+
for(const x of [.5,1,2])for(const y of [.5,1,2])for(const z of [.5,1,2])for(const p of s.parts) {
|
|
40
|
+
const b=bounds(s,p.name,{x,y,z})
|
|
41
|
+
for(const a of axes) {assert.ok(Number.isFinite(b[a].center));assert.ok(Number.isFinite(b[a].size)&&b[a].size>0)}
|
|
42
|
+
if(x===1&&y===1&&z===1)for(const a of axes)near(b[a].center,p.transform.position[a])
|
|
43
|
+
checks++
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(JSON.stringify({models:all.length,partScaleChecks:checks,provenance:files.map(f=>({path:f.pathname,sha256:createHash('sha256').update(fs.readFileSync(f)).digest('hex')})),scope:'finite numbers and source validity, NOT assembly correctness'}))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('MIXER: fixed motor attachment cannot be inferred from independent scale positions',()=>{
|
|
50
|
+
const s=source('MIXER'),body=part(s,'body'),motor=part(s,'motor')
|
|
51
|
+
const overlap=body.transform.position.y+body.transform.size.y/2-(motor.transform.position.y-motor.transform.size.y/2)
|
|
52
|
+
near(overlap,5)
|
|
53
|
+
for(const h of [400,800,1600]) {
|
|
54
|
+
// Explicit proposed intent: motor height fixed; body height proportional.
|
|
55
|
+
const top=500*h/800,motorCenter=top-overlap+motor.transform.size.y/2
|
|
56
|
+
near(top-(motorCenter-80),5)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('AGV: concentric wheels/hubs survive scaling but wheel ground contact does not always',()=>{
|
|
61
|
+
const s=source('AGV'),rows=[]
|
|
62
|
+
for(const scale of [unit,{x:1,y:2,z:1},{x:2,y:1,z:2}])for(const suffix of ['front-left','front-right','rear-left','rear-right']) {
|
|
63
|
+
const w=bounds(s,`wheel-${suffix}`,scale),h=bounds(s,`hub-${suffix}`,scale),r=w.y.size/2
|
|
64
|
+
for(const a of axes)near(w[a].center,h[a].center)
|
|
65
|
+
near(w.y.size,w.z.size)
|
|
66
|
+
rows.push({scale,suffix,bottom:w.y.min,radius:r})
|
|
67
|
+
}
|
|
68
|
+
assert.ok(rows.some(r=>Math.abs(r.bottom)>1))
|
|
69
|
+
console.log(JSON.stringify({agv:rows.filter(r=>r.suffix==='front-left'),gap:'roundness does not imply floor contact; wheel center must reference radius'}))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('SIGNAL_TOWER_3: real contact chain survives height changes with fixed lamp sizes',()=>{
|
|
73
|
+
const s=source('SIGNAL_TOWER_3')
|
|
74
|
+
const pairs=[['foot','pole'],['pole','bracket'],['bracket','lamp-lower'],['lamp-lower','ring-lower'],['ring-lower','lamp-middle'],['lamp-middle','ring-upper'],['ring-upper','lamp-upper'],['lamp-upper','cap']]
|
|
75
|
+
for(const height of [550,1100,2200,3300])for(const [a,b] of pairs) {
|
|
76
|
+
const before=bounds(s,b).y.min-bounds(s,a).y.max
|
|
77
|
+
near(bounds(s,b,{x:1,y:height/s.base.y,z:1}).y.min-bounds(s,a,{x:1,y:height/s.base.y,z:1}).y.max,before)
|
|
78
|
+
}
|
|
79
|
+
for(const n of ['lamp-lower','lamp-middle','lamp-upper'])near(bounds(s,n,{x:1,y:3,z:1}).y.size,90)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('WORKER: intentional non-round primitives must not be forced to circular profiles',()=>{
|
|
83
|
+
const s=source('WORKER'),scale={x:2,y:1,z:.5}
|
|
84
|
+
for(const n of ['leg-left','arm-left','hand-left','helmet','helmet-brim']) {
|
|
85
|
+
assert.equal(part(s,n).keepRound,false)
|
|
86
|
+
const b=bounds(s,n,scale)
|
|
87
|
+
for(const a of axes)near(b[a].size,part(s,n).transform.size[a]*scale[a])
|
|
88
|
+
}
|
|
89
|
+
assert.equal(s.joints,undefined)
|
|
90
|
+
console.log(JSON.stringify({worker:'static sample only; no walk/pose animation evidence',gap:'ellipse/ellipsoid semantics must coexist with circular rollers'}))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('KIOSK: screen aperture margins and protrusion are distinct from same-center attachment',()=>{
|
|
94
|
+
const s=source('KIOSK'),screen=bounds(s,'screen'),bezel=bounds(s,'bezel')
|
|
95
|
+
near((bezel.x.size-screen.x.size)/2,30);near((bezel.y.size-screen.y.size)/2,30)
|
|
96
|
+
near(screen.z.max-bezel.z.max,8)
|
|
97
|
+
for(const width of [205,410,820]) {
|
|
98
|
+
const opening=width-60;assert.ok(opening>0);near((width-opening)/2,30)
|
|
99
|
+
}
|
|
100
|
+
assert.ok(50-60<0) // candidate fixed-margin intent must reject a too-small bezel.
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
for(const type of ['CELL_TRAY','FORMATION_TRAY'])test(`${type}: fixed count, wall clearance and cell/contact correspondence`,()=>{
|
|
104
|
+
const s=source(type),cell=part(s,'cell'),tub=part(s,'tub')
|
|
105
|
+
for(const scale of [.5,1,2]) {
|
|
106
|
+
const plan=repeatPlanOfPart(cell,{x:scale,y:1,z:1},s.base)
|
|
107
|
+
assert.equal(plan.count,8)
|
|
108
|
+
const edge=(repeatOffset(plan.count-1,plan.count,plan.pitch)+cell.transform.size.x/2)*scale
|
|
109
|
+
// Explicit v3 candidate: wall thickness remains 30mm. Geometry fits only above a bound.
|
|
110
|
+
const clearance=s.base.x*scale/2-tub.shape.hollow.wall-edge
|
|
111
|
+
near(clearance,36*scale-30)
|
|
112
|
+
if(type==='FORMATION_TRAY') {
|
|
113
|
+
const other=repeatPlanOfPart(part(s,'contact'),{x:scale,y:1,z:1},s.base)
|
|
114
|
+
assert.equal(other.count,plan.count);near(other.pitch,plan.pitch)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(JSON.stringify({type,fixedCount:8,candidateFixedWallClearanceAtHalfScaleMm:-12,minimumScale:30/36,scope:'fixed-wall candidate; not a claim about current rendered wall scaling'}))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('ROLL_CRADLE: state-driven diameter is geometry change, not rigid joint motion',()=>{
|
|
121
|
+
const s=source('ROLL_CRADLE'),{c}=channel(s,'fed','roll'),r=part(s,'roll'),core=part(s,'core')
|
|
122
|
+
assert.equal(c.path,'scale')
|
|
123
|
+
for(const t of [0,.25,.5,.75,1]) {
|
|
124
|
+
const v=sampleChannel(c,t)
|
|
125
|
+
near(v.x,v.z);near(v.y,1)
|
|
126
|
+
assert.ok(r.transform.size.x*v.x>=core.transform.size.x)
|
|
127
|
+
near(r.transform.size.y*v.y,1000)
|
|
128
|
+
}
|
|
129
|
+
near(r.transform.size.x*sampleChannel(c,1).x,224)
|
|
130
|
+
console.log(JSON.stringify({cradle:{diameterRangeMm:[700,224],coreDiameterMm:200},gap:'state-dependent shape dimensions needed beyond rigid J; resizing radius times state factor is bilinear if both are variable'}))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('MODULE_PALLET: terminals exceed base height; base is not automatic containment',()=>{
|
|
134
|
+
const s=source('MODULE_PALLET'),deck=bounds(s,'deck'),module=bounds(s,'module')
|
|
135
|
+
near(deck.y.max,module.y.min)
|
|
136
|
+
for(const n of ['terminal-left','terminal-right'])near(bounds(s,n).y.max-s.base.y,45)
|
|
137
|
+
for(const n of ['foot-front-left','foot-front-right','foot-back-left','foot-back-right']) {
|
|
138
|
+
near(bounds(s,n).y.min,0);near(bounds(s,n).y.max,deck.y.min)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('ROLL_PRESS: opening state versus finite web thickness exposes collision intent ambiguity',()=>{
|
|
143
|
+
const s=source('ROLL_PRESS'),upper=bounds(s,'roll-upper'),lower=bounds(s,'roll-lower'),web=bounds(s,'web')
|
|
144
|
+
const uc=channel(s,'nip','roll-upper').c,wc=channel(s,'nip','web').c,rows=[]
|
|
145
|
+
for(const opening of [0,12,24,120,240]) {
|
|
146
|
+
const u=sampleChannel(uc,opening/240).y,w=sampleChannel(wc,opening/240).y
|
|
147
|
+
near((upper.y.min+u)-lower.y.max,opening)
|
|
148
|
+
near(web.y.center+w,(upper.y.min+u+lower.y.max)/2)
|
|
149
|
+
const clearance=(opening-web.y.size)/2
|
|
150
|
+
near(web.y.min+w-lower.y.max,clearance)
|
|
151
|
+
rows.push({opening,clearancePerSideMm:clearance})
|
|
152
|
+
}
|
|
153
|
+
console.log(JSON.stringify({rollPress:rows,gap:'24mm web at zero gap overlaps both rolls by 12mm; distinguish schematic compression from forbidden collision'}))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('FORMATION_CHARGER: shelf/lamp repeat pairing and envelope at count thresholds',()=>{
|
|
157
|
+
const s=source('FORMATION_CHARGER'),rows=[]
|
|
158
|
+
for(const height of [1400,2799.99,2800,2800.01,5600]) {
|
|
159
|
+
const scale={x:1,y:height/s.base.y,z:1}
|
|
160
|
+
const a=repeatPlanOfPart(part(s,'shelf'),scale,s.base),b=repeatPlanOfPart(part(s,'channel-lamp'),scale,s.base)
|
|
161
|
+
assert.equal(a.count,b.count);near(a.pitch,b.pitch)
|
|
162
|
+
near(bounds(s,'shelf',scale).y.center,bounds(s,'channel-lamp',scale).y.center)
|
|
163
|
+
const first=bounds(s,'shelf',scale).y.center+repeatOffset(0,a.count,a.pitch)-20
|
|
164
|
+
rows.push({height,count:a.count,firstShelfBottom:first})
|
|
165
|
+
}
|
|
166
|
+
console.log(JSON.stringify({charger:rows,gap:'paired instances need common stable index; whole-base count does not guarantee plinth/HMI clearance'}))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('GANTRY_CRANE_3DOF: 27 travel combinations inherit each translation exactly once',()=>{
|
|
170
|
+
const s=source('GANTRY_CRANE_3DOF')
|
|
171
|
+
for(const x of [-1600,0,1600])for(const z of [-900,0,900])for(const drop of [0,900,1800]) {
|
|
172
|
+
const q={'bridge-travel':x,'trolley-traverse':z,'hoist-drop':drop},offsets={}
|
|
173
|
+
for(const p of s.parts) {
|
|
174
|
+
const inherited=p.parent?offsets[p.parent]:{x:0,y:0,z:0},j=s.joints.find(j=>j.child===p.name)
|
|
175
|
+
if(j)assert.equal(j.type,'prismatic')
|
|
176
|
+
offsets[p.name]=Object.fromEntries(axes.map(a=>[a,inherited[a]+(j?j.axis[a]*q[j.name]:0)]))
|
|
177
|
+
}
|
|
178
|
+
near(offsets['hook-seat'].x,x);near(offsets['hook-seat'].z,z);near(offsets['hook-seat'].y,-drop)
|
|
179
|
+
near(part(s,'hook').transform.position.y+offsets.hook.y-(part(s,'hook-seat').transform.position.y+offsets['hook-seat'].y),60)
|
|
180
|
+
near(offsets['leg-1'].x,0)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {compilePayloadInspection} from './v3-payload-fit.mjs'
|
|
2
|
+
import {ContractError} from './v3-typed-plan.mjs'
|
|
3
|
+
const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
|
|
4
|
+
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]
|
|
5
|
+
const columns=r=>[0,1,2].map(i=>r.map(row=>row[i]))
|
|
6
|
+
export function boxRelation(a,b,tolerance=1e-6){
|
|
7
|
+
// Static OBB SAT: 3 + 3 face normals and 9 cross-product axes.
|
|
8
|
+
for(const box of [a,b]){
|
|
9
|
+
if(!box||!Array.isArray(box.center)||box.center.length!==3||!Array.isArray(box.half)||box.half.length!==3||!Array.isArray(box.axes)||box.axes.length!==3||box.axes.some(v=>!Array.isArray(v)||v.length!==3)||[...box.center,...box.half,...box.axes.flat()].some(v=>!Number.isFinite(v))||box.half.some(v=>v<=0))throw new ContractError('OBB_DOMAIN','collision','finite positive OBB required')
|
|
10
|
+
for(let i=0;i<3;i++)for(let j=0;j<3;j++)if(Math.abs(dot(box.axes[i],box.axes[j])-(i===j?1:0))>1e-9)throw new ContractError('OBB_FRAME','collision','orthonormal axes required')
|
|
11
|
+
}
|
|
12
|
+
if(!Number.isFinite(tolerance)||tolerance<0)throw new ContractError('OBB_DOMAIN','collision','invalid tolerance')
|
|
13
|
+
const delta=b.center.map((v,i)=>v-a.center[i]),axes=[...a.axes,...b.axes,...a.axes.flatMap(x=>b.axes.map(y=>cross(x,y)))]
|
|
14
|
+
let minOverlap=Infinity,uncertain=false
|
|
15
|
+
for(const axis of axes){
|
|
16
|
+
const norm=Math.hypot(...axis)
|
|
17
|
+
if(norm===0)continue
|
|
18
|
+
if(norm<1e-12){uncertain=true;continue}
|
|
19
|
+
const n=axis.map(v=>v/norm),radius=box=>box.half.reduce((s,h,i)=>s+h*Math.abs(dot(box.axes[i],n)),0)
|
|
20
|
+
const overlap=radius(a)+radius(b)-Math.abs(dot(delta,n))
|
|
21
|
+
if(overlap < -tolerance)return {relation:'separated',separationMm:-overlap}
|
|
22
|
+
minOverlap=Math.min(minOverlap,overlap)
|
|
23
|
+
}
|
|
24
|
+
if(uncertain)return {relation:'unverified',reason:'NEAR_PARALLEL_NUMERICS'}
|
|
25
|
+
return {relation:minOverlap<=tolerance?'touching':'penetrating',minimumAxisOverlapMm:Math.max(0,minOverlap)}
|
|
26
|
+
}
|
|
27
|
+
function envelope(g){
|
|
28
|
+
const d=g.shape.dimensions;let half
|
|
29
|
+
if(['box','roundedBox'].includes(g.shape.provider))half=[d.width/2,d.height/2,d.depth/2]
|
|
30
|
+
else if(g.shape.provider==='cylinder')half=[d.radius,d.length/2,d.radius]
|
|
31
|
+
else return null
|
|
32
|
+
return {center:g.pose.t,axes:columns(g.pose.r),half}
|
|
33
|
+
}
|
|
34
|
+
export function compileCollisionInspection(model,declaration){
|
|
35
|
+
const plan=compilePayloadInspection(model,declaration)
|
|
36
|
+
return {evaluate(input,payload){
|
|
37
|
+
const result=plan.evaluate(input,payload),binding=result.evaluation.bindings.find(b=>b.id===payload.bindingId)
|
|
38
|
+
const a=payload.yawDeg*Math.PI/180,c=Math.cos(a),s=Math.sin(a),r=binding.pose.r
|
|
39
|
+
const mv=v=>r.map(row=>dot(row,v)),axes=[[c,0,-s],[0,1,0],[s,0,c]].map(mv)
|
|
40
|
+
const box={center:mv(payload.center).map((v,i)=>v+binding.pose.t[i]),axes,half:payload.dimensions.map(v=>v/2)}
|
|
41
|
+
const pairs=result.evaluation.geometry.map(g=>{
|
|
42
|
+
const bounds=envelope(g)
|
|
43
|
+
if(!bounds)return {geometryId:g.id,status:'unverified',reason:'UNSUPPORTED_ENVELOPE'}
|
|
44
|
+
const relation=boxRelation(box,bounds),exact=g.shape.provider==='box'
|
|
45
|
+
if(relation.relation==='separated')return {geometryId:g.id,status:'satisfied',reason:exact?'BOX_SEPARATED':'CONSERVATIVE_ENVELOPE_SEPARATED',...relation}
|
|
46
|
+
if(!exact)return {geometryId:g.id,status:'unverified',reason:'ENVELOPE_OVERLAP_NEEDS_NARROW_PHASE'}
|
|
47
|
+
if(relation.relation==='unverified')return {geometryId:g.id,status:'unverified',reason:relation.reason}
|
|
48
|
+
return {geometryId:g.id,status:relation.relation==='penetrating'?'failed':'satisfied',reason:relation.relation==='penetrating'?'BOX_PENETRATION':'TOUCH_WITHIN_TOLERANCE',...relation}
|
|
49
|
+
})
|
|
50
|
+
const status=pairs.some(p=>p.status==='failed')?'failed':pairs.some(p=>p.status==='unverified')?'unverified':'satisfied'
|
|
51
|
+
result.inspection.collision={status,scope:'payload-vs-every-model-part-at-current-state',method:'obb-sat-v1',distanceToleranceMm:1e-6,pairs}
|
|
52
|
+
return result
|
|
53
|
+
}}
|
|
54
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {boxRelation,compileCollisionInspection} from './v3-payload-collision.mjs'
|
|
4
|
+
import {forkliftBindings} from './v3-capability-samples.mjs'
|
|
5
|
+
import {defaults} from './v3-full-forklift.mjs'
|
|
6
|
+
import {OBB} from '../../../things-scene/node_modules/three/examples/jsm/math/OBB.js'
|
|
7
|
+
import * as THREE from '../../../things-scene/node_modules/three/build/three.module.js'
|
|
8
|
+
const identity=[[1,0,0],[0,1,0],[0,0,1]],box=(center,half=[1,1,1],axes=identity)=>({center,half,axes})
|
|
9
|
+
const payload={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[620,100,850],center:[260,50,0],yawDeg:0}
|
|
10
|
+
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7)
|
|
11
|
+
test('SAT separates, touches, penetrates and handles complete containment symmetrically',()=>{
|
|
12
|
+
for(const [x,expected] of [[3,'separated'],[2,'touching'],[1,'penetrating'],[0,'penetrating']]){
|
|
13
|
+
const a=box([0,0,0]),b=box([x,0,0]);assert.equal(boxRelation(a,b).relation,expected);assert.equal(boxRelation(b,a).relation,expected)
|
|
14
|
+
}
|
|
15
|
+
assert.equal(boxRelation(box([0,0,0],[10,10,10]),box([0,0,0])).relation,'penetrating')
|
|
16
|
+
assert.equal(boxRelation(box([0,0,0]),box([2+2e-6,0,0])).relation,'separated')
|
|
17
|
+
assert.equal(boxRelation(box([0,0,0]),box([2+5e-7,0,0])).relation,'touching')
|
|
18
|
+
})
|
|
19
|
+
test('rotated boxes with overlapping world AABBs can be separated',()=>{
|
|
20
|
+
const t=Math.PI/4,axes=[[Math.cos(t),0,-Math.sin(t)],[0,1,0],[Math.sin(t),0,Math.cos(t)]]
|
|
21
|
+
const a=box([0,0,0],[5,1,.1],axes),b=box([.5,0,.5],[5,1,.1],axes)
|
|
22
|
+
assert.equal(boxRelation(a,b).relation,'separated')
|
|
23
|
+
})
|
|
24
|
+
test('payload bottom contact does not hide upper fork penetration',()=>{
|
|
25
|
+
const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration)
|
|
26
|
+
const r=p.evaluate(defaults,{...payload,dimensions:[620,500,850],center:[260,250,0]})
|
|
27
|
+
assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.inspection.collision.status,'failed')
|
|
28
|
+
for(const side of ['left','right'])assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-high-'+side).reason,'BOX_PENETRATION')
|
|
29
|
+
assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-low-left').reason,'TOUCH_WITHIN_TOLERANCE')
|
|
30
|
+
assert.equal(r.inspection.safety.status,'unverified')
|
|
31
|
+
})
|
|
32
|
+
test('all parts are checked, curved envelope overlap stays unverified, never automatically excluded',()=>{
|
|
33
|
+
const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration)
|
|
34
|
+
const r=p.evaluate(defaults,{...payload,dimensions:[50,50,50],center:[260,343,-1485]})
|
|
35
|
+
assert.equal(r.inspection.collision.pairs.length,20)
|
|
36
|
+
assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='body').status,'unverified')
|
|
37
|
+
assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='body').reason,'ENVELOPE_OVERLAP_NEEDS_NARROW_PHASE')
|
|
38
|
+
})
|
|
39
|
+
test('225 state combinations retain upper-fork collision and lower-fork touching',()=>{
|
|
40
|
+
const s=forkliftBindings(),p=compileCollisionInspection(s.model,s.declaration);let states=0
|
|
41
|
+
for(const forkLength of [425,850,1700])for(const lift of [0,500,1000,1500,2000])for(const reach of [0,200,400])for(const yawDeg of [-30,-15,0,15,30]){
|
|
42
|
+
const r=p.evaluate({...defaults,forkLength,lift,reach},{...payload,dimensions:[620,500,forkLength],center:[260,250,0],yawDeg})
|
|
43
|
+
assert.equal(r.inspection.collision.status,'failed')
|
|
44
|
+
for(const side of ['left','right'])assert.equal(r.inspection.collision.pairs.find(p=>p.geometryId==='fork-high-'+side).reason,'BOX_PENETRATION')
|
|
45
|
+
states++
|
|
46
|
+
}
|
|
47
|
+
assert.equal(states,225);console.log({payloadCollisionStates:states,pairChecks:states*20})
|
|
48
|
+
})
|
|
49
|
+
test('invalid OBB and numerical near-parallel ambiguity fail closed',()=>{
|
|
50
|
+
assert.throws(()=>boxRelation(box([0,0,0],[-1,1,1]),box([0,0,0])))
|
|
51
|
+
assert.throws(()=>boxRelation(box([0,0,0],[1,1,1],[[2,0,0],[0,1,0],[0,0,1]]),box([0,0,0])))
|
|
52
|
+
const a=1e-14,rot=[[Math.cos(a),Math.sin(a),0],[-Math.sin(a),Math.cos(a),0],[0,0,1]]
|
|
53
|
+
assert.equal(boxRelation(box([0,0,0]),box([0,0,0],[1,1,1],rot)).relation,'unverified')
|
|
54
|
+
})
|
|
55
|
+
test('1000 deterministic general 3D pairs agree with Three.js OBB implementation',()=>{
|
|
56
|
+
let state=983451,intersections=0,separations=0
|
|
57
|
+
const random=()=>{state=(Math.imul(1664525,state)+1013904223)>>>0;return state/2**32}
|
|
58
|
+
const make=()=>{const matrix=new THREE.Matrix3().setFromMatrix4(new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(random()*6,random()*6,random()*6)))
|
|
59
|
+
const e=matrix.elements,center=Array.from({length:3},()=>random()*20-10),half=Array.from({length:3},()=>.1+random()*6)
|
|
60
|
+
return {ours:box(center,half,[[e[0],e[1],e[2]],[e[3],e[4],e[5]],[e[6],e[7],e[8]]]),reference:new OBB(new THREE.Vector3(...center),new THREE.Vector3(...half),matrix)}}
|
|
61
|
+
for(let i=0;i<1000;i++){
|
|
62
|
+
const a=make(),b=make(),expected=a.reference.intersectsOBB(b.reference),r=boxRelation(a.ours,b.ours)
|
|
63
|
+
assert.notEqual(r.relation,'unverified');assert.equal(r.relation!=='separated',expected)
|
|
64
|
+
assert.equal(boxRelation(b.ours,a.ours).relation,r.relation)
|
|
65
|
+
if(expected)intersections++;else separations++
|
|
66
|
+
}
|
|
67
|
+
assert.ok(intersections>0&&separations>0);console.log({obbReferencePairs:1000,intersections,separations})
|
|
68
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Bounded geometric inspection, not a load-bearing or collision-safety certificate.
|
|
2
|
+
import {compileCapabilities} from './v3-capability-plan.mjs'
|
|
3
|
+
import {ContractError} from './v3-typed-plan.mjs'
|
|
4
|
+
const fail=(code,message)=>{throw new ContractError(code,'payload',message)}
|
|
5
|
+
const dot=(a,b)=>a.reduce((s,v,i)=>s+v*b[i],0)
|
|
6
|
+
const mv=(r,v)=>r.map(row=>dot(row,v))
|
|
7
|
+
const local=(pose,p)=>[0,1,2].map(i=>pose.r.reduce((s,row,j)=>s+row[i]*(p[j]-pose.t[j]),0))
|
|
8
|
+
const world=(pose,p)=>mv(pose.r,p).map((v,i)=>v+pose.t[i])
|
|
9
|
+
function clip(poly,axis,bound,sign){
|
|
10
|
+
const out=[]
|
|
11
|
+
for(let i=0;i<poly.length;i++){
|
|
12
|
+
const a=poly[i],b=poly[(i+1)%poly.length],da=sign*(a[axis]-bound),db=sign*(b[axis]-bound),inside=da<=0,next=db<=0
|
|
13
|
+
if(inside)out.push(a)
|
|
14
|
+
if(inside!==next){const t=da/(da-db);out.push(a.map((v,j)=>v+t*(b[j]-v)))}
|
|
15
|
+
}
|
|
16
|
+
return out
|
|
17
|
+
}
|
|
18
|
+
function overlapArea(poly,width,depth){
|
|
19
|
+
for(const [axis,bound,sign] of [[0,width/2,1],[0,-width/2,-1],[1,depth/2,1],[1,-depth/2,-1]])poly=clip(poly,axis,bound,sign)
|
|
20
|
+
return Math.abs(poly.reduce((sum,p,i)=>{const q=poly[(i+1)%poly.length];return sum+p[0]*q[1]-q[0]*p[1]},0))/2
|
|
21
|
+
}
|
|
22
|
+
export function compilePayloadInspection(model,declaration){
|
|
23
|
+
const plan=compileCapabilities(model,declaration)
|
|
24
|
+
return {evaluate(input,payload){
|
|
25
|
+
const keys=['bindingId','type','occupied','dimensions','center','yawDeg']
|
|
26
|
+
if(!payload||typeof payload!=='object'||Array.isArray(payload)||Object.keys(payload).length!==keys.length||keys.some(k=>!Object.hasOwn(payload,k)))fail('SCHEMA','expected bindingId,type,occupied,dimensions,center,yawDeg')
|
|
27
|
+
if(typeof payload.bindingId!=='string')fail('SCHEMA','bindingId required')
|
|
28
|
+
for(const key of ['dimensions','center'])if(!Array.isArray(payload[key])||payload[key].length!==3||payload[key].some(v=>typeof v!=='number'||!Number.isFinite(v)))fail('PAYLOAD_DOMAIN','finite 3D vectors required')
|
|
29
|
+
if(payload.dimensions.some(v=>v<=0)||!Number.isFinite(payload.yawDeg))fail('PAYLOAD_DOMAIN','positive dimensions and finite yaw required')
|
|
30
|
+
// Explicit supported numeric envelope. Never silently clamp a larger asset.
|
|
31
|
+
if([...payload.dimensions,...payload.center].some(v=>Math.abs(v)>1e6)||Math.abs(payload.yawDeg)>360)fail('PAYLOAD_LIMIT','experimental envelope: 1e6 mm and yaw ±360 degrees')
|
|
32
|
+
const evaluation=plan.evaluate(input,{[payload.bindingId]:{type:payload.type,occupied:payload.occupied}})
|
|
33
|
+
const binding=evaluation.bindings.find(b=>b.id===payload.bindingId)
|
|
34
|
+
if(!binding)fail('TARGET_ABSENT',payload.bindingId)
|
|
35
|
+
const [w,h,d]=payload.dimensions,a=payload.yawDeg*Math.PI/180,c=Math.cos(a),s=Math.sin(a)
|
|
36
|
+
const rotate=([x,y,z])=>[c*x+s*z,y,-s*x+c*z].map((v,i)=>v+payload.center[i])
|
|
37
|
+
const bottom=[[-w/2,-h/2,-d/2],[w/2,-h/2,-d/2],[w/2,-h/2,d/2],[-w/2,-h/2,d/2]].map(rotate)
|
|
38
|
+
const corners=[...bottom,...bottom.map(v=>[v[0],v[1]+h,v[2]])]
|
|
39
|
+
const distanceToleranceMm=1e-6,areaToleranceMm2=1e-6
|
|
40
|
+
let containment={status:'unverified',reason:'NO_REGION'}
|
|
41
|
+
if(binding.region){const excess=Math.max(...corners.flatMap(p=>p.map((v,i)=>Math.abs(v)-binding.region.dimensions[i]/2)));containment={status:excess<=distanceToleranceMm?'satisfied':'failed',reason:excess<=distanceToleranceMm?'BOX_CONTAINED':'OUTSIDE_REGION',excessMm:Math.max(0,excess)}}
|
|
42
|
+
const up=mv(binding.pose.r,[0,1,0]),bottomWorld=bottom.map(p=>world(binding.pose,p))
|
|
43
|
+
const contacts=binding.contacts.map(surface=>{
|
|
44
|
+
const base={geometryId:surface.geometryId,feature:surface.feature}
|
|
45
|
+
if(binding.role!=='seat'||surface.provider!=='box'||surface.face!=='top')return {...base,status:'unverified',reason:'UNSUPPORTED_CONTACT_PAIR'}
|
|
46
|
+
if(Math.abs(dot(up,surface.normal)-1)>1e-9)return {...base,status:'unverified',reason:'NONPARALLEL_FACES'}
|
|
47
|
+
const geometry=evaluation.geometry.find(g=>g.id===surface.geometryId)
|
|
48
|
+
if(!geometry)fail('TARGET_ABSENT',surface.geometryId)
|
|
49
|
+
const points=bottomWorld.map(p=>local(surface.pose,p)),dim=geometry.shape.dimensions
|
|
50
|
+
const overlap=overlapArea(points.map(p=>[p[0],p[2]]),dim.width,dim.depth)
|
|
51
|
+
const minGap=Math.min(...points.map(p=>p[1])),maxGap=Math.max(...points.map(p=>p[1]))
|
|
52
|
+
const reason=overlap<=areaToleranceMm2?'NO_POSITIVE_CONTACT_AREA':minGap < -distanceToleranceMm?'PENETRATES_SUPPORT_PLANE':maxGap>distanceToleranceMm?'ABOVE_SUPPORT':'PLANAR_CONTACT'
|
|
53
|
+
return {...base,status:reason==='PLANAR_CONTACT'?'satisfied':'failed',reason,overlapAreaMm2:overlap,minGapMm:minGap,maxGapMm:maxGap}
|
|
54
|
+
})
|
|
55
|
+
const contactStatus=!contacts.length?'unverified':contacts.some(c=>c.status==='failed')?'failed':contacts.some(c=>c.status==='unverified')?'unverified':'satisfied'
|
|
56
|
+
return {evaluation,inspection:{bindingId:binding.id,method:'box-payload/box-top-v1',distanceToleranceMm,areaToleranceMm2,containment,contact:{status:contactStatus,surfaces:contacts},collision:{status:'unverified',reason:'GLOBAL_COLLISION_NOT_CHECKED'},stability:{status:'unverified',reason:'CENTER_OF_MASS_AND_FORCES_NOT_CHECKED'},safety:{status:'unverified',reason:'NOT_CHECKED'}}}
|
|
57
|
+
}}
|
|
58
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {test} from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {compilePayloadInspection} from './v3-payload-fit.mjs'
|
|
4
|
+
import {forkliftBindings,robotBinding} from './v3-capability-samples.mjs'
|
|
5
|
+
import {defaults} from './v3-full-forklift.mjs'
|
|
6
|
+
import {defaults as robotDefaults} from './v3-full-robot.mjs'
|
|
7
|
+
const payload={bindingId:'load-low',type:'PALLET',occupied:0,dimensions:[620,100,850],center:[260,50,0],yawDeg:0}
|
|
8
|
+
const plan=s=>compilePayloadInspection(s.model,s.declaration)
|
|
9
|
+
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-5,`${a} != ${b}`)
|
|
10
|
+
test('both physical fork surfaces support the payload through size/lift/reach/yaw states',()=>{
|
|
11
|
+
const p=plan(forkliftBindings());let states=0
|
|
12
|
+
for(const forkLength of [425,850,1700])for(const lift of [0,1000,2000])for(const reach of [0,200,400])for(const yawDeg of [-30,0,30]){
|
|
13
|
+
const q={...payload,dimensions:[620,100,forkLength],yawDeg},r=p.evaluate({...defaults,forkLength,lift,reach},q)
|
|
14
|
+
assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.inspection.containment.status,'unverified')
|
|
15
|
+
assert.equal(r.evaluation.geometry.length,20)
|
|
16
|
+
for(const c of r.inspection.contact.surfaces){near(c.minGapMm,0);near(c.maxGapMm,0);assert.ok(c.overlapAreaMm2>0);if(!yawDeg)near(c.overlapAreaMm2,90*forkLength)}
|
|
17
|
+
states++
|
|
18
|
+
}
|
|
19
|
+
console.log({payloadContactStates:states})
|
|
20
|
+
})
|
|
21
|
+
test('floating, penetration, missing one fork and zero-area edge touch fail explicitly',()=>{
|
|
22
|
+
const p=plan(forkliftBindings())
|
|
23
|
+
for(const [center,reason] of [[[260,51,0],'ABOVE_SUPPORT'],[[260,49,0],'PENETRATES_SUPPORT_PLANE'],[[0,50,0],'NO_POSITIVE_CONTACT_AREA'],[[-355,50,0],'NO_POSITIVE_CONTACT_AREA']]){
|
|
24
|
+
const r=p.evaluate(defaults,{...payload,center}).inspection
|
|
25
|
+
assert.equal(r.contact.status,'failed');assert.ok(r.contact.surfaces.some(c=>c.reason===reason))
|
|
26
|
+
if(center[0]===-355)assert.equal(r.contact.surfaces[0].overlapAreaMm2,0)
|
|
27
|
+
}
|
|
28
|
+
// Narrow object fits between the forks: no support, even though a broad region could contain it.
|
|
29
|
+
const r=p.evaluate(defaults,{...payload,dimensions:[100,100,850]}).inspection;assert.ok(r.contact.surfaces.every(c=>c.reason==='NO_POSITIVE_CONTACT_AREA'))
|
|
30
|
+
})
|
|
31
|
+
test('containment checks all rotated box corners and does not imply support',()=>{
|
|
32
|
+
const s=forkliftBindings();for(const [id,value] of [['regionW',1200],['regionH',400],['regionD',900]])s.model.constants.push({id,value,unit:'mm'})
|
|
33
|
+
s.declaration.bindings[0].region={kind:'centered-box',dimensions:['regionW','regionH','regionD']}
|
|
34
|
+
const p=plan(s),ok=p.evaluate(defaults,payload).inspection;assert.equal(ok.containment.status,'satisfied')
|
|
35
|
+
const outside=p.evaluate(defaults,{...payload,yawDeg:45}).inspection;assert.equal(outside.containment.status,'failed')
|
|
36
|
+
const floating=p.evaluate(defaults,{...payload,center:[260,60,0]}).inspection;assert.equal(floating.containment.status,'satisfied');assert.equal(floating.contact.status,'failed')
|
|
37
|
+
})
|
|
38
|
+
test('contact result invariant under shared rigid rotation; not a gravity stability proof',()=>{
|
|
39
|
+
const original=plan(forkliftBindings()).evaluate(defaults,payload).inspection
|
|
40
|
+
for(const angle of [-65,30,90]){
|
|
41
|
+
const s=forkliftBindings();s.model.constants.push({id:'tilt',value:angle,unit:'deg'});s.model.nodes.find(n=>n.id==='mastPose').args[5]='tilt'
|
|
42
|
+
const r=plan(s).evaluate({...defaults,lift:700,reach:120},payload).inspection
|
|
43
|
+
assert.equal(r.contact.status,'satisfied');r.contact.surfaces.forEach((c,i)=>near(c.overlapAreaMm2,original.contact.surfaces[i].overlapAreaMm2));assert.equal(r.stability.status,'unverified')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
test('contact never certifies global collision, eligibility or safety',()=>{
|
|
47
|
+
const p=plan(forkliftBindings())
|
|
48
|
+
// Height 500 crosses the upper fork level. Bottom contact remains correct, not globally safe.
|
|
49
|
+
const r=p.evaluate(defaults,{...payload,type:'NOT_ACCEPTED',occupied:1,dimensions:[620,500,850],center:[260,250,0]})
|
|
50
|
+
assert.equal(r.inspection.contact.status,'satisfied');assert.equal(r.evaluation.bindings[0].checks.eligibility.status,'failed')
|
|
51
|
+
assert.equal(r.inspection.collision.status,'unverified');assert.equal(r.inspection.safety.status,'unverified')
|
|
52
|
+
const robot=plan(robotBinding()).evaluate(robotDefaults,{...payload,bindingId:'tool-grip',type:'BOX'})
|
|
53
|
+
assert.equal(robot.inspection.contact.status,'unverified')
|
|
54
|
+
})
|
|
55
|
+
test('invalid payloads reject and subsequent evaluations recover without model mutation',()=>{
|
|
56
|
+
const s=forkliftBindings(),snapshot=structuredClone(s),p=plan(s),baseline=p.evaluate(defaults,payload)
|
|
57
|
+
for(const change of [{dimensions:[0,1,1]},{center:[NaN,0,0]},{yawDeg:Infinity},{dimensions:[1e7,1,1]},{extra:1}])assert.throws(()=>p.evaluate(defaults,{...payload,...change}))
|
|
58
|
+
assert.deepEqual(p.evaluate(defaults,payload),baseline);assert.deepEqual(s,snapshot)
|
|
59
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {fullModel} from './v3-full-conveyor.mjs'
|
|
2
|
+
export function repeatedBinding(){
|
|
3
|
+
const model=fullModel()
|
|
4
|
+
model.inputs.push({id:'selectedIndex',unit:'count',min:0,max:100,role:'design'})
|
|
5
|
+
model.nodes.push(
|
|
6
|
+
{id:'selectedRoller',op:'select-item@1',args:['rollers','selectedIndex'],outputs:{placed:'selectedRoller'},params:{member:'rollerPlaced'}},
|
|
7
|
+
{id:'rollerEnd',op:'feature@1',args:['rollerShape'],outputs:{pose:'rollerEnd'},params:{frame:'rollerEndFrame',name:'end'}}
|
|
8
|
+
)
|
|
9
|
+
const target={placement:'selectedRoller',feature:'rollerEnd'}
|
|
10
|
+
// Illustrative binding for reference testing, not an asserted industrial roller use case.
|
|
11
|
+
const declaration={version:'r3-capability-3',assetFrame:'world',bindings:[{id:'selected-end',label:'선택한 롤러 끝면',role:'grip',frame:'rollerEndFrame',pose:target,contacts:[{...target}],accepts:['TEST_FIXTURE'],capacity:1}]}
|
|
12
|
+
return {model,declaration}
|
|
13
|
+
}
|