Skip to content

Figure and scene configuration

See the Figure guide for precedence, schema behavior, and backend semantics.

A composed layer plus reproducible scene-level rendering intent.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class Figure:
    """A composed layer plus reproducible scene-level rendering intent."""

    layer: Layer
    scene: SceneSettings = field(default_factory=SceneSettings)

    def camera(self, camera: Camera | str = "perspective", **kwargs) -> "Figure":
        """Return a Figure with a concrete or high-level camera.

        String kinds ``perspective``, ``orthographic``, and ``thin_lens``
        construct their corresponding camera classes from ``kwargs``. High-level
        kinds ``fit``, ``principal_axis``, ``attribute_extremum``, and ``section``
        compile the layer tree and resolve immediately to a concrete camera.
        """
        if isinstance(camera, str):
            kind = camera
            if kind in {"fit", "principal_axis", "attribute_extremum", "section"}:
                from ..workflow.framing import resolve_camera

                output = self.scene.output
                kwargs.setdefault(
                    "resolution",
                    (output.width, output.height)
                    if output is not None
                    else (1024, 800),
                )
                resolved = resolve_camera(
                    self.layer,
                    cast(
                        Literal[
                            "fit", "principal_axis", "attribute_extremum", "section"
                        ],
                        kind,
                    ),
                    **kwargs,
                )
            elif kind == "perspective":
                resolved = PerspectiveCamera(**kwargs)
            elif kind == "orthographic":
                resolved = OrthographicCamera(**kwargs)
            elif kind == "thin_lens":
                resolved = ThinLensCamera(**kwargs)
            else:
                raise ValueError(f"Unknown camera kind: {kind!r}")
        else:
            if kwargs:
                raise TypeError("Keyword camera options require a string camera kind.")
            resolved = camera
        return replace(self, scene=replace(self.scene, camera=resolved))

    def turntable(self, **kwargs) -> tuple["Figure", ...]:
        """Return fitted Figures at evenly spaced azimuths around the scene.

        Keyword arguments are forwarded to :func:`turntable_cameras`; output
        dimensions default to this Figure's declared output size.
        """
        from ..workflow.framing import turntable_cameras

        output = self.scene.output
        kwargs.setdefault(
            "resolution",
            (output.width, output.height) if output is not None else (1024, 800),
        )
        return tuple(
            replace(self, scene=replace(self.scene, camera=camera))
            for camera in turntable_cameras(self.layer, **kwargs)
        )

    def light(self, light: Light | str = "point", **kwargs) -> "Figure":
        """Return a Figure with one additional point or directional light."""
        if isinstance(light, str):
            kind = light
            if kind == "point":
                resolved: Light = PointLight(**kwargs)
            elif kind == "directional":
                resolved = DirectionalLight(**kwargs)
            else:
                raise ValueError(f"Unknown light kind: {kind!r}")
        else:
            if kwargs:
                raise TypeError("Keyword light options require a string light kind.")
            resolved = light
        lights = self.scene.lights or ()
        return replace(self, scene=replace(self.scene, lights=(*lights, resolved)))

    def clear_lights(self) -> "Figure":
        """Return a Figure with all explicit point and directional lights removed."""
        return replace(self, scene=replace(self.scene, lights=()))

    def environment(
        self, environment: Environment | str | Path | None = None, **kwargs
    ) -> "Figure":
        """Return a Figure with declarative environment settings.

        A string or Path sets the environment-map path. ``None`` constructs an
        Environment from ``kwargs``; pass ``enabled=False`` to disable it.
        """
        if isinstance(environment, (str, Path)):
            environment = Environment(path=Path(environment), **kwargs)
        elif environment is None:
            environment = Environment(**kwargs)
        elif kwargs:
            raise TypeError("Keyword environment options require a path or None.")
        return replace(self, scene=replace(self.scene, environment=environment))

    def output(self, output: OutputSettings | None = None, **kwargs) -> "Figure":
        """Return a Figure with semantic output settings."""
        if output is None:
            output = OutputSettings(**kwargs)
        elif kwargs:
            raise TypeError("Keyword output options cannot accompany OutputSettings.")
        return replace(self, scene=replace(self.scene, output=output))

    def to_config(self, base: Config | None = None) -> Config:
        """Resolve this Figure's scene intent over Config defaults or ``base``."""
        return self.scene.to_config(base)

    def to_spec(self, *, data_ids=None, function_ids=None):
        """Convert this Figure to a canonical, validated FigureSpec."""
        from ..spec import to_spec

        return to_spec(self, data_ids=data_ids, function_ids=function_ids)

    def to_json(self, *, data_ids=None, function_ids=None, indent=2, canonical=False):
        """Serialize this Figure through its canonical specification."""
        return self.to_spec(data_ids=data_ids, function_ids=function_ids).to_json(
            indent=indent, canonical=canonical
        )

camera(camera='perspective', **kwargs)

Return a Figure with a concrete or high-level camera.

String kinds perspective, orthographic, and thin_lens construct their corresponding camera classes from kwargs. High-level kinds fit, principal_axis, attribute_extremum, and section compile the layer tree and resolve immediately to a concrete camera.

Source code in hakowan/grammar/figure.py
def camera(self, camera: Camera | str = "perspective", **kwargs) -> "Figure":
    """Return a Figure with a concrete or high-level camera.

    String kinds ``perspective``, ``orthographic``, and ``thin_lens``
    construct their corresponding camera classes from ``kwargs``. High-level
    kinds ``fit``, ``principal_axis``, ``attribute_extremum``, and ``section``
    compile the layer tree and resolve immediately to a concrete camera.
    """
    if isinstance(camera, str):
        kind = camera
        if kind in {"fit", "principal_axis", "attribute_extremum", "section"}:
            from ..workflow.framing import resolve_camera

            output = self.scene.output
            kwargs.setdefault(
                "resolution",
                (output.width, output.height)
                if output is not None
                else (1024, 800),
            )
            resolved = resolve_camera(
                self.layer,
                cast(
                    Literal[
                        "fit", "principal_axis", "attribute_extremum", "section"
                    ],
                    kind,
                ),
                **kwargs,
            )
        elif kind == "perspective":
            resolved = PerspectiveCamera(**kwargs)
        elif kind == "orthographic":
            resolved = OrthographicCamera(**kwargs)
        elif kind == "thin_lens":
            resolved = ThinLensCamera(**kwargs)
        else:
            raise ValueError(f"Unknown camera kind: {kind!r}")
    else:
        if kwargs:
            raise TypeError("Keyword camera options require a string camera kind.")
        resolved = camera
    return replace(self, scene=replace(self.scene, camera=resolved))

clear_lights()

Return a Figure with all explicit point and directional lights removed.

Source code in hakowan/grammar/figure.py
def clear_lights(self) -> "Figure":
    """Return a Figure with all explicit point and directional lights removed."""
    return replace(self, scene=replace(self.scene, lights=()))

environment(environment=None, **kwargs)

Return a Figure with declarative environment settings.

A string or Path sets the environment-map path. None constructs an Environment from kwargs; pass enabled=False to disable it.

Source code in hakowan/grammar/figure.py
def environment(
    self, environment: Environment | str | Path | None = None, **kwargs
) -> "Figure":
    """Return a Figure with declarative environment settings.

    A string or Path sets the environment-map path. ``None`` constructs an
    Environment from ``kwargs``; pass ``enabled=False`` to disable it.
    """
    if isinstance(environment, (str, Path)):
        environment = Environment(path=Path(environment), **kwargs)
    elif environment is None:
        environment = Environment(**kwargs)
    elif kwargs:
        raise TypeError("Keyword environment options require a path or None.")
    return replace(self, scene=replace(self.scene, environment=environment))

light(light='point', **kwargs)

Return a Figure with one additional point or directional light.

Source code in hakowan/grammar/figure.py
def light(self, light: Light | str = "point", **kwargs) -> "Figure":
    """Return a Figure with one additional point or directional light."""
    if isinstance(light, str):
        kind = light
        if kind == "point":
            resolved: Light = PointLight(**kwargs)
        elif kind == "directional":
            resolved = DirectionalLight(**kwargs)
        else:
            raise ValueError(f"Unknown light kind: {kind!r}")
    else:
        if kwargs:
            raise TypeError("Keyword light options require a string light kind.")
        resolved = light
    lights = self.scene.lights or ()
    return replace(self, scene=replace(self.scene, lights=(*lights, resolved)))

output(output=None, **kwargs)

Return a Figure with semantic output settings.

Source code in hakowan/grammar/figure.py
def output(self, output: OutputSettings | None = None, **kwargs) -> "Figure":
    """Return a Figure with semantic output settings."""
    if output is None:
        output = OutputSettings(**kwargs)
    elif kwargs:
        raise TypeError("Keyword output options cannot accompany OutputSettings.")
    return replace(self, scene=replace(self.scene, output=output))

to_config(base=None)

Resolve this Figure's scene intent over Config defaults or base.

Source code in hakowan/grammar/figure.py
def to_config(self, base: Config | None = None) -> Config:
    """Resolve this Figure's scene intent over Config defaults or ``base``."""
    return self.scene.to_config(base)

to_json(*, data_ids=None, function_ids=None, indent=2, canonical=False)

Serialize this Figure through its canonical specification.

Source code in hakowan/grammar/figure.py
def to_json(self, *, data_ids=None, function_ids=None, indent=2, canonical=False):
    """Serialize this Figure through its canonical specification."""
    return self.to_spec(data_ids=data_ids, function_ids=function_ids).to_json(
        indent=indent, canonical=canonical
    )

to_spec(*, data_ids=None, function_ids=None)

Convert this Figure to a canonical, validated FigureSpec.

Source code in hakowan/grammar/figure.py
def to_spec(self, *, data_ids=None, function_ids=None):
    """Convert this Figure to a canonical, validated FigureSpec."""
    from ..spec import to_spec

    return to_spec(self, data_ids=data_ids, function_ids=function_ids)

turntable(**kwargs)

Return fitted Figures at evenly spaced azimuths around the scene.

Keyword arguments are forwarded to :func:turntable_cameras; output dimensions default to this Figure's declared output size.

Source code in hakowan/grammar/figure.py
def turntable(self, **kwargs) -> tuple["Figure", ...]:
    """Return fitted Figures at evenly spaced azimuths around the scene.

    Keyword arguments are forwarded to :func:`turntable_cameras`; output
    dimensions default to this Figure's declared output size.
    """
    from ..workflow.framing import turntable_cameras

    output = self.scene.output
    kwargs.setdefault(
        "resolution",
        (output.width, output.height) if output is not None else (1024, 800),
    )
    return tuple(
        replace(self, scene=replace(self.scene, camera=camera))
        for camera in turntable_cameras(self.layer, **kwargs)
    )

Optional declarative camera, lighting, environment, and output intent.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class SceneSettings:
    """Optional declarative camera, lighting, environment, and output intent."""

    camera: Camera | None = None
    lights: tuple[Light, ...] | None = None
    environment: Environment | None = None
    output: OutputSettings | None = None

    def to_config(self, base: Config | None = None) -> Config:
        """Resolve these settings over a copy of ``base`` or Config defaults."""
        config = copy.deepcopy(base) if base is not None else Config()
        if self.camera is not None:
            config.sensor = _sensor(self.camera)
        if self.lights is not None:
            config.emitters = [
                emitter for emitter in config.emitters if isinstance(emitter, Envmap)
            ]
            config.emitters.extend(_emitter(light) for light in self.lights)
        if self.environment is not None:
            config.emitters = [
                emitter
                for emitter in config.emitters
                if not isinstance(emitter, Envmap)
            ]
            if self.environment.enabled:
                filename = self.environment.path
                if filename is None:
                    filename = Envmap().filename
                config.emitters.insert(
                    0,
                    Envmap(
                        filename=filename,
                        scale=self.environment.scale,
                        up=list(self.environment.up),
                        rotation=self.environment.rotation,
                    ),
                )
            config.environment_visible = self.environment.visible
            config.integrator.hide_emitters = not self.environment.visible
        if self.output is not None:
            config.film.width = self.output.width
            config.film.height = self.output.height
            config.sampler.seed = self.output.sampler_seed
            config.background = self.output.background
            passes: set[str] = {str(item) for item in self.output.passes}
            config.render_passes = passes - {"beauty"}
        return config

to_config(base=None)

Resolve these settings over a copy of base or Config defaults.

Source code in hakowan/grammar/figure.py
def to_config(self, base: Config | None = None) -> Config:
    """Resolve these settings over a copy of ``base`` or Config defaults."""
    config = copy.deepcopy(base) if base is not None else Config()
    if self.camera is not None:
        config.sensor = _sensor(self.camera)
    if self.lights is not None:
        config.emitters = [
            emitter for emitter in config.emitters if isinstance(emitter, Envmap)
        ]
        config.emitters.extend(_emitter(light) for light in self.lights)
    if self.environment is not None:
        config.emitters = [
            emitter
            for emitter in config.emitters
            if not isinstance(emitter, Envmap)
        ]
        if self.environment.enabled:
            filename = self.environment.path
            if filename is None:
                filename = Envmap().filename
            config.emitters.insert(
                0,
                Envmap(
                    filename=filename,
                    scale=self.environment.scale,
                    up=list(self.environment.up),
                    rotation=self.environment.rotation,
                ),
            )
        config.environment_visible = self.environment.visible
        config.integrator.hide_emitters = not self.environment.visible
    if self.output is not None:
        config.film.width = self.output.width
        config.film.height = self.output.height
        config.sampler.seed = self.output.sampler_seed
        config.background = self.output.background
        passes: set[str] = {str(item) for item in self.output.passes}
        config.render_passes = passes - {"beauty"}
    return config

Perspective look-at camera in normalized scene coordinates.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class PerspectiveCamera:
    """Perspective look-at camera in normalized scene coordinates."""

    eye: tuple[float, float, float] = (0.0, 0.0, 5.0)
    target: tuple[float, float, float] = (0.0, 0.0, 0.0)
    up: tuple[float, float, float] = (0.0, 1.0, 0.0)
    fov: float = 28.8415
    fov_axis: FovAxis = "smaller"
    near: float = 0.01
    far: float = 10000.0

    def __post_init__(self) -> None:
        """Validate look-at vectors, clipping planes, and field of view."""
        _validate_camera(self.eye, self.target, self.up, self.near, self.far)
        if not 0.0 < self.fov < 180.0:
            raise ValueError("Camera field of view must be in (0, 180).")

__post_init__()

Validate look-at vectors, clipping planes, and field of view.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate look-at vectors, clipping planes, and field of view."""
    _validate_camera(self.eye, self.target, self.up, self.near, self.far)
    if not 0.0 < self.fov < 180.0:
        raise ValueError("Camera field of view must be in (0, 180).")

Orthographic look-at camera with an explicit vertical extent.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class OrthographicCamera:
    """Orthographic look-at camera with an explicit vertical extent."""

    eye: tuple[float, float, float] = (0.0, 0.0, 5.0)
    target: tuple[float, float, float] = (0.0, 0.0, 0.0)
    up: tuple[float, float, float] = (0.0, 1.0, 0.0)
    near: float = 0.01
    far: float = 10000.0
    scale: float = 2.0

    def __post_init__(self) -> None:
        """Validate look-at vectors, clipping planes, and scale."""
        _validate_camera(self.eye, self.target, self.up, self.near, self.far)
        if self.scale <= 0.0:
            raise ValueError("OrthographicCamera.scale must be positive.")

__post_init__()

Validate look-at vectors, clipping planes, and scale.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate look-at vectors, clipping planes, and scale."""
    _validate_camera(self.eye, self.target, self.up, self.near, self.far)
    if self.scale <= 0.0:
        raise ValueError("OrthographicCamera.scale must be positive.")

Bases: PerspectiveCamera

Perspective camera with aperture-controlled depth of field.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class ThinLensCamera(PerspectiveCamera):
    """Perspective camera with aperture-controlled depth of field."""

    aperture_radius: float = 0.1
    focus_distance: float = 0.0

    def __post_init__(self) -> None:
        """Validate perspective and depth-of-field parameters."""
        super(ThinLensCamera, self).__post_init__()
        if self.aperture_radius < 0.0:
            raise ValueError("ThinLensCamera.aperture_radius must be non-negative.")
        if self.focus_distance < 0.0:
            raise ValueError("ThinLensCamera.focus_distance must be non-negative.")

__post_init__()

Validate perspective and depth-of-field parameters.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate perspective and depth-of-field parameters."""
    super(ThinLensCamera, self).__post_init__()
    if self.aperture_radius < 0.0:
        raise ValueError("ThinLensCamera.aperture_radius must be non-negative.")
    if self.focus_distance < 0.0:
        raise ValueError("ThinLensCamera.focus_distance must be non-negative.")

Isotropic point light at a normalized world-space position.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class PointLight:
    """Isotropic point light at a normalized world-space position."""

    position: tuple[float, float, float] = (0.0, 0.0, 5.0)
    color: ColorLike = "white"
    intensity: float = 1.0

    def __post_init__(self) -> None:
        """Validate the light intensity."""
        if self.intensity < 0.0:
            raise ValueError("PointLight.intensity must be non-negative.")

__post_init__()

Validate the light intensity.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate the light intensity."""
    if self.intensity < 0.0:
        raise ValueError("PointLight.intensity must be non-negative.")

Distant light whose parallel rays travel along direction.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class DirectionalLight:
    """Distant light whose parallel rays travel along ``direction``."""

    direction: tuple[float, float, float] = (0.0, 0.0, -1.0)
    color: ColorLike = "white"
    intensity: float = 1.0

    def __post_init__(self) -> None:
        """Validate the light intensity and direction."""
        if self.intensity < 0.0:
            raise ValueError("DirectionalLight.intensity must be non-negative.")
        if sum(value * value for value in self.direction) <= 1e-20:
            raise ValueError("DirectionalLight.direction must be non-zero.")

__post_init__()

Validate the light intensity and direction.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate the light intensity and direction."""
    if self.intensity < 0.0:
        raise ValueError("DirectionalLight.intensity must be non-negative.")
    if sum(value * value for value in self.direction) <= 1e-20:
        raise ValueError("DirectionalLight.direction must be non-zero.")

Environment lighting and optional camera-visible background.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class Environment:
    """Environment lighting and optional camera-visible background."""

    path: Path | None = None
    scale: float = 1.0
    up: tuple[float, float, float] = (0.0, 1.0, 0.0)
    rotation: float = 180.0
    visible: bool = False
    enabled: bool = True
    _source: Path | None = field(default=None, repr=False, compare=False)

    def __post_init__(self) -> None:
        """Validate the environment intensity scale."""
        if self.scale < 0.0:
            raise ValueError("Environment.scale must be non-negative.")

__post_init__()

Validate the environment intensity scale.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate the environment intensity scale."""
    if self.scale < 0.0:
        raise ValueError("Environment.scale must be non-negative.")

Semantic image dimensions, optional raster background, passes, and seed.

Source code in hakowan/grammar/figure.py
@dataclass(frozen=True, slots=True)
class OutputSettings:
    """Semantic image dimensions, optional raster background, passes, and seed."""

    width: int = 1024
    height: int = 800
    background: Literal["light", "dark"] | None = None
    passes: tuple[RenderPassName, ...] = ("beauty",)
    sampler_seed: int = 0

    def __post_init__(self) -> None:
        """Validate image dimensions and render-pass uniqueness."""
        if self.width <= 0 or self.height <= 0:
            raise ValueError("Output width and height must be positive.")
        if len(set(self.passes)) != len(self.passes):
            raise ValueError("Output passes must not contain duplicates.")

__post_init__()

Validate image dimensions and render-pass uniqueness.

Source code in hakowan/grammar/figure.py
def __post_init__(self) -> None:
    """Validate image dimensions and render-pass uniqueness."""
    if self.width <= 0 or self.height <= 0:
        raise ValueError("Output width and height must be positive.")
    if len(set(self.passes)) != len(self.passes):
        raise ValueError("Output passes must not contain duplicates.")

Camera framing

Resolve a high-level framing request to a concrete serializable camera.

Source code in hakowan/workflow/framing.py
def resolve_camera(
    root: Layer,
    mode: Literal["fit", "principal_axis", "attribute_extremum", "section"],
    *,
    direction: DirectionLike = "isometric",
    layer: LayerSelector = None,
    component: ComponentSelector = None,
    bounds: npt.ArrayLike | None = None,
    projection: Projection = "perspective",
    margin: float = 0.08,
    resolution: tuple[int, int] = (1024, 800),
    up_axis: Literal["y", "z"] = "y",
    fov: float = 35.0,
    fov_axis: Literal["x", "y", "diagonal", "smaller", "larger"] = "smaller",
    up: npt.ArrayLike | None = None,
    near: float | None = None,
    far: float | None = None,
    axis: int = 0,
    sign: Literal["+", "-"] = "+",
    attribute: str | None = None,
    extremum: Extremum = "max",
    normal: npt.ArrayLike | None = None,
    offset: float = 0.0,
    aperture_radius: float = 0.1,
    focus_distance: float = 0.0,
) -> Camera:
    """Resolve a high-level framing request to a concrete serializable camera."""
    camera_direction: DirectionLike = direction
    if sign not in {"+", "-"}:
        raise ValueError("Principal-axis sign must be '+' or '-'")
    if extremum not in {"min", "max"}:
        raise ValueError("Attribute extremum must be 'min' or 'max'")
    _, views, points = _compiled_selection(
        root, layer=layer, component=component, bounds=bounds
    )
    target: npt.ArrayLike | None = None
    if mode == "principal_axis":
        if axis not in (0, 1, 2):
            raise ValueError("Principal axis must be 0, 1, or 2")
        centered = points - points.mean(axis=0)
        _, singular_values, vectors = np.linalg.svd(centered, full_matrices=False)
        if axis >= vectors.shape[0] or singular_values[axis] <= 1e-12:
            raise ValueError(
                f"Principal axis {axis} is unavailable for this {len(points)}-point selection"
            )
        if any(
            other != axis
            and np.isclose(
                singular_values[axis], singular_values[other], rtol=1e-6, atol=1e-12
            )
            for other in range(len(singular_values))
        ):
            raise ValueError(f"Principal axis {axis} is not unique for this selection")
        principal = _canonical_axis(vectors[axis])
        if sign == "-":
            principal = -principal
        camera_direction = principal
        if up is None:
            world_axes = np.eye(3, dtype=np.float64)
            up = world_axes[int(np.argmin(np.abs(world_axes @ principal)))]
    elif mode == "attribute_extremum":
        if attribute is None:
            raise ValueError("attribute_extremum framing requires attribute=")
        candidates: list[tuple[float, np.ndarray]] = []
        for view in views:
            assert view.data_frame is not None
            mesh = view.data_frame.mesh
            if not mesh.has_attribute(attribute):
                continue
            source, values = _attribute_values(mesh, attribute)
            magnitudes = (
                values[:, 0] if values.shape[1] == 1 else np.linalg.norm(values, axis=1)
            )
            index = int(
                np.argmin(magnitudes) if extremum == "min" else np.argmax(magnitudes)
            )
            world = _world_points(view)
            if source.element_type == lagrange.AttributeElement.Vertex:
                position = world[index]
            elif source.element_type == lagrange.AttributeElement.Facet:
                ids = np.asarray(mesh.get_facet_vertices(index), dtype=np.int64)
                position = world[ids].mean(axis=0)
            else:
                raise ValueError(
                    f"Attribute {attribute!r} must be defined on vertices or facets"
                )
            candidates.append((float(magnitudes[index]), position))
        if not candidates:
            raise ValueError(f"Selected layers have no attribute {attribute!r}")
        chosen = (
            min(candidates, key=lambda item: item[0])
            if extremum == "min"
            else max(candidates, key=lambda item: item[0])
        )
        target = chosen[1]
    elif mode == "section":
        if normal is None:
            raise ValueError("section framing requires normal=")
        section_normal = np.asarray(normal, dtype=np.float64)
        if section_normal.shape != (3,) or np.linalg.norm(section_normal) <= 1e-12:
            raise ValueError("Section normal must be a non-zero three-vector")
        section_normal /= np.linalg.norm(section_normal)
        center = (points.min(axis=0) + points.max(axis=0)) * 0.5
        target = center + section_normal * (
            offset - float(np.dot(section_normal, center))
        )
        camera_direction = section_normal
    elif mode != "fit":
        raise ValueError(f"Unknown camera framing mode: {mode!r}")
    return _camera_from_points(
        points,
        direction=camera_direction,
        up_axis=up_axis,
        margin=margin,
        projection=projection,
        resolution=resolution,
        fov=fov,
        fov_axis=fov_axis,
        target=target,
        up=up,
        near=near,
        far=far,
        aperture_radius=aperture_radius,
        focus_distance=focus_distance,
    )

Return evenly spaced concrete cameras around a selected scene region.

Source code in hakowan/workflow/framing.py
def turntable_cameras(
    root: Layer,
    *,
    count: int = 12,
    elevation: float = 20.0,
    start: float = 0.0,
    layer: LayerSelector = None,
    component: ComponentSelector = None,
    projection: Projection = "perspective",
    margin: float = 0.08,
    resolution: tuple[int, int] = (1024, 800),
    up_axis: Literal["y", "z"] = "y",
    fov: float = 35.0,
) -> tuple[Camera, ...]:
    """Return evenly spaced concrete cameras around a selected scene region."""
    if count <= 0:
        raise ValueError("Turntable count must be positive")
    _, _, points = _compiled_selection(
        root, layer=layer, component=component, bounds=None
    )
    elevation_rad = np.radians(elevation)
    cameras: list[Camera] = []
    for azimuth in np.linspace(start, start + 360.0, count, endpoint=False):
        angle = np.radians(azimuth)
        if up_axis == "z":
            direction = (
                np.sin(angle) * np.cos(elevation_rad),
                -np.cos(angle) * np.cos(elevation_rad),
                np.sin(elevation_rad),
            )
        else:
            direction = (
                np.sin(angle) * np.cos(elevation_rad),
                np.sin(elevation_rad),
                np.cos(angle) * np.cos(elevation_rad),
            )
        cameras.append(
            _camera_from_points(
                points,
                direction=direction,
                up_axis=up_axis,
                margin=margin,
                projection=projection,
                resolution=resolution,
                fov=fov,
                fov_axis="smaller",
            )
        )
    return tuple(cameras)