Skip to content

Compile

Compile declarative layer trees into backend-ready scenes.

compile(root, *, preserve_attributes=False)

Compile a layer tree into a resolution-independent scene.

Traverses the layer tree, resolves channels, applies transforms and scales, and finalises per-view data frames. Scene- and screen-space sizes remain in their declared units; call :func:prepare_scene with the render configuration before passing the scene to a backend.

Parameters:

Name Type Description Default
root Layer | object

Root visualization layer.

required
preserve_attributes bool

Keep inactive source attributes in compiled meshes for observation and pixel picking. Defaults to False for lean render payloads.

False

Returns:

Type Description
Scene

A resolution-independent scene containing one view per leaf path.

Source code in hakowan/compiler/compile.py
def compile(root: layer.Layer | object, *, preserve_attributes: bool = False) -> Scene:
    """Compile a layer tree into a resolution-independent scene.

    Traverses the layer tree, resolves channels, applies transforms and scales,
    and finalises per-view data frames. Scene- and screen-space sizes remain in
    their declared units; call :func:`prepare_scene` with the render configuration
    before passing the scene to a backend.

    Args:
        root: Root visualization layer.
        preserve_attributes: Keep inactive source attributes in compiled meshes
            for observation and pixel picking. Defaults to False for lean render
            payloads.

    Returns:
        A resolution-independent scene containing one view per leaf path.

    """
    if not isinstance(root, layer.Layer):
        from ..grammar.figure import Figure

        if not isinstance(root, Figure):
            raise TypeError(f"Expected Layer or Figure, got {type(root)!r}")
        root = root.layer
    # Step 1: condense each path from the root layer tree into a view.
    scene, node_options = condense_layer_tree_to_scene(root)
    logger.debug(f"Created scene with {len(scene)} views")

    # Step 2: carry out transform operations on each view.
    for view in scene:
        apply_transform(view)

    # Step 3: preprocess channels.
    for view in scene:
        preprocess_channels(view)

    # Step 4: process channels and apply scales.
    for view in scene:
        process_channels(view)
    # Collect overlays while processed texture metadata and source attributes
    # are still available.
    collect_overlays(scene)

    # Step 5: finalize the data frame.
    for view in scene:
        view.finalize(preserve_attributes=preserve_attributes)

    # Step 6: lay out juxtaposition cells.
    if node_options:
        scene.apply_layout(node_options)

    # Step 7: compute the global scene transform.
    scene.compute_global_transform()
    return scene

condense_layer_tree_to_scene(root)

Flatten each leaf path into a resolved View and collect layout options.

Source code in hakowan/compiler/compile.py
def condense_layer_tree_to_scene(
    root: layer.Layer,
) -> tuple[Scene, dict[int, layer.LayoutOptions]]:
    """Flatten each leaf path into a resolved View and collect layout options."""
    scene = Scene()
    # Deterministic pre-order IDs identify layout nodes both during recursive
    # packing and in serialized WebGL cell tags.
    node_options: dict[int, layer.LayoutOptions] = {}
    next_layout_id = 0

    def generate_view(ancestors: list[layer.Layer]) -> View:
        """Generate a view from a path in the layer tree.

        :param ancestors: A list of layers from the root layer to a leaf layer. Layers closer to the
                          root have precedence over layers closer to the leaf.

        :return: a view.
        """
        view = View()
        for lyr in ancestors:
            if view.data_frame is None:
                view.data_frame = copy.deepcopy(lyr._spec.data)
            if view.mark is None:
                view.mark = lyr._spec.mark
            if view.name is None:
                view.name = lyr._spec.name
            if lyr._spec.annotations:
                view.annotations.extend(copy.deepcopy(lyr._spec.annotations))
            if view.transform is None:
                view.transform = copy.deepcopy(lyr._spec.transform)
            elif lyr._spec.transform is not None:
                view.transform *= lyr._spec.transform
            if len(lyr._spec.channels) > 0:
                view.channels.extend(copy.deepcopy(lyr._spec.channels))

        if view.mark is None:
            assert view.data_frame is not None, "Data component is not specified"
            if view.data_frame.mesh.num_facets == 0:
                logger.debug("Apply default point mark to facet-free data.")
                view.mark = mark.Mark.Point
            else:
                logger.debug("Apply default surface mark.")
                view.mark = mark.Mark.Surface

        view.validate()
        view.initialize_bbox()
        return view

    def traverse(
        lyr: layer.Layer, ancestors: list[layer.Layer], cell_key: tuple
    ) -> None:
        nonlocal next_layout_id
        # `ancestors` is a list of layers from the root to the current layer.
        # `cell_key` identifies which juxtaposition cell this branch belongs to.
        ancestors.append(lyr)
        if lyr._layout is not None and len(lyr._children) > 0:
            # Juxtaposition node: record its options and extend the cell key so
            # each child becomes a distinct cell (or sub-layout).
            node_id = next_layout_id
            next_layout_id += 1
            node_options[node_id] = lyr._layout
            for i, child in enumerate(lyr._children):
                traverse(child, ancestors, cell_key + ((node_id, i),))
        elif len(lyr._children) == 0:
            # Leaf layer: condense the ancestor path into a view.
            view = generate_view(ancestors)
            view._layout_cell = cell_key
            scene.append(view)
        else:
            # Overlay node (created by `+`): children share the same cell.
            for child in lyr._children:
                traverse(child, ancestors, cell_key)
        ancestors.pop()

    traverse(root, [], ())
    return scene, node_options

prepare_scene(scene, config)

Resolve configuration-dependent units and return a backend-ready scene.

Source code in hakowan/compiler/compile.py
def prepare_scene(scene: Scene, config: Config) -> Scene:
    """Resolve configuration-dependent units and return a backend-ready scene."""
    scene.resolve_size_spaces(config)
    return scene

Validation

Validate a Layer or Figure without rendering or mutating it.

Static checks cover attributes, channels, scales, transforms, resources, scene settings, and backend capabilities. compile_check=True then runs the real compiler on deep-copied data and checks empty geometry, camera framing/clipping, and likely layer occlusion.

Parameters:

Name Type Description Default
root Layer | object

Layer or Figure to validate.

required
backend BackendName | None

Target backend, or the configured default when omitted.

None
strict bool

Promote backend approximations and ignored features to errors.

True
compile_check bool

Compile and inspect the resolved scene after static checks.

True

Returns:

Type Description
ValidationReport

A structured report; intrinsic failures are errors in every mode.

Source code in hakowan/workflow/validation.py
def validate(
    root: Layer | object,
    backend: BackendName | None = None,
    *,
    strict: bool = True,
    compile_check: bool = True,
) -> ValidationReport:
    """Validate a Layer or Figure without rendering or mutating it.

    Static checks cover attributes, channels, scales, transforms, resources,
    scene settings, and backend capabilities. ``compile_check=True`` then runs
    the real compiler on deep-copied data and checks empty geometry, camera
    framing/clipping, and likely layer occlusion.

    Args:
        root: Layer or Figure to validate.
        backend: Target backend, or the configured default when omitted.
        strict: Promote backend approximations and ignored features to errors.
        compile_check: Compile and inspect the resolved scene after static checks.

    Returns:
        A structured report; intrinsic failures are errors in every mode.

    """
    figure = None
    if not isinstance(root, Layer):
        from ..grammar.figure import Figure

        if not isinstance(root, Figure):
            raise TypeError(f"Expected a Layer or Figure, got {type(root)!r}")
        figure = root
        root = root.layer
    try:
        capabilities = get_backend_capabilities(backend)
    except ValueError as exc:
        backend_name = str(backend) if backend is not None else "unknown"
        return ValidationReport(
            backend=backend_name,
            strict=strict,
            diagnostics=(
                Diagnostic(
                    "backend.unknown",
                    "error",
                    "backend",
                    str(exc),
                    "Choose a backend returned by hkw.list_backend_capabilities().",
                ),
            ),
        )
    validator = _Validator(capabilities, strict)
    if figure is not None:
        from ..grammar.figure import ThinLensCamera

        if (
            isinstance(figure.scene.camera, ThinLensCamera)
            and capabilities.name == "webgl"
        ):
            validator.issue(
                "backend.camera.thin_lens",
                "scene.camera",
                "WebGL renders a thin-lens camera as standard perspective.",
                hint="Use a perspective camera on WebGL or render with Mitsuba/Blender for depth of field.",
                degradation=True,
            )
        environment = figure.scene.environment
        if (
            environment is not None
            and environment.enabled
            and environment.path is not None
            and not environment.path.is_file()
        ):
            validator.issue(
                "environment.path.missing",
                "scene.environment.path",
                f"Environment map '{environment.path}' does not exist.",
                hint="Correct the path or place the environment asset beside the specification file.",
            )
        output = figure.scene.output
        if output is not None:
            requested = {str(item) for item in output.passes if item != "beauty"}
            unsupported = requested - capabilities.render_passes
            if unsupported:
                validator.issue(
                    "backend.passes.unsupported",
                    "scene.output.passes",
                    f"Backend '{capabilities.name}' does not support passes {sorted(unsupported)}.",
                    hint="Remove unsupported passes or choose a backend that advertises them.",
                    degradation=True,
                )
    for index, view in enumerate(_flatten_views(root)):
        validator.validate_view(view, index)
    if compile_check and not any(
        item.severity == "error" for item in validator.diagnostics
    ):
        try:
            from ..compiler import compile as compile_layer

            scene = compile_layer(root, preserve_attributes=True)
            _validate_compiled_scene(scene, figure, validator)
        except Exception as exc:
            message = str(exc).strip() or type(exc).__name__
            validator.issue(
                "compile.failed",
                "layer",
                f"Layer compilation failed: {message}",
                hint=f"Fix the reported {type(exc).__name__} in the layer or transform configuration.",
            )
    return ValidationReport(
        backend=capabilities.name,
        strict=strict,
        diagnostics=tuple(validator.diagnostics),
    )

Validation result for a layer and target backend.

Source code in hakowan/workflow/validation.py
@dataclass(frozen=True, slots=True)
class ValidationReport:
    """Validation result for a layer and target backend."""

    backend: str
    strict: bool
    diagnostics: tuple[Diagnostic, ...]

    @property
    def valid(self) -> bool:
        """Return whether the report contains no error diagnostics."""
        return not any(item.severity == "error" for item in self.diagnostics)

    @property
    def errors(self) -> tuple[Diagnostic, ...]:
        """Return error diagnostics in their original order."""
        return tuple(item for item in self.diagnostics if item.severity == "error")

    @property
    def warnings(self) -> tuple[Diagnostic, ...]:
        """Return warning diagnostics in their original order."""
        return tuple(item for item in self.diagnostics if item.severity == "warning")

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-safe report including the derived validity flag."""
        return {
            "backend": self.backend,
            "strict": self.strict,
            "valid": self.valid,
            "diagnostics": [item.to_dict() for item in self.diagnostics],
        }

    def raise_for_errors(self) -> None:
        """Raise :class:`ValidationError` when the report is invalid."""
        if not self.valid:
            raise ValidationError(self)

errors property

Return error diagnostics in their original order.

valid property

Return whether the report contains no error diagnostics.

warnings property

Return warning diagnostics in their original order.

raise_for_errors()

Raise :class:ValidationError when the report is invalid.

Source code in hakowan/workflow/validation.py
def raise_for_errors(self) -> None:
    """Raise :class:`ValidationError` when the report is invalid."""
    if not self.valid:
        raise ValidationError(self)

to_dict()

Return a JSON-safe report including the derived validity flag.

Source code in hakowan/workflow/validation.py
def to_dict(self) -> dict[str, Any]:
    """Return a JSON-safe report including the derived validity flag."""
    return {
        "backend": self.backend,
        "strict": self.strict,
        "valid": self.valid,
        "diagnostics": [item.to_dict() for item in self.diagnostics],
    }

One actionable validation finding.

Source code in hakowan/workflow/validation.py
@dataclass(frozen=True, slots=True)
class Diagnostic:
    """One actionable validation finding."""

    code: str
    severity: Severity
    path: str
    message: str
    hint: str | None = None

    def to_dict(self) -> dict[str, str | None]:
        """Return this diagnostic as a JSON-safe mapping."""
        return asdict(self)

to_dict()

Return this diagnostic as a JSON-safe mapping.

Source code in hakowan/workflow/validation.py
def to_dict(self) -> dict[str, str | None]:
    """Return this diagnostic as a JSON-safe mapping."""
    return asdict(self)

Bases: ValueError

Raised by :meth:ValidationReport.raise_for_errors.

Source code in hakowan/workflow/validation.py
class ValidationError(ValueError):
    """Raised by :meth:`ValidationReport.raise_for_errors`."""

    def __init__(self, report: ValidationReport):
        """Initialize the exception from a structured validation report."""
        self.report = report
        detail = "; ".join(f"{item.path}: {item.message}" for item in report.errors)
        super().__init__(detail or "Hakowan validation failed")

__init__(report)

Initialize the exception from a structured validation report.

Source code in hakowan/workflow/validation.py
def __init__(self, report: ValidationReport):
    """Initialize the exception from a structured validation report."""
    self.report = report
    detail = "; ".join(f"{item.path}: {item.message}" for item in report.errors)
    super().__init__(detail or "Hakowan validation failed")