Source code for simvx.core.scene_io.emitter

"""Greenfield scene emitter: live ``Node`` tree → canonical ``.py`` source.

Produces a single class definition whose ``__init__`` reconstructs the tree
via ``add_child`` calls, with non-default :class:`Property` values passed
as keyword arguments. The output is a *string*; round-trip identity through
:func:`simvx.core.scene_io.parse_source` is guaranteed by construction
because the emitted form is canonical and parso preserves any text it
parses verbatim under :meth:`SourceTree.dump`.

The emitter is intentionally string-based rather than parso-based: building
a fresh tree is the right tool when there is no source to preserve. The
diff-and-edit layer (Tier 3b) handles the round-trip case where source
already exists on disk.

Every line it writes that has anything to break goes through
:mod:`~simvx.core.scene_io.layout`: the constructions, the ``add_child`` calls,
the class header and the ``from ... import ...`` lines are laid out the way a
formatter run at the project's width lays them out, so the file reads like code
someone wrote rather than one 220-column line per node.

Values are spelled the way a formatter spells them -- double quotes where they
cost no extra escaping, an exponent without its ``+`` -- and a value that is
written is written whole: the number carries the digits that read it back as the
value stored, at the precision it is stored at, in the notation ``repr`` uses
for a float that size. So a scene this emitter wrote is a scene the project's
formatter has nothing to say about, and a value it writes loses no digit on the
way to the file.

Whether a value is written at all is a separate question, and one this module
answers less finely: a position, rotation or scale within 1e-9 of its default
counts as that default and is left off the call entirely, so a position of
5e-10 reaches disk as no position at all.

What it cannot write, it refuses: a value with no source form raises
:class:`UnemittableValueError` rather than reaching the file as a flattened,
truncated or hollowed-out version of itself. :func:`emit_scene` takes a
``report`` list for callers that would rather keep the rest of the save.
"""

from __future__ import annotations

import ast
import dataclasses
import inspect
import keyword
import logging
import math
import sys
from collections.abc import Iterator
from enum import Enum
from pathlib import PurePath
from typing import Any

import numpy as np

from ..descriptors import declared_default
from ..math.types import Quat, Vec2, Vec3
from ..node import Node
from ..physics.shapes import BoxShape3D, CapsuleShape3D, CylinderShape3D, SphereShape3D
from ..physics.shapes2d import CapsuleShape2D, CircleShape2D, RectangleShape2D, SegmentShape2D
from .layout import wrap_import, wrap_statement

log = logging.getLogger(__name__)

#: Column the body of the emitted ``__init__`` sits at: one indent for the
#: class, one for the method. Every statement written there is laid out to fit
#: the line limit from that column (:mod:`~simvx.core.scene_io.layout`).
_BODY_INDENT = 8


__all__ = [
    "UnemittableValueError",
    "emit_node_construction",
    "emit_scene",
    "emit_value",
    "expression_describes",
    "expression_is_opaque",
    "expression_reads_a_shape",
    "helper_import_module",
    "iter_runtime_kwargs",
    "structural_type_name",
    "var_name_base",
]


#: Modules the helper names this emitter writes are imported from, for the
#: names that do NOT live in ``simvx.core``. Kept here because the emitter is
#: the only thing that decides which name to write, so it is also the only
#: thing that can say where that name comes from.
_HELPER_MODULES: dict[str, str] = {"Path": "pathlib"}


[docs] class UnemittableValueError(ValueError): """A live value has no source form, so the scene cannot be written whole. Raised by :func:`emit_scene` (and therefore by :meth:`~simvx.core.scene_io.SceneFile.from_runtime`) unless the caller passes a ``report`` list, which selects keep-and-report instead: the file is emitted without the refused values and every refusal is appended to that list for the caller to show. :attr:`refusals` carries the same messages. """ def __init__(self, refusals: list[str]) -> None: self.refusals: list[str] = list(refusals) count = len(self.refusals) head = f"{count} value{'' if count == 1 else 's'} in this scene cannot be written as source:" super().__init__("\n".join([head, *(f" {r}" for r in self.refusals)]))
# --------------------------------------------------------------------------- # Public surface # ---------------------------------------------------------------------------
[docs] def emit_scene( root: Node, *, class_name: str | None = None, extra_imports: list[str] | None = None, report: list[str] | None = None, ) -> str: """Emit a complete ``.py`` source file for the live tree rooted at ``root``. A value with no source form -- a texture built from pixels in memory, a multi-dimensional array, a node reference -- stops the emission by default: writing the file without it would produce a scene that silently loads back different from the one that was saved. Pass ``report`` to choose the other policy: emit anyway, leaving the refused values out, and append one message per refusal to that list so the caller can show them and keep the document marked unsaved. Args: root: Root node of the tree. class_name: Class name to use. Defaults to ``root.name`` when it is a valid identifier and differs from the engine type name; otherwise falls back to the engine type name. extra_imports: Verbatim import lines to include in place of the auto-generated ones. Empty/``None`` → generated imports, one ``from <module> import ...`` line per module the emitted source names (see :func:`helper_import_module`). report: Mutated in place, one message per refused value, and the emission continues. ``None`` (the default) raises :class:`UnemittableValueError` instead. Returns: Complete Python source string, laid out to the line limit: a statement that does not fit on one line is broken across several, in the shape :mod:`~simvx.core.scene_io.layout` chooses for it. The output round-trips through ``parse_source(s).dump() == s`` by construction (parso preserves any text it parses verbatim). Raises: UnemittableValueError: A value has no source form and no ``report`` list was given to collect it. """ root_type_cls = type(root) root_type_name = root_type_cls.__name__ if class_name is None: candidate = root.name if candidate and candidate.isidentifier() and candidate != root_type_name: class_name = candidate else: class_name = root_type_name or "Scene" ctx = _EmitterContext() # Reserved before any child variable so a hoisted resource never takes a # name a node was going to use. ctx.hoist_shared_resources(root) body_lines = ctx.emit_node(root, "self", is_root=True) root_kwargs: list[tuple[str, str]] = [] root_attr_lines: list[str] = [] for key, value in ctx.build_root_kwargs(root): if key == "name" and value == _fmt_str(class_name): continue # A root kwarg that names a hoisted local cannot ride on # ``super().__init__()``: that call comes first, before the local # exists. Setting the property straight after is the same assignment. if ctx.refers_to_hoisted(getattr(root, key, None)): root_attr_lines.append(wrap_statement(f"self.{key} = {value}", indent=_BODY_INDENT)) else: root_kwargs.append((key, value)) super_args = ", ".join(["**kwargs", *(f"{k}={v}" for k, v in root_kwargs)]) # Every kwarg is now decided, so the hoisted locals that nothing refers to # are known and left out. hoist_lines = ctx.hoisted_lines() base_type = root_type_name if base_type == class_name: mro = root_type_cls.__mro__ base_type = mro[1].__name__ if len(mro) > 1 else "Node" parts: list[str] = [] if extra_imports: parts.extend(extra_imports) else: used = set(ctx.used_types) if base_type: used.add(base_type) by_module: dict[str, set[str]] = {} for name in used: by_module.setdefault(helper_import_module(name), set()).add(name) for module in sorted(by_module): parts.append(wrap_import(module, sorted(by_module[module]))) parts.append("") parts.append("") parts.append(wrap_statement(f"class {class_name}({base_type})", suffix=":")) parts.append(" def __init__(self, **kwargs):") parts.append(" " + wrap_statement(f"super().__init__({super_args})", indent=_BODY_INDENT)) body = [*hoist_lines, *root_attr_lines, *body_lines] if body: parts.append("") parts.extend(f" {line}" for line in body) parts.append("") if ctx.refusals: if report is None: raise UnemittableValueError(ctx.refusals) report.extend(ctx.refusals) return "\n".join(parts)
[docs] def emit_node_construction(node: Node, var_name: str, *, used_types: set[str] | None = None, indent: int = 0) -> str: """Emit the ``var_name = Type(kwargs...)`` statement for ``node``. ``used_types`` is mutated in place when supplied so callers can accumulate imports across multiple emissions; the type of ``node`` itself is added, plus the types of any complex kwarg values (``Vec2``/``Vec3``/``Quat``). ``indent`` is the column the statement will be written at. A construction that does not fit the line limit from there comes back broken across several lines, laid out as a formatter would (:func:`~simvx.core.scene_io.layout.wrap_statement`); the first line carries no indent of its own, since the caller is placing it. """ ctx = _EmitterContext(used_types=used_types if used_types is not None else set()) type_name = type(node).__name__ ctx.used_types.add(type_name) kwargs = ctx.build_kwargs(node) kwargs_str = ", ".join(f"{k}={v}" for k, v in kwargs) return wrap_statement(f"{var_name} = {type_name}({kwargs_str})", indent=indent)
[docs] def iter_runtime_kwargs( node: Node, *, used_types: set[str] | None = None, unemittable: set[str] | None = None, derived: set[str] | None = None, ) -> list[tuple[str, str]]: """Return the ``(kwarg_name, formatted_expr)`` pairs the emitter would emit for ``node`` in greenfield mode. This is the canonical answer to "which constructor kwargs reflect the non-default state of this node?": used by the editor's round-trip save path (``simvx.editor.scene_diff``) to reconcile a parsed source file against a live runtime tree without duplicating the Property/spatial-default iteration logic. The returned list includes: * ``name=...`` when ``node.name`` differs from the engine type name. * Spatial kwargs (``position``/``rotation``/``scale``) for ``Node2D`` / ``Node3D`` when they deviate from origin/identity/one. * One entry per declared ``Property`` whose current value differs from its declared default and is serialisable via :func:`emit_value`. A :class:`~simvx.core.Texture` is written as its constructor call here, once per property that holds it. Writing a shared one into a local instead needs a view of the whole file, which :func:`emit_scene` has and this per-node view does not. ``used_types`` is mutated in place when supplied so callers can accumulate the names the returned expressions refer to (``Vec2``/``Vec3``/``Quat``, ``Texture``, ``Path``). Ask :func:`helper_import_module` which module each one comes from: not all of them are ``simvx.core`` exports. ``unemittable`` is mutated in place the same way, collecting the name of every Property that holds a NON-default value this emitter cannot write as source. Absence from the returned pairs is otherwise ambiguous -- it means either "at its default" or "could not be expressed" -- and a caller that edits existing source has to tell those apart before it deletes a line. ``derived`` is the third reason a name can be absent: the value in it is one the engine worked out for this node rather than one the author wrote (:meth:`~simvx.core.Node._record_derived`), so it is not written into their constructor call. A caller editing existing source must not read that as a stale line either -- the author's own number is what the file should keep. """ ctx = _EmitterContext( used_types=used_types if used_types is not None else set(), unemittable=unemittable, derived=derived, ) return ctx.build_kwargs(node)
[docs] def structural_type_name(node: Node) -> str: """Importable type name the emitter writes for ``node``. Used by the round-trip diff layer to decide whether to add an import for a newly-introduced runtime child. """ return type(node).__name__
[docs] def emit_value(val: Any) -> str | None: """Format ``val`` as Python source, or ``None`` when it has no source form. A number is written so that reading it back gives the same number: the fewest digits that parse to exactly the value held, at the precision it is held at (a ``Vec2`` component is a float32), in the notation ``repr`` uses for a float that size, so ``2.0`` is still written ``2.0``. A string is written with the quotes a formatter would leave it in. :class:`Vec2`, :class:`Vec3`, :class:`Quat`, :class:`~pathlib.Path`, :class:`~simvx.core.Resource`, a file-backed :class:`~simvx.core.Texture`, lists, tuples, and ``str``-keyed dicts serialise recursively. So do the primitive collision shapes of both dimensions -- ``SphereShape3D``, ``BoxShape3D``, ``CapsuleShape3D``, ``CylinderShape3D``, ``CircleShape2D``, ``RectangleShape2D``, ``CapsuleShape2D`` and ``SegmentShape2D`` -- each written as the constructor call that rebuilds its geometry, with the fields the constructor would produce anyway left off. ``None`` is the answer for everything else: :class:`Node` instances, callables, modules, a texture over pixels held in memory, an array with more than one dimension, a collision shape carrying a point cloud or a mesh, and any container holding one of those -- a container is refused whole rather than emitted with a hole in it. """ return _format_value(val)
[docs] def helper_import_module(name: str) -> str: """The module an emitted helper ``name`` must be imported from. Everything the emitter writes by name is a ``simvx.core`` export, except the handful listed in :data:`_HELPER_MODULES` -- ``Path`` is the one, and it comes from :mod:`pathlib`. Callers that add imports for the names the emitter reports in ``used_types`` must ask here rather than assume ``simvx.core``, or they write a file that fails to load on the name they just introduced. """ return _HELPER_MODULES.get(name, "simvx.core")
# --------------------------------------------------------------------------- # Emitter context # --------------------------------------------------------------------------- class _EmitterContext: """Per-emission state: tracked imports, variable counter, name collisions.""" def __init__( self, *, used_types: set[str] | None = None, unemittable: set[str] | None = None, derived: set[str] | None = None, ) -> None: self.used_types: set[str] = used_types if used_types is not None else set() #: Properties holding a non-default value no expression can carry. self.unemittable: set[str] = unemittable if unemittable is not None else set() #: Properties left out because the value in them is the engine's own #: arithmetic rather than the author's (:meth:`Node._record_derived`). self.derived: set[str] = derived if derived is not None else set() #: One human-readable message per refused value, in emission order. self.refusals: list[str] = [] #: ``id(resource) -> local variable name`` for resources emitted once and #: referenced by name everywhere else. Empty unless #: :meth:`hoist_shared_resources` ran, which only whole-scene emission does. self.hoisted: dict[int, str] = {} #: ``(id(resource), source line, names the line refers to)`` for each #: hoisted local, in emission order, held until :meth:`hoisted_lines` #: knows which ones are read. self._hoist_lines: list[tuple[int, str, set[str]]] = [] #: The hoisted resources a kwarg that survived actually refers to. self._hoist_used: set[int] = set() #: Keeps every hoisted resource alive, so its ``id`` cannot be reused by #: another object while this context is still formatting values. self._hoisted_keepalive: list[Any] = [] self._seen_names: dict[str, int] = {} def build_root_kwargs(self, node: Node) -> list[tuple[str, str]]: return self.build_kwargs(node) def hoist_shared_resources(self, root: Node) -> None: """Reserve one ``var = Texture(...)`` local per resource the tree shares. A :class:`~simvx.core.Texture` is an identity: two nodes holding the same one hold one image, one backend slot, and one :meth:`Texture.update` that changes what both of them draw. So is a collision :class:`~simvx.core.physics.shapes.Shape`, and more sharply: :meth:`Shape.build` memoises one backend handle per world per resource, so two nodes sharing one shape are one backend record and two constructor calls in the file would be two. Emitting the constructor call at each use would load back resources that merely look alike, so the shared ones are written once into a local and referenced by name. Identity is what counts, not equality: two separately built spheres of the same radius are two resources and stay two constructor calls. A resource used once is left where it is. One whose source cannot be written -- or one whose every holder the emitter goes on to refuse -- leaves no local behind: the refusal is reported against each property that held it, not against a local nobody asked for. Which of those holders survive is only known once the body is emitted, so the lines are held here and asked for afterwards, via :meth:`hoisted_lines`. """ counts: dict[int, int] = {} found: list[Any] = [] for node in _walk_tree(root): for prop_name in node.get_properties(): for resource in _iter_hoistable(getattr(node, prop_name, None)): if id(resource) not in counts: counts[id(resource)] = 0 found.append(resource) counts[id(resource)] += 1 for resource in found: if counts[id(resource)] < 2: continue # Into a set of its own: a line that turns out to have no reader is # dropped, and its ``Texture``/``Path``/shape import must go with it. names: set[str] = set() expr = _format_value(resource, names=names) if expr is None: continue var = self._unique_var(_hoisted_var_name(resource)) self.hoisted[id(resource)] = var self._hoisted_keepalive.append(resource) self._hoist_lines.append((id(resource), wrap_statement(f"{var} = {expr}", indent=_BODY_INDENT), names)) def hoisted_lines(self) -> list[str]: """The hoist lines a kwarg actually ended up referring to. Call after the whole body is built. A hoisted resource every holder of which was refused has no reader left, and writing its local anyway would leave an assignment nothing uses -- and an import nothing uses -- in a file the emitter promises is loadable and clean. The names the surviving lines refer to join ``used_types`` here. """ lines: list[str] = [] for resource_id, line, names in self._hoist_lines: if resource_id not in self._hoist_used: continue self.used_types |= names lines.append(line) return lines def refers_to_hoisted(self, val: Any) -> bool: """True when ``val`` contains a resource that was hoisted to a local.""" return any(id(r) in self.hoisted for r in _iter_hoistable(val)) def build_kwargs(self, node: Node) -> list[tuple[str, str]]: from ..nodes_2d.node2d import Node2D from ..nodes_3d.node3d import Node3D kwargs: list[tuple[str, str]] = [] real_type = type(node) if node.name != real_type.__name__: kwargs.append(("name", _fmt_str(node.name))) kwargs.extend(self._spatial_kwargs(node)) # Spatial fields are emitted via _spatial_kwargs above with their own # default-detection; skip them here to avoid double-emission once # promoted to Property descriptors. skip_spatial = isinstance(node, (Node2D, Node3D)) for prop_name, prop in node.get_properties().items(): if skip_spatial and prop_name in ("position", "rotation", "scale"): continue val = getattr(node, prop_name) if _is_default(val, declared_default(prop)): continue if engine_computed(node, prop_name): self.derived.add(prop_name) continue formatted = _format_value(val, names=self.used_types, hoisted=self.hoisted) if formatted is None: self.unemittable.add(prop_name) self.refusals.append(f"{_node_label(node)}.{prop_name}: {_refusal_reason(val)}") continue # Counted only now: a resource inside a value the emitter went on to # refuse whole is formatted and then thrown away, and a local no # surviving kwarg reads is a local that must not be written. self._hoist_used.update(id(r) for r in _iter_hoistable(val) if id(r) in self.hoisted) kwargs.append((prop_name, formatted)) return kwargs def emit_node(self, node: Node, var_name: str, *, is_root: bool = False) -> list[str]: lines: list[str] = [] real_type = type(node) type_name = real_type.__name__ if not is_root: self.used_types.add(type_name) kwargs = self.build_kwargs(node) kwargs_str = ", ".join(f"{k}={v}" for k, v in kwargs) lines.append(wrap_statement(f"{var_name} = {type_name}({kwargs_str})", indent=_BODY_INDENT)) target = var_name for child in node.children: child_var = self._unique_var(child.name) lines.extend(self.emit_node(child, child_var)) lines.append(wrap_statement(f"{target}.add_child({child_var})", indent=_BODY_INDENT)) return lines def _spatial_kwargs(self, node: Node) -> list[tuple[str, str]]: from ..nodes_2d.node2d import Node2D from ..nodes_3d.node3d import Node3D kwargs: list[tuple[str, str]] = [] if isinstance(node, Node3D): pos = node.position if engine_computed(node, "position"): self.derived.add("position") elif not _is_zero_vec3(pos): self.used_types.add("Vec3") kwargs.append(("position", _format_value(Vec3(pos)))) rot = node.rotation if not _is_identity_quat(rot): self.used_types.add("Quat") kwargs.append(("rotation", _format_value(rot))) scl = node.scale if not _is_one_vec3(scl): self.used_types.add("Vec3") kwargs.append(("scale", _format_value(Vec3(scl)))) elif isinstance(node, Node2D): pos = node.position if engine_computed(node, "position"): self.derived.add("position") elif not _is_zero_vec2(pos): self.used_types.add("Vec2") kwargs.append(("position", _format_value(Vec2(pos)))) rot = node.rotation if abs(float(rot)) > 1e-9: kwargs.append(("rotation", _fmt_num(rot))) scl = node.scale if not _is_one_vec2(scl): self.used_types.add("Vec2") kwargs.append(("scale", _format_value(Vec2(scl)))) return kwargs def _unique_var(self, name: str) -> str: """A local variable name derived from ``name``, unique within this emission. :func:`var_name_base` decides the identifier; this adds the ``_1``/``_2`` suffix that keeps two locals apart. The counter spans the whole emission, not one set of siblings, because every local this writes -- children, grandchildren, and the ones hoisted for shared textures -- lands in the same ``__init__`` scope, where a repeated name binds one object twice and drops the other. """ base = var_name_base(name) if base in self._seen_names: self._seen_names[base] += 1 return f"{base}_{self._seen_names[base]}" self._seen_names[base] = 0 return base # --------------------------------------------------------------------------- # Variable naming # ---------------------------------------------------------------------------
[docs] def var_name_base(name: str) -> str: """The local a scene file binds ``name`` to, before de-duplication. ``name`` is whatever the tree offers -- a node's user-visible name, or the file stem of a texture being hoisted -- so it has to be made into an identifier before it can be written. Lowercased, spaces and hyphens turned into underscores, anything else dropped; a leading digit, an empty result, ``self`` and every keyword (``class``, ``import``, ``return``, and the soft ones) take a ``node_`` prefix. ``return.png`` is an ordinary name for a back-arrow icon, and ``return = Texture(...)`` is not Python: without the prefix the whole save dies in the parser with nothing written and nothing said about which asset caused it. This is the one place the rule lives. Everything that has to predict, match or reproduce a name a scene file binds calls it rather than restating it: a second copy that drifts turns into a node the diff cannot find and silently re-adds under another name. """ base = name.lower().replace(" ", "_").replace("-", "_") base = "".join(c for c in base if c.isalnum() or c == "_") if not base or base[0].isdigit() or base == "self" or keyword.iskeyword(base) or keyword.issoftkeyword(base): base = f"node_{base}" return base
# --------------------------------------------------------------------------- # Value formatting # --------------------------------------------------------------------------- #: Returned by :func:`_field_default` for a dataclass field that declares no #: default at all, so every value of it must be written out. _NO_DEFAULT: Any = object() def _value_resource_name(value: Any) -> str | None: """The ``simvx.core`` name of a value RESOURCE, or ``None`` if it is not one. A value resource is a dataclass instance whose type the public surface exports under its own name -- :class:`~simvx.core.physics.material.PhysicsMaterial` is the first -- which is exactly the condition for the emitted expression ``PhysicsMaterial(friction=0.9)`` to be readable back from the one generated ``from simvx.core import ...`` line. A dataclass a game defined in its own file is deliberately not serialisable: the scene file has no way to import it, so writing its name would emit source that cannot be loaded. """ if isinstance(value, type) or not dataclasses.is_dataclass(value): return None core = sys.modules.get("simvx.core") if core is None: # pragma: no cover - the emitter is reached through simvx.core return None name = type(value).__name__ return name if getattr(core, name, None) is type(value) else None #: The collision shapes a scene file can carry, and what rebuilds each one. #: #: The primitives are small value objects -- a radius, a pair of extents, two #: endpoints -- so each is written as the plain constructor call an author would #: have typed, ``SphereShape3D(radius=2.0)``, and reads back as the same #: geometry. Both dimensions are here, because a 2D scene loses its colliders on #: a save exactly as a 3D one does. The kinds that carry a point cloud or a whole #: mesh (the hull, the two polygon shapes, the concave mesh) have no reasonable #: source form, so they stay among the values :func:`_format_value` refuses. #: #: Keyed by the exact class rather than by name: a game's own subclass of one of #: these would be written as its base and load back as the wrong type, which is #: the silent loss the refusal channel exists to prevent. _SHAPE_FIELDS: dict[type, tuple[str, ...]] = { SphereShape3D: ("radius",), BoxShape3D: ("half_extents",), CapsuleShape3D: ("radius", "height"), CylinderShape3D: ("radius", "height"), CircleShape2D: ("radius",), RectangleShape2D: ("half_extents",), CapsuleShape2D: ("radius", "height"), SegmentShape2D: ("a", "b", "radius"), } #: The names those classes are written under, for reading a call back without a #: value in hand (:func:`expression_reads_a_shape`). _SHAPE_CLASSES: dict[str, type] = {cls.__name__: cls for cls in _SHAPE_FIELDS} #: Each shape's ``__init__`` signature, read from the class once rather than #: restated here, so a shape whose default moves does not leave this module #: omitting a field on the strength of a number that has changed. Read to write #: a call (:func:`_format_shape`) and to read one back (:func:`_read_shape_arguments`). _SHAPE_SIGNATURES: dict[type, inspect.Signature] = {cls: inspect.signature(cls) for cls in _SHAPE_FIELDS} #: The helper calls a shape's geometry can be spelled with, for reading one of #: those constructor calls back out of a file (:func:`_read_shape_arguments`). _VECTOR_FACTORIES: dict[str, type] = {"Vec2": Vec2, "Vec3": Vec3} def _format_shape( value: Any, fields: tuple[str, ...], *, names: set[str] | None = None, hoisted: dict[int, str] | None = None ) -> str | None: """``CapsuleShape3D(radius=0.4)``: the geometry that differs from the default. A field the constructor would have produced anyway is left off, the way every other value this module writes leaves out what the reader would produce without being told, so a scene holding the default sphere writes ``SphereShape3D()``. That makes this module's own form one spelling of several: an author may have written the same sphere as ``SphereShape3D(0.5)`` or ``SphereShape3D(radius=0.5)``. A save that edits an existing file must not take one for the other, which is what :func:`expression_describes` is for. """ name = type(value).__name__ parameters = _SHAPE_SIGNATURES[type(value)].parameters parts: list[str] = [] for field in fields: current = getattr(value, field) default = parameters[field].default if default is not inspect.Parameter.empty and _is_default(current, _coerce_like(current, default)): continue formatted = _format_value(current, names=names, hoisted=hoisted) if formatted is None: # pragma: no cover - shape geometry is numbers and vectors return None parts.append(f"{field}={formatted}") _register(names, name) return f"{name}({', '.join(parts)})" def _coerce_like(current: Any, default: Any) -> Any: """``default`` in the type ``current`` is held as, for comparing the two. ``BoxShape3D`` declares its default half-extents as a tuple and keeps them as a :class:`Vec3`, and a tuple compares against one element-wise; every other shape field is already a number. """ if isinstance(current, Vec2 | Vec3) and isinstance(default, tuple | list): return type(current)(*default) return default
[docs] def expression_describes(expression: str, value: Any) -> bool: """Does a scene file's ``expression`` already build the ``value`` a node holds? The question a save asks about a line it is thinking of rewriting or deleting: does the author's own text say what the scene says? Comparing the text against this module's own form does not settle that for a value written as a constructor call whose parameters have defaults, because one such value has several honest spellings -- ``SphereShape3D()``, ``SphereShape3D(0.5)`` and ``SphereShape3D(radius=0.5)`` are one sphere and only the first is what :func:`_format_shape` writes. Those are the collision shapes, and they are the only kind this answers for; any other value answers ``False`` whatever its expression says, leaving the caller's own comparison in charge. A caller comparing text alone does not see through a difference of spelling, so ``Vec2(0, 0)`` is not recognised as the ``Vec2(0.0, 0.0)`` this module writes for that value. No file is imported and no shape is built: the expression is read as source, its arguments are matched to the class's own parameters and read as the literals and vectors they are written as, and the geometry is compared field by field. An expression this cannot read that way answers ``False`` as well, which says nothing about what it builds -- :func:`expression_is_opaque` is the question a caller asks to tell the two answers apart. """ call = _shape_call(expression, value) if call is None: return False written = _read_shape_arguments(call, type(value)) if written is None: return False for field, argument in written.items(): held = getattr(value, field) if not _is_default(held, _coerce_like(held, argument)): return False return True
[docs] def expression_is_opaque(expression: str, value: Any) -> bool: """Does ``expression`` build ``value``'s kind of shape without saying which one? ``SphereShape3D(radius=RADIUS)`` names the class a save would name and then says nothing a save can check: the radius is a value only running the file would produce. Such a line is the author's own record of the geometry and the only one there is, so a save that rewrote it against the scene, or took it out as stale, would be guessing -- :func:`expression_describes` cannot vouch for it, and cannot deny it either. ``False`` for anything else, including a call this module *can* read: one that reads back as different geometry is a genuine disagreement, and the scene is what a save carries. """ call = _shape_call(expression, value) return call is not None and _read_shape_arguments(call, type(value)) is None
[docs] def expression_reads_a_shape(expression: str) -> bool: """Does ``expression`` build a collision shape with its geometry written out? No value is needed: the class the call names says which geometry to read, and the arguments are read against that class's own parameters. So ``SphereShape3D(radius=0.5)`` answers ``True`` whatever the slot holds now, while ``SphereShape3D(radius=RADIUS)`` answers ``False`` -- it names a shape and then hides which one behind a value only running the file would produce. So do ``make_shape()``, ``self._shape``, and ``ConvexHullShape3D(points)``, the last because a shape carrying vertex data has no source form at all. What it settles, for a save holding a line and a scene that disagree with it: a collider whose KIND was changed in the editor leaves the file spelling one shape class where the scene now holds another. Where both sides read as shapes, that is a disagreement the scene settles, and not a reference to geometry built elsewhere, which a save must leave exactly as it stands. """ try: body = ast.parse(expression.strip(), mode="eval").body except (SyntaxError, ValueError): return False if not isinstance(body, ast.Call) or not isinstance(body.func, ast.Name): return False shape_class = _SHAPE_CLASSES.get(body.func.id) return shape_class is not None and _read_shape_arguments(body, shape_class) is not None
def _shape_call(expression: str, value: Any) -> ast.Call | None: """``expression`` as a call to ``value``'s own shape class, or ``None`` when it is not one.""" if type(value) not in _SHAPE_FIELDS: return None try: body = ast.parse(expression.strip(), mode="eval").body except (SyntaxError, ValueError): return None if not isinstance(body, ast.Call) or not isinstance(body.func, ast.Name): return None return body if body.func.id == type(value).__name__ else None def _read_shape_arguments(call: ast.Call, shape_class: type) -> dict[str, Any] | None: """The geometry ``call`` writes out, field by field, for ``shape_class``. ``None`` when any part of the geometry is written as something only running the file would produce -- a name, a factory call, an argument computed rather than written out -- or when the call does not bind the class's own parameters at all. """ fields = _SHAPE_FIELDS[shape_class] keywords: dict[str, ast.expr] = {} for keyword_argument in call.keywords: if keyword_argument.arg is None: # ``**more`` unpacked into the call: what it passes is not written here. return None keywords[keyword_argument.arg] = keyword_argument.value try: bound = _SHAPE_SIGNATURES[shape_class].bind(*call.args, **keywords) except TypeError: return None bound.apply_defaults() written: dict[str, Any] = {} for field in fields: argument = bound.arguments.get(field, _NO_DEFAULT) if isinstance(argument, ast.expr): argument = _literal_expression(argument) if argument is _NO_DEFAULT: return None written[field] = argument return written def _literal_expression(expr: ast.expr) -> Any: """The value ``expr`` writes out, or ``_NO_DEFAULT`` when it writes none. The literals a shape's geometry is made of, and the vector calls one of them is spelled with, which is the whole of what it reads: a name, an arithmetic expression or a call to anything else is a value only running the file would produce, and a save has no business guessing at it. """ try: return ast.literal_eval(expr) except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError): pass if not isinstance(expr, ast.Call) or not isinstance(expr.func, ast.Name) or expr.keywords: return _NO_DEFAULT factory = _VECTOR_FACTORIES.get(expr.func.id) if factory is None: return _NO_DEFAULT args = [_literal_expression(arg) for arg in expr.args] if not all(isinstance(arg, int | float) for arg in args): return _NO_DEFAULT try: return factory(*args) except (TypeError, ValueError): return _NO_DEFAULT def _field_default(field: dataclasses.Field) -> Any: if field.default is not dataclasses.MISSING: return field.default if field.default_factory is not dataclasses.MISSING: return field.default_factory() return _NO_DEFAULT def _format_value_resource( value: Any, name: str, *, names: set[str] | None = None, hoisted: dict[int, str] | None = None ) -> str | None: """``PhysicsMaterial(friction=0.9)``: the non-default fields, and no more. Emitting only what differs keeps a scene diff small and keeps the file honest about what the author actually chose. A field the emitter cannot write at all makes the whole resource unserialisable rather than silently dropping one value out of a group that only means something together. """ parts: list[str] = [] kwargs: dict[str, Any] = {} for field in dataclasses.fields(value): if not field.init: continue current = getattr(value, field.name) default = _field_default(field) if default is not _NO_DEFAULT and _is_default(current, default): continue formatted = _format_value(current, names=names, hoisted=hoisted) if formatted is None: return None parts.append(f"{field.name}={formatted}") kwargs[field.name] = current.value if isinstance(current, Enum) else current if not _enum_fields_survive(value, kwargs): return None _register(names, name) return f"{name}({', '.join(parts)})" def _enum_fields_survive(value: Any, kwargs: dict[str, Any]) -> bool: """Does the emitted expression actually reconstruct this resource's Enum fields? An Enum member is written as the plain value it wraps, so the loaded file hands the dataclass a string where the author had a member. A ``Property`` coerces that back because its default is a member; a dataclass field does NOT, unless the type arranges it -- :class:`~simvx.core.physics.material.PhysicsMaterial` does, in ``__post_init__``. One that did not would load into a resource holding a raw string, which compares unequal to the member and breaks every identity test downstream, and it would do so silently. So the emitter asks rather than assumes: rebuild from what is about to be written and require every Enum field to come back as the same member. A type that cannot do that is refused whole, which is the same answer this function's caller gives to any other field it cannot write. """ enum_fields = [f.name for f in dataclasses.fields(value) if f.init and isinstance(getattr(value, f.name), Enum)] if not enum_fields: return True try: rebuilt = type(value)(**kwargs) except Exception: return False # Enum members are singletons, so identity is exactly the question being asked. return all(getattr(rebuilt, n, None) is getattr(value, n) for n in enum_fields) def _register(names: set[str] | None, name: str) -> None: """Record a helper name the expression being built refers to.""" if names is not None: names.add(name) def _format_value(val: Any, *, names: set[str] | None = None, hoisted: dict[int, str] | None = None) -> str | None: """The source expression for ``val``, or ``None`` when it has none. The single authority on what this emitter can express. Every branch either produces source or answers ``None``, and a container answers ``None`` as a whole when any of its items does: a list written with a hole in it would load back as a list with a ``None`` in it, which is not the value that was saved and says nothing about why. ``names`` is mutated in place with every helper name the returned expression refers to (``Vec2``, ``Path``, ``Texture``, ...), including names that appear only inside a container, so a caller can add the imports that make the expression resolve. ``hoisted`` maps ``id(texture)`` to the local variable a shared texture was emitted into; a texture listed there is written as that name instead of as another constructor call. """ if val is None: return "None" if isinstance(val, Enum): # Enum members are written as the plain value they wrap. Reading the # scene back converts them to members again, because a Property whose # default is an Enum member coerces every assignment to that member # type. Handled before the scalar check so that ``IntEnum`` / # ``StrEnum`` members take this path too rather than falling through to # the number / string formatting below. return _format_value(val.value, names=names, hoisted=hoisted) if isinstance(val, bool): return repr(val) if isinstance(val, Quat): _register(names, "Quat") return f"Quat({_fmt_num(val.w)}, {_fmt_num(val.x)}, {_fmt_num(val.y)}, {_fmt_num(val.z)})" if isinstance(val, Vec3): _register(names, "Vec3") return f"Vec3({_fmt_num(val[0])}, {_fmt_num(val[1])}, {_fmt_num(val[2])})" if isinstance(val, Vec2): _register(names, "Vec2") return f"Vec2({_fmt_num(val[0])}, {_fmt_num(val[1])})" if isinstance(val, PurePath): _register(names, "Path") return f"Path({_fmt_str(str(val))})" if isinstance(val, np.ndarray): return _format_ndarray(val) if isinstance(val, int | float): return _fmt_num(val) if isinstance(val, str): return _fmt_str(val) if isinstance(val, tuple | list): items: list[str] = [] for item in val: formatted = _format_value(item, names=names, hoisted=hoisted) if formatted is None: return None items.append(formatted) body = ", ".join(items) if isinstance(val, tuple): return f"({body},)" if len(val) == 1 else f"({body})" return f"[{body}]" if isinstance(val, dict): pairs: list[str] = [] for key, item in val.items(): if not isinstance(key, str): return None formatted = _format_value(item, names=names, hoisted=hoisted) if formatted is None: return None pairs.append(f"{_fmt_str(key)}: {formatted}") return "{" + ", ".join(pairs) + "}" if isinstance(val, _texture_class()): return _format_texture(val, names=names, hoisted=hoisted) if isinstance(val, _resource_class()): _register(names, "Resource") return f"Resource({_fmt_str(val.package)}, {_fmt_str(val.name)})" resource_name = _value_resource_name(val) if resource_name is not None: return _format_value_resource(val, resource_name, names=names, hoisted=hoisted) shape_fields = _SHAPE_FIELDS.get(type(val)) if shape_fields is not None: if hoisted is not None: var = hoisted.get(id(val)) if var is not None: return var return _format_shape(val, shape_fields, names=names, hoisted=hoisted) return None def _format_ndarray(val: np.ndarray) -> str | None: """A 1-D numeric array as a list literal, or ``None`` for anything else. An array is written as the flat list a Property reads back happily, which only holds for one dimension of numbers. A 2-D array flattened into that same list loads as a different shape, so it is refused; so is a dtype whose elements are not numbers, which has no float form to write in the first place. """ if val.ndim >= 2 or val.dtype.kind not in "biuf": return None # Every element is written in its own type's form: a float32 passed through # ``float()`` first would reach the file as its float64 expansion, which is # 0.30000001192092896 for an element holding 0.3, and an int64 past 2**53 # passed through it comes back a different integer, so two neighbouring ids # collapse onto one wrong value. items = ", ".join(_fmt_num(v if val.dtype.kind == "f" else v.item()) for v in val.flat) return f"[{items}]" # --------------------------------------------------------------------------- # Textures and package resources # --------------------------------------------------------------------------- def _texture_class() -> type[Any]: """The :class:`~simvx.core.Texture` type, imported on first use. Deferred so importing the emitter does not drag in the graphics data types; ``simvx.core.scene_io`` is authoring-side and is itself imported lazily. """ from ..graphics.texture import Texture return Texture def _resource_class() -> type[Any]: from ..resource import Resource return Resource def _format_texture( texture: Any, *, names: set[str] | None = None, hoisted: dict[int, str] | None = None ) -> str | None: """``Texture("ui/icon.png", filter="nearest")``, or ``None`` for pixels in memory. Exactly the sampling settings the texture was given: one left unset follows whichever consumer samples it, so writing its current effective value would freeze a decision the author never made. """ if hoisted is not None: var = hoisted.get(id(texture)) if var is not None: return var source = _format_texture_source(texture.source, names=names) if source is None: return None stated = [ f"{key}={_fmt_str(value) if isinstance(value, str) else repr(value)}" for key, value in ( ("filter", texture.filter), ("colour_space", texture.colour_space), ("premultiply_alpha", texture.premultiply_alpha), ("mipmaps", texture.mipmaps), ) if value is not None ] _register(names, "Texture") return f"Texture({', '.join([source, *stated])})" def _format_texture_source(source: Any, *, names: set[str] | None = None) -> str | None: """The source expression for a texture source, or ``None`` when it is in memory. A file-backed source is a name the loaded scene can resolve for itself. Pixels (an ndarray, encoded bytes) exist only in the process that made them; writing them into a scene file would mean pasting an image into source code. """ if isinstance(source, str): return _fmt_str(source) if isinstance(source, PurePath): _register(names, "Path") return f"Path({_fmt_str(str(source))})" if isinstance(source, _resource_class()): _register(names, "Resource") return f"Resource({_fmt_str(source.package)}, {_fmt_str(source.name)})" return None def _iter_hoistable(val: Any) -> Iterator[Any]: """Every shareable resource reachable from ``val``, through the containers the emitter writes. A resource is a value with a lifetime of its own that two nodes can hold the same one of, and where holding the same one *means* something at run time: a :class:`~simvx.core.Texture` (one image, one backend slot) and a primitive collision shape (one backend record per world, memoised by :meth:`Shape.build`). Those are what a whole-scene emission writes once into a local rather than once per holder. A shape kind the emitter has no source form for is not yielded: hoisting one would reserve a local for a line that can never be written. """ if isinstance(val, _texture_class()) or type(val) in _SHAPE_FIELDS: yield val elif isinstance(val, dict): for item in val.values(): yield from _iter_hoistable(item) elif isinstance(val, tuple | list): for item in val: yield from _iter_hoistable(item) elif not isinstance(val, type) and dataclasses.is_dataclass(val): for field in dataclasses.fields(val): yield from _iter_hoistable(getattr(val, field.name, None)) def _hoisted_var_name(resource: Any) -> str: """Local variable name for a hoisted resource. A texture takes the stem of the file it is loaded from, which is the name the author already knows it by. A shape has no file, so it takes its class name; two shapes of one kind are then told apart by the emitter's own ``_1`` suffix, as two nodes of one type already are. """ if type(resource) in _SHAPE_FIELDS: return var_name_base(type(resource).__name__) source = resource.source if isinstance(source, str | PurePath): stem = PurePath(str(source)).stem elif isinstance(source, _resource_class()): stem = PurePath(source.name).stem else: stem = "" return stem or "texture" def _walk_tree(root: Node) -> Iterator[Node]: yield root for child in root.children: yield from _walk_tree(child) # --------------------------------------------------------------------------- # Refusals # --------------------------------------------------------------------------- def _node_label(node: Node) -> str: return f"{type(node).__name__} {node.name!r}" def _refusal_reason(val: Any) -> str: """Why ``val`` has no source form, in terms of what to do instead. Called only for a value :func:`_format_value` has already refused, so every branch here describes a real refusal rather than guessing at one. """ if isinstance(val, _texture_class()): return ( f"a Texture over {_source_description(val.source)} cannot be written to a scene file. " f'Either give it a file-backed source (Texture("assets/tiles.png")), or build the pixels ' f"in on_ready() and assign the texture there -- a scene is code, and that is where code goes." ) if isinstance(val, np.ndarray): if val.ndim >= 2: return ( f"a {val.ndim}-dimensional array has no literal form: written out it would flatten to " f"{val.size} numbers and load back the wrong shape. Load the image from a file, or build " f"the array in on_ready() and assign it there." ) return f"an array of {val.dtype} has no literal form; only numbers can be written." if isinstance(val, Node): return "a Node cannot be written as a value; look the node up in on_ready() instead." if isinstance(val, tuple | list): for index, item in enumerate(val): if _format_value(item) is None: return f"item {index} of this {type(val).__name__} cannot be written: {_refusal_reason(item)}" return f"this {type(val).__name__} cannot be written." if isinstance(val, dict): for key, item in val.items(): if not isinstance(key, str): return f"a dict key of type {type(key).__name__} cannot be written; scene dict keys are strings." if _format_value(item) is None: return f"key {key!r} of this dict cannot be written: {_refusal_reason(item)}" return "this dict cannot be written." if callable(val): name = getattr(val, "__name__", type(val).__name__) return f"a callable ({name}) cannot be written as a value; connect or assign it in on_ready() instead." return f"a {type(val).__name__} has no source form the loader could read back." def _source_description(source: Any) -> str: """How to name a texture source in a refusal message.""" if isinstance(source, np.ndarray): return f"pixels in memory (a {'x'.join(str(d) for d in source.shape)} ndarray)" if isinstance(source, bytes | bytearray | memoryview): return f"encoded image bytes ({len(bytes(source))} of them)" return f"a {type(source).__name__}" def _fmt_num(v: Any) -> str: """``v`` as a literal that reads back as the same number, bit for bit. A float is written with the fewest digits that read back as the value held, at the precision it is held at, in the notation :func:`repr` uses for a float that size (:func:`_float_form`). So a property set to ``1 / 3`` loads back as that same float rather than as the six digits a ``%g`` format kept of it, and one set to ``2.0`` still reads ``2.0``. """ if isinstance(v, bool): return repr(v) if isinstance(v, int): return str(v) if isinstance(v, float | np.floating): fv = float(v) if not math.isfinite(fv): # NaN / +inf / -inf: emit the Python literal that round-trips. return f"float({_fmt_str(str(fv))})" return _exponent_form(_float_form(v)) return str(v) #: The decimal exponents :func:`repr` writes a float out in full for: at ``-4`` #: it still gives ``0.0001``, one lower it gives ``1e-05``, and at ``16`` it #: gives ``1e16`` where ``15`` gave every digit. _POSITIONAL_EXPONENTS = (-4, 16) def _float_form(v: float | np.floating) -> str: """``v`` spelled with its own digits, in the notation ``repr`` picks for it. The digits are the fewest that read back as ``v`` at the precision ``v`` is stored at -- a :class:`~simvx.core.Vec2` component is a float32, and writing the float64 expansion of one, ``0.30000001192092896`` for a component the author set to ``0.3``, is sixteen digits of noise past the ``0.3`` around a value the loader narrows back to float32 anyway. The notation follows the decimal exponent of those digits, switching where :func:`repr` switches, rather than being left to whichever printer produced them: numpy's own threshold is elsewhere, so a float32 three million prints as ``3e+06`` while a float64 three million prints as ``3000000.0``, and one ``Vec2`` can hold both. Written this way a float64 comes out exactly as ``repr`` spells it, and a float32 comes out the way ``repr`` would spell a float that size, carrying the handful of digits float32 needs rather than the seventeen a float64 expansion can run to. """ scientific = np.format_float_scientific(v, unique=True, trim="-") low, high = _POSITIONAL_EXPONENTS if low <= int(scientific.partition("e")[2]) < high: # ``trim="0"`` keeps the ``.0`` of an integral value, and the sign of a # negative zero comes through: it is a different float from zero, and a # scene that loses it loses a facing. return np.format_float_positional(v, unique=True, trim="0") return scientific def _exponent_form(text: str) -> str: """A float literal with its exponent spelled the way a formatter spells it. The printers write ``1e+20``; a formatter drops the ``+`` and keeps both the ``-`` of a negative exponent and its leading zero, giving ``1e20`` and ``1e-05``. A literal with no exponent comes back untouched. """ before, sep, after = text.partition("e") if not sep: return text return f"{before}e{after[1:] if after.startswith('+') else after}" def _fmt_str(s: str) -> str: """``s`` as the string literal a formatter would leave alone. Double quotes, which is what a formatter normalises to, unless the swap would add escaping: ``'say "hi"'`` keeps the single quotes :func:`repr` gives it, because the alternative spells two more backslashes. Counted rather than guessed at, which is the difference between a rule that agrees with the formatter and one that agrees with it on the cases someone thought of: ``'"`` holds both quote characters and still comes out double-quoted, since escaping the one costs exactly what escaping the other did. The starting point is always a ``repr``, which escapes ``'`` in a single-quoted literal and never escapes ``"``; the two substitutions below are the whole of the difference between that literal and its double-quoted twin. """ literal = repr(s) if literal[0] == '"': # ``repr`` already reached for double quotes, which it does exactly when # the string holds an apostrophe and no double quote -- and going back # to single ones would then have to escape every one of them. return literal body = literal[1:-1] swapped = body.replace("\\'", "'").replace('"', '\\"') if swapped.count("\\") > body.count("\\"): return literal return f'"{swapped}"' # --------------------------------------------------------------------------- # Default-value comparisons # --------------------------------------------------------------------------- def _is_same_geometry(val: Any, default: Any) -> bool: """Are these two collision-shape resources the same geometry? A :class:`~simvx.core.physics.shapes.Shape` is a resource and deliberately has no ``__eq__``: two bodies holding one sphere share a backend record, and two spheres of equal radius do not. The question here is narrower -- would the file read back the same collider with this kwarg left off -- and the geometry is exactly what the emitted call spells, so that is what is compared. Without it a node holding the shape its own Property default builds would have that shape written out on every save, since the default factory hands back a fresh object each time it is asked. """ fields = _SHAPE_FIELDS.get(type(val)) if fields is None or type(val) is not type(default): return False return all(_is_default(getattr(val, name), getattr(default, name)) for name in fields) def _is_default(val: Any, default: Any) -> bool: if _is_same_geometry(val, default): return True try: eq = val == default if isinstance(eq, np.ndarray): return bool(eq.all()) return bool(eq) except (TypeError, ValueError, AttributeError): # Justified: a value whose ``__eq__`` rejects ``default`` (incompatible # type, or a numpy array of mismatched shape giving an ambiguous truth # value) is, by definition, not equal to the default, so it must be # emitted. Swallowing here only affects whether the property is written # out, never correctness. Logged for diagnosability of odd types. log.debug("_is_default comparison failed for %r vs %r", type(val), type(default), exc_info=True) return False #: Distinguishes "nothing was recorded for this name" from a recorded ``None``. _NOTHING_RECORDED: object = object()
[docs] def engine_computed(node: Node, name: str) -> bool: """Is ``node.name`` still holding exactly what the engine worked out for it? A control measures its own size and a container places its children, both writing into properties an author also writes into (:meth:`~simvx.core.Node._record_derived`). Emitting the result would put the engine's arithmetic into the author's constructor call, where nothing afterwards could tell it from a number they typed, so :func:`iter_runtime_kwargs` leaves such a value out. Only "still holds it" counts. A later write of any kind -- the author's, a game's, the inspector's -- makes the two differ and the value is emitted as usual, so no write path has to clear the record. Published because the omission has a second half: an inspector showing a value nobody will save has to say so, or the user types into a void. """ recorded = node._derived.get(name, _NOTHING_RECORDED) return recorded is not _NOTHING_RECORDED and _is_default(getattr(node, name, _NOTHING_RECORDED), recorded)
def _is_zero_vec2(v: Any) -> bool: return abs(float(v[0])) < 1e-9 and abs(float(v[1])) < 1e-9 def _is_zero_vec3(v: Any) -> bool: return abs(float(v[0])) < 1e-9 and abs(float(v[1])) < 1e-9 and abs(float(v[2])) < 1e-9 def _is_one_vec2(v: Any) -> bool: return abs(float(v[0]) - 1.0) < 1e-9 and abs(float(v[1]) - 1.0) < 1e-9 def _is_one_vec3(v: Any) -> bool: return abs(float(v[0]) - 1.0) < 1e-9 and abs(float(v[1]) - 1.0) < 1e-9 and abs(float(v[2]) - 1.0) < 1e-9 def _is_identity_quat(q: Any) -> bool: return abs(float(q.w) - 1.0) < 1e-9 and abs(float(q.x)) < 1e-9 and abs(float(q.y)) < 1e-9 and abs(float(q.z)) < 1e-9