Building Worlds with Specifications#

In World Structure Manipulation you built a world by hand: creating bodies, opening a world.modify_world() block, wiring connections, and registering degrees of freedom one statement at a time. That is precise, but the recipe for a single object ends up scattered across many calls, and it is bound to one specific world.

A specification captures that recipe as a single, world-independent object. It describes what an entity is — its geometry, its pose, the connection that attaches it, the semantic meaning it carries — without touching any world. You materialize it later with one method call, and the specification takes care of the bookkeeping: modification blocks, connections, degrees of freedom, and annotation registration.

Two properties make specifications convenient:

  • Reusable. A specification never binds to a world. Materializing it copies the prototype geometry and pose, so you can materialize the same specification into many worlds, or many times into one world under different names.

  • Composable. Specifications nest. A body specification carries child specifications; an annotation specification carries nested part specifications; a world specification carries a whole list of objects to materialize.

Used Concepts:

The materialization verbs#

Every specification carries a name and knows how to turn itself into a domain object. There are three distinct contracts, and which one a specification offers tells you what kind of thing it describes:

  • spawn(world, ...) — used by specifications that describe an entity together with the connection that attaches it to a parent. Spawning materializes the entity, attaches it, and recursively materializes its children, all inside one modification block.

  • connect(world, child, parent=) — used by connection specifications, which join two already existing entities. A connection is not a kinematic structure entity — it is the attachment between two of them — so there is nothing to spawn and no parent to attach it to.

  • to_domain_object(...) — materializes an entity in isolation, without attaching it to any world. Use it when you want a free-standing Body to pass somewhere else.

Throughout this guide we need a world with a root to materialize into. World.create_with_root_body() gives us exactly that: a fresh world whose single root body is named map.

import logging
logging.disable(logging.CRITICAL)

import numpy as np

from semantic_digital_twin.world import World
from semantic_digital_twin.spatial_computations.raytracer import RayTracer
from semantic_digital_twin.world_description.connections import (
    FixedConnection,
    PrismaticConnection,
)

world = World.create_with_root_body()
assert world.root.name.name == "map"
print("Root body:", world.root.name)
Root body: map

Body specifications#

A BodySpecification describes a Body. The most direct way to build one is through the shape constructors, mirroring the shapes you already know from Creating Custom Bodies:

  • BodySpecification.box(name, scale, ...)

  • BodySpecification.sphere(name, radius, ...)

  • BodySpecification.cylinder(name, width, height, ...)

  • BodySpecification.mesh(name, filename, ...)

Calling spawn materializes the body and attaches it to the world root with a FixedConnection by default.

from semantic_digital_twin.api import BodySpecification
from semantic_digital_twin.world_description.geometry import Scale, Color

world = World.create_with_root_body()

table_top = BodySpecification.box(
    name="table_top", 
    scale=Scale(1.2, 0.8, 0.05), 
    color=Color(0.6, 0.4, 0.2, 1.0)
).spawn(world)

assert table_top.name.name == "table_top"
assert isinstance(table_top.parent_connection, FixedConnection)
assert table_top.parent_connection.parent is world.root
print("Spawned", table_top.name, "attached via", type(table_top.parent_connection).__name__)
Spawned table_top attached via FixedConnection

Placing and renaming#

Each shape constructor accepts a parent_T_self: the default placement of the entity in its parent frame. Because a specification is reusable, that placement and the name can also be overridden when you spawn, together with the parent the entity attaches to.

from semantic_digital_twin.spatial_types import HomogeneousTransformationMatrix

# Bake a default pose straight into the specification.
leg = BodySpecification.box(
    name="leg_0",
    scale=Scale(0.05, 0.05, 0.7),
    parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=0.55, y=0.35, z=-0.35),
).spawn(world, parent=table_top)

# The same specification is reusable; override the pose (and name) per spawn.
leg_spec = BodySpecification.box("leg", Scale(0.05, 0.05, 0.7))
for index, (x, y) in enumerate([(0.55, -0.35), (-0.55, 0.35), (-0.55, -0.35)], start=1):
    leg_spec.spawn(
        world,
        name=f"leg_{index}",
        parent=table_top,
        parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=x, y=y, z=-0.35),
    )

# root + table_top + four legs
assert len(world.bodies) == 6
# The baked-in pose placed leg_0 at its parent-frame offset.
root_T_leg_0 = world.compute_forward_kinematics(world.root, leg)
np.testing.assert_allclose(root_T_leg_0.to_position().to_np()[:3], [0.55, 0.35, -0.35])
assert leg.parent_connection.parent is table_top
print("Bodies now in the world:", sorted(str(body.name) for body in world.bodies))
Bodies now in the world: ['leg_0', 'leg_1', 'leg_2', 'leg_3', 'map', 'table_top']

The legs attach to table_top rather than the world root, and the single leg_spec produced independent bodies. The specification was neither consumed nor mutated.

Nesting with child specifications#

Re-attaching children by hand, as above, is fine for ad-hoc placement. When the parent/child structure is fixed, encode it directly in the specification through child_specifications. Spawning the parent then materializes the whole subtree.

world = World.create_with_root_body()

shelf = BodySpecification.box(
    name="shelf",
    scale=Scale(0.8, 0.3, 0.02),
    child_specifications=[
        BodySpecification.box("book", Scale(0.15, 0.2, 0.25)),
        BodySpecification.sphere("ball", 0.06),
    ],
).spawn(world)

book = world.get_body_by_name("book")
assert book.parent_connection.parent is shelf
assert world.get_body_by_name("ball").parent_connection.parent is shelf
print("book and ball are both children of the shelf")
book and ball are both children of the shelf

Other ways to describe geometry#

Beyond primitive shapes, a body specification can describe composite geometry. These mirror the corresponding Body constructors:

  • BodySpecification.mesh(name, filename, ...) loads the geometry from a mesh file.

  • BodySpecification.from_event(name, event) builds the geometry from the bounding boxes of a random event — the construction used by semantic annotations with hollow or carved geometry.

  • BodySpecification.from_3d_points(name, points_3d) builds the convex hull of a point cloud.

from importlib.resources import files
from pathlib import Path

from semantic_digital_twin.spatial_types import Point3

resources = Path(files("semantic_digital_twin")).parent.parent / "resources"

world = World.create_with_root_body()

# A mesh body, loaded from file.
milk_mesh = BodySpecification.mesh(
    name="milk_mesh", filename=str(resources / "stl" / "milk.stl")
).spawn(world)

# A hollow crate: the outer box minus the carved-out interior, expressed as one event.
outer = Scale(0.3, 0.3, 0.3).to_simple_event().as_composite_set()
inner = Scale(0.26, 0.26, 0.3).to_simple_event().as_composite_set()
crate = BodySpecification.from_event("crate", outer - inner).spawn(world)

# The convex hull of a point cloud.
hull = BodySpecification.from_3d_points(
    "hull",
    [Point3(0, 0, 0), Point3(0.2, 0, 0), Point3(0, 0.2, 0), Point3(0, 0, 0.2)],
).spawn(world)

assert len(milk_mesh.collision.shapes) == 1
# Carving the interior out of the crate leaves a composite of boxes.
assert len(crate.collision.shapes) > 1
assert len(hull.collision.shapes) == 1
print("Crate is a composite of", len(crate.collision.shapes), "boxes")
Crate is a composite of 4 boxes

A body specification also exposes body-only fields that the shape constructors leave at their defaults: inertial for inertia properties and visual_shapes for a visual geometry that differs from the collision geometry.

Because a specification carries its geometry, it can be measured before anything is spawned: scale returns the extents of the combined collision geometry — the world-independent counterpart of a spawned entity’s scale.

from semantic_digital_twin.world_description.shape_collection import ShapeCollection
from semantic_digital_twin.world_description.geometry import Box
from semantic_digital_twin.world_description.inertial_properties import Inertial

world = World.create_with_root_body()

detailed = BodySpecification(
    name="detailed_box",
    shapes=Box(scale=Scale(1, 1, 1)).as_shape_collection(),
    visual_shapes=ShapeCollection([Box(scale=Scale(1.1, 1.1, 1.1))]),
    inertial=Inertial(mass=2.0),
).spawn(world)

assert detailed.visual is not detailed.collision
assert len(detailed.visual.shapes) == 1
assert detailed.inertial.mass == 2.0
assert BodySpecification.box("probe", Scale(1.2, 0.8, 0.05)).scale == Scale(1.2, 0.8, 0.05)
print("Distinct visual/collision geometry, mass =", detailed.inertial.mass)
Distinct visual/collision geometry, mass = 2.0

Region specifications#

A RegionSpecification is the Region analogue of BodySpecification. Regions describe abstract spatial areas rather than physical objects (see Regions), so a region specification carries no inertia or visuals — only geometry, a pose, and children. It shares the same shape constructors, including parent_T_self.

from semantic_digital_twin.api import RegionSpecification

world = World.create_with_root_body()

placement_area = RegionSpecification.box(
    name="placement_area", 
    scale=Scale(0.4, 0.4, 0.01)
).spawn(world)
assert len(placement_area.area.shapes) == 1
print("Spawned region:", placement_area.name)
Spawned region: placement_area

Connection specifications#

By default a spawned body is rigidly fixed to its parent. To give it a degree of freedom — a drawer that slides, a door that swings — pair the body specification with a connection specification. Each connection family is its own specification type that carries exactly the parameters that family needs:

Specification

Connection

Use for

FixedConnectionSpecification

FixedConnection

A constant relative pose (the default)

Connection6DoFSpecification

Connection6DoF

A free-floating object that may move and rotate freely

PrismaticConnectionSpecification

PrismaticConnection

One translational DoF (a sliding drawer)

RevoluteConnectionSpecification

RevoluteConnection

One rotational DoF (a swinging door)

ScrewConnectionSpecification

ScrewConnection

One DoF coupling rotation and translation (a bottle cap on its thread)

Every entity specification carries an optional connection_specification; when it is left unset, spawn attaches the entity with a fixed connection. Set it to give the entity a degree of freedom. The active families (Prismatic/Revolute/Screw) require a movement axis, and optionally accept a multiplier, an offset, and dof_limits. A ScrewConnectionSpecification additionally requires a screw_pitch: the distance between adjacent threads along the axis, which is what couples its rotation to its translation.

from semantic_digital_twin.api import PrismaticConnectionSpecification
from semantic_digital_twin.spatial_types import Vector3

world = World.create_with_root_body()

drawer = BodySpecification.box(
    name="drawer",
    scale=Scale(0.4, 0.5, 0.2),
    connection_specification=PrismaticConnectionSpecification(axis=Vector3.Z()),
).spawn(world)

assert isinstance(drawer.parent_connection, PrismaticConnection)
print("Drawer is attached by a", type(drawer.parent_connection).__name__)
Drawer is attached by a PrismaticConnection

Warning

An active connection without an axis is rejected at spawn time. Spawning a PrismaticConnectionSpecification() (no axis) raises MissingConnectionAxisError, because the connection cannot generate its degree of freedom without one.

Connecting existing entities#

The connection specifications above are also usable on their own, to join two entities that already exist. Unlike a spawn, which materializes a new entity, connect requires you to supply the child explicitly — a connection joins two pre-existing entities, it does not create one. If you omit parent, the world root is used.

This pairs naturally with to_domain_object, which materializes a free-standing body without attaching it anywhere.

from semantic_digital_twin.api import FixedConnectionSpecification

world = World.create_with_root_body()

# Materialize a body in isolation, then attach it with an explicit connection.
free_body = BodySpecification.box("crate", Scale(0.3, 0.3, 0.3)).to_domain_object()
connection = FixedConnectionSpecification().connect(
    world,
    parent=world.root,
    child=free_body,
    parent_T_connection=HomogeneousTransformationMatrix.from_xyz_rpy(x=1, y=2, z=3),
)

assert connection.child is free_body
root_T_crate = world.compute_forward_kinematics(world.root, free_body)
np.testing.assert_allclose(root_T_crate.to_position().to_np()[:3], [1, 2, 3])
print("Crate position:", root_T_crate.to_position().to_np()[:3].tolist())
Crate position: [1.0, 2.0, 3.0]

Semantic annotation specifications#

Specifications also describe semantic annotations. There are two ways to build one, trading convenience for control.

Building an annotation specification directly#

SemanticAnnotationWithRootSpecification couples an annotation type with the specification of the body or region it is rooted in. Use it when you want full control over the root geometry (any BodySpecification/RegionSpecification you like), its pose, and its name. Spawning it materializes the root entity, attaches it, registers the annotation, and materializes any children.

from semantic_digital_twin.api import SemanticAnnotationWithRootSpecification
from semantic_digital_twin.semantic_annotations.semantic_annotations import Milk

world = World.create_with_root_body()

milk = SemanticAnnotationWithRootSpecification(
    name="milk",
    semantic_annotation_type=Milk,
    root_specification=BodySpecification.box(
         name="milk", 
         scale=Scale(0.1, 0.1, 0.2),
         parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=0.3, z=0.8)
    ),
).spawn(world)

assert isinstance(milk, Milk)
assert milk in world.semantic_annotations
assert isinstance(milk.root.parent_connection, FixedConnection)
print("Spawned", type(milk).__name__, "rooted via", type(milk.root.parent_connection).__name__)
Spawned Milk rooted via FixedConnection

The root entity is what attaches to the parent, so the connection lives on the root specification. You rarely have to state it: each annotation type builds its own through parent_connection_specification() — a FixedConnectionSpecification for most annotations, a PrismaticConnectionSpecification for a Slider, a RevoluteConnectionSpecification for a Hinge. Because each connection family carries exactly the parameters it uses, that method takes the parameters of that family and no others: Slider.parent_connection_specification(axis=...) is valid, while offering an axis to a fixed annotation is an error rather than a silently ignored argument.

What a type declares is only a default: setting connection_specification on the root specification replaces it, so a Milk may rest rigidly or float freely as a Connection6DoF.

from semantic_digital_twin.semantic_annotations.semantic_annotations import Slider

world = World.create_with_root_body()

slider = SemanticAnnotationWithRootSpecification(
    name="slider",
    semantic_annotation_type=Slider,
    root_specification=BodySpecification.box("slider", Scale(0.1, 0.1, 0.1)),
).spawn(world)  # Slider's default parent connection is prismatic about z

assert isinstance(slider.root.parent_connection, PrismaticConnection)
print("Slider root is attached by a", type(slider.root.parent_connection).__name__)
Slider root is attached by a PrismaticConnection

Default root specifications and nested parts#

Most annotation classes can build their own geometry from a Scale. get_default_root_specification returns a ready-made BodySpecification (or RegionSpecification) with that geometry filled in. This is the easy path: it hides the geometry construction — which for many annotations is a composite shape built from random events (a hollow handle, a carved container case, a wall minus its apertures) — behind a single scale. Reach for it when a default shape is good enough, and build the root specification directly (previous section) when you need a custom root shape.

get_specification then wraps any root specification into the type’s SemanticAnnotationWithRootSpecification, so building an annotation specification is two composed calls. Geometry parameters beyond the scale — a handle’s thickness, a container case’s wall_thickness — live on get_default_root_specification, whose signature names them, so geometry is described in exactly one place:

Drawer.get_specification(
    "drawer",
    Drawer.get_default_root_specification(
        scale=Scale(0.4, 0.5, 0.6), wall_thickness=0.05
    ),
)

That builder also takes a connection_specification, exactly as BodySpecification.box(...) does, so custom geometry and a custom attachment stay a single expression. Neither call needs a name for the root: a root specification built without one defers naming, and the annotation stamps its own name onto the root entity at spawn time.

get_specification’s part_specifications argument mounts nested annotations onto part-whole relationship fields, keyed by the field name — spelled out before anything touches a world.

from semantic_digital_twin.semantic_annotations.semantic_annotations import Drawer, Handle

world = World.create_with_root_body()

drawer = Drawer.get_specification(
    "drawer",
    Drawer.get_default_root_specification(scale=Scale(0.4, 0.5, 0.6)),
    part_specifications={
        "handle": Handle.get_specification(
            "handle", Handle.get_default_root_specification(scale=Scale(0.1, 0.05, 0.05))
        ),
    },
).spawn(world)

assert isinstance(drawer.handle, Handle)
assert drawer.handle.root.parent_connection.parent is drawer.root
print("Drawer has a", type(drawer.handle).__name__, "mounted on its root")
Drawer has a Handle mounted on its root

Annotation specifications take the same spawn-time overrides as entity specifications: name, parent, and parent_T_self. Because the annotation stamps its name onto its root, one specification can produce many identically shaped annotations under different names and poses.

handle_specification = Handle.get_specification(
    "handle", Handle.get_default_root_specification(scale=Scale(0.1, 0.05, 0.05))
)
left = handle_specification.spawn(
    world,
    name="left_handle",
    parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(y=-0.2),
)
right = handle_specification.spawn(
    world,
    name="right_handle",
    parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(y=0.2),
)

assert left.name.name == "left_handle" and right.name.name == "right_handle"
assert left.root.name.name == "left_handle"
print("One specification, two handles:", left.name, "and", right.name)
One specification, two handles: left_handle and right_handle

Inert constructor fields#

annotation_kwargs carries the annotation’s inert constructor fields — plain dataclass fields that are neither geometry nor part-whole relationships. They are handed to the annotation constructor unchanged at spawn time. A Table, for example, references the region it supports objects on:

from semantic_digital_twin.semantic_annotations.semantic_annotations import Table

world = World.create_with_root_body()

surface = RegionSpecification.box("surface", Scale(1, 1, 0.01)).spawn(world)
table = Table.get_specification(
    "table",
    Table.get_default_root_specification(scale=Scale(1, 1, 0.5)),
    annotation_kwargs={"supporting_surface": surface},
).spawn(world)

assert table.supporting_surface is surface
print("Table supports objects on", table.supporting_surface.name)
Table supports objects on surface

To-many parts and geometry that reacts to them#

A list value mounts several parts onto a to-many field, while a single value mounts onto a singular field. The wall/aperture pair below shows two more behaviors at once: an Aperture is region-rooted — its root is a Region, not a Body, so its default root specification is a RegionSpecification — and a Wall cuts its collision geometry around the mounted apertures at spawn time. Each part’s placement in the whole is its root specification’s parent_T_self, which is a plain field you can set after building the specification.

from semantic_digital_twin.semantic_annotations.semantic_annotations import Aperture, Wall
from semantic_digital_twin.world_description.world_entity import Region

world = World.create_with_root_body()

plain_wall = Wall.get_specification(
    "plain_wall", Wall.get_default_root_specification(scale=Scale(0.1, 3, 3))
).spawn(world)

window_left = Aperture.get_specification(
    "window_left", Aperture.get_default_root_specification(scale=Scale(0.1, 0.5, 0.5))
)
window_left.root_specification.parent_T_self = (
    HomogeneousTransformationMatrix.from_xyz_rpy(y=-0.8)
)
window_right = Aperture.get_specification(
    "window_right", Aperture.get_default_root_specification(scale=Scale(0.1, 0.5, 0.5))
)
window_right.root_specification.parent_T_self = (
    HomogeneousTransformationMatrix.from_xyz_rpy(y=0.8)
)

wall = Wall.get_specification(
    "wall",
    Wall.get_default_root_specification(scale=Scale(0.1, 3, 3)),
    part_specifications={"apertures": [window_left, window_right]},
).spawn(world)

assert len(wall.apertures) == 2
assert all(isinstance(aperture.root, Region) for aperture in wall.apertures)
# Cutting the windows out of the wall turns one box into a composite.
assert len(wall.root.collision.shapes) > len(plain_wall.root.collision.shapes)
print(
    "Wall went from", len(plain_wall.root.collision.shapes),
    "shape to", len(wall.root.collision.shapes), "shapes",
)
Wall went from 1 shape to 5 shapes

Parts that carry the whole#

Mounting a part onto a mechanical_joint field does more than attach a child: the joint is what the whole hangs from. Spawning rewires the tree to parent -> joint -> whole, with the joint’s own parent connection (here a revolute connection about z) carrying the motion.

from semantic_digital_twin.semantic_annotations.semantic_annotations import Hinge
from semantic_digital_twin.world_description.connections import RevoluteConnection

world = World.create_with_root_body()

hinge_part = Hinge.get_specification(
    "hinge",
    Hinge.get_default_root_specification(scale=Scale(0.05, 0.05, 0.05)),
    parent_connection_specification=Hinge.parent_connection_specification(
        axis=Vector3.Z()
    ),
)
flap = Drawer.get_specification(
    "flap",
    Drawer.get_default_root_specification(scale=Scale(0.4, 0.5, 0.2)),
    part_specifications={"mechanical_joint": hinge_part},
).spawn(world)

# world.root -(revolute)-> hinge -(fixed)-> flap
assert flap.root.parent_connection.parent is flap.mechanical_joint.root
assert flap.mechanical_joint.root.parent_connection.parent is world.root
assert isinstance(flap.mechanical_joint.root.parent_connection, RevoluteConnection)
print("The flap hangs from its", type(flap.mechanical_joint).__name__)
The flap hangs from its Hinge

The specification validates part keys at construction time, so misuse — a list on a singular field, a key that is not a part-whole field, or a part-whole field smuggled in through annotation_kwargs — fails fast, before any world is mutated.

World specifications#

The largest building block is WorldSpecification, which describes an entire scene: an environment, an optional robot, and the objects placed around them. Its environment is a concrete World — usually parsed from a model file with the from_urdf or from_mjcf classmethods. Calling to_domain_object returns a fresh, augmented world every time; the stored environment is deep-copied and never mutated, so one specification can produce many independent worlds.

import os
from importlib.resources import files
from pathlib import Path

from semantic_digital_twin.api import WorldSpecification

table_urdf = os.path.join(
    Path(files("semantic_digital_twin")).parent.parent, "resources", "urdf", "table.urdf"
)

specification = WorldSpecification.from_urdf(
    table_urdf,
    objects=[
        SemanticAnnotationWithRootSpecification(
            name="milk",
            semantic_annotation_type=Milk,
            root_specification=BodySpecification.box("milk", Scale(0.1, 0.1, 0.2)),
        ),
        BodySpecification.box("cup", Scale(0.07, 0.07, 0.1)),
    ],
)

world = specification.to_domain_object()
assert len(world.get_semantic_annotations_by_type(Milk)) == 1
assert world.get_body_by_name("cup") is not None

# The specification is reusable: each call yields an independent world.
another_world = specification.to_domain_object()
assert world is not another_world
assert len(another_world.get_semantic_annotations_by_type(Milk)) == 1
print("Materialized two independent worlds, each with one milk and one cup")
Materialized two independent worlds, each with one milk and one cup

from_mjcf is the MJCF twin of from_urdf and takes the same arguments:

mjcf_world = WorldSpecification.from_mjcf(
    str(resources / "mjcf" / "table.xml"),
    objects=[BodySpecification.box("cup", Scale(0.07, 0.07, 0.1))],
).to_domain_object()

assert mjcf_world.get_body_by_name("cup") is not None
print("MJCF table world has", len(mjcf_world.bodies), "bodies")
MJCF table world has 8 bodies

Adding a robot#

A world specification can also merge robots into the environment. Each one is described by a RobotSpecification, which bundles a robot’s semantic annotation class with the poses that place it: world_T_odom sets the localization pose, and odom_T_robot_start the robot’s start pose. The robot is parsed from its own description and inserted as world.root -> odom -> connection -> robot. The connection attaching the robot to its odom is the drive determined by the robot’s mobile base, or a fixed connection when the robot has no mobile base.

from semantic_digital_twin.api import RobotSpecification
from semantic_digital_twin.robots.pr2 import PR2

world = WorldSpecification.from_urdf(
    file_path=table_urdf,
    robots=[
        RobotSpecification(
            semantic_annotation_type=PR2,
            world_T_odom=HomogeneousTransformationMatrix.from_xyz_rpy(x=1.0),
            odom_T_robot_start=HomogeneousTransformationMatrix.from_xyz_rpy(y=2.0),
        )
    ],
).to_domain_object()
Unknown attribute "type" in /robot[@name='pr2']/link[@name='base_laser_link']
Unknown attribute "type" in /robot[@name='pr2']/link[@name='wide_stereo_optical_frame']
Unknown attribute "type" in /robot[@name='pr2']/link[@name='narrow_stereo_optical_frame']
Unknown attribute "type" in /robot[@name='pr2']/link[@name='laser_tilt_link']
Unknown tag "material" in /robot[@name='pr2']/link[@name='l_force_torque_link']/collision[1]
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
Cell In[19], line 13
      9             world_T_odom=HomogeneousTransformationMatrix.from_xyz_rpy(x=1.0),
     10             odom_T_robot_start=HomogeneousTransformationMatrix.from_xyz_rpy(y=2.0),
     11         )
     12     ],
---> 13 ).to_domain_object()

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/api.py:1306, in WorldSpecification.to_domain_object(self)
   1304 world = deepcopy(self.world)
   1305 for robot_specification in self.robots:
-> 1306     robot_specification.spawn(world)
   1308 for object_specification in self.objects:
   1309     object_specification.spawn(world)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/api.py:1112, in RobotSpecification.spawn(self, world)
   1107 connection_type = self.semantic_annotation_type.get_drive_connection_type()
   1108 is_active = issubclass(connection_type, ActiveConnection)
   1110 robot_world = URDFParser.from_file(
   1111     self.semantic_annotation_type.get_ros_file_path()
-> 1112 ).parse()
   1113 robot_id = self.semantic_annotation_type.from_world(robot_world).id
   1115 with world.modify_world():

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/adapters/urdf.py:193, in URDFParser.parse(self)
    191 world = World()
    192 world.name = self.prefix
--> 193 with world.modify_world():
    194     world.add_kinematic_structure_entity(root)
    195     main_joints = []

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/world.py:255, in WorldModelUpdateContextManager.__exit__(self, exc_type, exc_val, exc_tb)
    253 try:
    254     if exc_type is None:
--> 255         self.world._notify_model_change(
    256             publish_changes=self.publish_changes
    257         )
    258         run_pending_publications = True
    259 finally:

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/world.py:1792, in World._notify_model_change(self, publish_changes, **kwargs)
   1786 def _notify_model_change(self, publish_changes: bool = True, **kwargs) -> None:
   1787     """
   1788     Notifies the system of a model change and updates the necessary states, caches,
   1789     and forward kinematics expressions while also triggering registered callbacks
   1790     for model changes.
   1791     """
-> 1792     self._model_manager.update_model_version_and_notify_callbacks(
   1793         publish_changes=publish_changes, **kwargs
   1794     )
   1795     self.notify_state_change(publish_changes=publish_changes, **kwargs)
   1797     for callback in list(self.state.state_change_callbacks):

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/world.py:404, in WorldModelManager.update_model_version_and_notify_callbacks(self, **kwargs)
    402 self.version += 1
    403 for callback in list(self.model_change_callbacks):
--> 404     callback.notify_model_change(**kwargs)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/callbacks/callback.py:115, in ModelChangeCallback.notify_model_change(self, **kwargs)
    113 def notify_model_change(self, **kwargs):
    114     if not self._is_paused:
--> 115         self.on_model_change(**kwargs)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/collision_detector.py:142, in CollisionDetectorModelUpdater.on_model_change(self, **kwargs)
    140 if self._world.is_empty():
    141     return
--> 142 self.collision_detector.sync_world_model()
    143 self.compile_collision_fks()

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:337, in BulletCollisionDetector.sync_world_model(self)
    335     return
    336 for body in self._world.bodies_with_collision:
--> 337     self.add_body(body)
    338 self._ordered_bullet_objects = list(self.body_to_bullet_object.values())

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:352, in BulletCollisionDetector.add_body(self, body)
    351 def add_body(self, body: Body):
--> 352     o = create_shape_from_body(body=body, mesh_decomposer=self.mesh_decomposer)
    353     self.kineverse_world.add_collision_object(o)
    354     self.body_to_bullet_object[body] = o

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:173, in create_shape_from_body(body, mesh_decomposer)
    171 shapes = []
    172 for collision_id, geometry in enumerate(body.collision):
--> 173     shape = create_shape_from_geometry(
    174         geometry=geometry, mesh_decomposer=mesh_decomposer
    175     )
    176     link_T_geometry = bullet.Transform.from_np(geometry.origin.to_np())
    177     shapes.append((link_T_geometry, shape))

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:147, in create_shape_from_geometry(geometry, mesh_decomposer)
    142     shape = create_cylinder_shape(
    143         diameter=geometry.width, height=geometry.height
    144     )
    146 case Mesh():
--> 147     shape = load_convex_mesh_shape(
    148         mesh=geometry,
    149         single_shape=False,
    150         scale=geometry.scale,
    151         mesh_decomposer=mesh_decomposer,
    152     )
    154 case _:
    155     raise NotImplementedError()

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:213, in load_convex_mesh_shape(mesh, single_shape, scale, mesh_decomposer)
    203 """
    204 Loads a convex mesh shape from a mesh.
    205 
   (...)    210 :return: the bullet convex shape.
    211 """
    212 if not mesh.mesh.is_convex and mesh_decomposer is not None:
--> 213     obj_pkg_filename = convert_to_decomposed_obj_and_save_in_tmp(
    214         mesh=mesh, mesh_decomposer=mesh_decomposer
    215     )
    216 else:
    217     obj_pkg_filename = mesh.filename

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:260, in convert_to_decomposed_obj_and_save_in_tmp(mesh, mesh_decomposer, cache_dir, log_path)
    258 if not trimesh_obj.is_convex and mesh_decomposer is not None:
    259     with suppress_stdout_stderr():
--> 260         mesh_decomposer.apply_to_mesh_and_save(mesh, obj_file_name)
    261     logging.info(f'Saved convex decomposition to "{obj_file_name}".')
    262 else:

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/pipeline/mesh_decomposition/vhacd.py:132, in VHACDMeshDecomposer.apply_to_mesh_and_save(self, mesh, output_path)
    131 def apply_to_mesh_and_save(self, mesh: Mesh, output_path: str) -> str:
--> 132     parts = self.apply_to_mesh(mesh)
    133     trimesh.Scene([p.mesh for p in parts]).export(output_path, file_type="obj")
    134     return output_path

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/pipeline/mesh_decomposition/vhacd.py:113, in VHACDMeshDecomposer.apply_to_mesh(self, mesh)
    112 def apply_to_mesh(self, mesh: Mesh) -> List[Mesh]:
--> 113     decomposed = mesh.mesh.convex_decomposition(
    114         maxConvexHulls=self.max_convex_hulls,
    115         resolution=self.resolution,
    116         minimumVolumePercentErrorAllowed=self.minimum_volume_percent_error_allowed,
    117         maxRecursionDepth=self.max_recursion_depth,
    118         shrinkWrap=self.shrink_wrap,
    119         fillMode=self.fill_mode.value,
    120         maxNumVerticesPerCH=self.max_vertices_per_convex_hull,
    121         asyncACD=self.asynchronous,
    122         minEdgeLength=self.min_edge_length,
    123         findBestPlane=self.find_best_plane,
    124     )
    125     new_geometry = [
    126         Mesh.from_trimesh(mesh=decomposed_part, origin=mesh.origin)
    127         for decomposed_part in decomposed
    128     ]
    129     return new_geometry

File /opt/ros/cram-env/lib/python3.12/site-packages/trimesh/base.py:3031, in Trimesh.convex_decomposition(self, **kwargs)
   3018 def convex_decomposition(self, **kwargs) -> list["Trimesh"]:
   3019     """
   3020     Compute an approximate convex decomposition of a mesh
   3021     using `pip install pyVHACD`.
   (...)   3027     **kwargs : VHACD keyword arguments
   3028     """
   3029     return [
   3030         Trimesh(**kwargs)
-> 3031         for kwargs in decomposition.convex_decomposition(self, **kwargs)
   3032     ]

File /opt/ros/cram-env/lib/python3.12/site-packages/trimesh/decomposition.py:48, in convex_decomposition(mesh, **kwargs)
     37 # the faces are triangulated in a (len(face), ...vertex-index)
     38 # for vtkPolyData
     39 # i.e. so if shaped to four columns the first column is all 3
     40 faces = (
     41     np.column_stack((np.ones(len(mesh.faces), dtype=np.int64) * 3, mesh.faces))
     42     .ravel()
     43     .astype(np.uint32)
     44 )
     46 return [
     47     {"vertices": v, "faces": f}
---> 48     for v, f in compute_vhacd(mesh.vertices, faces, **kwargs)
     49 ]

KeyboardInterrupt: 

Because robots is a list, a world can hold several of them, each with its own localization — including several instances of the same robot. Names are not unique in a world, so two PR2s contribute two joints called pr2/torso_lift_joint. Each robot is therefore annotated while it still owns the world it was parsed into, and its odom body carries its own identifier as a name prefix, so the localization frames stay distinguishable.

two_robot_world = WorldSpecification.from_urdf(
    file_path=table_urdf,
    robots=[
        RobotSpecification(
            semantic_annotation_type=PR2,
            world_T_odom=HomogeneousTransformationMatrix.from_xyz_rpy(x=1.0),
        ),
        RobotSpecification(
            semantic_annotation_type=PR2,
            world_T_odom=HomogeneousTransformationMatrix.from_xyz_rpy(x=-1.0),
        ),
    ],
).to_domain_object()

assert len(two_robot_world.get_semantic_annotations_by_type(PR2)) == 2
print("Two independently localized PR2s share one world")

Putting it together#

The example below assembles a small tabletop scene with a single specification — a table from URDF, a drawer with a handle, and a milk carton placed above the table — and visualizes the result. The milk’s pose is baked into its root body specification through parent_T_self.

world = WorldSpecification.from_urdf(
    file_path=table_urdf,
    objects=[
        Drawer.get_specification(
            "drawer",
            Drawer.get_default_root_specification(scale=Scale(0.4, 0.5, 0.3)),
            part_specifications={
                "handle": Handle.get_specification(
                    "handle",
                    Handle.get_default_root_specification(scale=Scale(0.1, 0.05, 0.05)),
                ),
            },
        ),
        SemanticAnnotationWithRootSpecification(
            name="milk",
            semantic_annotation_type=Milk,
            root_specification=BodySpecification.box(
                "milk",
                Scale(0.1, 0.1, 0.2),
                parent_T_self=HomogeneousTransformationMatrix.from_xyz_rpy(x=0.3, z=0.8),
            ),
        ),
    ],
).to_domain_object()

assert len(world.get_semantic_annotations_by_type(Drawer)) == 1
assert len(world.get_semantic_annotations_by_type(Milk)) == 1
print("Semantic annotations:")
print(*world.semantic_annotations, sep="\n")

rt = RayTracer(world)
rt.update_scene()
rt.scene.show("jupyter")

Warning

As with the other tutorials, visualizing a world directly in a notebook with the RayTracer is only meant for quick inspection. For proper visualization, see Visualizing Worlds.

If you think you have understood everything in this tutorial, you may try out our self-assessment quiz for this user guide