"""Auto-split from the former flat demo_steps.py."""
import logging
from simvx.core import Node
from simvx.core.input import MouseButton
from simvx.core.scripted_demo import DemoRunner
log = logging.getLogger(__name__)
def _get_editor(runner: DemoRunner):
"""Find the Root among the runner's siblings.
Auto-dismisses the first-run TourGuide if active, since it covers
the entire screen and blocks all simulated mouse input.
"""
from simvx.editor.root import Root
parent = runner.parent
if not parent:
return None
for c in parent.children:
if isinstance(c, Root):
tour = getattr(c, "_tour", None)
if tour and getattr(tour, "is_active", False):
tour.stop()
return c
return None
def _get_state(runner: DemoRunner):
editor = _get_editor(runner)
return editor.state if editor else None
def _resolve_node(runner: DemoRunner, path: str) -> Node | None:
state = _get_state(runner)
return state.find_node(path) if state else None
def _smoothstep(t: float) -> float:
t = max(0.0, min(1.0, t))
return t * t * (3.0 - 2.0 * t)
def _init_click(step, runner: DemoRunner, target: tuple):
"""Prepare a step for cursor-move-and-click at *target*."""
step._mc_target = target
step._mc_origin = runner._cursor_pos
step._mc_phase = 0
step._mc_t = 0.0
def _drive_click(runner: DemoRunner, step, dt: float, move_dur: float = 0.25) -> bool:
"""Drive cursor move -> press -> release. Returns True when done."""
if step._mc_phase == 0: # move
step._mc_t += dt
t = min(step._mc_t / max(move_dur, 0.001), 1.0)
t = _smoothstep(t)
ox, oy = step._mc_origin
tx, ty = step._mc_target
runner._cursor_pos = (ox + (tx - ox) * t, oy + (ty - oy) * t)
runner._sim.move_mouse(*runner._cursor_pos)
if t >= 1.0:
runner._cursor_pos = step._mc_target
step._mc_phase = 1
step._mc_t = 0.0
elif step._mc_phase == 1: # press
# Sim now drives Input state, ``@on_input`` decorators, and UI widget
# dispatch in one call, no separate ``_dispatch_ui_mouse`` needed.
runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
step._mc_phase = 2
step._mc_t = 0.0
elif step._mc_phase == 2: # release
step._mc_t += dt
if step._mc_t >= 0.05:
runner._sim.release_mouse(MouseButton.LEFT)
return True
return False
def _init_drag(step, runner: DemoRunner, start: tuple, end: tuple):
"""Prepare a step for cursor move -> press -> drag -> release.
*start* is where the press lands, *end* where the release lands.
"""
step._mc_target = start
step._mc_end = end
step._mc_origin = runner._cursor_pos
step._mc_phase = 0
step._mc_t = 0.0
def _drive_drag(runner: DemoRunner, step, dt: float, move_dur: float = 0.2, drag_dur: float = 0.2) -> bool:
"""Drive cursor move -> press -> drag-to-end -> release. Returns True when done.
Thin wrapper over ``InputSimulator.move_mouse``/``press_mouse``/
``release_mouse``, the same primitive UITestHarness.drag() uses, routed
through the runner's bound tree so ``Control._on_gui_input`` fires for
every motion sample (panels read ``event.position`` for drag deltas).
"""
if step._mc_phase == 0: # move cursor to the press point
step._mc_t += dt
t = _smoothstep(min(step._mc_t / max(move_dur, 0.001), 1.0))
ox, oy = step._mc_origin
tx, ty = step._mc_target
runner._cursor_pos = (ox + (tx - ox) * t, oy + (ty - oy) * t)
runner._sim.move_mouse(*runner._cursor_pos)
if t >= 1.0:
runner._cursor_pos = step._mc_target
step._mc_phase = 1
step._mc_t = 0.0
elif step._mc_phase == 1: # press at the start point
runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
step._mc_phase = 2
step._mc_t = 0.0
elif step._mc_phase == 2: # drag toward the end point, sampling motion
step._mc_t += dt
t = _smoothstep(min(step._mc_t / max(drag_dur, 0.001), 1.0))
sx, sy = step._mc_target
ex, ey = step._mc_end
runner._cursor_pos = (sx + (ex - sx) * t, sy + (ey - sy) * t)
runner._sim.move_mouse(*runner._cursor_pos)
if t >= 1.0:
runner._cursor_pos = step._mc_end
step._mc_phase = 3
step._mc_t = 0.0
elif step._mc_phase == 3: # release at the end point
step._mc_t += dt
if step._mc_t >= 0.05:
runner._sim.release_mouse(MouseButton.LEFT)
return True
return False
def _drive_double_click(runner: DemoRunner, step, dt: float, gap: float = 0.08) -> bool:
"""Drive cursor move -> click -> click (a double-click). Returns True when done.
Wraps two ``InputSimulator`` press/release pairs at the same point, the
same primitive UITestHarness.double_click() uses, separated by a short
*gap* so the target widget's own double-click timer registers both as one
double-click.
"""
if step._mc_phase == 0: # move
step._mc_t += dt
t = _smoothstep(min(step._mc_t / 0.2, 1.0))
ox, oy = step._mc_origin
tx, ty = step._mc_target
runner._cursor_pos = (ox + (tx - ox) * t, oy + (ty - oy) * t)
runner._sim.move_mouse(*runner._cursor_pos)
if t >= 1.0:
runner._cursor_pos = step._mc_target
step._mc_phase = 1
step._mc_t = 0.0
elif step._mc_phase == 1: # first click (press + release)
runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
runner._sim.release_mouse(MouseButton.LEFT)
step._mc_phase = 2
step._mc_t = 0.0
elif step._mc_phase == 2: # short gap so the widget's dbl-click timer keeps both
step._mc_t += dt
if step._mc_t >= gap:
step._mc_phase = 3
step._mc_t = 0.0
elif step._mc_phase == 3: # second click (press + release)
runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
runner._sim.release_mouse(MouseButton.LEFT)
return True
return False
def _send_key(runner: DemoRunner, key: str):
"""Press and release a key through the UI input system."""
if runner._tree:
runner._tree.ui_input(key=key, pressed=True)
runner._tree.ui_input(key=key, pressed=False)
def _drive_backspace_clear(runner: DemoRunner, step, dt: float) -> bool:
"""Clear focused widget text via backspace. Returns True when done."""
if step._cleared >= step._clear_count:
return True
step._mc_t += dt
while step._mc_t >= 0.01 and step._cleared < step._clear_count:
_send_key(runner, "backspace")
step._cleared += 1
step._mc_t -= 0.01
return step._cleared >= step._clear_count
def _drive_type_chars(runner: DemoRunner, step, dt: float, text: str) -> bool:
"""Type *text* char by char. Returns True when done."""
step._mc_t += dt
while step._mc_t >= 0.02 and step._char_idx < len(text):
ch = text[step._char_idx]
if runner._tree:
runner._tree.ui_input(char=ch)
step._char_idx += 1
step._mc_t -= 0.02
return step._char_idx >= len(text)
def _walk_children(node):
"""Recursively yield all descendants of a UI node."""
for child in node.children:
yield child
yield from _walk_children(child)
def _find_tree_item(root_item, target_node):
"""Walk TreeItem hierarchy to find the item whose data matches *target_node*."""
if root_item.data is target_node:
return root_item
for child in root_item.children:
found = _find_tree_item(child, target_node)
if found:
return found
return None
def _colour_to_hex(colour: tuple) -> str:
"""Convert an (r, g, b[, a]) float tuple to a #RRGGBB hex string."""
r = max(0, min(255, int(round(colour[0] * 255))))
g = max(0, min(255, int(round(colour[1] * 255))))
b = max(0, min(255, int(round(colour[2] * 255))))
return f"#{r:02X}{g:02X}{b:02X}"
def _find_prop_widget(inspector, prop: str, component: int = -1):
"""Find the inspector widget for *prop*, optionally a vector component.
Falls back to ``mat_`` prefix for material properties (colour, roughness, etc.).
"""
widget = inspector._property_widgets.get(prop)
if widget is None:
widget = inspector._property_widgets.get(f"mat_{prop}")
if widget is None:
return None
if component >= 0 and hasattr(widget, "_spinboxes"):
sbs = widget._spinboxes
return sbs[component] if component < len(sbs) else None
return widget
def _world_to_screen(world_pos, camera, screen_size):
"""Project a 3D world position to 2D screen coordinates.
Args:
world_pos: (x, y, z) world position
camera: Camera3D node (provides view_matrix, projection_matrix)
screen_size: (width, height) in pixels
Returns:
(screen_x, screen_y) tuple, or None if behind camera
"""
import numpy as np
sw, sh = float(screen_size[0]), float(screen_size[1])
aspect = sw / sh if sh > 0 else 1.0
view = camera.view_matrix
proj = camera.projection_matrix(aspect)
vp = proj @ view
p = np.array([float(world_pos[0]), float(world_pos[1]), float(world_pos[2]), 1.0], dtype=np.float32)
clip = vp @ p
if clip[3] <= 0:
return None # behind camera
ndc_x = clip[0] / clip[3]
ndc_y = clip[1] / clip[3]
# Vulkan Y-flip: proj[1,1] < 0
if proj[1, 1] < 0:
ndc_y = -ndc_y
screen_x = (ndc_x + 1.0) * 0.5 * sw
screen_y = (1.0 - ndc_y) * 0.5 * sh
return (screen_x, screen_y)
def _resolve_root_type(name: str) -> type:
"""Resolve a node type name for NewSceneSelect."""
return Node._registry.get(name, Node)