kulisse 1.0.1__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .coverage
7
+ coverage.xml
kulisse-1.0.1/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2023 Johannes Roos <jhnnsrs@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
kulisse-1.0.1/PKG-INFO ADDED
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.5
2
+ Name: kulisse
3
+ Version: 1.0.1
4
+ Summary: device and scene models for arkitekt: self-contained binary glTF with a joint table, authored from URDF and checked before upload
5
+ Project-URL: Repository, https://github.com/arkitektio/kulisse
6
+ Project-URL: Specification, https://github.com/arkitektio/kulisse/blob/main/KULISSE.md
7
+ Author-email: jhnnsrs <jhnnsrs@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: complete
12
+ Requires-Dist: numpy>=1.26; extra == 'complete'
13
+ Requires-Dist: pycollada>=0.8; extra == 'complete'
14
+ Requires-Dist: trimesh>=4; extra == 'complete'
15
+ Requires-Dist: xacro>=2; extra == 'complete'
16
+ Provides-Extra: urdf
17
+ Requires-Dist: numpy>=1.26; extra == 'urdf'
18
+ Requires-Dist: pycollada>=0.8; extra == 'urdf'
19
+ Requires-Dist: trimesh>=4; extra == 'urdf'
20
+ Requires-Dist: xacro>=2; extra == 'urdf'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # kulisse
24
+
25
+ Device and scene models for arkitekt. A model is one **self-contained binary glTF** (GLB) with
26
+ an optional joint table: what a viewer draws as a `<Model>` or drives as a `<Robot>`, what an
27
+ app registers as an asset, and what a server keeps in a `KulisseStore`.
28
+
29
+ kulisse is strictly the authoring side, like fabriks for meshes: no network code, no storage
30
+ opinions. The **format** is the contract ([`KULISSE.md`](KULISSE.md)), and the server reads it
31
+ with its own implementation (`arkitekt_datalayer.kulisse`), never by importing this package.
32
+
33
+ ```bash
34
+ pip install kulisse # check, reference, snippet: standard library only
35
+ pip install "kulisse[urdf]" # import URDF / Xacro (numpy, trimesh, pycollada, xacro)
36
+
37
+ kulisse import robot.urdf.xacro --out robot.glb --package my_robot=./share/my_robot
38
+ kulisse check robot.glb
39
+ kulisse joints robot.glb joints.json --out robot.glb
40
+ kulisse snippet robot.glb --name robot
41
+ ```
42
+
43
+ ```python
44
+ import kulisse
45
+
46
+ report = kulisse.check("robot.glb") # ModelReport, or ModelRefused(code, message)
47
+ kulisse.reference("robot.glb") # "sha256:…", what a blok names as for_model
48
+ kulisse.snippet("robot.glb") # '<Robot src="@self.assets.robot" … />'
49
+ ```
@@ -0,0 +1,27 @@
1
+ # kulisse
2
+
3
+ Device and scene models for arkitekt. A model is one **self-contained binary glTF** (GLB) with
4
+ an optional joint table: what a viewer draws as a `<Model>` or drives as a `<Robot>`, what an
5
+ app registers as an asset, and what a server keeps in a `KulisseStore`.
6
+
7
+ kulisse is strictly the authoring side, like fabriks for meshes: no network code, no storage
8
+ opinions. The **format** is the contract ([`KULISSE.md`](KULISSE.md)), and the server reads it
9
+ with its own implementation (`arkitekt_datalayer.kulisse`), never by importing this package.
10
+
11
+ ```bash
12
+ pip install kulisse # check, reference, snippet: standard library only
13
+ pip install "kulisse[urdf]" # import URDF / Xacro (numpy, trimesh, pycollada, xacro)
14
+
15
+ kulisse import robot.urdf.xacro --out robot.glb --package my_robot=./share/my_robot
16
+ kulisse check robot.glb
17
+ kulisse joints robot.glb joints.json --out robot.glb
18
+ kulisse snippet robot.glb --name robot
19
+ ```
20
+
21
+ ```python
22
+ import kulisse
23
+
24
+ report = kulisse.check("robot.glb") # ModelReport, or ModelRefused(code, message)
25
+ kulisse.reference("robot.glb") # "sha256:…", what a blok names as for_model
26
+ kulisse.snippet("robot.glb") # '<Robot src="@self.assets.robot" … />'
27
+ ```
@@ -0,0 +1,56 @@
1
+ """kulisse: authoring device models for Arkitekt's 3D view.
2
+
3
+ A model is a plain GLB. This package turns a URDF or Xacro into one, checks any GLB
4
+ against the rules a model is held to, and writes the blok snippet that shows it. It
5
+ uploads nothing itself: an app registers the file as an asset
6
+ (``app.register_asset("scope", "scope.glb")``), names it in a blok
7
+ (``@self.assets.scope``) and uploads it when it runs.
8
+
9
+ Installed with ``pip install kulisse`` (``kulisse[urdf]`` to import URDF/Xacro); also a command,
10
+ ``kulisse``. The wire format both sides implement is ``KULISSE.md``.
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ from .urdf import UrdfImportError, import_urdf, with_joints
16
+ from .validate import ModelPolicy, ModelRefused, ModelReport, model_ref, validate_model
17
+
18
+ __all__ = [
19
+ "ModelPolicy",
20
+ "ModelRefused",
21
+ "ModelReport",
22
+ "UrdfImportError",
23
+ "check",
24
+ "import_urdf",
25
+ "model_ref",
26
+ "reference",
27
+ "snippet",
28
+ "validate_model",
29
+ "with_joints",
30
+ ]
31
+
32
+
33
+ def reference(path: str | Path) -> str:
34
+ """The reference of the model file at ``path``: the hash a blok decorating it names as ``for_model``."""
35
+ return model_ref(Path(path).read_bytes())
36
+
37
+
38
+ def check(path: str | Path, policy: ModelPolicy | None = None) -> ModelReport:
39
+ """Hold the model file at ``path`` to the rules; what it contains, or ``ModelRefused``."""
40
+ data = Path(path).read_bytes()
41
+ return validate_model(data, model_ref(data), policy)
42
+
43
+
44
+ def snippet(path: str | Path, name: str | None = None) -> str:
45
+ """The blok component that places the model at ``path``, registered as the asset ``name``.
46
+
47
+ The name is the file's, without its extension, when none is given: what the app
48
+ passes to ``app.register_asset``.
49
+ """
50
+ name = name or Path(path).stem
51
+ if not name.isidentifier():
52
+ raise ValueError(f"an asset's name is an identifier: {name!r} is not one, pass a name")
53
+ report = check(path)
54
+ if report.joints:
55
+ return f'<Robot src="@self.assets.{name}" joints="@self.State.joints" />'
56
+ return f'<Model src="@self.assets.{name}" />'
@@ -0,0 +1,100 @@
1
+ """The ``kulisse`` command: import, check, joints, snippet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from dataclasses import fields
9
+ from pathlib import Path
10
+
11
+ from . import check, snippet
12
+ from .urdf import UrdfImportError, import_urdf, with_joints
13
+ from .validate import ModelPolicy, ModelRefused, model_ref
14
+
15
+
16
+ def _pairs(values: list[str], separator: str, what: str) -> dict[str, str]:
17
+ pairs: dict[str, str] = {}
18
+ for value in values:
19
+ name, found, rest = value.partition(separator)
20
+ if not found or not name:
21
+ raise SystemExit(f"{what} is written name{separator}value, not {value!r}")
22
+ pairs[name] = rest
23
+ return pairs
24
+
25
+
26
+ def _policy(path: str | None) -> ModelPolicy | None:
27
+ """A policy read from a JSON file, as ``modelPolicy`` returns it (camelCase or snake_case keys)."""
28
+ if path is None:
29
+ return None
30
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
31
+ known = {field.name for field in fields(ModelPolicy)}
32
+ values: dict[str, object] = {}
33
+ for key, value in raw.items():
34
+ name = "".join("_" + letter.lower() if letter.isupper() else letter for letter in key)
35
+ if name not in known:
36
+ raise SystemExit(f"{path}: unknown policy field {key!r}")
37
+ values[name] = frozenset(value) if name == "extensions" else int(value)
38
+ return ModelPolicy(**values) # type: ignore[arg-type]
39
+
40
+
41
+ def _report(path: Path, policy: ModelPolicy | None) -> None:
42
+ report = check(path, policy)
43
+ print(f"{path} {report.ref}")
44
+ stats = report.stats
45
+ print(f" {report.size} bytes, {stats.nodes} nodes, {stats.triangles} triangles, {stats.textures} textures, {stats.animations} clips, {len(report.joints)} joints")
46
+ for warning in report.warnings:
47
+ print(f" warning: {warning}")
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ """Run the ``kulisse`` command; the exit code is 1 when a model or an import is refused."""
52
+ parser = argparse.ArgumentParser(prog="kulisse", description="Author device models for Arkitekt's 3D view.")
53
+ commands = parser.add_subparsers(dest="command", required=True)
54
+
55
+ importer = commands.add_parser("import", help="convert a URDF or Xacro file into a GLB model")
56
+ importer.add_argument("urdf", type=Path)
57
+ importer.add_argument("--out", type=Path, required=True, help="the GLB file to write")
58
+ importer.add_argument("--package", action="append", default=[], metavar="NAME=DIR", help="where a package:// the file refers to is on disk")
59
+ importer.add_argument("--arg", action="append", default=[], metavar="NAME:=VALUE", help="a Xacro argument")
60
+
61
+ checker = commands.add_parser("check", help="hold a GLB to the rules a model is held to")
62
+ checker.add_argument("glb", type=Path)
63
+ checker.add_argument("--policy", help="a JSON file with an organization's policy; the deployment defaults otherwise")
64
+
65
+ jointer = commands.add_parser("joints", help="write a joint table into a GLB that was not imported from URDF")
66
+ jointer.add_argument("glb", type=Path)
67
+ jointer.add_argument("table", type=Path, help="a JSON file: a list of joints as extras.arkitekt.joints holds them")
68
+ jointer.add_argument("--out", type=Path, required=True)
69
+
70
+ snipper = commands.add_parser("snippet", help="print the blok component that references a model")
71
+ snipper.add_argument("glb", type=Path)
72
+ snipper.add_argument("--name", default=None, help="the name the app registers the file under; the file's name without its extension by default")
73
+
74
+ args = parser.parse_args(argv)
75
+ try:
76
+ if args.command == "import":
77
+ data = import_urdf(args.urdf, packages={name: Path(root) for name, root in _pairs(args.package, "=", "--package").items()}, arguments=_pairs(args.arg, ":=", "--arg"))
78
+ args.out.write_bytes(data)
79
+ _report(args.out, None)
80
+ print(f"wrote {args.out} {model_ref(data)}")
81
+ elif args.command == "check":
82
+ _report(args.glb, _policy(args.policy))
83
+ elif args.command == "joints":
84
+ data = with_joints(args.glb.read_bytes(), json.loads(args.table.read_text(encoding="utf-8")))
85
+ args.out.write_bytes(data)
86
+ _report(args.out, None)
87
+ print(f"wrote {args.out} {model_ref(data)}")
88
+ else:
89
+ print(snippet(args.glb, args.name))
90
+ except ModelRefused as refusal:
91
+ print(f"refused ({refusal.code}): {refusal.message}", file=sys.stderr)
92
+ return 1
93
+ except (UrdfImportError, ValueError, OSError) as error:
94
+ print(f"error: {error}", file=sys.stderr)
95
+ return 1
96
+ return 0
97
+
98
+
99
+ if __name__ == "__main__": # pragma: no cover
100
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,394 @@
1
+ """URDF and Xacro to a model: a GLB whose nodes are the robot's links and joints.
2
+
3
+ One node per link; for each joint that moves, a pivot node at the joint's origin, named
4
+ after the joint, with the child link beneath it; a fixed joint is plain parent and child.
5
+ The joints are written into ``extras.arkitekt`` with URDF's own semantics, so nothing is
6
+ lost on the way. Collision, inertial and simulator tags are dropped.
7
+
8
+ Identical input must give identical bytes, or the same robot ends up as two models. The
9
+ GLB is therefore written here, in document order, with nothing in it that depends on the
10
+ time, the machine or a dictionary's mood.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import math
18
+ import re
19
+ import struct
20
+ import xml.etree.ElementTree as ET
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any, cast
24
+ from xml.dom.minidom import Document
25
+
26
+ if TYPE_CHECKING:
27
+ # The urdf extra brings numpy; the rest of kulisse must import without it.
28
+ import numpy as np
29
+
30
+ GENERATOR = "kulisse 1"
31
+ """Written into ``asset.generator``. Changes only when the bytes this module writes change."""
32
+
33
+ MOVING = ("revolute", "continuous", "prismatic")
34
+
35
+
36
+ class UrdfImportError(ValueError):
37
+ """A URDF this importer cannot turn into a model, with the reason."""
38
+
39
+
40
+ def _floats(text: str | None, count: int, default: tuple[float, ...]) -> tuple[float, ...]:
41
+ if text is None:
42
+ return default
43
+ values = tuple(float(part) for part in text.split())
44
+ if len(values) != count:
45
+ raise UrdfImportError(f"expected {count} numbers, got {text!r}")
46
+ return values
47
+
48
+
49
+ def _quaternion(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
50
+ """URDF's fixed-axis roll, pitch, yaw as a glTF quaternion (x, y, z, w)."""
51
+ cr, sr = math.cos(roll / 2), math.sin(roll / 2)
52
+ cp, sp = math.cos(pitch / 2), math.sin(pitch / 2)
53
+ cy, sy = math.cos(yaw / 2), math.sin(yaw / 2)
54
+ return (
55
+ sr * cp * cy - cr * sp * sy,
56
+ cr * sp * cy + sr * cp * sy,
57
+ cr * cp * sy - sr * sp * cy,
58
+ cr * cp * cy + sr * sp * sy,
59
+ )
60
+
61
+
62
+ def _multiply(a: tuple[float, ...], b: tuple[float, ...]) -> tuple[float, float, float, float]:
63
+ ax, ay, az, aw = a
64
+ bx, by, bz, bw = b
65
+ return (
66
+ aw * bx + ax * bw + ay * bz - az * by,
67
+ aw * by - ax * bz + ay * bw + az * bx,
68
+ aw * bz + ax * by - ay * bx + az * bw,
69
+ aw * bw - ax * bx - ay * by - az * bz,
70
+ )
71
+
72
+
73
+ def _clean(values: tuple[float, ...]) -> list[float]:
74
+ """Numbers as they are written: rounded past float noise, and never a negative zero."""
75
+ return [0.0 if abs(value) < 1e-12 else round(value, 9) for value in values]
76
+
77
+
78
+ @dataclass
79
+ class Origin:
80
+ """A URDF ``<origin>``: translation and roll/pitch/yaw."""
81
+
82
+ xyz: tuple[float, ...] = (0.0, 0.0, 0.0)
83
+ rpy: tuple[float, ...] = (0.0, 0.0, 0.0)
84
+
85
+ @classmethod
86
+ def parse(cls, element: ET.Element | None) -> Origin:
87
+ """The origin an element states, or the identity when it states none."""
88
+ if element is None:
89
+ return cls()
90
+ return cls(_floats(element.get("xyz"), 3, (0.0, 0.0, 0.0)), _floats(element.get("rpy"), 3, (0.0, 0.0, 0.0)))
91
+
92
+ def node(self) -> dict[str, Any]:
93
+ """The transform as glTF node properties, left out where it is the identity."""
94
+ out: dict[str, Any] = {}
95
+ if any(self.xyz):
96
+ out["translation"] = _clean(self.xyz)
97
+ if any(self.rpy):
98
+ out["rotation"] = _clean(_quaternion(*self.rpy))
99
+ return out
100
+
101
+
102
+ @dataclass
103
+ class Builder:
104
+ """A glTF document and its binary chunk, grown in the order things are added."""
105
+
106
+ nodes: list[dict[str, Any]] = field(default_factory=list)
107
+ meshes: list[dict[str, Any]] = field(default_factory=list)
108
+ materials: list[dict[str, Any]] = field(default_factory=list)
109
+ accessors: list[dict[str, Any]] = field(default_factory=list)
110
+ views: list[dict[str, Any]] = field(default_factory=list)
111
+ images: list[dict[str, Any]] = field(default_factory=list)
112
+ textures: list[dict[str, Any]] = field(default_factory=list)
113
+ binary: bytearray = field(default_factory=bytearray)
114
+ material_index: dict[str, int] = field(default_factory=dict)
115
+
116
+ def view(self, data: bytes, target: int | None = None) -> int:
117
+ """Append ``data`` to the binary chunk as a buffer view; its index."""
118
+ self.binary.extend(b"\0" * (-len(self.binary) % 4))
119
+ view: dict[str, Any] = {"buffer": 0, "byteOffset": len(self.binary), "byteLength": len(data)}
120
+ if target is not None:
121
+ view["target"] = target
122
+ self.binary.extend(data)
123
+ self.views.append(view)
124
+ return len(self.views) - 1
125
+
126
+ def accessor(self, array: np.ndarray, kind: str, component: int, target: int, bounds: bool = False) -> int:
127
+ """An accessor over ``array``, with min/max when ``bounds``; its index."""
128
+ accessor: dict[str, Any] = {
129
+ "bufferView": self.view(array.tobytes(), target),
130
+ "componentType": component,
131
+ "count": int(array.shape[0]),
132
+ "type": kind,
133
+ }
134
+ if bounds:
135
+ accessor["min"] = [float(value) for value in array.min(axis=0)]
136
+ accessor["max"] = [float(value) for value in array.max(axis=0)]
137
+ self.accessors.append(accessor)
138
+ return len(self.accessors) - 1
139
+
140
+ def mesh(self, vertices: np.ndarray, normals: np.ndarray, faces: np.ndarray, material: int | None) -> int:
141
+ """One triangle-list primitive; the mesh index."""
142
+ primitive: dict[str, Any] = {
143
+ "attributes": {
144
+ "POSITION": self.accessor(vertices.astype("<f4"), "VEC3", 5126, 34962, bounds=True),
145
+ "NORMAL": self.accessor(normals.astype("<f4"), "VEC3", 5126, 34962),
146
+ },
147
+ "indices": self.accessor(faces.astype("<u4").reshape(-1), "SCALAR", 5125, 34963),
148
+ }
149
+ if material is not None:
150
+ primitive["material"] = material
151
+ self.meshes.append({"primitives": [primitive]})
152
+ return len(self.meshes) - 1
153
+
154
+ def material(self, key: str, rgba: tuple[float, ...] | None, texture: Path | None) -> int:
155
+ """The material for ``key``, made once; its index."""
156
+ known = self.material_index.get(key)
157
+ if known is not None:
158
+ return known
159
+ pbr: dict[str, Any] = {"metallicFactor": 0.1, "roughnessFactor": 0.7}
160
+ if rgba is not None:
161
+ pbr["baseColorFactor"] = _clean(rgba)
162
+ if texture is not None:
163
+ data = texture.read_bytes()
164
+ mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}.get(texture.suffix.lower())
165
+ if mime is None:
166
+ raise UrdfImportError(f"texture {texture.name} is not a PNG, JPEG or WebP")
167
+ self.images.append({"bufferView": self.view(data), "mimeType": mime})
168
+ self.textures.append({"source": len(self.images) - 1})
169
+ pbr["baseColorTexture"] = {"index": len(self.textures) - 1}
170
+ material: dict[str, Any] = {"pbrMetallicRoughness": pbr}
171
+ if rgba is not None and rgba[3] < 1:
172
+ material["alphaMode"] = "BLEND"
173
+ self.materials.append(material)
174
+ self.material_index[key] = len(self.materials) - 1
175
+ return self.material_index[key]
176
+
177
+ def node(self, properties: dict[str, Any]) -> int:
178
+ """Append a node; its index."""
179
+ self.nodes.append(properties)
180
+ return len(self.nodes) - 1
181
+
182
+ def glb(self, root: int, extras: dict[str, Any]) -> bytes:
183
+ """The finished GLB with ``root`` as the scene and ``extras`` on it."""
184
+ document: dict[str, Any] = {
185
+ "asset": {"version": "2.0", "generator": GENERATOR},
186
+ "scene": 0,
187
+ "scenes": [{"nodes": [root]}],
188
+ "nodes": self.nodes,
189
+ }
190
+ for key, value in (
191
+ ("meshes", self.meshes),
192
+ ("materials", self.materials),
193
+ ("textures", self.textures),
194
+ ("images", self.images),
195
+ ("accessors", self.accessors),
196
+ ("bufferViews", self.views),
197
+ ):
198
+ if value:
199
+ document[key] = value
200
+ binary = bytes(self.binary) + b"\0" * (-len(self.binary) % 4)
201
+ if binary:
202
+ document["buffers"] = [{"byteLength": len(binary)}]
203
+ document["extras"] = extras
204
+ payload = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
205
+ payload += b" " * (-len(payload) % 4)
206
+ chunks = struct.pack("<II", len(payload), 0x4E4F534A) + payload
207
+ if binary:
208
+ chunks += struct.pack("<II", len(binary), 0x004E4942) + binary
209
+ return struct.pack("<III", 0x46546C67, 2, 12 + len(chunks)) + chunks
210
+
211
+
212
+ def expand(path: Path, arguments: dict[str, str] | None = None) -> str:
213
+ """The URDF text of ``path``, with Xacro expanded when it is a Xacro file."""
214
+ if path.suffix != ".xacro" and not arguments:
215
+ return path.read_text(encoding="utf-8")
216
+ try:
217
+ import xacro
218
+ except ImportError as error: # pragma: no cover - the extra installs it
219
+ raise UrdfImportError("expanding Xacro needs the xacro package: pip install 'kulisse[urdf]'") from error
220
+ # xacro's own annotation names a file object; what it returns is a minidom Document.
221
+ document = cast(Document, xacro.process_file(str(path), mappings=dict(arguments or {})))
222
+ return document.toxml()
223
+
224
+
225
+ def _resolve(filename: str, base: Path, packages: dict[str, Path]) -> Path:
226
+ match = re.fullmatch(r"package://([^/]+)/(.*)", filename)
227
+ if match:
228
+ package, rest = match.groups()
229
+ if package not in packages:
230
+ raise UrdfImportError(f"{filename} names the package {package!r}; say where it is with --package {package}=<directory>")
231
+ return packages[package] / rest
232
+ return (base / filename.removeprefix("file://")).resolve()
233
+
234
+
235
+ def _geometry(element: ET.Element, base: Path, packages: dict[str, Path]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
236
+ """Vertices, normals and faces of one visual's geometry, in the visual's frame."""
237
+ import numpy as np
238
+ import trimesh
239
+
240
+ shape = next(iter(element), None)
241
+ if shape is None:
242
+ raise UrdfImportError("a visual has an empty geometry")
243
+ if shape.tag == "box":
244
+ mesh = trimesh.creation.box(extents=_floats(shape.get("size"), 3, (1.0, 1.0, 1.0)))
245
+ elif shape.tag == "cylinder":
246
+ mesh = trimesh.creation.cylinder(radius=float(shape.get("radius", 1)), height=float(shape.get("length", 1)), sections=32)
247
+ elif shape.tag == "sphere":
248
+ mesh = trimesh.creation.icosphere(subdivisions=3, radius=float(shape.get("radius", 1)))
249
+ elif shape.tag == "mesh":
250
+ source = _resolve(shape.get("filename", ""), base, packages)
251
+ if not source.is_file():
252
+ raise UrdfImportError(f"mesh file {source} does not exist")
253
+ mesh = cast(trimesh.Trimesh, trimesh.load(source, force="mesh", process=False))
254
+ scale = shape.get("scale")
255
+ if scale is not None:
256
+ mesh.apply_scale(_floats(scale, 3, (1.0, 1.0, 1.0)))
257
+ else:
258
+ raise UrdfImportError(f"geometry <{shape.tag}> is not supported")
259
+ return np.asarray(mesh.vertices), np.asarray(mesh.vertex_normals), np.asarray(mesh.faces)
260
+
261
+
262
+ def import_urdf(
263
+ path: str | Path,
264
+ *,
265
+ packages: dict[str, str | Path] | None = None,
266
+ arguments: dict[str, str] | None = None,
267
+ ) -> bytes:
268
+ """Convert a URDF or Xacro file into the bytes of a GLB model.
269
+
270
+ Args:
271
+ path: The ``.urdf`` or ``.urdf.xacro`` file.
272
+ packages: Where each ``package://<name>/`` the file refers to is on disk.
273
+ arguments: Xacro arguments, as ``name: value``.
274
+
275
+ Raises:
276
+ UrdfImportError: For a floating or planar joint, a joint and a link sharing a name,
277
+ a robot with no single root link, or geometry that cannot be read.
278
+ """
279
+ import numpy as np
280
+
281
+ path = Path(path)
282
+ roots = {name: Path(directory) for name, directory in (packages or {}).items()}
283
+ text = expand(path, arguments)
284
+ robot = ET.fromstring(text)
285
+
286
+ links = {link.get("name", ""): link for link in robot.findall("link")}
287
+ joints = robot.findall("joint")
288
+ named_materials = {material.get("name", ""): material for material in robot.findall("material")}
289
+ for joint in joints:
290
+ name, kind = joint.get("name", ""), joint.get("type", "")
291
+ if kind in ("floating", "planar"):
292
+ raise UrdfImportError(f"joint {name!r} is {kind}; only fixed, revolute, continuous and prismatic joints are supported")
293
+ if kind != "fixed" and kind not in MOVING:
294
+ raise UrdfImportError(f"joint {name!r} has the unknown type {kind!r}")
295
+ if kind in MOVING and name in links:
296
+ raise UrdfImportError(f"the joint and the link {name!r} share a name; a node name must be unique, so rename one of them")
297
+ children_of: dict[str, list[ET.Element]] = {}
298
+ child_links: set[str] = set()
299
+ for joint in joints:
300
+ parent = joint.find("parent")
301
+ child = joint.find("child")
302
+ if parent is None or child is None:
303
+ raise UrdfImportError(f"joint {joint.get('name')!r} needs a parent and a child")
304
+ children_of.setdefault(parent.get("link", ""), []).append(joint)
305
+ child_links.add(child.get("link", ""))
306
+ root_links = [name for name in links if name not in child_links]
307
+ if len(root_links) != 1:
308
+ raise UrdfImportError(f"a robot has one root link; this one has {len(root_links)}: {root_links}")
309
+
310
+ builder = Builder()
311
+ table: list[dict[str, Any]] = []
312
+
313
+ def material_of(visual: ET.Element) -> int | None:
314
+ material = visual.find("material")
315
+ if material is None:
316
+ return None
317
+ name = material.get("name", "")
318
+ source = material if (material.find("color") is not None or material.find("texture") is not None) else named_materials.get(name)
319
+ if source is None:
320
+ return None
321
+ color = source.find("color")
322
+ texture = source.find("texture")
323
+ rgba = _floats(color.get("rgba"), 4, (1.0, 1.0, 1.0, 1.0)) if color is not None else None
324
+ image = _resolve(texture.get("filename", ""), path.parent, roots) if texture is not None else None
325
+ return builder.material(name or json.dumps([rgba, str(image)]), rgba, image)
326
+
327
+ def build_link(name: str, transform: dict[str, Any]) -> int:
328
+ link = links.get(name)
329
+ if link is None:
330
+ raise UrdfImportError(f"a joint refers to the link {name!r}, which does not exist")
331
+ children: list[int] = []
332
+ for index, visual in enumerate(link.findall("visual")):
333
+ geometry = visual.find("geometry")
334
+ if geometry is None:
335
+ continue
336
+ mesh = builder.mesh(*_geometry(geometry, path.parent, roots), material_of(visual))
337
+ children.append(builder.node({"name": f"{name}_visual_{index}", "mesh": mesh, **Origin.parse(visual.find("origin")).node()}))
338
+ for joint in children_of.get(name, []):
339
+ joint_name, kind = joint.get("name", ""), joint.get("type", "")
340
+ origin = Origin.parse(joint.find("origin")).node()
341
+ child = joint.find("child")
342
+ assert child is not None
343
+ if kind == "fixed":
344
+ children.append(build_link(child.get("link", ""), origin))
345
+ continue
346
+ children.append(builder.node({"name": joint_name, **origin, "children": [build_link(child.get("link", ""), {})]}))
347
+ axis_element = joint.find("axis")
348
+ axis = np.array(_floats(axis_element.get("xyz") if axis_element is not None else None, 3, (1.0, 0.0, 0.0)))
349
+ length = float(np.linalg.norm(axis))
350
+ if length == 0:
351
+ raise UrdfImportError(f"joint {joint_name!r} has a zero axis")
352
+ entry: dict[str, Any] = {"name": joint_name, "node": joint_name, "type": kind, "axis": _clean(tuple(axis / length))}
353
+ if kind != "continuous":
354
+ limit = joint.find("limit")
355
+ entry["limit"] = {
356
+ "lower": float(limit.get("lower", 0)) if limit is not None else 0.0,
357
+ "upper": float(limit.get("upper", 0)) if limit is not None else 0.0,
358
+ }
359
+ mimic = joint.find("mimic")
360
+ if mimic is not None:
361
+ entry["mimic"] = {"joint": mimic.get("joint", ""), "multiplier": float(mimic.get("multiplier", 1)), "offset": float(mimic.get("offset", 0))}
362
+ table.append(entry)
363
+ node: dict[str, Any] = {"name": name, **transform}
364
+ if children:
365
+ node["children"] = children
366
+ return builder.node(node)
367
+
368
+ root = build_link(root_links[0], {})
369
+ # URDF is Z-up, glTF Y-up: the root is turned once, and every frame below it follows.
370
+ upright = (-math.sqrt(0.5), 0.0, 0.0, math.sqrt(0.5))
371
+ existing = tuple(builder.nodes[root].get("rotation", (0.0, 0.0, 0.0, 1.0)))
372
+ builder.nodes[root]["rotation"] = _clean(_multiply(upright, existing))
373
+
374
+ # The table is in document order, not in the order the tree was walked.
375
+ order = {joint.get("name", ""): index for index, joint in enumerate(joints)}
376
+ table.sort(key=lambda entry: order[entry["name"]])
377
+ extras = {"arkitekt": {"version": 1, "joints": table, "source": "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()}}
378
+ return builder.glb(root, extras)
379
+
380
+
381
+ def with_joints(data: bytes, joints: list[dict[str, Any]]) -> bytes:
382
+ """A copy of a GLB with ``joints`` as its joint table, for models that were not imported from URDF."""
383
+ magic, version, _length = struct.unpack_from("<III", data, 0)
384
+ json_length, json_type = struct.unpack_from("<II", data, 12)
385
+ if magic != 0x46546C67 or version != 2 or json_type != 0x4E4F534A:
386
+ raise UrdfImportError("the file is not a GLB")
387
+ document = json.loads(data[20 : 20 + json_length])
388
+ extras = document.setdefault("extras", {})
389
+ extras["arkitekt"] = {"version": 1, "joints": joints}
390
+ payload = json.dumps(document, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
391
+ payload += b" " * (-len(payload) % 4)
392
+ rest = data[20 + json_length :]
393
+ body = struct.pack("<II", len(payload), 0x4E4F534A) + payload + rest
394
+ return struct.pack("<III", 0x46546C67, 2, 12 + len(body)) + body
@@ -0,0 +1,449 @@
1
+ """What a GLB must be to be a model, checked on its bytes.
2
+
3
+ The rule table ``KULISSE.md`` states: what a server holds a model to before it enters a
4
+ ``KulisseStore`` (arkitekt-datalayer's own reader of the format) and what every viewer runs
5
+ before it shows one, plus the check only a client can make: texture sizes live in the binary chunk, which
6
+ the server measures but never decodes. An author runs it here first, so a model that
7
+ would be refused is never published.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import re
16
+ import struct
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ GLB_MAGIC = 0x46546C67 # "glTF"
21
+ CHUNK_JSON = 0x4E4F534A # "JSON"
22
+ CHUNK_BIN = 0x004E4942 # "BIN\0"
23
+
24
+ REF_PATTERN = re.compile(r"sha256:[0-9a-f]{64}")
25
+ NODE_NAME_PATTERN = re.compile(r"[a-z0-9_]+")
26
+
27
+ JOINT_TYPES = frozenset({"revolute", "continuous", "prismatic"})
28
+
29
+ _COMPONENT_BYTES = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4}
30
+ _TYPE_COMPONENTS = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
31
+
32
+ DEFAULT_EXTENSIONS = frozenset(
33
+ {
34
+ "KHR_lights_punctual",
35
+ "KHR_materials_emissive_strength",
36
+ "KHR_materials_variants",
37
+ "KHR_texture_transform",
38
+ "KHR_mesh_quantization",
39
+ "EXT_meshopt_compression",
40
+ "KHR_draco_mesh_compression",
41
+ }
42
+ )
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ModelPolicy:
47
+ """The limits a model is held to when it is created."""
48
+
49
+ max_size: int = 50 * 1024 * 1024
50
+ max_json: int = 4 * 1024 * 1024
51
+ max_nodes: int = 10_000
52
+ max_triangles: int = 2_000_000
53
+ max_texture_size: int = 4096
54
+ max_texture_memory: int = 256 * 1024 * 1024
55
+ max_animations: int = 64
56
+ extensions: frozenset[str] = DEFAULT_EXTENSIONS
57
+
58
+
59
+ class ModelRefused(ValueError):
60
+ """A model that may not enter the store, with the code clients switch on."""
61
+
62
+ def __init__(self, code: str, message: str) -> None:
63
+ """A refusal: ``code`` is what clients switch on, ``message`` what a person reads."""
64
+ super().__init__(message)
65
+ self.code = code
66
+ self.message = message
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class ModelStats:
71
+ """What a model contains, counted from its JSON."""
72
+
73
+ nodes: int
74
+ triangles: int
75
+ textures: int
76
+ animations: int
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ModelReport:
81
+ """What validation learned about an accepted model."""
82
+
83
+ ref: str
84
+ size: int
85
+ nodes: list[str]
86
+ stats: ModelStats
87
+ warnings: list[str] = field(default_factory=list)
88
+ joints: list[dict[str, Any]] = field(default_factory=list)
89
+
90
+
91
+ def model_ref(data: bytes) -> str:
92
+ """The reference of these exact bytes."""
93
+ return "sha256:" + hashlib.sha256(data).hexdigest()
94
+
95
+
96
+ def check_ref_format(ref: str) -> None:
97
+ """Refuse a reference that is not ``sha256:`` and 64 lowercase hex digits."""
98
+ if not REF_PATTERN.fullmatch(ref):
99
+ raise ModelRefused("bad_reference", f"a model reference is 'sha256:' and 64 lowercase hex characters, not {ref!r}")
100
+
101
+
102
+ def validate_model(data: bytes, ref: str, policy: ModelPolicy | None = None) -> ModelReport:
103
+ """Check ``data`` against ``ref`` and ``policy``; return what it contains or raise ``ModelRefused``."""
104
+ policy = policy or ModelPolicy()
105
+ check_ref_format(ref)
106
+ if len(data) > policy.max_size:
107
+ raise ModelRefused("too_large", f"the model is {len(data)} bytes, over the limit of {policy.max_size}")
108
+ actual = model_ref(data)
109
+ if actual != ref:
110
+ raise ModelRefused("hash_mismatch", f"the stored bytes are {actual}, not {ref}")
111
+
112
+ document, binary_length = _read_chunks(data, policy)
113
+ warnings: list[str] = []
114
+ _check_self_contained(document)
115
+ _check_extensions(document, policy, warnings)
116
+ _check_bounds(document, binary_length)
117
+ node_names = _node_names(document, warnings)
118
+ _check_extras(document, node_names)
119
+ stats = _stats(document)
120
+ _check_counts(stats, policy)
121
+ _check_textures(document, data, policy, warnings)
122
+ return ModelReport(ref=ref, size=len(data), nodes=sorted(node_names), stats=stats, warnings=warnings, joints=_joints(document))
123
+
124
+
125
+ def _read_chunks(data: bytes, policy: ModelPolicy) -> tuple[dict[str, Any], int]:
126
+ """The parsed JSON chunk and the length of the binary chunk (0 when there is none)."""
127
+ if len(data) < 20:
128
+ raise ModelRefused("bad_header", "the file is shorter than a GLB header and one chunk header")
129
+ magic, version, declared = struct.unpack_from("<III", data, 0)
130
+ if magic != GLB_MAGIC:
131
+ raise ModelRefused("bad_header", "the file does not start with the glTF magic; only binary glTF (.glb) is stored")
132
+ if version != 2:
133
+ raise ModelRefused("bad_header", f"the container version is {version}, only 2 is supported")
134
+ if declared != len(data):
135
+ raise ModelRefused("bad_header", f"the header declares {declared} bytes but the file has {len(data)}")
136
+
137
+ json_length, json_type = struct.unpack_from("<II", data, 12)
138
+ if json_type != CHUNK_JSON:
139
+ raise ModelRefused("bad_structure", "the first chunk is not the JSON chunk")
140
+ if json_length > policy.max_json:
141
+ raise ModelRefused("bad_structure", f"the JSON chunk is {json_length} bytes, over the limit of {policy.max_json}")
142
+ json_end = 20 + json_length
143
+ if json_end > len(data):
144
+ raise ModelRefused("bad_structure", "the JSON chunk runs past the end of the file")
145
+ try:
146
+ document = json.loads(data[20:json_end].decode("utf-8"))
147
+ except (UnicodeDecodeError, ValueError) as error:
148
+ raise ModelRefused("bad_structure", f"the JSON chunk does not parse: {error}") from error
149
+ if not isinstance(document, dict):
150
+ raise ModelRefused("bad_structure", "the JSON chunk is not an object")
151
+ asset = document.get("asset")
152
+ if not isinstance(asset, dict) or asset.get("version") != "2.0":
153
+ raise ModelRefused("bad_structure", "asset.version must be '2.0'")
154
+
155
+ binary_length = 0
156
+ if json_end < len(data):
157
+ if json_end + 8 > len(data):
158
+ raise ModelRefused("bad_structure", "a chunk header is cut off after the JSON chunk")
159
+ binary_length, binary_type = struct.unpack_from("<II", data, json_end)
160
+ if binary_type != CHUNK_BIN:
161
+ raise ModelRefused("bad_structure", "the chunk after the JSON chunk is not the binary chunk")
162
+ if json_end + 8 + binary_length > len(data):
163
+ raise ModelRefused("bad_structure", "the binary chunk runs past the end of the file")
164
+ return document, binary_length
165
+
166
+
167
+ def _items(document: dict[str, Any], key: str) -> list[dict[str, Any]]:
168
+ value = document.get(key, [])
169
+ if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
170
+ raise ModelRefused("bad_structure", f"'{key}' must be a list of objects")
171
+ return value
172
+
173
+
174
+ def _check_self_contained(document: dict[str, Any]) -> None:
175
+ for key in ("buffers", "images"):
176
+ for index, item in enumerate(_items(document, key)):
177
+ if "uri" in item:
178
+ raise ModelRefused("external_reference", f"{key}[{index}] has a uri; a model carries all its data in its binary chunk")
179
+
180
+
181
+ def _check_extensions(document: dict[str, Any], policy: ModelPolicy, warnings: list[str]) -> None:
182
+ required = document.get("extensionsRequired", [])
183
+ used = document.get("extensionsUsed", [])
184
+ if not isinstance(required, list) or not isinstance(used, list):
185
+ raise ModelRefused("bad_structure", "extensionsRequired and extensionsUsed must be lists")
186
+ refused = sorted(set(required) - policy.extensions)
187
+ if refused:
188
+ raise ModelRefused("unsupported_extension", f"the model requires extensions that are not allowed: {refused}")
189
+ ignored = sorted(set(used) - set(required) - policy.extensions)
190
+ if ignored:
191
+ warnings.append(f"extensions used but not supported are ignored by viewers: {ignored}")
192
+
193
+
194
+ def _is_fallback_buffer(buffer: dict[str, Any]) -> bool:
195
+ """A meshopt fallback buffer has a length but no data in the file."""
196
+ extension = buffer.get("extensions", {}).get("EXT_meshopt_compression")
197
+ return isinstance(extension, dict) and extension.get("fallback") is True
198
+
199
+
200
+ def _span(item: dict[str, Any], what: str) -> tuple[int, int]:
201
+ offset, length = item.get("byteOffset", 0), item.get("byteLength")
202
+ if not isinstance(offset, int) or not isinstance(length, int) or offset < 0 or length < 0:
203
+ raise ModelRefused("bad_structure", f"{what} needs a non-negative integer byteOffset and byteLength")
204
+ return offset, length
205
+
206
+
207
+ def _check_bounds(document: dict[str, Any], binary_length: int) -> None:
208
+ buffers = _items(document, "buffers")
209
+ views = _items(document, "bufferViews")
210
+ for index, buffer in enumerate(buffers):
211
+ if _is_fallback_buffer(buffer):
212
+ continue
213
+ if index != 0:
214
+ raise ModelRefused("bad_structure", f"buffers[{index}] has no data: only the first buffer is the binary chunk")
215
+ _, length = _span(buffer, "buffers[0]")
216
+ if length > binary_length:
217
+ raise ModelRefused("bad_structure", f"buffers[0] declares {length} bytes but the binary chunk has {binary_length}")
218
+
219
+ def check_view(what: str, source: dict[str, Any]) -> None:
220
+ buffer_index = source.get("buffer")
221
+ if not isinstance(buffer_index, int) or not 0 <= buffer_index < len(buffers):
222
+ raise ModelRefused("bad_structure", f"{what} names a buffer that does not exist")
223
+ offset, length = _span(source, what)
224
+ if offset + length > _span(buffers[buffer_index], f"buffers[{buffer_index}]")[1]:
225
+ raise ModelRefused("bad_structure", f"{what} runs past the end of buffers[{buffer_index}]")
226
+
227
+ for index, view in enumerate(views):
228
+ check_view(f"bufferViews[{index}]", view)
229
+ compressed = view.get("extensions", {}).get("EXT_meshopt_compression")
230
+ if isinstance(compressed, dict):
231
+ check_view(f"bufferViews[{index}] (compressed)", compressed)
232
+
233
+ for index, accessor in enumerate(_items(document, "accessors")):
234
+ count = accessor.get("count")
235
+ if not isinstance(count, int) or count < 0:
236
+ raise ModelRefused("bad_structure", f"accessors[{index}] needs a non-negative integer count")
237
+ view_index = accessor.get("bufferView")
238
+ if view_index is None:
239
+ continue
240
+ if not isinstance(view_index, int) or not 0 <= view_index < len(views):
241
+ raise ModelRefused("bad_structure", f"accessors[{index}] names a bufferView that does not exist")
242
+ component_type = accessor.get("componentType")
243
+ kind = accessor.get("type")
244
+ component = _COMPONENT_BYTES.get(component_type) if isinstance(component_type, int) else None
245
+ components = _TYPE_COMPONENTS.get(kind) if isinstance(kind, str) else None
246
+ if component is None or components is None:
247
+ raise ModelRefused("bad_structure", f"accessors[{index}] has an unknown componentType or type")
248
+ element = component * components
249
+ view = views[view_index]
250
+ stride = view.get("byteStride", element)
251
+ offset = accessor.get("byteOffset", 0)
252
+ if not isinstance(stride, int) or not isinstance(offset, int) or stride < 0 or offset < 0:
253
+ raise ModelRefused("bad_structure", f"accessors[{index}] has an invalid byteOffset or byteStride")
254
+ needed = offset + (stride * (count - 1) + element if count else 0)
255
+ if needed > _span(view, f"bufferViews[{view_index}]")[1]:
256
+ raise ModelRefused("bad_structure", f"accessors[{index}] runs past the end of bufferViews[{view_index}]")
257
+
258
+
259
+ def _node_names(document: dict[str, Any], warnings: list[str]) -> set[str]:
260
+ """The names bindings can address. Unnamed nodes are fine; a repeated name is not addressable."""
261
+ names: set[str] = set()
262
+ repeated: set[str] = set()
263
+ unconventional: set[str] = set()
264
+ for node in _items(document, "nodes"):
265
+ name = node.get("name")
266
+ if not isinstance(name, str) or not name:
267
+ continue
268
+ if name in names:
269
+ repeated.add(name)
270
+ names.add(name)
271
+ if not NODE_NAME_PATTERN.fullmatch(name):
272
+ unconventional.add(name)
273
+ if repeated:
274
+ warnings.append(f"node names used more than once cannot be bound reliably: {sorted(repeated)}")
275
+ if unconventional:
276
+ warnings.append(f"node names outside [a-z0-9_]+: {sorted(unconventional)}")
277
+ return names
278
+
279
+
280
+ def _number(value: object) -> bool:
281
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
282
+
283
+
284
+ def _check_extras(document: dict[str, Any], node_names: set[str]) -> None:
285
+ """``extras.arkitekt``, if present, is the joint table of this schema version."""
286
+ extras = document.get("extras")
287
+ arkitekt = extras.get("arkitekt") if isinstance(extras, dict) else None
288
+ if arkitekt is None:
289
+ return
290
+
291
+ def fail(message: str) -> ModelRefused:
292
+ return ModelRefused("bad_extras", f"extras.arkitekt: {message}")
293
+
294
+ if not isinstance(arkitekt, dict):
295
+ raise fail("must be an object")
296
+ if arkitekt.get("version") != 1:
297
+ raise fail(f"version {arkitekt.get('version')!r} is not supported, only 1")
298
+ joints = arkitekt.get("joints", [])
299
+ if not isinstance(joints, list):
300
+ raise fail("joints must be a list")
301
+ source = arkitekt.get("source")
302
+ if source is not None and not (isinstance(source, str) and REF_PATTERN.fullmatch(source)):
303
+ raise fail("source must be a sha256: reference")
304
+
305
+ joint_names: set[str] = set()
306
+ driven: set[str] = set()
307
+ for index, joint in enumerate(joints):
308
+ where = f"joints[{index}]"
309
+ if not isinstance(joint, dict):
310
+ raise fail(f"{where} must be an object")
311
+ name, node, kind, axis = joint.get("name"), joint.get("node"), joint.get("type"), joint.get("axis")
312
+ if not isinstance(name, str) or not name:
313
+ raise fail(f"{where} needs a name")
314
+ if name in joint_names:
315
+ raise fail(f"joint {name!r} is defined twice")
316
+ joint_names.add(name)
317
+ if node not in node_names:
318
+ raise fail(f"joint {name!r} drives node {node!r}, which the model does not have")
319
+ if node in driven:
320
+ raise fail(f"node {node!r} is driven by more than one joint")
321
+ driven.add(node)
322
+ if kind not in JOINT_TYPES:
323
+ raise fail(f"joint {name!r} has type {kind!r}; supported are {sorted(JOINT_TYPES)}")
324
+ if not (isinstance(axis, list) and len(axis) == 3 and all(_number(component) for component in axis)):
325
+ raise fail(f"joint {name!r} needs an axis of three numbers")
326
+ if abs(math.sqrt(sum(component * component for component in axis)) - 1.0) > 1e-3:
327
+ raise fail(f"the axis of joint {name!r} is not a unit vector")
328
+ limit = joint.get("limit")
329
+ if kind == "continuous":
330
+ if limit is not None:
331
+ raise fail(f"continuous joint {name!r} has no limit")
332
+ else:
333
+ if not (isinstance(limit, dict) and _number(limit.get("lower")) and _number(limit.get("upper"))):
334
+ raise fail(f"joint {name!r} needs a limit with lower and upper")
335
+ if limit["lower"] > limit["upper"]:
336
+ raise fail(f"the limit of joint {name!r} has lower above upper")
337
+
338
+ for joint in joints:
339
+ mimic = joint.get("mimic")
340
+ if mimic is None:
341
+ continue
342
+ if not (isinstance(mimic, dict) and _number(mimic.get("multiplier", 1.0)) and _number(mimic.get("offset", 0.0))):
343
+ raise fail(f"the mimic of joint {joint['name']!r} needs a numeric multiplier and offset")
344
+ if mimic.get("joint") not in joint_names or mimic.get("joint") == joint["name"]:
345
+ raise fail(f"joint {joint['name']!r} mimics {mimic.get('joint')!r}, which is not another joint of this model")
346
+
347
+
348
+ def _stats(document: dict[str, Any]) -> ModelStats:
349
+ accessors = _items(document, "accessors")
350
+
351
+ def count_of(index: object) -> int:
352
+ if not isinstance(index, int) or not 0 <= index < len(accessors):
353
+ raise ModelRefused("bad_structure", "a mesh primitive names an accessor that does not exist")
354
+ return int(accessors[index].get("count", 0))
355
+
356
+ triangles = 0
357
+ for mesh in _items(document, "meshes"):
358
+ primitives = mesh.get("primitives", [])
359
+ if not isinstance(primitives, list):
360
+ raise ModelRefused("bad_structure", "mesh primitives must be a list")
361
+ for primitive in primitives:
362
+ if not isinstance(primitive, dict):
363
+ raise ModelRefused("bad_structure", "a mesh primitive must be an object")
364
+ mode = primitive.get("mode", 4)
365
+ if mode not in (4, 5, 6):
366
+ continue
367
+ attributes = primitive.get("attributes", {})
368
+ if "indices" in primitive:
369
+ vertices = count_of(primitive["indices"])
370
+ elif isinstance(attributes, dict) and "POSITION" in attributes:
371
+ vertices = count_of(attributes["POSITION"])
372
+ else:
373
+ continue
374
+ triangles += vertices // 3 if mode == 4 else max(vertices - 2, 0)
375
+
376
+ return ModelStats(
377
+ nodes=len(_items(document, "nodes")),
378
+ triangles=triangles,
379
+ textures=len(_items(document, "images")),
380
+ animations=len(_items(document, "animations")),
381
+ )
382
+
383
+
384
+ def _check_counts(stats: ModelStats, policy: ModelPolicy) -> None:
385
+ for what, value, limit in (
386
+ ("nodes", stats.nodes, policy.max_nodes),
387
+ ("triangles", stats.triangles, policy.max_triangles),
388
+ ("animation clips", stats.animations, policy.max_animations),
389
+ ):
390
+ if value > limit:
391
+ raise ModelRefused("over_budget", f"the model has {value} {what}, over the limit of {limit}")
392
+
393
+
394
+ def _joints(document: dict[str, Any]) -> list[dict[str, Any]]:
395
+ extras = document.get("extras")
396
+ arkitekt = extras.get("arkitekt") if isinstance(extras, dict) else None
397
+ return list(arkitekt.get("joints", [])) if isinstance(arkitekt, dict) else []
398
+
399
+
400
+ def image_size(data: bytes) -> tuple[int, int] | None:
401
+ """Width and height from a PNG, JPEG or WebP header; ``None`` for anything else."""
402
+ if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
403
+ return struct.unpack(">II", data[16:24])
404
+ if len(data) >= 4 and data[:2] == b"\xff\xd8":
405
+ offset = 2
406
+ while offset + 9 < len(data):
407
+ if data[offset] != 0xFF:
408
+ return None
409
+ marker = data[offset + 1]
410
+ (length,) = struct.unpack(">H", data[offset + 2 : offset + 4])
411
+ if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
412
+ height, width = struct.unpack(">HH", data[offset + 5 : offset + 9])
413
+ return width, height
414
+ offset += 2 + length
415
+ return None
416
+ if len(data) >= 30 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
417
+ kind = data[12:16]
418
+ if kind == b"VP8X":
419
+ return 1 + int.from_bytes(data[24:27], "little"), 1 + int.from_bytes(data[27:30], "little")
420
+ if kind == b"VP8 ":
421
+ width, height = struct.unpack("<HH", data[26:30])
422
+ return width & 0x3FFF, height & 0x3FFF
423
+ if kind == b"VP8L":
424
+ (bits,) = struct.unpack("<I", data[21:25])
425
+ return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)
426
+ return None
427
+
428
+
429
+ def _check_textures(document: dict[str, Any], data: bytes, policy: ModelPolicy, warnings: list[str]) -> None:
430
+ """Texture sizes, read from the image headers in the binary chunk."""
431
+ json_length = struct.unpack_from("<I", data, 12)[0]
432
+ binary_offset = 20 + json_length + 8
433
+ views = _items(document, "bufferViews")
434
+ memory = 0
435
+ for index, image in enumerate(_items(document, "images")):
436
+ view_index = image.get("bufferView")
437
+ if not isinstance(view_index, int) or not 0 <= view_index < len(views):
438
+ raise ModelRefused("bad_structure", f"images[{index}] names a bufferView that does not exist")
439
+ offset, length = _span(views[view_index], f"bufferViews[{view_index}]")
440
+ size = image_size(data[binary_offset + offset : binary_offset + offset + length])
441
+ if size is None:
442
+ warnings.append(f"images[{index}] is not a PNG, JPEG or WebP whose size can be read")
443
+ continue
444
+ width, height = size
445
+ if max(width, height) > policy.max_texture_size:
446
+ raise ModelRefused("over_budget", f"images[{index}] is {width} x {height}, over the limit of {policy.max_texture_size} px per side")
447
+ memory += width * height * 4
448
+ if memory > policy.max_texture_memory:
449
+ raise ModelRefused("over_budget", f"textures decode to {memory} bytes, over the limit of {policy.max_texture_memory}")
@@ -0,0 +1,90 @@
1
+ [project]
2
+ name = "kulisse"
3
+ dynamic = ["version"]
4
+ description = "device and scene models for arkitekt: self-contained binary glTF with a joint table, authored from URDF and checked before upload"
5
+ authors = [{ name = "jhnnsrs", email = "jhnnsrs@gmail.com" }]
6
+ requires-python = ">=3.11"
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ # Checking a model, its reference and its blok snippet need nothing but the standard library:
10
+ # a viewer, a CI job or a server-side tool can hold a GLB to the rules without numpy.
11
+ dependencies = []
12
+
13
+ [project.urls]
14
+ Repository = "https://github.com/arkitektio/kulisse"
15
+ Specification = "https://github.com/arkitektio/kulisse/blob/main/KULISSE.md"
16
+
17
+ [project.optional-dependencies]
18
+ # Importing a URDF or Xacro: trimesh builds the primitives and loads mesh files (pycollada for
19
+ # .dae), numpy carries the transforms, xacro expands macros.
20
+ urdf = ["numpy>=1.26", "pycollada>=0.8", "trimesh>=4", "xacro>=2"]
21
+ complete = ["numpy>=1.26", "pycollada>=0.8", "trimesh>=4", "xacro>=2"]
22
+
23
+ [project.scripts]
24
+ kulisse = "kulisse.cli:main"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=8.3.5",
29
+ "pytest-cov>=6.1.1",
30
+ "python-semantic-release>=9.21.0",
31
+ "ruff>0.11",
32
+ "basedpyright>=1.21",
33
+ ]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ include = ["kulisse"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ include = ["kulisse"]
40
+
41
+ [tool.basedpyright]
42
+ include = ["kulisse"]
43
+ exclude = ["**/__pycache__", "tests"]
44
+ typeCheckingMode = "strict"
45
+ # trimesh and xacro ship no type information, so values that come back through them are Unknown
46
+ # whatever kulisse annotates; every other strict rule stays on.
47
+ reportUnknownMemberType = false
48
+ reportUnknownArgumentType = false
49
+ reportUnknownVariableType = false
50
+ reportUnknownParameterType = false
51
+ reportMissingTypeStubs = false
52
+
53
+ [tool.ruff.lint]
54
+ extend-select = ["ANN", "D1"]
55
+
56
+ [tool.ruff.lint.per-file-ignores]
57
+ "tests/*" = ["ANN001", "ANN201", "ANN202", "ANN206", "ANN401", "D1", "RUF100"]
58
+
59
+ [tool.ruff]
60
+ line-length = 140
61
+
62
+ [tool.semantic_release]
63
+ # Tag-only releases, as fabriks: the version lives in the git tag, hatch-vcs reads it at build
64
+ # time, and PSR is told the new version explicitly so the build is not a .devN local version.
65
+ build_command = """
66
+ python -m pip install 'uv>=0.7.12'
67
+ export SETUPTOOLS_SCM_PRETEND_VERSION="$NEW_VERSION"
68
+ uv build
69
+ """
70
+
71
+ [tool.semantic_release.branches.main]
72
+ match = "main"
73
+ prerelease = false
74
+
75
+ [tool.semantic_release.branches.next]
76
+ match = "next"
77
+ prerelease = true
78
+ prerelease_token = "rc"
79
+
80
+ [tool.semantic_release.branches.maintenance]
81
+ match = '^\d+\.x$'
82
+ prerelease = false
83
+
84
+ [build-system]
85
+ requires = ["hatchling", "hatch-vcs"]
86
+ build-backend = "hatchling.build"
87
+
88
+ [tool.hatch.version]
89
+ source = "vcs"
90
+ fallback-version = "0.0.0"