from __future__ import annotations import math import time from pathlib import Path from . import ( Color, SchemeTrainingRuntime, TrackConfig, TrackDrawKind, TrackInfo, TrackReport, TrackState, render_pixels, ) from .emulator_scheme import ( ACCELERATION_PROFILES, SCHEME_KINDS, clone_draft, default_draft, draft_to_scheme, load_draft, normalize_draft, save_draft, ) DRAWLIST_TAG = "track_core_drawlist" EDITOR_TAG = "scheme_editor" STATUS_TAG = "schema_status" FILE_PATH_TAG = "schema_file_path" LOAD_DIALOG_TAG = "schema_load_dialog" SAVE_DIALOG_TAG = "schema_save_dialog" def _load_dearpygui(): try: import dearpygui.dearpygui as dpg except ImportError as exc: raise SystemExit( "DearPyGui is not installed. Install the emulator extra with " '`python -m pip install -e ".[emulator]"`.' ) from exc return dpg def _rgba(color: Color) -> tuple[int, int, int, int]: return (color.r, color.g, color.b, 255) def _color_from_draft(draft: dict) -> Color: rgb = draft.get("color", [0, 255, 0]) return Color(int(rgb[0]), int(rgb[1]), int(rgb[2])) def _config_from_ui(dpg) -> TrackConfig: line_length = float(dpg.get_value("line_length")) active_length = min(float(dpg.get_value("active_length")), max(0.1, line_length - 0.001)) config = TrackConfig.default_config() config.draw_kind = ( TrackDrawKind.circular if dpg.get_value("draw_kind") == "circular" else TrackDrawKind.linear ) config.line_length_m = line_length config.active_line_length_m = active_length config.head_offset_m = float(dpg.get_value("head_offset")) config.line_leds_num = int(dpg.get_value("led_count")) return config def _toggle_draw_kind(dpg) -> None: next_kind = "linear" if dpg.get_value("draw_kind") == "circular" else "circular" dpg.set_value("draw_kind", next_kind) def _manual_state_to_core(dpg, draft: dict | None = None) -> tuple[TrackConfig, TrackInfo, TrackReport]: config = _config_from_ui(dpg) color = _color_from_draft(draft or default_draft()) info = TrackInfo() info.color = color info.id = int((draft or {}).get("id", 1)) info.is_running = dpg.get_value("manual_running") info.num_segments = 1 report = TrackReport() report.id = info.id report.state = TrackState.run if info.is_running else TrackState.stop report.mileage_m = max(0.0, float(dpg.get_value("manual_mileage"))) report.speed_m_s = 0.0 report.time_elapsed_ms = 0 return config, info, report def _draw_linear(dpg, pixels: list[Color]) -> None: width = 560 x0 = 24 y0 = 174 gap = 1 if len(pixels) <= 240 else 0 cell_w = max(1, int((width - gap * max(0, len(pixels) - 1)) / max(1, len(pixels)))) cell_h = 34 pitch = cell_w + gap dpg.draw_rectangle( (x0, y0), (x0 + width, y0 + cell_h), color=(55, 58, 64, 255), fill=(16, 18, 22, 255), parent=DRAWLIST_TAG, ) index = 0 while index < len(pixels): color = pixels[index] if color == Color.black(): index += 1 continue start = index while index < len(pixels) and pixels[index] == color: index += 1 count = index - start x = x0 + start * pitch span_width = count * cell_w + max(0, count - 1) * gap dpg.draw_rectangle( (x, y0), (x + span_width, y0 + cell_h), color=_rgba(color), fill=_rgba(color), parent=DRAWLIST_TAG, ) for index in range(len(pixels) + 1): x = x0 + index * pitch - (gap if index == len(pixels) and gap else 0) dpg.draw_line((x, y0), (x, y0 + cell_h), color=(70, 74, 84, 255), parent=DRAWLIST_TAG) dpg.draw_rectangle( (x0 - 1, y0 - 1), (x0 + width + 1, y0 + cell_h + 1), color=(90, 96, 108, 255), parent=DRAWLIST_TAG, ) dpg.draw_text((x0, y0 + 52), f"{len(pixels)} LEDs", color=(190, 195, 205, 255), parent=DRAWLIST_TAG) def _draw_circular(dpg, pixels: list[Color]) -> None: center = (304, 158) radius = 122 led_radius = 5 if len(pixels) <= 160 else 3 dpg.draw_circle(center, radius, color=(80, 86, 98, 255), thickness=1, parent=DRAWLIST_TAG) for index, color in enumerate(pixels): angle = (2.0 * math.pi * index / max(1, len(pixels))) - (math.pi / 2.0) pos = ( center[0] + math.cos(angle) * radius, center[1] + math.sin(angle) * radius, ) dpg.draw_circle( pos, led_radius, color=(42, 45, 52, 255), fill=_rgba(color), parent=DRAWLIST_TAG, ) dpg.draw_text((24, 300), f"{len(pixels)} LEDs", color=(190, 195, 205, 255), parent=DRAWLIST_TAG) class TrackRuntimeApp: def __init__(self) -> None: self.runtime = SchemeTrainingRuntime() self.draft = default_draft("ST") self.file_path: Path | None = None self.last_frame_s = time.perf_counter() self.last_valid_draft = clone_draft(self.draft) self.status = "Ready" self.has_unapplied_edits = False def load_scheme(self, dpg, run: bool | None = None) -> bool: if run is None: run = True if not self.runtime.has_program() else not self.runtime.all_stopped() try: normalized = normalize_draft(self.draft) scheme = draft_to_scheme(normalized) except Exception as exc: self.set_status(dpg, f"Invalid scheme: {exc}", error=True) return False self.draft = normalized self.runtime.clear() self.runtime.add_scheme(scheme) if run: self.runtime.start() else: self.runtime.stop() self.last_frame_s = time.perf_counter() self.last_valid_draft = clone_draft(normalized) self.has_unapplied_edits = False self.set_status(dpg, "Loaded into runtime" if run else "Loaded into runtime (stopped)") return True def start(self) -> None: self.runtime.start() self.last_frame_s = time.perf_counter() def stop(self) -> None: self.runtime.stop() def set_status(self, dpg, message: str, error: bool = False) -> None: self.status = message if dpg.does_item_exist(STATUS_TAG): dpg.set_value(STATUS_TAG, message) dpg.configure_item(STATUS_TAG, color=(255, 110, 90) if error else (170, 220, 170)) def refresh_file_path(self, dpg) -> None: if dpg.does_item_exist(FILE_PATH_TAG): dpg.set_value(FILE_PATH_TAG, str(self.file_path) if self.file_path else "") def mark_dirty(self, dpg) -> None: self.has_unapplied_edits = True self.set_status(dpg, "Edited; click Apply to validate and reload") def apply_editor_values(self, dpg) -> None: self.draft["id"] = int(dpg.get_value("schema_id")) self.draft["kind"] = dpg.get_value("schema_kind") self.draft["acceleration_profile"] = dpg.get_value("schema_accel") color = dpg.get_value("schema_color") self.draft["color"] = [int(color[0]), int(color[1]), int(color[2])] def apply_schema(self, dpg) -> bool: self.apply_editor_values(dpg) return self.load_scheme(dpg) def reset_template(self, dpg, kind: str | None = None) -> None: self.draft = default_draft(kind or self.draft.get("kind", "ST")) self.file_path = None self.refresh_file_path(dpg) rebuild_editor(dpg, self) self.mark_dirty(dpg) def save_current(self, dpg) -> None: if self.file_path is None: dpg.show_item(SAVE_DIALOG_TAG) return try: self.draft = save_draft(self.file_path, self.draft) self.set_status(dpg, f"Saved {self.file_path}") rebuild_editor(dpg, self) except Exception as exc: self.set_status(dpg, f"Save failed: {exc}", error=True) def save_as(self, dpg, path: Path) -> None: if path.suffix == "": path = path.with_suffix(".json") try: self.draft = save_draft(path, self.draft) self.file_path = path self.refresh_file_path(dpg) self.set_status(dpg, f"Saved {path}") rebuild_editor(dpg, self) except Exception as exc: self.set_status(dpg, f"Save failed: {exc}", error=True) def load_from_file(self, dpg, path: Path) -> None: try: draft = load_draft(path) draft_to_scheme(draft) except Exception as exc: self.set_status(dpg, f"Load failed: {exc}", error=True) return self.draft = draft self.file_path = path self.refresh_file_path(dpg) rebuild_editor(dpg, self) self.mark_dirty(dpg) self.set_status(dpg, f"Loaded {path}; click Apply to validate and reload") def _draw_status(self, dpg) -> None: if dpg.get_value("source_mode") == "manual": _, info, report = _manual_state_to_core(dpg, self.last_valid_draft) state = "run" if info.is_running else "stop" dpg.draw_text( (24, 18), f"manual {state} {report.mileage_m:6.2f}m {dpg.get_value('draw_kind')} model", color=(220, 225, 235, 255), parent=DRAWLIST_TAG, ) return states = self.runtime.state_collection() if states: state = states[0] dpg.draw_text( (24, 18), f"id {state.id} {state.time_elapsed_ms / 1000.0:5.1f}s " f"{state.mileage_m:6.2f}m {state.speed_m_s:4.2f}m/s", color=(220, 225, 235, 255), parent=DRAWLIST_TAG, ) def _runtime_pixels(self, dpg, config: TrackConfig) -> list[Color]: now_s = time.perf_counter() delta_s = min(0.1, now_s - self.last_frame_s) self.last_frame_s = now_s if dpg.get_value("source_mode") == "runtime": self.runtime.tick(config, delta_s * float(dpg.get_value("time_scale"))) return self.runtime.render_pixels(config) manual_config, info, report = _manual_state_to_core(dpg, self.last_valid_draft) return render_pixels(manual_config, info, report) def render(self, dpg) -> None: dpg.delete_item(DRAWLIST_TAG, children_only=True) config = _config_from_ui(dpg) pixels = self._runtime_pixels(dpg, config) if config.draw_kind == TrackDrawKind.circular: _draw_circular(dpg, pixels) else: _draw_linear(dpg, pixels) self._draw_status(dpg) def _segment_input_float(dpg, tag: str, value: float, callback) -> None: dpg.add_input_float( tag=tag, default_value=float(value), width=92, min_value=0.0, min_clamped=True, step=0.1, format="%.2f", callback=callback, ) def _segment_input_int(dpg, tag: str, value: int, callback) -> None: dpg.add_input_int( tag=tag, default_value=int(value), width=82, min_value=0, min_clamped=True, step=1, callback=callback, ) def _editor_changed(dpg, app: TrackRuntimeApp) -> None: app.apply_editor_values(dpg) app.mark_dirty(dpg) def _set_simple_segment_value(dpg, app: TrackRuntimeApp, index: int, field: str, value) -> None: app.draft["segments"][index][field] = value _editor_changed(dpg, app) def _set_rsmt_time(dpg, app: TrackRuntimeApp, index: int, value: int) -> None: app.draft["segments"][index]["time_s"] = value _editor_changed(dpg, app) def _set_rsmt_sub_value( dpg, app: TrackRuntimeApp, segment_index: int, sub_index: int, field: str, value, ) -> None: app.draft["segments"][segment_index]["sub_segments"][sub_index][field] = value _editor_changed(dpg, app) def _add_simple_segment(dpg, app: TrackRuntimeApp) -> None: app.apply_editor_values(dpg) kind = app.draft["kind"] segments = app.draft["segments"] if kind == "SM": last = segments[-1] if segments else {"mileage_m": 0, "speed_m_s": 1.0} segments.append({"mileage_m": int(last["mileage_m"]) + 5, "speed_m_s": float(last["speed_m_s"])}) elif kind == "MT": last = segments[-1] if segments else {"time_s": 0, "mileage_m": 5} segments.append({"time_s": int(last["time_s"]) + 5, "mileage_m": max(1, int(last["mileage_m"]))}) else: last = segments[-1] if segments else {"time_s": 0, "speed_m_s": 1.0} segments.append({"time_s": int(last["time_s"]) + 5, "speed_m_s": float(last["speed_m_s"])}) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _remove_simple_segment(dpg, app: TrackRuntimeApp, index: int) -> None: app.apply_editor_values(dpg) if len(app.draft["segments"]) > 1: app.draft["segments"].pop(index) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _duplicate_simple_segment(dpg, app: TrackRuntimeApp, index: int) -> None: app.apply_editor_values(dpg) source = clone_draft(app.draft["segments"][index]) if app.draft["kind"] == "SM": source["mileage_m"] = int(source["mileage_m"]) + 5 else: source["time_s"] = int(source["time_s"]) + 5 app.draft["segments"].insert(index + 1, source) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _add_rsmt_segment(dpg, app: TrackRuntimeApp) -> None: app.apply_editor_values(dpg) segments = app.draft["segments"] last = segments[-1] if segments else {"time_s": 0} segments.append( { "time_s": int(last["time_s"]) + 10, "sub_segments": [ {"mileage_m": 0, "speed_m_s": 1.0}, {"mileage_m": 8, "speed_m_s": 1.0}, ], } ) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _remove_rsmt_segment(dpg, app: TrackRuntimeApp, index: int) -> None: app.apply_editor_values(dpg) if len(app.draft["segments"]) > 1: app.draft["segments"].pop(index) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _add_rsmt_sub_segment(dpg, app: TrackRuntimeApp, segment_index: int) -> None: app.apply_editor_values(dpg) sub_segments = app.draft["segments"][segment_index]["sub_segments"] last = sub_segments[-1] if sub_segments else {"mileage_m": 0, "speed_m_s": 1.0} sub_segments.append({"mileage_m": int(last["mileage_m"]) + 5, "speed_m_s": float(last["speed_m_s"])}) rebuild_editor(dpg, app) app.mark_dirty(dpg) def _remove_rsmt_sub_segment(dpg, app: TrackRuntimeApp, segment_index: int, sub_index: int) -> None: app.apply_editor_values(dpg) sub_segments = app.draft["segments"][segment_index]["sub_segments"] if len(sub_segments) > 1: sub_segments.pop(sub_index) rebuild_editor(dpg, app) app.mark_dirty(dpg) def rebuild_editor(dpg, app: TrackRuntimeApp) -> None: dpg.delete_item(EDITOR_TAG, children_only=True) draft = app.draft with dpg.group(parent=EDITOR_TAG): dpg.add_text("Training Scheme") dpg.add_input_text( label="file", tag=FILE_PATH_TAG, default_value=str(app.file_path) if app.file_path else "", readonly=True, width=410, ) with dpg.group(horizontal=True): dpg.add_button(label="Load JSON", callback=lambda *_: dpg.show_item(LOAD_DIALOG_TAG)) dpg.add_button(label="Apply", callback=lambda *_: app.apply_schema(dpg)) dpg.add_button(label="Save", callback=lambda *_: app.save_current(dpg)) dpg.add_button(label="Save As", callback=lambda *_: dpg.show_item(SAVE_DIALOG_TAG)) dpg.add_button(label="Reset", callback=lambda *_: app.reset_template(dpg)) dpg.add_separator() with dpg.group(horizontal=True): dpg.add_input_int( label="id", tag="schema_id", default_value=int(draft["id"]), width=90, min_value=0, max_value=255, min_clamped=True, max_clamped=True, callback=lambda *_: _editor_changed(dpg, app), ) dpg.add_combo( SCHEME_KINDS, label="kind", tag="schema_kind", default_value=draft["kind"], width=96, callback=lambda *_: app.reset_template(dpg, dpg.get_value("schema_kind")), ) dpg.add_combo( ACCELERATION_PROFILES, label="accel", tag="schema_accel", default_value=draft["acceleration_profile"], width=110, callback=lambda *_: _editor_changed(dpg, app), ) dpg.add_color_edit( default_value=(*draft["color"], 255), label="color", tag="schema_color", no_alpha=True, width=260, callback=lambda *_: _editor_changed(dpg, app), ) dpg.add_separator() if draft["kind"] == "RSMT": _build_rsmt_editor(dpg, app) else: _build_simple_editor(dpg, app) dpg.add_separator() dpg.add_text(app.status, tag=STATUS_TAG, color=(170, 220, 170)) def _build_simple_editor(dpg, app: TrackRuntimeApp) -> None: kind = app.draft["kind"] dpg.add_button(label="Add Segment", callback=lambda *_: _add_simple_segment(dpg, app)) with dpg.table(header_row=True, borders_innerH=True, borders_outerH=True, borders_innerV=True): dpg.add_table_column(label="#", width_fixed=True) if kind == "SM": dpg.add_table_column(label="mileage m") dpg.add_table_column(label="speed m/s") elif kind == "MT": dpg.add_table_column(label="time s") dpg.add_table_column(label="mileage m") else: dpg.add_table_column(label="time s") dpg.add_table_column(label="speed m/s") dpg.add_table_column(label="actions") for index, segment in enumerate(app.draft["segments"]): with dpg.table_row(): dpg.add_text(str(index)) if kind == "SM": _segment_input_int( dpg, f"segment_{index}_mileage", int(segment["mileage_m"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "mileage_m", int(value) ), ) dpg.set_item_user_data(f"segment_{index}_mileage", index) _segment_input_float( dpg, f"segment_{index}_speed", float(segment["speed_m_s"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "speed_m_s", float(value) ), ) dpg.set_item_user_data(f"segment_{index}_speed", index) elif kind == "MT": _segment_input_int( dpg, f"segment_{index}_time", int(segment["time_s"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "time_s", int(value) ), ) dpg.set_item_user_data(f"segment_{index}_time", index) _segment_input_int( dpg, f"segment_{index}_mileage", int(segment["mileage_m"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "mileage_m", int(value) ), ) dpg.set_item_user_data(f"segment_{index}_mileage", index) else: _segment_input_int( dpg, f"segment_{index}_time", int(segment["time_s"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "time_s", int(value) ), ) dpg.set_item_user_data(f"segment_{index}_time", index) _segment_input_float( dpg, f"segment_{index}_speed", float(segment["speed_m_s"]), lambda sender, value, user_data: _set_simple_segment_value( dpg, app, user_data, "speed_m_s", float(value) ), ) dpg.set_item_user_data(f"segment_{index}_speed", index) with dpg.group(horizontal=True): dpg.add_button( label="copy", callback=lambda _, __, ___=None, i=index: _duplicate_simple_segment(dpg, app, i), ) dpg.add_button( label="del", callback=lambda _, __, ___=None, i=index: _remove_simple_segment(dpg, app, i), ) def _build_rsmt_editor(dpg, app: TrackRuntimeApp) -> None: dpg.add_button(label="Add Time Segment", callback=lambda *_: _add_rsmt_segment(dpg, app)) for segment_index, segment in enumerate(app.draft["segments"]): with dpg.collapsing_header(label=f"time segment {segment_index}", default_open=True): with dpg.group(horizontal=True): dpg.add_text("time s") _segment_input_int( dpg, f"rsmt_{segment_index}_time", int(segment["time_s"]), lambda sender, value, user_data: _set_rsmt_time(dpg, app, user_data, int(value)), ) dpg.set_item_user_data(f"rsmt_{segment_index}_time", segment_index) dpg.add_button( label="Add Sub-Segment", callback=lambda _, __, ___=None, i=segment_index: _add_rsmt_sub_segment(dpg, app, i), ) dpg.add_button( label="Delete Time Segment", callback=lambda _, __, ___=None, i=segment_index: _remove_rsmt_segment(dpg, app, i), ) with dpg.table(header_row=True, borders_innerH=True, borders_outerH=True, borders_innerV=True): dpg.add_table_column(label="#") dpg.add_table_column(label="mileage m") dpg.add_table_column(label="speed m/s") dpg.add_table_column(label="actions") for sub_index, sub_segment in enumerate(segment["sub_segments"]): with dpg.table_row(): dpg.add_text(str(sub_index)) _segment_input_int( dpg, f"rsmt_{segment_index}_{sub_index}_mileage", int(sub_segment["mileage_m"]), lambda sender, value, user_data: _set_rsmt_sub_value( dpg, app, user_data[0], user_data[1], "mileage_m", int(value) ), ) dpg.set_item_user_data(f"rsmt_{segment_index}_{sub_index}_mileage", (segment_index, sub_index)) _segment_input_float( dpg, f"rsmt_{segment_index}_{sub_index}_speed", float(sub_segment["speed_m_s"]), lambda sender, value, user_data: _set_rsmt_sub_value( dpg, app, user_data[0], user_data[1], "speed_m_s", float(value) ), ) dpg.set_item_user_data(f"rsmt_{segment_index}_{sub_index}_speed", (segment_index, sub_index)) dpg.add_button( label="del", callback=lambda _, __, ___=None, i=segment_index, j=sub_index: _remove_rsmt_sub_segment( dpg, app, i, j ), ) def _dialog_path(app_data) -> Path: if "file_path_name" in app_data: return Path(app_data["file_path_name"]) return Path(app_data["current_path"]) / app_data["file_name"] def _build_file_dialogs(dpg, app: TrackRuntimeApp) -> None: with dpg.file_dialog( directory_selector=False, show=False, callback=lambda _, app_data, ___=None: app.load_from_file(dpg, _dialog_path(app_data)), tag=LOAD_DIALOG_TAG, width=700, height=420, ): dpg.add_file_extension(".json", color=(120, 190, 255, 255)) with dpg.file_dialog( directory_selector=False, show=False, callback=lambda _, app_data, ___=None: app.save_as(dpg, _dialog_path(app_data)), tag=SAVE_DIALOG_TAG, width=700, height=420, ): dpg.add_file_extension(".json", color=(120, 190, 255, 255)) def main() -> None: dpg = _load_dearpygui() app = TrackRuntimeApp() dpg.create_context() dpg.create_viewport(title="track-core emulator", width=1160, height=690) with dpg.window(label="track-core emulator", tag="main_window", width=1140, height=660): with dpg.group(horizontal=True): with dpg.child_window(width=500, height=630, border=True): dpg.add_child_window(tag=EDITOR_TAG, width=480, height=610, border=False) with dpg.child_window(width=610, height=630, border=True): dpg.add_text("Runtime Preview") with dpg.group(horizontal=True): dpg.add_combo(("runtime", "manual"), default_value="runtime", label="source", tag="source_mode") dpg.add_combo(("circular", "linear"), default_value="circular", label="track model", tag="draw_kind") dpg.add_button(label="toggle model", callback=lambda *_: _toggle_draw_kind(dpg)) with dpg.group(horizontal=True): dpg.add_button(label="reload schema", callback=lambda *_: app.load_scheme(dpg)) dpg.add_button(label="start", callback=lambda *_: app.start()) dpg.add_button(label="stop", callback=lambda *_: app.stop()) dpg.add_slider_float(label="time scale", tag="time_scale", default_value=1.0, min_value=0.0, max_value=8.0) dpg.add_slider_float(label="line length m", tag="line_length", default_value=40.0, min_value=5.0, max_value=80.0) dpg.add_slider_float(label="active length m", tag="active_length", default_value=10.0, min_value=0.1, max_value=40.0) dpg.add_slider_float(label="head offset m", tag="head_offset", default_value=0.0, min_value=-40.0, max_value=40.0) dpg.add_slider_int(label="LEDs", tag="led_count", default_value=160, min_value=8, max_value=400) dpg.add_checkbox(label="manual running", default_value=True, tag="manual_running") dpg.add_slider_float(label="manual position m", tag="manual_mileage", default_value=8.5, min_value=0.0, max_value=120.0) dpg.add_drawlist(width=590, height=330, tag=DRAWLIST_TAG) _build_file_dialogs(dpg, app) rebuild_editor(dpg, app) app.load_scheme(dpg) dpg.setup_dearpygui() dpg.set_primary_window("main_window", True) dpg.show_viewport() while dpg.is_dearpygui_running(): app.render(dpg) dpg.render_dearpygui_frame() dpg.destroy_context() if __name__ == "__main__": main()