Files
chataway/python/track_core/emulator_scheme.py
T
crosstyan 7b0f6a523d feat(emulator): add editable training schema panel
Redesign the DearPyGui emulator into a two-column layout with an interactive training schema editor and runtime preview.

Add JSON load/save support for ST, SM, MT, and RSMT schemes, including draft normalization, conversion to core schemes, and explicit Apply validation so intermediate edits do not restart or block the preview.

Update manual mode to clamp negative mileage, remove speed from manual positioning, preserve stopped runtime state when reloading schemas, and keep linear rendering visible with span-based drawing.

Fix the core linear pingpong render plan so reverse travel remains visible near endpoints and prefix/postfix colors stay tied to physical sides instead of reversing with heading.

Add C++ and Python regressions for schema conversion, emulator edit behavior, manual mode, linear endpoint visibility, and fixed prefix/postfix colors.
2026-05-18 17:23:00 +08:00

358 lines
12 KiB
Python

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),
]