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.
- kulisse-1.0.1/.gitignore +7 -0
- kulisse-1.0.1/LICENSE +19 -0
- kulisse-1.0.1/PKG-INFO +49 -0
- kulisse-1.0.1/README.md +27 -0
- kulisse-1.0.1/kulisse/__init__.py +56 -0
- kulisse-1.0.1/kulisse/cli.py +100 -0
- kulisse-1.0.1/kulisse/py.typed +0 -0
- kulisse-1.0.1/kulisse/urdf.py +394 -0
- kulisse-1.0.1/kulisse/validate.py +449 -0
- kulisse-1.0.1/pyproject.toml +90 -0
kulisse-1.0.1/.gitignore
ADDED
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
|
+
```
|
kulisse-1.0.1/README.md
ADDED
|
@@ -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"
|