"""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