openworldformat 0.4.4__py3-none-any.whl
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.
- openworldformat/__init__.py +1765 -0
- openworldformat/agent.py +142 -0
- openworldformat/cinematography.py +404 -0
- openworldformat/dataset.py +83 -0
- openworldformat/entity_refs.py +25 -0
- openworldformat/eval.py +226 -0
- openworldformat/physics.py +424 -0
- openworldformat/soundtrack.py +94 -0
- openworldformat-0.4.4.dist-info/METADATA +278 -0
- openworldformat-0.4.4.dist-info/RECORD +12 -0
- openworldformat-0.4.4.dist-info/WHEEL +5 -0
- openworldformat-0.4.4.dist-info/top_level.txt +1 -0
openworldformat/eval.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Tasks: goals as predicates over the fold — the benchmark harness.
|
|
2
|
+
|
|
3
|
+
A task is a world, a trajectory, and a goal: a list of predicates over
|
|
4
|
+
what the fold produces — the document (entities, positions) and the
|
|
5
|
+
state document's values. Scoring is therefore deterministic and needs
|
|
6
|
+
no engine: fold the log, evaluate the predicates. That is the whole
|
|
7
|
+
difference from the embodied-agent benchmarks that require a
|
|
8
|
+
simulator, and it is why the predicates live here and not there.
|
|
9
|
+
|
|
10
|
+
Deliberately library-level, not a spec extension: this module is the
|
|
11
|
+
format's research surface growing a task language, and it graduates to
|
|
12
|
+
`spec/extensions/` only when a second implementer wants it — the
|
|
13
|
+
repository's own rule, applied to itself.
|
|
14
|
+
|
|
15
|
+
Run it::
|
|
16
|
+
|
|
17
|
+
python -m openworldformat.eval examples/tasks/
|
|
18
|
+
|
|
19
|
+
or from code, to score an agent's ops as they happen::
|
|
20
|
+
|
|
21
|
+
result = run_task(manifest, entries, task, state_doc)
|
|
22
|
+
result["ok"] # every predicate held, every budget respected
|
|
23
|
+
result["failures"] # why not
|
|
24
|
+
result["metrics"] # edits, ops, entries, authors, span_ms, revision
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import (
|
|
35
|
+
WorldFormatError,
|
|
36
|
+
build_history,
|
|
37
|
+
fold_log,
|
|
38
|
+
fold_path,
|
|
39
|
+
fold_state,
|
|
40
|
+
parse_log_line,
|
|
41
|
+
parse_manifest,
|
|
42
|
+
read_package,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = ["run_task", "load_task_file", "entry_metrics", "main"]
|
|
46
|
+
|
|
47
|
+
#: Default tolerance for the `near` predicate — the outcomes precedent.
|
|
48
|
+
NEAR_TOLERANCE = 0.15
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _entity_by_name(state: dict, name):
|
|
52
|
+
return next((e for e in state["entities"] if e.get("name") == name), None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_num(v) -> bool:
|
|
56
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_predicate(predicate: dict, state: dict, values: dict) -> list:
|
|
60
|
+
"""One goal predicate against one fold. Returns its failures (empty =
|
|
61
|
+
held)."""
|
|
62
|
+
if "exists" in predicate:
|
|
63
|
+
name = predicate["exists"].get("entity")
|
|
64
|
+
if _entity_by_name(state, name) is None:
|
|
65
|
+
return [f"entity '{name}' does not exist"]
|
|
66
|
+
return []
|
|
67
|
+
if "gone" in predicate:
|
|
68
|
+
name = predicate["gone"].get("entity")
|
|
69
|
+
if _entity_by_name(state, name) is not None:
|
|
70
|
+
return [f"entity '{name}' still exists"]
|
|
71
|
+
return []
|
|
72
|
+
if "near" in predicate:
|
|
73
|
+
near = predicate["near"]
|
|
74
|
+
name = near.get("entity")
|
|
75
|
+
entity = _entity_by_name(state, name)
|
|
76
|
+
if entity is None:
|
|
77
|
+
return [f"entity '{name}' does not exist"]
|
|
78
|
+
transform = entity.get("transform")
|
|
79
|
+
position = transform.get("position") if isinstance(transform, dict) else None
|
|
80
|
+
if not isinstance(position, list) or len(position) != 3:
|
|
81
|
+
return [f"entity '{name}' has no position"]
|
|
82
|
+
tolerance = near.get("tolerance")
|
|
83
|
+
tolerance = NEAR_TOLERANCE if tolerance is None else tolerance
|
|
84
|
+
want = near.get("position")
|
|
85
|
+
distance = math.dist(position, want)
|
|
86
|
+
if distance > tolerance:
|
|
87
|
+
return [f"entity '{name}' at {position} is {distance:.3f} from {want} (tolerance {tolerance})"]
|
|
88
|
+
return []
|
|
89
|
+
if "field" in predicate:
|
|
90
|
+
field = predicate["field"]
|
|
91
|
+
name = field.get("name")
|
|
92
|
+
if name not in values:
|
|
93
|
+
return [f"field '{name}' has no value"]
|
|
94
|
+
v = values[name]
|
|
95
|
+
failures = []
|
|
96
|
+
if "equals" in field and v != field["equals"]:
|
|
97
|
+
failures.append(f"field '{name}' is {v!r}, expected {field['equals']!r}")
|
|
98
|
+
for comparator, ok in (("at_least", lambda a, b: a >= b), ("at_most", lambda a, b: a <= b)):
|
|
99
|
+
if comparator in field:
|
|
100
|
+
bound = field[comparator]
|
|
101
|
+
if not _is_num(v) or not _is_num(bound):
|
|
102
|
+
failures.append(f"field '{name}' is {v!r}, not comparable to {bound!r}")
|
|
103
|
+
elif not ok(v, bound):
|
|
104
|
+
failures.append(f"field '{name}' is {v!r}, expected {comparator.replace('_', ' ')} {bound!r}")
|
|
105
|
+
return failures
|
|
106
|
+
return [f"unknown predicate {json.dumps(predicate)}"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_budget(budget: dict, metrics: dict) -> list:
|
|
110
|
+
"""Budgets are the cost side of a task: solve it, but not by a million
|
|
111
|
+
edits. Each limit is inclusive."""
|
|
112
|
+
failures = []
|
|
113
|
+
for key, metric in (("max_edits", "edits"), ("max_ops", "ops"), ("max_entries", "entries")):
|
|
114
|
+
if key in budget and metrics[metric] > budget[key]:
|
|
115
|
+
failures.append(f"budget exceeded: {metrics[metric]} {metric}, max {budget[key]}")
|
|
116
|
+
return failures
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def entry_metrics(chain: list, applied_edits: int) -> dict:
|
|
120
|
+
"""The trajectory's shape: entries, ops, edits, authors, span, revision —
|
|
121
|
+
what task scoring and dataset tooling both want per recording. The
|
|
122
|
+
authors field is the audit trail's dimension: filter it on one name
|
|
123
|
+
and you have what that one author did, model or visitor."""
|
|
124
|
+
return {
|
|
125
|
+
"entries": len(chain),
|
|
126
|
+
"ops": sum(len(e.get("ops") or []) for e in chain),
|
|
127
|
+
"edits": applied_edits,
|
|
128
|
+
"revision": chain[-1].get("revision") if chain else None,
|
|
129
|
+
"authors": sorted({(e.get("author") or {}).get("name") for e in chain} - {None}),
|
|
130
|
+
"span_ms": (
|
|
131
|
+
chain[-1].get("timestamp_ms", 0) - chain[0].get("timestamp_ms", 0)
|
|
132
|
+
if len(chain) > 1 else 0
|
|
133
|
+
),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_task(manifest: dict, entries: list, task: dict, state_doc: dict | None = None) -> dict:
|
|
138
|
+
"""Score one trajectory against one task.
|
|
139
|
+
|
|
140
|
+
:param manifest: the base world document (parsed)
|
|
141
|
+
:param entries: the trajectory — parsed log entries, in order (the
|
|
142
|
+
agent's ops, or a recorded session's)
|
|
143
|
+
:param task: ``{tip?, budget?, goal: [...]}`` — predicates per the
|
|
144
|
+
module docstring; ``tip`` folds a branch instead of the head
|
|
145
|
+
:param state_doc: the world's parsed ``state.json``, if it has one
|
|
146
|
+
:return: ``{"ok", "failures", "metrics", "state", "values"}``
|
|
147
|
+
:raises WorldFormatError: when the task has no goal
|
|
148
|
+
"""
|
|
149
|
+
tip = task.get("tip")
|
|
150
|
+
if tip is not None:
|
|
151
|
+
state = fold_path(manifest, entries, tip)
|
|
152
|
+
by_id = build_history(entries)["by_id"]
|
|
153
|
+
chain = [by_id[i] for i in state["path"]]
|
|
154
|
+
else:
|
|
155
|
+
state = fold_log(manifest, entries)
|
|
156
|
+
chain = list(entries)
|
|
157
|
+
values = fold_state(state_doc or {}, chain)["values"]
|
|
158
|
+
metrics = entry_metrics(chain, state["applied_edits"])
|
|
159
|
+
|
|
160
|
+
goal = task.get("goal")
|
|
161
|
+
if not isinstance(goal, list) or not goal:
|
|
162
|
+
raise WorldFormatError("task needs a goal: a non-empty list of predicates")
|
|
163
|
+
failures = []
|
|
164
|
+
for predicate in goal:
|
|
165
|
+
failures += _check_predicate(predicate, state, values)
|
|
166
|
+
failures += _check_budget(task.get("budget") or {}, metrics)
|
|
167
|
+
return {"ok": not failures, "failures": failures, "metrics": metrics, "state": state, "values": values}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_task_file(path) -> tuple:
|
|
171
|
+
"""Load a task file: the task document, plus the world it names.
|
|
172
|
+
|
|
173
|
+
Paths resolve relative to the task file. ``world`` is a `.world`
|
|
174
|
+
package directory (``manifest.json``, ``ops.jsonl`` unless an agent
|
|
175
|
+
is expected to supply the log, ``state.json`` if the world declares
|
|
176
|
+
state).
|
|
177
|
+
|
|
178
|
+
:return: ``(manifest, entries, task, state_doc)``
|
|
179
|
+
"""
|
|
180
|
+
path = Path(path)
|
|
181
|
+
task = json.loads(path.read_text())
|
|
182
|
+
world = Path(task["world"])
|
|
183
|
+
if not world.is_absolute():
|
|
184
|
+
world = (path.parent / world).resolve()
|
|
185
|
+
manifest, entries, _head, _package = read_package(world)
|
|
186
|
+
state_file = world / (task.get("state") or "state.json")
|
|
187
|
+
state_doc = json.loads(state_file.read_text()) if state_file.exists() else None
|
|
188
|
+
return manifest, entries, task, state_doc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list | None = None) -> int:
|
|
192
|
+
"""The CLI: score task files (or directories of them). Exit 1 when any
|
|
193
|
+
task fails — the benchmark's teeth."""
|
|
194
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
195
|
+
if not argv:
|
|
196
|
+
print("usage: python -m openworldformat.eval TASK.json [TASK.json | DIR ...]", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
paths = []
|
|
199
|
+
for arg in argv:
|
|
200
|
+
p = Path(arg)
|
|
201
|
+
paths += sorted(p.glob("*.json")) if p.is_dir() else [p]
|
|
202
|
+
|
|
203
|
+
failed = 0
|
|
204
|
+
for p in paths:
|
|
205
|
+
manifest, entries, task, state_doc = load_task_file(p)
|
|
206
|
+
result = run_task(manifest, entries, task, state_doc)
|
|
207
|
+
name = task.get("task", p.stem)
|
|
208
|
+
m = result["metrics"]
|
|
209
|
+
if result["ok"]:
|
|
210
|
+
print(f"ok {name} ({m['edits']} edits, {m['ops']} ops, "
|
|
211
|
+
f"{len(m['authors'])} authors, revision {m['revision']})")
|
|
212
|
+
else:
|
|
213
|
+
failed += 1
|
|
214
|
+
print(f"FAIL {name}")
|
|
215
|
+
for failure in result["failures"]:
|
|
216
|
+
print(f" - {failure}")
|
|
217
|
+
total = len(paths)
|
|
218
|
+
if failed:
|
|
219
|
+
print(f"\n{failed} of {total} tasks failed")
|
|
220
|
+
return 1
|
|
221
|
+
print(f"\n{total} tasks passed")
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""The ext-physics reference implementation (extension version 0.1), in Python.
|
|
2
|
+
|
|
3
|
+
Spec: spec/extensions/physics.md. The extension declares bodies, not
|
|
4
|
+
simulation: how a world moves is an engine's business. What this module
|
|
5
|
+
provides is the contract's executable half —
|
|
6
|
+
|
|
7
|
+
- a deliberately minimal deterministic solver (spheres against
|
|
8
|
+
floors and axis-aligned statics, fixed-timestep semi-implicit
|
|
9
|
+
Euler) so the conformance outcome assertions run anywhere and in
|
|
10
|
+
CI. It is reference-grade, not production physics;
|
|
11
|
+
- trajectory write/fold — the op kind that lets a device with no
|
|
12
|
+
solver scrub recorded motion instead of simulating it;
|
|
13
|
+
- the outcome runner for conformance/outcomes/*.json.
|
|
14
|
+
|
|
15
|
+
The solver is deterministic by construction: no randomness, bodies in
|
|
16
|
+
document order, fixed dt, IEEE-754 doubles. Same engine and version,
|
|
17
|
+
same trajectory. Cross-engine, only the semantic contract holds —
|
|
18
|
+
never bit-exact, exactly as the core spec refuses.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
|
|
26
|
+
from . import classify_op
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EXTENSION_NAME",
|
|
30
|
+
"EXTENSION_VERSION",
|
|
31
|
+
"BOUNCE_SPEED",
|
|
32
|
+
"SLEEP_SPEED",
|
|
33
|
+
"collect_physics",
|
|
34
|
+
"simulate_physics",
|
|
35
|
+
"fold_trajectories",
|
|
36
|
+
"trajectory_op",
|
|
37
|
+
"run_outcomes",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: The extension this module implements.
|
|
41
|
+
EXTENSION_NAME = "ext-physics"
|
|
42
|
+
|
|
43
|
+
#: The extension version this module implements.
|
|
44
|
+
EXTENSION_VERSION = "0.1.0"
|
|
45
|
+
|
|
46
|
+
#: An impact at or above this speed (m/s) counts as a bounce.
|
|
47
|
+
BOUNCE_SPEED = 0.5
|
|
48
|
+
|
|
49
|
+
#: A dynamic body in contact moving slower than this (m/s) sleeps.
|
|
50
|
+
SLEEP_SPEED = 0.05
|
|
51
|
+
|
|
52
|
+
#: Default gravity when the environment block is absent (spec).
|
|
53
|
+
DEFAULT_GRAVITY = (0.0, -9.81, 0.0)
|
|
54
|
+
|
|
55
|
+
# Body-component defaults (spec: the extension page's field table).
|
|
56
|
+
DEFAULTS = {
|
|
57
|
+
"mass": 1.0,
|
|
58
|
+
"restitution": 0.5,
|
|
59
|
+
"friction": 0.5,
|
|
60
|
+
"gravity_scale": 1.0,
|
|
61
|
+
"linear_damping": 0.0,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_v3 = lambda x, y, z: [x, y, z]
|
|
65
|
+
_sub = lambda a, b: [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
66
|
+
_dot = lambda a, b: a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
67
|
+
_len = lambda a: math.sqrt(_dot(a, a))
|
|
68
|
+
_clamp = lambda v, lo, hi: min(hi, max(lo, v))
|
|
69
|
+
_is_num = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _component(entity: dict):
|
|
73
|
+
"""An entity's ``ext-physics`` component, or None."""
|
|
74
|
+
c = entity.get(EXTENSION_NAME)
|
|
75
|
+
return c if isinstance(c, dict) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _shape_extents(entity: dict):
|
|
79
|
+
"""The entity's parametric shape as a bounding box [x, y, z] extents."""
|
|
80
|
+
s = entity.get("shape")
|
|
81
|
+
if not isinstance(s, dict) or not s:
|
|
82
|
+
return None
|
|
83
|
+
e = next(iter(s.values()))
|
|
84
|
+
if not isinstance(e, dict):
|
|
85
|
+
return None
|
|
86
|
+
if "x" in e and "y" in e and "z" in e:
|
|
87
|
+
return _v3(e["x"], e["y"], e["z"]) # Cuboid, Wedge
|
|
88
|
+
if "radius" in e and "height" in e:
|
|
89
|
+
return _v3(2 * e["radius"], e["height"], 2 * e["radius"]) # Cylinder, Cone
|
|
90
|
+
if "radius" in e and "half_length" in e:
|
|
91
|
+
return _v3(2 * e["radius"], 2 * (e["half_length"] + e["radius"]), 2 * e["radius"]) # Capsule
|
|
92
|
+
if "radius" in e:
|
|
93
|
+
return _v3(2 * e["radius"], 2 * e["radius"], 2 * e["radius"]) # Sphere, Tetrahedron, Icosahedron
|
|
94
|
+
if "major_radius" in e and "minor_radius" in e:
|
|
95
|
+
w = 2 * (e["major_radius"] + e["minor_radius"]) # Torus, laid flat
|
|
96
|
+
return _v3(w, 2 * e["minor_radius"], w)
|
|
97
|
+
if "base_x" in e and "base_z" in e and "height" in e:
|
|
98
|
+
return _v3(e["base_x"], e["height"], e["base_z"]) # Pyramid
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _collider_of(entity: dict):
|
|
103
|
+
"""An entity's collider: ``{"kind": "sphere", "radius"}`` or
|
|
104
|
+
``{"kind": "box", "extents"}``, from the explicit collider when
|
|
105
|
+
present, else the parametric shape."""
|
|
106
|
+
comp = _component(entity)
|
|
107
|
+
c = comp.get("collider") if comp else None
|
|
108
|
+
if isinstance(c, dict):
|
|
109
|
+
if _is_num(c.get("sphere")):
|
|
110
|
+
return {"kind": "sphere", "radius": c["sphere"]}
|
|
111
|
+
if isinstance(c.get("cuboid"), list):
|
|
112
|
+
return {"kind": "box", "extents": list(c["cuboid"])}
|
|
113
|
+
extents = _shape_extents(entity)
|
|
114
|
+
if c == "shape" or c is None:
|
|
115
|
+
if extents:
|
|
116
|
+
shape = entity.get("shape")
|
|
117
|
+
if isinstance(shape, dict) and "Sphere" in shape:
|
|
118
|
+
return {"kind": "sphere", "radius": extents[0] / 2}
|
|
119
|
+
return {"kind": "box", "extents": extents}
|
|
120
|
+
return None
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def collect_physics(state: dict) -> dict:
|
|
125
|
+
"""Collect a world's physics: gravity, the dynamic bodies, and the
|
|
126
|
+
static colliders. Only entities carrying the component participate —
|
|
127
|
+
a body is declared, never inferred. Kinematic bodies are collected
|
|
128
|
+
for completeness; the reference treats them as static colliders (they
|
|
129
|
+
move by behaviors, which live outside this solver).
|
|
130
|
+
|
|
131
|
+
:param state: a manifest or a fold result (``{entities, environment}``)
|
|
132
|
+
:return: ``{"gravity", "dynamic", "kinematic", "statics"}``
|
|
133
|
+
"""
|
|
134
|
+
env = state.get("environment")
|
|
135
|
+
env = env if isinstance(env, dict) else {}
|
|
136
|
+
ext = env.get(EXTENSION_NAME)
|
|
137
|
+
ext = ext if isinstance(ext, dict) else {}
|
|
138
|
+
gravity = ext.get("gravity")
|
|
139
|
+
if gravity is None:
|
|
140
|
+
gravity = list(DEFAULT_GRAVITY)
|
|
141
|
+
|
|
142
|
+
dynamic: list = []
|
|
143
|
+
kinematic: list = []
|
|
144
|
+
statics: list = []
|
|
145
|
+
for entity in state.get("entities") or []:
|
|
146
|
+
c = _component(entity)
|
|
147
|
+
if c is None:
|
|
148
|
+
continue
|
|
149
|
+
transform = entity.get("transform")
|
|
150
|
+
pos = transform.get("position") if isinstance(transform, dict) else None
|
|
151
|
+
position = list(pos) if pos is not None else [0.0, 0.0, 0.0]
|
|
152
|
+
collider = _collider_of(entity)
|
|
153
|
+
if c.get("body") == "dynamic":
|
|
154
|
+
# Dynamics are spheres in the reference: the collider's radius, or
|
|
155
|
+
# the bounding sphere of whatever the entity declared.
|
|
156
|
+
extents = collider["extents"] if collider and collider["kind"] == "box" else None
|
|
157
|
+
if collider and collider["kind"] == "sphere":
|
|
158
|
+
radius = collider["radius"]
|
|
159
|
+
elif extents:
|
|
160
|
+
radius = _len([x / 2 for x in extents])
|
|
161
|
+
else:
|
|
162
|
+
radius = 0.5
|
|
163
|
+
dynamic.append({
|
|
164
|
+
"id": entity.get("id"),
|
|
165
|
+
"name": entity.get("name"),
|
|
166
|
+
"position": position,
|
|
167
|
+
"velocity": _v3(0.0, 0.0, 0.0),
|
|
168
|
+
"radius": radius,
|
|
169
|
+
"mass": c.get("mass") if c.get("mass") is not None else DEFAULTS["mass"],
|
|
170
|
+
"restitution": c.get("restitution") if c.get("restitution") is not None else DEFAULTS["restitution"],
|
|
171
|
+
"friction": c.get("friction") if c.get("friction") is not None else DEFAULTS["friction"],
|
|
172
|
+
"gravity_scale": c.get("gravity_scale") if c.get("gravity_scale") is not None else DEFAULTS["gravity_scale"],
|
|
173
|
+
"damping": c.get("linear_damping") if c.get("linear_damping") is not None else DEFAULTS["linear_damping"],
|
|
174
|
+
"asleep": False,
|
|
175
|
+
})
|
|
176
|
+
elif c.get("body") == "kinematic":
|
|
177
|
+
kinematic.append({"id": entity.get("id"), "name": entity.get("name")})
|
|
178
|
+
_push_static(statics, entity, position, collider)
|
|
179
|
+
else:
|
|
180
|
+
_push_static(statics, entity, position, collider)
|
|
181
|
+
return {"gravity": gravity, "dynamic": dynamic, "kinematic": kinematic, "statics": statics}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _push_static(statics: list, entity: dict, position: list, collider) -> None:
|
|
185
|
+
# A Plane is the floor at its y (the reference ignores its rotation);
|
|
186
|
+
# anything else is an axis-aligned box around its extents.
|
|
187
|
+
shape = entity.get("shape")
|
|
188
|
+
if isinstance(shape, dict) and "Plane" in shape and (collider is None or collider["kind"] != "sphere"):
|
|
189
|
+
statics.append({"id": entity.get("id"), "name": entity.get("name"), "kind": "floor", "y": position[1]})
|
|
190
|
+
return
|
|
191
|
+
if collider and collider["kind"] == "box":
|
|
192
|
+
extents = collider["extents"]
|
|
193
|
+
elif collider and collider["kind"] == "sphere":
|
|
194
|
+
extents = [2 * collider["radius"], 2 * collider["radius"], 2 * collider["radius"]]
|
|
195
|
+
else:
|
|
196
|
+
extents = _shape_extents(entity)
|
|
197
|
+
if not extents:
|
|
198
|
+
return
|
|
199
|
+
half = [x / 2 for x in extents]
|
|
200
|
+
statics.append({
|
|
201
|
+
"id": entity.get("id"),
|
|
202
|
+
"name": entity.get("name"),
|
|
203
|
+
"kind": "box",
|
|
204
|
+
"min": _v3(position[0] - half[0], position[1] - half[1], position[2] - half[2]),
|
|
205
|
+
"max": _v3(position[0] + half[0], position[1] + half[1], position[2] + half[2]),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_contact(body: dict, stat: dict) -> float:
|
|
210
|
+
"""Resolve one body against one static. Returns the impact speed, or -1."""
|
|
211
|
+
if stat["kind"] == "floor":
|
|
212
|
+
pen = stat["y"] + body["radius"] - body["position"][1]
|
|
213
|
+
if pen <= 0:
|
|
214
|
+
return -1
|
|
215
|
+
body["position"][1] = stat["y"] + body["radius"]
|
|
216
|
+
n = _v3(0.0, 1.0, 0.0)
|
|
217
|
+
else:
|
|
218
|
+
p = body["position"]
|
|
219
|
+
c = _v3(
|
|
220
|
+
_clamp(p[0], stat["min"][0], stat["max"][0]),
|
|
221
|
+
_clamp(p[1], stat["min"][1], stat["max"][1]),
|
|
222
|
+
_clamp(p[2], stat["min"][2], stat["max"][2]),
|
|
223
|
+
)
|
|
224
|
+
d = _sub(p, c)
|
|
225
|
+
dist = _len(d)
|
|
226
|
+
if dist >= body["radius"]:
|
|
227
|
+
return -1
|
|
228
|
+
n = [x / dist for x in d] if dist > 0 else _v3(0.0, 1.0, 0.0)
|
|
229
|
+
body["position"] = [c[0] + n[0] * body["radius"], c[1] + n[1] * body["radius"], c[2] + n[2] * body["radius"]]
|
|
230
|
+
vn = _dot(body["velocity"], n)
|
|
231
|
+
if vn >= 0:
|
|
232
|
+
return 0 # resting against it, no impact
|
|
233
|
+
# Reflect by restitution, damp tangentially by friction, one contact.
|
|
234
|
+
vt = _sub(body["velocity"], [x * vn for x in n])
|
|
235
|
+
restitution = -vn * body["restitution"]
|
|
236
|
+
keep = 1 - body["friction"]
|
|
237
|
+
body["velocity"] = _v3(
|
|
238
|
+
n[0] * restitution + vt[0] * keep,
|
|
239
|
+
n[1] * restitution + vt[1] * keep,
|
|
240
|
+
n[2] * restitution + vt[2] * keep,
|
|
241
|
+
)
|
|
242
|
+
return -vn
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def simulate_physics(state: dict, opts: dict | None = None) -> dict:
|
|
246
|
+
"""Simulate a world: deterministic, fixed-timestep, semi-implicit Euler.
|
|
247
|
+
Spheres against floors and axis-aligned statics, bodies in document
|
|
248
|
+
order, sleep on rest. Contacts are recorded on impact (normal speed
|
|
249
|
+
≥ SLEEP_SPEED); impacts at or above BOUNCE_SPEED are bounces.
|
|
250
|
+
|
|
251
|
+
:param state: a manifest or a fold result
|
|
252
|
+
:param opts: ``{until_s, dt_s, sample_dt_s}``
|
|
253
|
+
:return: ``{"samples", "contacts", "bounces", "resting", "settled_s"}``
|
|
254
|
+
"""
|
|
255
|
+
opts = opts or {}
|
|
256
|
+
dt = opts.get("dt_s")
|
|
257
|
+
dt = 1 / 120 if dt is None else dt
|
|
258
|
+
until = opts.get("until_s")
|
|
259
|
+
until = 5 if until is None else until
|
|
260
|
+
sample_dt = opts.get("sample_dt_s")
|
|
261
|
+
sample_dt = 0.1 if sample_dt is None else sample_dt
|
|
262
|
+
collected = collect_physics(state)
|
|
263
|
+
gravity, dynamic, statics = collected["gravity"], collected["dynamic"], collected["statics"]
|
|
264
|
+
|
|
265
|
+
samples: list = []
|
|
266
|
+
contacts: list = []
|
|
267
|
+
bounces: list = []
|
|
268
|
+
t = 0.0
|
|
269
|
+
next_sample = 0.0
|
|
270
|
+
all_asleep_at = None
|
|
271
|
+
|
|
272
|
+
def sample(at: float) -> None:
|
|
273
|
+
samples.append({
|
|
274
|
+
"t_s": round(at, 4),
|
|
275
|
+
"bodies": {b["name"]: list(b["position"]) for b in dynamic},
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
sample(t)
|
|
279
|
+
|
|
280
|
+
while t < until and all_asleep_at is None:
|
|
281
|
+
for body in dynamic:
|
|
282
|
+
if body["asleep"]:
|
|
283
|
+
continue
|
|
284
|
+
# Integrate, then resolve against every static in document order.
|
|
285
|
+
g = [x * body["gravity_scale"] for x in gravity]
|
|
286
|
+
damp = body["damping"] * dt
|
|
287
|
+
v = body["velocity"]
|
|
288
|
+
body["velocity"] = _v3(
|
|
289
|
+
v[0] + (g[0] - damp * v[0]) * dt,
|
|
290
|
+
v[1] + (g[1] - damp * v[1]) * dt,
|
|
291
|
+
v[2] + (g[2] - damp * v[2]) * dt,
|
|
292
|
+
)
|
|
293
|
+
p = body["position"]
|
|
294
|
+
v = body["velocity"]
|
|
295
|
+
body["position"] = _v3(p[0] + v[0] * dt, p[1] + v[1] * dt, p[2] + v[2] * dt)
|
|
296
|
+
touching = False
|
|
297
|
+
for stat in statics:
|
|
298
|
+
impact = _resolve_contact(body, stat)
|
|
299
|
+
if impact < 0:
|
|
300
|
+
continue
|
|
301
|
+
touching = True
|
|
302
|
+
if impact >= SLEEP_SPEED:
|
|
303
|
+
contacts.append({
|
|
304
|
+
"t_s": round(t, 4),
|
|
305
|
+
"body": body["name"],
|
|
306
|
+
"other": stat["name"],
|
|
307
|
+
"position": list(body["position"]),
|
|
308
|
+
"normal_speed": round(impact, 4),
|
|
309
|
+
})
|
|
310
|
+
if impact >= BOUNCE_SPEED:
|
|
311
|
+
bounces.append({"body": body["name"], "other": stat["name"], "t_s": round(t, 4)})
|
|
312
|
+
if touching and _len(body["velocity"]) < SLEEP_SPEED:
|
|
313
|
+
body["velocity"] = _v3(0.0, 0.0, 0.0)
|
|
314
|
+
body["asleep"] = True
|
|
315
|
+
t += dt
|
|
316
|
+
if dynamic and all(b["asleep"] for b in dynamic) and all_asleep_at is None:
|
|
317
|
+
all_asleep_at = t
|
|
318
|
+
if t >= next_sample:
|
|
319
|
+
sample(t)
|
|
320
|
+
next_sample += sample_dt
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
"samples": samples,
|
|
324
|
+
"contacts": contacts,
|
|
325
|
+
"bounces": bounces,
|
|
326
|
+
"resting": {b["name"]: list(b["position"]) for b in dynamic},
|
|
327
|
+
"settled_s": round(all_asleep_at if all_asleep_at is not None else t, 4),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def fold_trajectories(entries: list) -> dict:
|
|
332
|
+
"""Fold the ``ext-physics`` trajectory ops out of a log: the sampled
|
|
333
|
+
transforms of dynamic bodies, for playback without a solver. The op
|
|
334
|
+
folds to nothing for the document; this reads what it carried.
|
|
335
|
+
|
|
336
|
+
:param entries: parsed log entries, in order
|
|
337
|
+
:return: ``{"bodies": {name: [{"t_s", "position"}]}, "span_s": float}``
|
|
338
|
+
"""
|
|
339
|
+
bodies: dict = {}
|
|
340
|
+
span = 0.0
|
|
341
|
+
for entry in entries:
|
|
342
|
+
classified = entry.get("classified")
|
|
343
|
+
if classified is None:
|
|
344
|
+
classified = [classify_op(op) for op in entry.get("ops") or []]
|
|
345
|
+
for c in classified:
|
|
346
|
+
if c["kind"] != "extension" or c["name"] != EXTENSION_NAME:
|
|
347
|
+
continue
|
|
348
|
+
value = c["value"] or {}
|
|
349
|
+
t_s, sampled = value.get("t_s"), value.get("bodies")
|
|
350
|
+
if not isinstance(t_s, list) or not isinstance(sampled, dict):
|
|
351
|
+
continue
|
|
352
|
+
for name, positions in sampled.items():
|
|
353
|
+
if not isinstance(positions, list):
|
|
354
|
+
continue
|
|
355
|
+
track = bodies.setdefault(name, [])
|
|
356
|
+
for i in range(min(len(positions), len(t_s))):
|
|
357
|
+
track.append({"t_s": t_s[i], "position": positions[i]})
|
|
358
|
+
if t_s[i] > span:
|
|
359
|
+
span = t_s[i]
|
|
360
|
+
return {"bodies": bodies, "span_s": span}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def trajectory_op(sim: dict) -> dict:
|
|
364
|
+
"""Build an ``ext-physics`` trajectory op from simulation samples (or any
|
|
365
|
+
per-body sample lists): the writer's half of playback. Sample at or
|
|
366
|
+
below 10 Hz, per the spec.
|
|
367
|
+
|
|
368
|
+
:param sim: a :func:`simulate_physics` result
|
|
369
|
+
:return: the op — put it in an entry's ops array
|
|
370
|
+
"""
|
|
371
|
+
t_s = [s["t_s"] for s in sim["samples"]]
|
|
372
|
+
bodies: dict = {}
|
|
373
|
+
for s in sim["samples"]:
|
|
374
|
+
for name, p in s["bodies"].items():
|
|
375
|
+
bodies.setdefault(name, []).append(p)
|
|
376
|
+
return {EXTENSION_NAME: {"t_s": t_s, "bodies": bodies}}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def run_outcomes(manifest: dict, outcomes: dict) -> dict:
|
|
380
|
+
"""Run a conformance outcomes document against a world: simulate, then
|
|
381
|
+
check every assertion. This is the extension's conformance — outcome
|
|
382
|
+
predicates, not pixels.
|
|
383
|
+
|
|
384
|
+
:param manifest: the world (parsed)
|
|
385
|
+
:param outcomes: ``{simulate_s, options?, expect: [...]}``
|
|
386
|
+
:return: ``{"ok", "failures", "simulation"}``
|
|
387
|
+
"""
|
|
388
|
+
opts = {"until_s": outcomes.get("simulate_s")}
|
|
389
|
+
opts.update(outcomes.get("options") or {})
|
|
390
|
+
sim = simulate_physics(manifest, opts)
|
|
391
|
+
failures: list = []
|
|
392
|
+
|
|
393
|
+
for assertion in outcomes.get("expect") or []:
|
|
394
|
+
contact = assertion.get("contact")
|
|
395
|
+
if isinstance(contact, list):
|
|
396
|
+
a, b = contact[0], contact[1]
|
|
397
|
+
limit = assertion.get("within_s")
|
|
398
|
+
limit = math.inf if limit is None else limit
|
|
399
|
+
hit = any(
|
|
400
|
+
c["t_s"] <= limit and ((c["body"] == a and c["other"] == b) or (c["body"] == b and c["other"] == a))
|
|
401
|
+
for c in sim["contacts"]
|
|
402
|
+
)
|
|
403
|
+
if not hit:
|
|
404
|
+
failures.append(f"contact {a}/{b} within {limit}s never happened")
|
|
405
|
+
elif "rest" in assertion:
|
|
406
|
+
rest = assertion["rest"]
|
|
407
|
+
body, near = rest.get("body"), rest.get("near")
|
|
408
|
+
tolerance = rest.get("tolerance")
|
|
409
|
+
tolerance = 0.15 if tolerance is None else tolerance
|
|
410
|
+
at = sim["resting"].get(body)
|
|
411
|
+
ok = at is not None and _len(_sub(at, near)) <= tolerance
|
|
412
|
+
if not ok:
|
|
413
|
+
failures.append(
|
|
414
|
+
f"{body} rests at {json.dumps(at)}, not within {tolerance} of {json.dumps(near)}"
|
|
415
|
+
)
|
|
416
|
+
elif "bounces" in assertion:
|
|
417
|
+
want = assertion["bounces"]
|
|
418
|
+
body, minimum = want.get("body"), want.get("min")
|
|
419
|
+
count = sum(1 for x in sim["bounces"] if x["body"] == body)
|
|
420
|
+
if count < minimum:
|
|
421
|
+
failures.append(f"{body} bounced {count} times, expected at least {minimum}")
|
|
422
|
+
else:
|
|
423
|
+
failures.append(f"unknown assertion {json.dumps(assertion)}")
|
|
424
|
+
return {"ok": not failures, "failures": failures, "simulation": sim}
|