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.specifications.kinematic_structure_entities 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.from_rgba(red=0.6, green=0.4, blue=0.2, opacity=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 krrood_spatial_math.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.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 krrood_spatial_math.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.specifications.kinematic_structure_entities 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.specifications.connections import (
    PrismaticConnectionSpecification,
)
from krrood_spatial_math.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.specifications.connections 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.position.to_np()[:3], [1, 2, 3])
print("Crate position:", root_T_crate.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.specifications.semantic_annotations 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. Left unset, the root is fixed to its parent. Because each connection family carries exactly the parameters it uses, offering an axis to a fixed connection is an error rather than a silently ignored argument.

Setting connection_specification on the root specification chooses another connection, so a Milk may rest rigidly or float freely as a Connection6DoF, and a drawer slides on the prismatic connection its creator gives it. That connection is the drawer’s movable_joint.

from semantic_digital_twin.semantic_annotations.semantic_annotations import Drawer

world = World.create_with_root_body()

drawer = SemanticAnnotationWithRootSpecification(
    name="drawer",
    semantic_annotation_type=Drawer,
    root_specification=BodySpecification.box(
        "drawer",
        Scale(0.4, 0.5, 0.2),
        connection_specification=PrismaticConnectionSpecification(axis=Vector3.X()),
    ),
).spawn(world)

assert drawer.movable_joint is drawer.root.parent_connection
assert isinstance(drawer.movable_joint, PrismaticConnection)
print("The drawer slides on a", type(drawer.movable_joint).__name__)
The drawer slides on a PrismaticConnection

Default root specifications and nested parts#

Most annotation classes can build their own geometry from a Scale. get_default_root_kinematic_structure_entity_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_annotation_specification then wraps any root specification into the type’s SemanticAnnotationWithRootSpecification, so building an annotation specification is two composed calls. Both halves are spawnable, so the wrapping step is easy to forget: spawning a root specification on its own yields a bare body or region, with none of the annotation’s semantics attached. Geometry parameters beyond the scale — a handle’s thickness, a container case’s wall_thickness — live on get_default_root_kinematic_structure_entity_specification, whose signature names them, so geometry is described in exactly one place:

Drawer.get_annotation_specification(
    "drawer",
    Drawer.get_default_root_kinematic_structure_entity_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_annotation_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_annotation_specification(
    "drawer",
    Drawer.get_default_root_kinematic_structure_entity_specification(scale=Scale(0.4, 0.5, 0.6)),
    part_specifications={
        "handle": Handle.get_annotation_specification(
            "handle", Handle.get_default_root_kinematic_structure_entity_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_annotation_specification(
    "handle", Handle.get_default_root_kinematic_structure_entity_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_annotation_specification(
    "table",
    Table.get_default_root_kinematic_structure_entity_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_annotation_specification(
    "plain_wall", Wall.get_default_root_kinematic_structure_entity_specification(scale=Scale(0.1, 3, 3))
).spawn(world)

window_left = Aperture.get_annotation_specification(
    "window_left", Aperture.get_default_root_kinematic_structure_entity_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_annotation_specification(
    "window_right", Aperture.get_default_root_kinematic_structure_entity_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_annotation_specification(
    "wall",
    Wall.get_default_root_kinematic_structure_entity_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

Joints away from the child’s origin#

A door’s origin sits at its centre, but it swings about the hinge at its edge. The connection moves about its own frame, and connection_T_child places the child relative to that frame, so the door hangs on a single revolute connection without a body for the hinge. parent_T_self stays the pose of the door itself while the hinge is at its zero position.

from semantic_digital_twin.specifications.connections import (
    RevoluteConnectionSpecification,
)
from semantic_digital_twin.semantic_annotations.semantic_annotations import Door
from semantic_digital_twin.world_description.connections import RevoluteConnection

world = World.create_with_root_body()

hinge_T_door = HomogeneousTransformationMatrix.from_xyz_rpy(y=0.5)
door = Door.get_annotation_specification(
    "door",
    Door.get_default_root_kinematic_structure_entity_specification(scale=Scale(0.03, 1, 2)),
    parent_connection_specification=RevoluteConnectionSpecification(
        axis=Vector3.Z(), connection_T_child=hinge_T_door
    ),
).spawn(world)
assert isinstance(door.movable_joint, RevoluteConnection)

door.movable_joint.position = np.pi / 2
world.notify_state_change()

# The door's centre swung a quarter turn around the hinge, not around itself.
expected = hinge_T_door.inverse() @ HomogeneousTransformationMatrix.from_xyz_rpy(yaw=np.pi / 2) @ hinge_T_door
assert np.allclose(door.root.global_transform.to_np(), expected.to_np())
print("Door centre after opening:", door.root.global_transform.position.to_np()[:3])
Door centre after opening: [-0.5 -0.5  0. ]

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 comes from a world source — usually a parser obtained from a model file with the from_urdf or from_mjcf classmethods, or a map written in code. Calling create_world creates the environment anew every time, so one specification can produce many independent worlds and no two of them share an entity identifier.

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

from semantic_digital_twin.specifications.worlds 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.create_world()
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.create_world()
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))],
).create_world()

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.specifications.robots 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),
        )
    ],
).create_world()
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 ).create_world()

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/specifications/worlds.py:166, in WorldSpecification.create_world(self)
    164     world = World.create_with_root_body()
    165 for robot_specification in self.robots:
--> 166     robot_specification.spawn(world)
    168 for object_specification in self.objects:
    169     object_specification.spawn(world)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/specifications/robots.py:97, in RobotSpecification.spawn(self, world)
     94 is_active = issubclass(connection_type, ActiveConnection)
     95 drive_velocity_limits = self._drive_velocity_limits(connection_type)
---> 97 described_robot = self.semantic_annotation_type.from_description()
     98 robot_world = described_robot._world
     99 robot_id = described_robot.id

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/robots/robot_parts.py:1270, in AbstractRobot.from_description(cls)
   1259 @classmethod
   1260 def from_description(cls) -> Self:
   1261     """
   1262     :return: This robot, in a world holding nothing but it, read from its own
   1263         description: the URDF :meth:`get_ros_file_path` points to, unless the robot
   1264         is described in another format.
   1265     """
   1266     return cls.from_world(
   1267         URDFParser.from_file(
   1268             cls.get_ros_file_path(),
   1269             use_visual_as_collision_backup=cls.uses_visual_as_collision_backup,
-> 1270         ).create_world()
   1271     )

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/adapters/urdf.py:238, in URDFParser.create_world(self)
    236 world = World()
    237 world.name = self.prefix
--> 238 with world.modify_world():
    239     world.add_kinematic_structure_entity(root)
    240     main_joints = []

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/world.py:314, in WorldModelUpdateContextManager.__exit__(self, exc_type, exc_val, exc_tb)
    312 try:
    313     if exc_type is None:
--> 314         self.world._notify_model_change(
    315             publish_changes=self.publish_changes
    316         )
    317         run_pending_publications = True
    318 finally:

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/world.py:2109, in World._notify_model_change(self, publish_changes, **kwargs)
   2103 def _notify_model_change(self, publish_changes: bool = True, **kwargs) -> None:
   2104     """
   2105     Notifies the system of a model change and updates the necessary states, caches,
   2106     and forward kinematics expressions while also triggering registered callbacks
   2107     for model changes.
   2108     """
-> 2109     self._model_manager.update_model_version_and_notify_callbacks(
   2110         publish_changes=publish_changes, **kwargs
   2111     )
   2112     self.notify_state_change(
   2113         publish_changes=publish_changes, force_republish=True, **kwargs
   2114     )
   2116     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:521, in WorldModelManager.update_model_version_and_notify_callbacks(self, **kwargs)
    519 self.version += 1
    520 for callback in list(self.model_change_callbacks):
--> 521     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:170, in ModelChangeCallback.notify_model_change(self, **kwargs)
    168 def notify_model_change(self, **kwargs):
    169     if not self._is_paused:
--> 170         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:452, in BulletCollisionDetector.sync_world_model(self)
    450     return
    451 for body in self._world.bodies_with_collision:
--> 452     self.add_body(body)
    453 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:467, in BulletCollisionDetector.add_body(self, body)
    466 def add_body(self, body: Body):
--> 467     o = create_shape_from_body(body=body, mesh_decomposer=self.mesh_decomposer)
    468     self.kineverse_world.add_collision_object(o)
    469     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:239, in create_shape_from_body(body, mesh_decomposer)
    237 shapes = []
    238 for collision_id, geometry in enumerate(body.collision):
--> 239     shape = create_shape_from_geometry(
    240         geometry=geometry, mesh_decomposer=mesh_decomposer
    241     )
    242     link_T_geometry = bullet.Transform.from_np(geometry.origin.to_np())
    243     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:212, in create_shape_from_geometry(geometry, mesh_decomposer)
    207     shape = create_cylinder_shape(
    208         diameter=geometry.width, height=geometry.height
    209     )
    211 case Mesh():
--> 212     shape = load_convex_mesh_shape(
    213         mesh=geometry,
    214         single_shape=False,
    215         scale=geometry.scale,
    216         mesh_decomposer=mesh_decomposer,
    217     )
    219 case _:
    220     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:279, in load_convex_mesh_shape(mesh, single_shape, scale, mesh_decomposer)
    269 """
    270 Loads a convex mesh shape from a mesh.
    271 
   (...)    276 :return: the bullet convex shape.
    277 """
    278 if not mesh.mesh.is_convex and mesh_decomposer is not None:
--> 279     obj_pkg_filename = convert_to_decomposed_obj_and_save_in_tmp(
    280         mesh=mesh, mesh_decomposer=mesh_decomposer
    281     )
    282 elif mesh.local_file.suffix.removeprefix(".").lower() in BulletMeshFileFormat:
    283     obj_pkg_filename = str(mesh.local_file)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:331, in convert_to_decomposed_obj_and_save_in_tmp(mesh, mesh_decomposer, cache_dir, log_path)
    328         with suppress_stdout_stderr():
    329             mesh_decomposer.apply_to_mesh_and_save(mesh, unfinished_file_name)
--> 331 return save_in_cache(
    332     obj_file_name=str(cache_dir / f"{trimesh_quantized_hash(trimesh_obj)}.obj"),
    333     write=write,
    334 )

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:380, in save_in_cache(obj_file_name, write)
    376 with tempfile.NamedTemporaryFile(
    377     dir=os.path.dirname(obj_file_name), suffix=".obj", delete=False
    378 ) as unfinished_file:
    379     pass
--> 380 write(unfinished_file.name)
    381 logging.info(f'Saved obj to "{obj_file_name}".')
    382 os.replace(unfinished_file.name, obj_file_name)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/collision_checking/pybullet_collision_detector.py:329, in convert_to_decomposed_obj_and_save_in_tmp.<locals>.write(unfinished_file_name)
    327 if not trimesh_obj.is_convex and mesh_decomposer is not None:
    328     with suppress_stdout_stderr():
--> 329         mesh_decomposer.apply_to_mesh_and_save(mesh, unfinished_file_name)

File /__w/cognitive_robot_abstract_machine/cognitive_robot_abstract_machine/semantic_digital_twin/src/semantic_digital_twin/pipeline/mesh_decomposition/vhacd.py:131, in VHACDMeshDecomposer.apply_to_mesh_and_save(self, mesh, output_path)
    130 def apply_to_mesh_and_save(self, mesh: Mesh, output_path: str) -> str:
--> 131     parts = self.apply_to_mesh(mesh)
    132     trimesh.Scene([p.mesh for p in parts]).export(output_path, file_type="obj")
    133     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:112, in VHACDMeshDecomposer.apply_to_mesh(self, mesh)
    111 def apply_to_mesh(self, mesh: Mesh) -> list[Mesh]:
--> 112     decomposed = mesh.mesh.convex_decomposition(
    113         maxConvexHulls=self.max_convex_hulls,
    114         resolution=self.resolution,
    115         minimumVolumePercentErrorAllowed=self.minimum_volume_percent_error_allowed,
    116         maxRecursionDepth=self.max_recursion_depth,
    117         shrinkWrap=self.shrink_wrap,
    118         fillMode=self.fill_mode.value,
    119         maxNumVerticesPerCH=self.max_vertices_per_convex_hull,
    120         asyncACD=self.asynchronous,
    121         minEdgeLength=self.min_edge_length,
    122         findBestPlane=self.find_best_plane,
    123     )
    124     new_geometry = [
    125         Mesh.from_trimesh(mesh=decomposed_part, origin=mesh.origin)
    126         for decomposed_part in decomposed
    127     ]
    128     return new_geometry

File /opt/ros/cram-env/lib/python3.12/site-packages/trimesh/base.py:3075, in Trimesh.convex_decomposition(self, **kwargs)
   3062 def convex_decomposition(self, **kwargs) -> list["Trimesh"]:
   3063     """
   3064     Compute an approximate convex decomposition of a mesh
   3065     using `pip install pyVHACD`.
   (...)   3071     **kwargs : VHACD keyword arguments
   3072     """
   3073     return [
   3074         Trimesh(**kwargs)
-> 3075         for kwargs in decomposition.convex_decomposition(self, **kwargs)
   3076     ]

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),
        ),
    ],
).create_world()

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_annotation_specification(
            "drawer",
            Drawer.get_default_root_kinematic_structure_entity_specification(scale=Scale(0.4, 0.5, 0.3)),
            part_specifications={
                "handle": Handle.get_annotation_specification(
                    "handle",
                    Handle.get_default_root_kinematic_structure_entity_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),
            ),
        ),
    ],
).create_world()

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