diff --git a/python/tests/test_bindings.py b/python/tests/test_bindings.py index abc21d3..7337bdd 100644 --- a/python/tests/test_bindings.py +++ b/python/tests/test_bindings.py @@ -1,6 +1,8 @@ import pytest import track_core as tc +from track_core import emulator +from track_core import emulator_scheme def running_info(color=None): @@ -31,6 +33,42 @@ def config(draw_kind): return value +class FakeDpg: + def __init__(self, values): + self.values = values + + def get_value(self, tag): + return self.values[tag] + + def set_value(self, tag, value): + self.values[tag] = value + + def does_item_exist(self, tag): + return False + + def configure_item(self, tag, **kwargs): + pass + + +def emulator_values(**overrides): + values = { + "line_length": 40.0, + "active_length": 10.0, + "head_offset": 0.0, + "led_count": 160, + "draw_kind": "linear", + "runner_color": (0, 255, 0, 255), + "manual_running": True, + "manual_mileage": 8.5, + "schema_id": 1, + "schema_kind": "ST", + "schema_accel": "smooth", + "schema_color": (0, 255, 0, 255), + } + values.update(overrides) + return values + + def test_circular_render_wraps(): plan = tc.make_render_plan( config(tc.TrackDrawKind.circular), @@ -66,10 +104,22 @@ def test_linear_render_reverse_spans(): plan = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(15.0)) assert [(span.start_led, span.led_count, span.color) for span in plan.spans] == [ - (5, 2, tc.Color.blue()), - (7, 3, tc.Color.red()), (10, 3, tc.Color.red()), - (13, 2, tc.Color.cyan()), + (13, 2, tc.Color.blue()), + (5, 2, tc.Color.cyan()), + (7, 3, tc.Color.red()), + ] + + +def test_linear_render_prefix_postfix_colors_do_not_reverse(): + cfg = config(tc.TrackDrawKind.linear) + cfg.active_line_length_m = 5.0 + + forward = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(5.0)) + reverse = tc.make_render_plan(cfg, running_info(tc.Color.red()), report(15.0)) + + assert [(span.start_led, span.led_count, span.color) for span in reverse.spans] == [ + (span.start_led, span.led_count, span.color) for span in forward.spans ] @@ -100,6 +150,114 @@ def test_linear_render_boundary_sweep_does_not_raise(): assert len(pixels) == cfg.line_leds_num +@pytest.mark.parametrize("mileage", [10.1, 19.9]) +def test_linear_render_reverse_near_ends_stays_visible(mileage): + cfg = config(tc.TrackDrawKind.linear) + + pixels = tc.render_pixels(cfg, running_info(tc.Color.red()), report(mileage)) + + assert any(pixel != tc.Color.black() for pixel in pixels) + + +def test_manual_emulator_report_clamps_negative_mileage_and_ignores_speed(): + dpg = FakeDpg(emulator_values(manual_mileage=-12.0)) + + _, info, state_report = emulator._manual_state_to_core(dpg) + + assert info.is_running + assert state_report.mileage_m == 0.0 + assert state_report.speed_m_s == 0.0 + + +def test_emulator_track_model_toggle(): + dpg = FakeDpg(emulator_values(draw_kind="circular")) + + emulator._toggle_draw_kind(dpg) + assert dpg.get_value("draw_kind") == "linear" + + emulator._toggle_draw_kind(dpg) + assert dpg.get_value("draw_kind") == "circular" + + +def test_emulator_schema_reload_preserves_stopped_runtime(): + app = emulator.TrackRuntimeApp() + dpg = FakeDpg(emulator_values()) + + assert app.load_scheme(dpg) + assert not app.runtime.all_stopped() + + app.stop() + assert app.runtime.all_stopped() + + app.draft["segments"][1]["speed_m_s"] = 2.5 + assert app.load_scheme(dpg) + + assert app.runtime.all_stopped() + + +def test_emulator_editor_changes_wait_for_explicit_apply(): + app = emulator.TrackRuntimeApp() + dpg = FakeDpg(emulator_values()) + + assert app.load_scheme(dpg) + previous_valid = emulator_scheme.clone_draft(app.last_valid_draft) + + emulator._set_simple_segment_value(dpg, app, 0, "time_s", 3) + + assert app.draft["segments"][0]["time_s"] == 3 + assert app.last_valid_draft == previous_valid + assert app.has_unapplied_edits + assert "Edited" in app.status + + assert not app.apply_schema(dpg) + assert app.last_valid_draft == previous_valid + + +@pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"]) +def test_emulator_scheme_json_round_trips_all_kinds(tmp_path, kind): + draft = emulator_scheme.default_draft(kind) + path = tmp_path / f"{kind.lower()}.json" + + saved = emulator_scheme.save_draft(path, draft) + loaded = emulator_scheme.load_draft(path) + + assert loaded == saved + assert loaded["version"] == 1 + assert loaded["kind"] == kind + + +@pytest.mark.parametrize("kind", ["ST", "SM", "MT", "RSMT"]) +def test_emulator_scheme_draft_converts_to_core_scheme(kind): + scheme = emulator_scheme.draft_to_scheme(emulator_scheme.default_draft(kind)) + + runtime = tc.make_scheme_track_runtime(scheme) + runtime = tc.start_scheme_track(runtime) + + assert runtime.info().id == 1 + assert runtime.report().state == tc.TrackState.run + + +@pytest.mark.parametrize( + "kind,segments,error", + [ + ("ST", [{"time_s": 1, "speed_m_s": 1.0}], "first ST time_s"), + ("SM", [{"mileage_m": 1, "speed_m_s": 1.0}], "first SM mileage_m"), + ("MT", [{"time_s": 0, "mileage_m": 1}], "MT needs at least two segments"), + ( + "RSMT", + [{"time_s": 0, "sub_segments": [{"mileage_m": 1, "speed_m_s": 1.0}]}], + "first RSMT segment 0 mileage_m", + ), + ], +) +def test_emulator_scheme_rejects_invalid_segments(kind, segments, error): + draft = emulator_scheme.default_draft(kind) + draft["segments"] = segments + + with pytest.raises(ValueError, match=error): + emulator_scheme.normalize_draft(draft) + + def test_not_running_renders_black_pixels(): info = running_info() info.is_running = False diff --git a/python/track_core/emulator.py b/python/track_core/emulator.py index 84010d6..e46163a 100644 --- a/python/track_core/emulator.py +++ b/python/track_core/emulator.py @@ -2,29 +2,36 @@ from __future__ import annotations import math import time +from pathlib import Path from . import ( - AccelerationProfile, Color, - MTSegment, - RepeatedSMSegment, - SMSegment, - STSegment, SchemeTrainingRuntime, TrackConfig, TrackDrawKind, TrackInfo, TrackReport, TrackState, - make_mileage_time_scheme, - make_repeated_speed_mileage_time_scheme, - make_speed_mileage_scheme, - make_speed_time_scheme, 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(): @@ -42,44 +49,8 @@ def _rgba(color: Color) -> tuple[int, int, int, int]: return (color.r, color.g, color.b, 255) -def _sm(speed: float, mileage_m: int) -> SMSegment: - segment = SMSegment() - segment.speed_m_s = speed - segment.mileage_from_start_m = mileage_m - return segment - - -def _mt(mileage_m: int, time_s: int) -> MTSegment: - segment = MTSegment() - segment.mileage_to_travel_this_segment_m = mileage_m - segment.time_since_start_s = time_s - return segment - - -def _st(speed: float, time_s: int) -> STSegment: - segment = STSegment() - segment.speed_m_s = speed - segment.time_since_start_s = time_s - return segment - - -def _rsmt(time_s: int, segments: list[SMSegment]) -> RepeatedSMSegment: - segment = RepeatedSMSegment() - segment.time_since_start_s = time_s - segment.speed_mileage_segments = segments - return segment - - -def _profile_from_ui(dpg) -> AccelerationProfile: - return ( - AccelerationProfile.smooth - if dpg.get_value("accel_profile") == "smooth" - else AccelerationProfile.instant - ) - - -def _color_from_ui(dpg) -> Color: - rgb = dpg.get_value("runner_color") +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])) @@ -100,43 +71,73 @@ def _config_from_ui(dpg) -> TrackConfig: return config -def _manual_state_to_core(dpg) -> tuple[TrackConfig, TrackInfo, TrackReport]: +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_ui(dpg) + color = _color_from_draft(draft or default_draft()) info = TrackInfo() info.color = color - info.id = 1 + info.id = int((draft or {}).get("id", 1)) info.is_running = dpg.get_value("manual_running") info.num_segments = 1 report = TrackReport() - report.id = 1 + report.id = info.id report.state = TrackState.run if info.is_running else TrackState.stop - report.mileage_m = dpg.get_value("manual_mileage") - report.speed_m_s = dpg.get_value("manual_speed") + 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 = 840 - x0 = 30 - y0 = 180 + 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 - for index, color in enumerate(pixels): - x = x0 + index * (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 + cell_w, y0 + cell_h), - color=(55, 58, 64, 255), + (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), @@ -147,7 +148,7 @@ def _draw_linear(dpg, pixels: list[Color]) -> None: def _draw_circular(dpg, pixels: list[Color]) -> None: - center = (440, 158) + center = (304, 158) radius = 122 led_radius = 5 if len(pixels) <= 160 else 3 @@ -166,56 +167,42 @@ def _draw_circular(dpg, pixels: list[Color]) -> None: parent=DRAWLIST_TAG, ) - dpg.draw_text((30, 300), f"{len(pixels)} LEDs", color=(190, 195, 205, 255), 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 build_scheme(self, dpg): - kind = dpg.get_value("scheme_kind") - color = _color_from_ui(dpg) - profile = _profile_from_ui(dpg) + 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() - if kind == "SM": - return make_speed_mileage_scheme( - 1, - color, - profile, - [_sm(1.0, 0), _sm(2.8, 10), _sm(1.2, 24), _sm(2.0, 40)], - ) - if kind == "MT": - return make_mileage_time_scheme( - 1, - color, - profile, - [_mt(12, 0), _mt(20, 6), _mt(10, 18), _mt(1, 28)], - ) - if kind == "RSMT": - return make_repeated_speed_mileage_time_scheme( - 1, - color, - profile, - [ - _rsmt(0, [_sm(1.0, 0), _sm(3.0, 10), _sm(1.2, 24)]), - _rsmt(18, [_sm(2.2, 0), _sm(4.0, 8), _sm(1.0, 20)]), - _rsmt(42, [_sm(1.4, 0), _sm(2.4, 12), _sm(1.4, 24)]), - ], - ) - return make_speed_time_scheme( - 1, - color, - profile, - [_st(1.0, 0), _st(3.2, 8), _st(1.4, 18), _st(2.4, 30), _st(0.8, 42)], - ) + 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 - def load_scheme(self, dpg) -> None: + self.draft = normalized self.runtime.clear() - self.runtime.add_scheme(self.build_scheme(dpg)) - self.runtime.start() + 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() @@ -224,6 +211,99 @@ class TrackRuntimeApp: 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) @@ -233,7 +313,7 @@ class TrackRuntimeApp: 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) + 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: @@ -245,16 +325,380 @@ class TrackRuntimeApp: else: _draw_linear(dpg, pixels) - states = self.runtime.state_collection() - if states: - state = states[0] - dpg.draw_text( - (30, 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, + 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: @@ -262,33 +706,37 @@ def main() -> None: app = TrackRuntimeApp() dpg.create_context() - dpg.create_viewport(title="track-core emulator", width=920, height=560) + dpg.create_viewport(title="track-core emulator", width=1160, height=690) - with dpg.window(label="track-core emulator", tag="main_window", width=900, height=540): + with dpg.window(label="track-core emulator", tag="main_window", width=1140, height=660): with dpg.group(horizontal=True): - dpg.add_combo(("runtime", "manual"), default_value="runtime", label="source", tag="source_mode") - dpg.add_combo(("ST", "SM", "MT", "RSMT"), default_value="ST", label="scheme", tag="scheme_kind", callback=lambda *_: app.load_scheme(dpg)) - dpg.add_combo(("instant", "smooth"), default_value="smooth", label="accel", tag="accel_profile", callback=lambda *_: app.load_scheme(dpg)) - dpg.add_combo(("circular", "linear"), default_value="circular", label="draw", tag="draw_kind") + 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="load", 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_color_edit(default_value=(0, 255, 0, 255), label="color", tag="runner_color", no_alpha=True, callback=lambda *_: app.load_scheme(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 mileage m", tag="manual_mileage", default_value=8.5, min_value=-80.0, max_value=120.0) - dpg.add_slider_float(label="manual speed m/s", tag="manual_speed", default_value=1.0, min_value=0.0, max_value=10.0) - - dpg.add_drawlist(width=880, height=330, tag=DRAWLIST_TAG) + 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() diff --git a/python/track_core/emulator_scheme.py b/python/track_core/emulator_scheme.py new file mode 100644 index 0000000..aed9b1f --- /dev/null +++ b/python/track_core/emulator_scheme.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import copy +import json +import math +from pathlib import Path +from typing import Any + +from . import ( + AccelerationProfile, + Color, + MTSegment, + RepeatedSMSegment, + SMSegment, + STSegment, + make_mileage_time_scheme, + make_repeated_speed_mileage_time_scheme, + make_speed_mileage_scheme, + make_speed_time_scheme, +) + + +SCHEMA_VERSION = 1 +SCHEME_KINDS = ("ST", "SM", "MT", "RSMT") +ACCELERATION_PROFILES = ("instant", "smooth") + + +def _sm(speed: float, mileage_m: int) -> SMSegment: + segment = SMSegment() + segment.speed_m_s = speed + segment.mileage_from_start_m = mileage_m + return segment + + +def _mt(mileage_m: int, time_s: int) -> MTSegment: + segment = MTSegment() + segment.mileage_to_travel_this_segment_m = mileage_m + segment.time_since_start_s = time_s + return segment + + +def _st(speed: float, time_s: int) -> STSegment: + segment = STSegment() + segment.speed_m_s = speed + segment.time_since_start_s = time_s + return segment + + +def _rsmt(time_s: int, segments: list[SMSegment]) -> RepeatedSMSegment: + segment = RepeatedSMSegment() + segment.time_since_start_s = time_s + segment.speed_mileage_segments = segments + return segment + + +def default_draft(kind: str = "ST") -> dict[str, Any]: + if kind == "SM": + segments: list[dict[str, Any]] = [ + {"mileage_m": 0, "speed_m_s": 1.0}, + {"mileage_m": 10, "speed_m_s": 2.8}, + {"mileage_m": 24, "speed_m_s": 1.2}, + {"mileage_m": 40, "speed_m_s": 2.0}, + ] + elif kind == "MT": + segments = [ + {"time_s": 0, "mileage_m": 12}, + {"time_s": 6, "mileage_m": 20}, + {"time_s": 18, "mileage_m": 10}, + {"time_s": 28, "mileage_m": 1}, + ] + elif kind == "RSMT": + segments = [ + { + "time_s": 0, + "sub_segments": [ + {"mileage_m": 0, "speed_m_s": 1.0}, + {"mileage_m": 10, "speed_m_s": 3.0}, + {"mileage_m": 24, "speed_m_s": 1.2}, + ], + }, + { + "time_s": 18, + "sub_segments": [ + {"mileage_m": 0, "speed_m_s": 2.2}, + {"mileage_m": 8, "speed_m_s": 4.0}, + {"mileage_m": 20, "speed_m_s": 1.0}, + ], + }, + { + "time_s": 42, + "sub_segments": [ + {"mileage_m": 0, "speed_m_s": 1.4}, + {"mileage_m": 12, "speed_m_s": 2.4}, + {"mileage_m": 24, "speed_m_s": 1.4}, + ], + }, + ] + else: + kind = "ST" + segments = [ + {"time_s": 0, "speed_m_s": 1.0}, + {"time_s": 8, "speed_m_s": 3.2}, + {"time_s": 18, "speed_m_s": 1.4}, + {"time_s": 30, "speed_m_s": 2.4}, + {"time_s": 42, "speed_m_s": 0.8}, + ] + + return { + "version": SCHEMA_VERSION, + "id": 1, + "kind": kind, + "acceleration_profile": "smooth", + "color": [0, 255, 0], + "segments": segments, + } + + +def validation_error(draft: dict[str, Any]) -> str | None: + try: + normalize_draft(draft) + except ValueError as exc: + return str(exc) + return None + + +def normalize_draft(draft: dict[str, Any]) -> dict[str, Any]: + if not isinstance(draft, dict): + raise ValueError("scheme JSON must be an object") + + version = _integer(draft.get("version", SCHEMA_VERSION), "version") + if version != SCHEMA_VERSION: + raise ValueError(f"unsupported scheme version {version}") + + kind = str(draft.get("kind", "ST")) + if kind not in SCHEME_KINDS: + raise ValueError(f"unsupported scheme kind {kind!r}") + + profile = str(draft.get("acceleration_profile", "smooth")) + if profile not in ACCELERATION_PROFILES: + raise ValueError(f"unsupported acceleration profile {profile!r}") + + normalized = { + "version": SCHEMA_VERSION, + "id": _bounded_int(draft.get("id", 1), "id", 0, 255), + "kind": kind, + "acceleration_profile": profile, + "color": _color_list(draft.get("color", [0, 255, 0])), + "segments": _normalize_segments(kind, draft.get("segments", [])), + } + return normalized + + +def clone_draft(draft: dict[str, Any]) -> dict[str, Any]: + return copy.deepcopy(draft) + + +def load_draft(path: str | Path) -> dict[str, Any]: + with Path(path).open("r", encoding="utf-8") as stream: + return normalize_draft(json.load(stream)) + + +def save_draft(path: str | Path, draft: dict[str, Any]) -> dict[str, Any]: + normalized = normalize_draft(draft) + with Path(path).open("w", encoding="utf-8") as stream: + json.dump(normalized, stream, indent=2) + stream.write("\n") + return normalized + + +def draft_to_scheme(draft: dict[str, Any]): + normalized = normalize_draft(draft) + scheme_id = int(normalized["id"]) + color = Color(*normalized["color"]) + profile = ( + AccelerationProfile.smooth + if normalized["acceleration_profile"] == "smooth" + else AccelerationProfile.instant + ) + + kind = normalized["kind"] + segments = normalized["segments"] + if kind == "SM": + return make_speed_mileage_scheme( + scheme_id, + color, + profile, + [_sm(float(segment["speed_m_s"]), int(segment["mileage_m"])) for segment in segments], + ) + if kind == "MT": + return make_mileage_time_scheme( + scheme_id, + color, + profile, + [_mt(int(segment["mileage_m"]), int(segment["time_s"])) for segment in segments], + ) + if kind == "RSMT": + return make_repeated_speed_mileage_time_scheme( + scheme_id, + color, + profile, + [ + _rsmt( + int(segment["time_s"]), + [ + _sm(float(sub_segment["speed_m_s"]), int(sub_segment["mileage_m"])) + for sub_segment in segment["sub_segments"] + ], + ) + for segment in segments + ], + ) + return make_speed_time_scheme( + scheme_id, + color, + profile, + [_st(float(segment["speed_m_s"]), int(segment["time_s"])) for segment in segments], + ) + + +def _normalize_segments(kind: str, value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + raise ValueError("segments must be a list") + + if kind == "ST": + segments = [ + { + "time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535), + "speed_m_s": _nonnegative_float(segment.get("speed_m_s"), "speed_m_s"), + } + for segment in _objects(value, "segments") + ] + _require_nonempty(segments, "ST") + segments.sort(key=lambda segment: segment["time_s"]) + _require_first_zero(segments[0]["time_s"], "first ST time_s") + _require_strictly_increasing(segments, "time_s") + return segments + + if kind == "SM": + segments = [ + { + "mileage_m": _bounded_int(segment.get("mileage_m"), "mileage_m", 0, 65535), + "speed_m_s": _nonnegative_float(segment.get("speed_m_s"), "speed_m_s"), + } + for segment in _objects(value, "segments") + ] + _require_nonempty(segments, "SM") + segments.sort(key=lambda segment: segment["mileage_m"]) + _require_first_zero(segments[0]["mileage_m"], "first SM mileage_m") + _require_strictly_increasing(segments, "mileage_m") + return segments + + if kind == "MT": + segments = [ + { + "time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535), + "mileage_m": _bounded_int(segment.get("mileage_m"), "mileage_m", 0, 65535), + } + for segment in _objects(value, "segments") + ] + if len(segments) < 2: + raise ValueError("MT needs at least two segments") + segments.sort(key=lambda segment: segment["time_s"]) + _require_first_zero(segments[0]["time_s"], "first MT time_s") + _require_strictly_increasing(segments, "time_s") + for index, segment in enumerate(segments[:-1]): + if segment["mileage_m"] <= 0: + raise ValueError(f"MT segment {index} mileage_m must be greater than 0") + return segments + + segments = [] + for index, segment in enumerate(_objects(value, "segments")): + sub_segments = [ + { + "mileage_m": _bounded_int(sub_segment.get("mileage_m"), "mileage_m", 0, 65535), + "speed_m_s": _nonnegative_float(sub_segment.get("speed_m_s"), "speed_m_s"), + } + for sub_segment in _objects(segment.get("sub_segments", []), f"RSMT segment {index} sub_segments") + ] + _require_nonempty(sub_segments, f"RSMT segment {index}") + sub_segments.sort(key=lambda sub_segment: sub_segment["mileage_m"]) + _require_first_zero(sub_segments[0]["mileage_m"], f"first RSMT segment {index} mileage_m") + _require_strictly_increasing(sub_segments, "mileage_m") + segments.append( + { + "time_s": _bounded_int(segment.get("time_s"), "time_s", 0, 65535), + "sub_segments": sub_segments, + } + ) + + _require_nonempty(segments, "RSMT") + segments.sort(key=lambda segment: segment["time_s"]) + _require_first_zero(segments[0]["time_s"], "first RSMT time_s") + _require_strictly_increasing(segments, "time_s") + return segments + + +def _objects(value: Any, name: str) -> list[dict[str, Any]]: + if not isinstance(value, list): + raise ValueError(f"{name} must be a list") + if not all(isinstance(item, dict) for item in value): + raise ValueError(f"{name} entries must be objects") + return value + + +def _require_nonempty(segments: list[dict[str, Any]], kind: str) -> None: + if not segments: + raise ValueError(f"{kind} needs at least one segment") + + +def _require_first_zero(value: int, name: str) -> None: + if value != 0: + raise ValueError(f"{name} must be 0") + + +def _require_strictly_increasing(segments: list[dict[str, Any]], field: str) -> None: + for index in range(1, len(segments)): + if int(segments[index][field]) <= int(segments[index - 1][field]): + raise ValueError(f"{field} must be strictly increasing") + + +def _integer(value: Any, name: str) -> int: + if isinstance(value, bool): + raise ValueError(f"{name} must be an integer") + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer") from exc + if result != value and not (isinstance(value, float) and value.is_integer()): + raise ValueError(f"{name} must be an integer") + return result + + +def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int: + result = _integer(value, name) + if result < minimum or result > maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}") + return result + + +def _nonnegative_float(value: Any, name: str) -> float: + try: + result = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a number") from exc + if not math.isfinite(result) or result < 0.0: + raise ValueError(f"{name} must be a finite non-negative number") + return result + + +def _color_list(value: Any) -> list[int]: + if not isinstance(value, (list, tuple)) or len(value) < 3: + raise ValueError("color must contain red, green, and blue values") + return [ + _bounded_int(value[0], "color red", 0, 255), + _bounded_int(value[1], "color green", 0, 255), + _bounded_int(value[2], "color blue", 0, 255), + ] diff --git a/src/render.cpp b/src/render.cpp index d44e0a8..eda05f0 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -249,8 +249,8 @@ TrackRenderPlan make_track_render_plan( } case TrackDrawKind::linear: { const auto drawer = LinearLineDrawer::from_report(config, report); - const auto magic_color_ahead = Color::blue(); - const auto magic_color_behind = Color::cyan(); + const auto prefix_color = Color::cyan(); + const auto postfix_color = Color::blue(); const auto center_offset = static_cast(drawer.center_offset_leds_num()); const auto line_leds_num = static_cast(config.line_leds_num); const auto fill_positive_side = [&](std::uint16_t count, Color near_center, Color far_end) { @@ -281,16 +281,8 @@ TrackRenderPlan make_track_render_plan( }; const auto ahead = drawer.center_ahead_leds_num(); const auto behind = drawer.center_behind_leds_num(); - switch (drawer.direction) { - case LinearLineDrawer::head_to_tail: - fill_positive_side(ahead, info.color, magic_color_ahead); - fill_negative_side(behind, info.color, magic_color_behind); - break; - case LinearLineDrawer::tail_to_head: - fill_negative_side(ahead, info.color, magic_color_ahead); - fill_positive_side(behind, info.color, magic_color_behind); - break; - } + fill_positive_side(ahead, info.color, postfix_color); + fill_negative_side(behind, info.color, prefix_color); break; } } diff --git a/tests/track_core_tests.cpp b/tests/track_core_tests.cpp index 51884fa..f4ed912 100644 --- a/tests/track_core_tests.cpp +++ b/tests/track_core_tests.cpp @@ -117,18 +117,81 @@ void test_linear_render_reverse() { const auto plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), report); require_eq_u32(static_cast(plan.span_count), 4, "reverse span count"); - require_eq_u32(plan.spans[0].start_led, 5, "reverse span 0 start"); - require_eq_u32(plan.spans[0].led_count, 2, "reverse span 0 count"); - require(plan.spans[0].color == track_core::Color::blue(), "reverse span 0 color"); - require_eq_u32(plan.spans[1].start_led, 7, "reverse span 1 start"); - require_eq_u32(plan.spans[1].led_count, 3, "reverse span 1 count"); - require(plan.spans[1].color == track_core::Color::red(), "reverse span 1 color"); - require_eq_u32(plan.spans[2].start_led, 10, "reverse span 2 start"); - require_eq_u32(plan.spans[2].led_count, 3, "reverse span 2 count"); - require(plan.spans[2].color == track_core::Color::red(), "reverse span 2 color"); - require_eq_u32(plan.spans[3].start_led, 13, "reverse span 3 start"); - require_eq_u32(plan.spans[3].led_count, 2, "reverse span 3 count"); - require(plan.spans[3].color == track_core::Color::cyan(), "reverse span 3 color"); + require_eq_u32(plan.spans[0].start_led, 10, "reverse span 0 start"); + require_eq_u32(plan.spans[0].led_count, 3, "reverse span 0 count"); + require(plan.spans[0].color == track_core::Color::red(), "reverse span 0 color"); + require_eq_u32(plan.spans[1].start_led, 13, "reverse span 1 start"); + require_eq_u32(plan.spans[1].led_count, 2, "reverse span 1 count"); + require(plan.spans[1].color == track_core::Color::blue(), "reverse span 1 color"); + require_eq_u32(plan.spans[2].start_led, 5, "reverse span 2 start"); + require_eq_u32(plan.spans[2].led_count, 2, "reverse span 2 count"); + require(plan.spans[2].color == track_core::Color::cyan(), "reverse span 2 color"); + require_eq_u32(plan.spans[3].start_led, 7, "reverse span 3 start"); + require_eq_u32(plan.spans[3].led_count, 3, "reverse span 3 count"); + require(plan.spans[3].color == track_core::Color::red(), "reverse span 3 color"); +} + +void test_linear_render_prefix_postfix_colors_do_not_reverse() { + track_core::TrackConfig config{ + .draw_kind = track_core::TrackDrawKind::linear, + .line_length_m = 10.0F, + .active_line_length_m = 5.0F, + .head_offset_m = 0.0F, + .line_leds_num = 20, + }; + const track_core::TrackReport forward_report{ + .id = 3, + .state = track_core::TrackState::run, + .mileage_m = 5.0F, + .speed_m_s = 2.0F, + }; + const track_core::TrackReport reverse_report{ + .id = 3, + .state = track_core::TrackState::run, + .mileage_m = 15.0F, + .speed_m_s = 2.0F, + }; + + const auto forward_plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), forward_report); + const auto reverse_plan = track_core::make_track_render_plan(config, running_info(track_core::Color::red()), reverse_report); + + require_eq_u32(static_cast(forward_plan.span_count), static_cast(reverse_plan.span_count), "linear same-position span count"); + for (std::size_t i = 0; i < forward_plan.span_count; ++i) { + require_eq_u32(forward_plan.spans[i].start_led, reverse_plan.spans[i].start_led, "linear same-position span start"); + require_eq_u32(forward_plan.spans[i].led_count, reverse_plan.spans[i].led_count, "linear same-position span count"); + require(forward_plan.spans[i].color == reverse_plan.spans[i].color, "linear same-position span color"); + } +} + +void test_linear_render_reverse_near_ends_stays_visible() { + track_core::TrackConfig config{ + .draw_kind = track_core::TrackDrawKind::linear, + .line_length_m = 10.0F, + .active_line_length_m = 4.0F, + .head_offset_m = 0.0F, + .line_leds_num = 20, + }; + const auto info = running_info(track_core::Color::red()); + + const auto require_visible = [&](float mileage_m, const char *message) { + const track_core::TrackReport report{ + .id = 4, + .state = track_core::TrackState::run, + .mileage_m = mileage_m, + .speed_m_s = 2.0F, + }; + const auto plan = track_core::make_track_render_plan(config, info, report); + + std::uint32_t total_leds = 0; + for (std::size_t i = 0; i < plan.span_count; ++i) { + total_leds += plan.spans[i].led_count; + } + + require(total_leds > 0, message); + }; + + require_visible(10.1F, "reverse render visible just after right-end bounce"); + require_visible(19.9F, "reverse render visible just before left-end return"); } void test_linear_render_boundaries_apply() { @@ -505,6 +568,8 @@ int main() { test_circular_render_wraps(); test_linear_render_forward_and_memory_strip(); test_linear_render_reverse(); + test_linear_render_prefix_postfix_colors_do_not_reverse(); + test_linear_render_reverse_near_ends_stays_visible(); test_linear_render_boundaries_apply(); test_memory_strip_bounds(); test_scheme_decoder();