Scenes¶
Scenes are Node subclasses defined in .py source files. Loading a scene imports the module and instantiates the class; there is no other scene format and no code generation. Player saves are separate; see Save system.
A scene intended to be a root is a Node subclass. Nesting one inside another is self.add_child(OtherScene()) in __init__. Custom behaviour is a Python class; instances configure via __init__ kwargs.
Loading¶
from simvx.core.scene_io import load_scene
scene = load_scene("scenes/level1.py") # imports the module, instantiates the primary class
tree.set_root(scene)
The loader prefers, in order: a class whose name matches the file’s stem (level1.py → class Level1), Root, then the only top-level Node subclass in the file.
Folder-as-scene is also supported: a directory with __init__.py (or <folder>/<folder>.py) is loaded as a package and the same naming heuristic applies.
Saving from source¶
The editor’s save path lives in simvx.core.scene_io:
from simvx.core.scene_io import SceneFile
# Greenfield: emit a fresh .py from a runtime tree.
SceneFile.from_runtime(root).save("scenes/level1.py")
# Preserve user formatting: parse the existing source, reconcile the tree
# against it, write back. Comments, blank lines, hand-written code, quote
# style and import ordering survive the round-trip.
sf = SceneFile.load("scenes/level1.py")
from simvx.editor.scene_diff import apply_runtime_diff
apply_runtime_diff(sf.scene_class(), root)
sf.save()
The editor’s state.save_scene() uses greenfield mode for a new scene and preserve mode for an
existing one.
What the editor promises¶
Any valid Python opens. A scene is read with CPython’s own parser, so
match,except*, type aliases, t-strings and any newer syntax are fine. A file that does not parse is refused with the interpreter’s own message.An edit rewrites only the statements it concerns. Renaming a node touches the lines that build and name it; changing a property touches its call; adding a child adds its statements and import. Helper methods, constants, comments and
on_ready()come back as you left them.A save with no edits writes the file back unchanged, except that a property written at its default in another spelling (
Vec2(0, 0)forVec2(0.0, 0.0)) is dropped, and a node that sizes itself on construction (aLabelfitting its text) may gainsize_xandsize_yonce.Written code is formatted as
ruff formatat 120 columns would format it, and numbers are written as the shortest decimal that reads back exactly (a float32Vec2component as0.33333334).
The save asks before it rewrites your code¶
Some scenes cannot be written back exactly as authored: children built in a loop, a conditional or a helper method give the save no statement to match, and deleting a child removes the statements that used it. The editor works the save out in memory first:
plan = state.plan_save("scenes/level1.py") # writes nothing
if plan.clean: # nothing beyond your edits
state.commit_save(plan)
else:
for entry in plan.report:
print(entry, entry.category)
A destructive entry (your __init__ would change beyond your edit) is shown to you before
anything is written. An informational one (a value the file cannot carry; your line stays as it
was) is written, reported in the console, and the tab stays marked modified. A plan whose file or
scene changed after it was made is not committed; plan again.
What a scene file cannot carry¶
Every value in a scene must be writable as source. Paths, numbers, vectors, enums, Resource, a
Texture over a file and the primitive collision shapes (SphereShape3D, BoxShape3D,
CapsuleShape3D, CylinderShape3D, CircleShape2D, RectangleShape2D, CapsuleShape2D,
SegmentShape2D) all are. Pixels in memory, a texture built from them, a node reference, a
callable, and shapes made of vertex data (ConvexHullShape3D, ConcaveMeshShape3D,
ConvexPolygonShape2D, ConcavePolygonShape2D) are not: build those in on_ready().
SceneFile.from_runtime raises UnemittableValueError for such a value rather than write a file
that loads back as a different scene. Pass report= to write what it can and collect the rest:
from simvx.core.scene_io import SceneFile
refusals: list[str] = []
SceneFile.from_runtime(root, report=refusals).save("scenes/level1.py")
for message in refusals: # each names the node and property
print(message)
Only declared Property values are written; a plain attribute a node sets in its own __init__
is not part of the scene.
In preserve mode, a value the author wrote as an expression the editor cannot evaluate
(rotation=math.radians(45), texture=Art.ICON) is left exactly as written. An editor change to
it is reported rather than written, until the file yields the value the scene holds. A Texture
shared by two nodes is written once and referenced by name, so the reloaded scene shares it too.
Reusable prefabs¶
A “prefab” is a Python class. Multiple instances are multiple constructor calls:
from .enemy import Enemy
self.add_child(Enemy(position=Vec2(10, 0)))
self.add_child(Enemy(position=Vec2(20, 0)))
self.add_child(Enemy(position=Vec2(30, 0)))
Variants are subclasses (class FastEnemy(Enemy):) or factory functions (def spawn_enemy(): return Enemy(...)).
Project-wide refactoring¶
simvx.core.scene_io.symbols answers where a class is defined and where it is used, and rewrites both. Every function takes the source text of one module; the renames return the rewritten text, splicing the identifiers that move and leaving every other byte as the author wrote it:
from simvx.core.scene_io import (
find_class_definitions, find_class_uses,
rename_class_in_source, rename_module_in_imports,
)
source = Path("src/player.py").read_text()
defs = find_class_definitions(source) # top-level class refs
uses = find_class_uses(source, "Player") # every reference, classified
renamed = rename_class_in_source(source, "Player", "Hero")
A use’s kind is import, import_alias, base_class, instantiation, isinstance_arg, annotation or bare_reference. The search is scope-aware: a local Player = MockPlayer shadows the class in its function, and an aliased import (Player as P) keeps its alias.
The editor wraps these in simvx.editor.project_classes.rename_class(project_index, old, new, *, rename_file=False), which rewrites every file in the project, optionally renames the defining file and updates importers, and rolls back if a write fails.
File and folder refactoring¶
simvx.editor.refactor_extract.extract_to_folder(file_path, project_index) splits a multi-class .py into a package: one file per class plus an __init__.py re-exporting each, so imports keep working. simvx.editor.refactor_inline.inline_to_file(folder_path, project_index, *, force=False) is the inverse. Both refuse files with top-level functions, side-effecting imports or conditional blocks; force=True proceeds and lists each suspect construct in InlineResult.flagged. Custom classes in the editor covers the same actions in the editor.
Renaming a node keeps its place¶
When a node is renamed in the editor, apply_runtime_diff(scene_class, root, identity_hints=hints) renames the statement in place rather than removing and re-adding it, using a dict[Node, str] of the names each node had when the scene was loaded.
API reference¶
simvx.core.scene_io: load_scene, SceneFile, SceneClass, SceneModule, emit_scene.
simvx.core.scene_io.symbols: find_class_definitions, find_class_uses, rename_class_in_source, rename_module_in_imports.
simvx.editor.scene_diff: apply_runtime_diff (with identity_hints).
simvx.editor.project_classes: ProjectClassIndex, rename_class, RenameResult.
simvx.editor.refactor_extract: extract_to_folder, ExtractRefused.
simvx.editor.refactor_inline: inline_to_file, FolderInlineRefused.
simvx.core.scene_tree: SceneTree.change_scene, add_singleton.