track and industry labels on map; Mesh LOD module skeleton

Map: per-track industry labels rendered via Dear ImGui on both the popout
and in-game overlay. Three zoom stages - per-track labels, merged same-name
clusters, and one-per-industry labels at wider zoom. Settings: font size
(min/max with zoom scaling), leader lines, parallel rotation, collision
spread, utility track filters (repair/diesel/loader/interchange), zoom
thresholds per category. C# runs a world-space AABB solver at rebuild so
offsets are rotation-invariant; native runs a per-frame screen-space
fine-tuning pass and draws leader lines.

Mesh LOD: new module skeleton with 4-level LOD group injected on
HandleModelsLoaded - three progressive renderer cull tiers (sorted by
bounds volume) plus a 12-tri proxy box at max distance. lodBias-corrected
distance-to-screenHeight conversion. Debounced live refresh on settings
change. Disabled by default.
This commit is contained in:
Seton Carmichael 2026-06-25 12:33:37 -04:00
parent c918dd7867
commit c5e75ad54a
13 changed files with 1785 additions and 0 deletions

View file

@ -49,6 +49,24 @@ enum UICmd : int32_t {
ToggleEotd = 27, // toggle EOTD dot visibility
ToggleEotdOnlyWhenCulled = 28, // toggle hiding EOTD when car icons are visible
SetEotdSizeScale = 29, // set EOTD dot size; y = [1.0, 10.0]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels (names auto-stripped)
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]
};
// Bit indices for ME bool settings packed into PopoutWindow::imMEFlags.

View file

@ -449,6 +449,204 @@ void Renderer_ReleaseSwapchain(PopoutWindow* win) {
// is the floating window's map image rect, so the chrome tracks that window.
// reserveResizeGrip: shorten the toolbar so the in-game ImGui window's bottom-right
// resize grip stays grabbable (the popout uses its OS window border instead).
// Draw SpawnPoint name labels over the map image. imgPos/imgSize define the
// pixel rect the map occupies on screen. C# pushes (name, u, v) per frame so
// the labels are always at the right world position regardless of pan/zoom.
static void DrawTrackLabels(PopoutWindow* win, ImDrawList* dl,
ImVec2 imgPos, ImVec2 imgSize) {
if (!win || !win->imTrackLabelsEnabled.load()) return;
std::lock_guard<std::mutex> lk(win->imTrackLabelMutex);
if (win->imTrackLabels.empty()) return;
ImFont* font = ImGui::GetFont();
const float baseSz = win->imTrackLabelFontSize.load();
const float lineThick = win->imTrackLabelLineThickness.load();
const bool doLeaders = win->imTrackLeaderLinesEnabled.load();
const bool doCollide = win->imTrackCollisionEnabled.load();
const bool doParallel = win->imTrackParallelEnabled.load();
const float kParallelOffset = 4.f; // extra gap between label and track in parallel mode
const float kDeg2Rad = 3.14159265f / 180.f;
const float kPad = 3.f; // padding added around each text bbox for collision
const float kLineDist = 10.f; // min pixels between label center and anchor to draw a leader line
const ImU32 kOutline = IM_COL32( 0, 0, 0, 220); // 4-direction text outline
const ImU32 kText = IM_COL32(255, 255, 255, 255); // fully opaque white text
const ImU32 kLineShadow= IM_COL32( 0, 0, 0, 180); // line shadow (drawn slightly thicker)
const ImU32 kLine = IM_COL32(255, 255, 255, 220); // near-opaque white leader line
// UV → screen pixel. V is flipped: viewport V=0 at bottom, ImGui Y=0 at top.
auto uvToScreen = [&](float u, float v) -> ImVec2 {
return { imgPos.x + u * imgSize.x, imgPos.y + (1.f - v) * imgSize.y };
};
int n = (int)win->imTrackLabels.size();
// ---- Build layout: one box (pos=top-left, half=half-extents) per label ----
// The UV already incorporates the C# world-space spread offset, so label positions
// are rotation-invariant. We simply center each box at its UV; the native collision
// and avoid-track passes handle any residual screen-space fine-tuning.
struct Box { ImVec2 pos; ImVec2 half; }; // pos = top-left of padded box
std::vector<Box> boxes(n);
std::vector<ImVec2> textSizes(n);
std::vector<ImVec2> initCenters(n); // center positions before native fine-tuning
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
const float sz = baseSz * lbl.scale;
ImVec2 tsz = font->CalcTextSizeA(sz, FLT_MAX, 0.f, lbl.name);
textSizes[i] = tsz;
ImVec2 center = uvToScreen(lbl.u, lbl.v);
boxes[i].half = { tsz.x * 0.5f + kPad, tsz.y * 0.5f + kPad };
boxes[i].pos = { center.x - boxes[i].half.x, center.y - boxes[i].half.y };
initCenters[i] = center;
}
// ---- Iterative AABB collision avoidance ----
// Push overlapping boxes apart along the minimum-overlap axis. For stacked
// parallel-track labels this is almost always Y, giving clean vertical separation.
const int kMaxIter = 40;
for (int iter = 0; doCollide && iter < kMaxIter; iter++) {
bool anyOverlap = false;
for (int i = 0; i < n; i++) {
ImVec2 ci = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
for (int j = i + 1; j < n; j++) {
ImVec2 cj = { boxes[j].pos.x + boxes[j].half.x,
boxes[j].pos.y + boxes[j].half.y };
float overlapX = (boxes[i].half.x + boxes[j].half.x) - fabsf(ci.x - cj.x);
float overlapY = (boxes[i].half.y + boxes[j].half.y) - fabsf(ci.y - cj.y);
if (overlapX <= 0.f || overlapY <= 0.f) continue;
anyOverlap = true;
// Push along whichever axis has less overlap (minimum separating axis)
float pushX = 0.f, pushY = 0.f;
if (overlapX < overlapY) {
pushX = overlapX * 0.55f * (ci.x < cj.x ? -1.f : 1.f);
} else {
pushY = overlapY * 0.55f * (ci.y < cj.y ? -1.f : 1.f);
}
boxes[i].pos.x += pushX; boxes[i].pos.y += pushY;
boxes[j].pos.x -= pushX; boxes[j].pos.y -= pushY;
// Recompute centers after move for subsequent pairs in this iteration
ci = { boxes[i].pos.x + boxes[i].half.x, boxes[i].pos.y + boxes[i].half.y };
}
}
if (!anyOverlap) break;
}
// ---- Track-line avoidance: push each label off its own track line(s) ----
// The track passes through each anchor at lbl.angle degrees. We compute the
// signed perpendicular distance from the label center to that line and push
// outward if the AABB still overlaps it.
const bool doAvoidTrack = win->imTrackLabelAvoidTrack.load();
const float kTrackHalfPx = 2.f; // approximate half-width of the rendered track line
if (doAvoidTrack) {
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
if (lbl.anchorCount == 0) continue; // Stage-3 industry labels have no specific track
float rad = lbl.angle * kDeg2Rad;
float perpX = -sinf(rad), perpY = cosf(rad); // unit perpendicular (left of track)
// Use text height only for clearance — avoids over-pushing wide labels on
// diagonal tracks that the full Minkowski projection would cause.
float minClear = boxes[i].half.y + kTrackHalfPx;
ImVec2 ci = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
float minAbsDist = FLT_MAX;
float bestSignedDist = 0.f;
for (int ai = lbl.anchorStart; ai < lbl.anchorStart + lbl.anchorCount; ai++) {
ImVec2 anchor = uvToScreen(win->imAnchorUs[ai], win->imAnchorVs[ai]);
float signedDist = (ci.x - anchor.x) * perpX + (ci.y - anchor.y) * perpY;
if (fabsf(signedDist) < minAbsDist) {
minAbsDist = fabsf(signedDist);
bestSignedDist = signedDist;
}
}
if (minAbsDist < minClear) {
float push = minClear - minAbsDist;
float dir = bestSignedDist >= 0.f ? 1.f : -1.f;
boxes[i].pos.x += perpX * push * dir;
boxes[i].pos.y += perpY * push * dir;
}
}
}
// Cap how far native fine-tuning can move a label from its UV-derived position.
// The C# world-space solver already handles the primary spread; this prevents any
// residual collision push from producing unexpectedly long leader lines.
const float kMaxDisplace = 60.f;
for (int i = 0; i < n; i++) {
ImVec2 solved = { boxes[i].pos.x + boxes[i].half.x, boxes[i].pos.y + boxes[i].half.y };
float dx = solved.x - initCenters[i].x, dy = solved.y - initCenters[i].y;
float d2 = dx*dx + dy*dy;
if (d2 > kMaxDisplace * kMaxDisplace) {
float sc = kMaxDisplace / sqrtf(d2);
boxes[i].pos.x = initCenters[i].x + dx*sc - boxes[i].half.x;
boxes[i].pos.y = initCenters[i].y + dy*sc - boxes[i].half.y;
}
}
// Compute draw centers from solved box positions.
// No hard edge-clamp: C# already culls centroids outside [0.05, 1.05] UV;
// text that spills slightly past the window edge is clipped naturally by ImGui.
std::vector<ImVec2> drawCenters(n);
for (int i = 0; i < n; i++) {
drawCenters[i] = { boxes[i].pos.x + boxes[i].half.x,
boxes[i].pos.y + boxes[i].half.y };
}
// ---- Pass 1: leader lines (drawn under text so text stays readable) ----
if (doLeaders) {
for (int i = 0; i < n; i++) {
auto& lbl = win->imTrackLabels[i];
ImVec2 lblCenter = drawCenters[i];
for (int ai = lbl.anchorStart; ai < lbl.anchorStart + lbl.anchorCount; ai++) {
// Anchors come from C# UV (already in rotated screen space) — no unrot needed.
ImVec2 anchor = uvToScreen(win->imAnchorUs[ai], win->imAnchorVs[ai]);
float dx = anchor.x - lblCenter.x, dy = anchor.y - lblCenter.y;
if (dx * dx + dy * dy < kLineDist * kLineDist) continue;
dl->AddLine(lblCenter, anchor, kLineShadow, lineThick + 1.5f);
dl->AddLine(lblCenter, anchor, kLine, lineThick);
}
}
}
// ---- Pass 2: text (4-direction outline then white fill; rotate in parallel mode) ----
for (int i = 0; i < n; i++) {
const char* name = win->imTrackLabels[i].name;
const float sz = baseSz * win->imTrackLabels[i].scale;
// Center text on the re-rotated draw position (math: drawCenter = solver_center rerotated,
// and solver_center was placed at the north-up UV position ± any spread offset).
ImVec2 tp = { drawCenters[i].x - textSizes[i].x * 0.5f,
drawCenters[i].y - textSizes[i].y * 0.5f };
int vtxBase = dl->VtxBuffer.Size;
dl->AddText(font, sz, ImVec2(tp.x - 1.f, tp.y ), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x + 1.f, tp.y ), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x, tp.y - 1.f), kOutline, name);
dl->AddText(font, sz, ImVec2(tp.x, tp.y + 1.f), kOutline, name);
dl->AddText(font, sz, tp, kText, name);
if (doParallel) {
// Rotate vertices around the draw center using the screen-space track angle.
// lbl.angle is already updated each frame by C# to reflect the current camera rotation.
float rad = win->imTrackLabels[i].angle * kDeg2Rad;
float cosA = cosf(rad), sinA = sinf(rad);
float cx = drawCenters[i].x, cy = drawCenters[i].y;
for (int v = vtxBase; v < dl->VtxBuffer.Size; v++) {
float dx = dl->VtxBuffer[v].pos.x - cx;
float dy = dl->VtxBuffer[v].pos.y - cy;
dl->VtxBuffer[v].pos.x = cx + dx * cosA - dy * sinA;
dl->VtxBuffer[v].pos.y = cy + dx * sinA + dy * cosA;
}
}
}
}
static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
bool reserveResizeGrip, bool showPopOutBtn,
const MapThemeData& theme, float mapAlpha = 1.0f) {
@ -911,6 +1109,171 @@ static void BuildMapUI(PopoutWindow* win, ImVec2 origin, float w, float h,
ImGui::Separator();
// Track name labels submenu
if (ImGui::BeginMenu("Track labels")) {
{
bool v = win->imTrackLabelsEnabled.load();
if (ImGui::MenuItem("Enabled", nullptr, v)) {
win->imTrackLabelsEnabled.store(!v);
pushCmd(UICmd::ToggleTrackLabels);
}
}
ImGui::Separator();
// ── Style sub-menu ──────────────────────────────────────────────────
if (ImGui::BeginMenu("Style")) {
{
float v = win->imTrackLabelFontSize.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Font size (max)##tl", &v, 8.f, 32.f, "%.0f px"))
win->imTrackLabelFontSize.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetFontSize, win->imTrackLabelFontSize.load());
}
{
float v = win->imTrackLabelFontSizeMin.load();
float maxSz = win->imTrackLabelFontSize.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Font size (min)##tl", &v, 4.f, maxSz, "%.0f px"))
win->imTrackLabelFontSizeMin.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetFontSizeMin, win->imTrackLabelFontSizeMin.load());
}
{
float v = win->imTrackLabelLineThickness.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Line weight##tl", &v, 1.f, 4.f, "%.1f px"))
win->imTrackLabelLineThickness.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetLineThick, win->imTrackLabelLineThickness.load());
}
ImGui::Separator();
{
bool v = win->imTrackLeaderLinesEnabled.load();
if (ImGui::MenuItem("Leader lines", nullptr, v)) {
win->imTrackLeaderLinesEnabled.store(!v);
pushCmd(UICmd::ToggleLeaderLines);
}
}
{
bool v = win->imTrackCollisionEnabled.load();
if (ImGui::MenuItem("Spread overlapping labels", nullptr, v)) {
win->imTrackCollisionEnabled.store(!v);
pushCmd(UICmd::ToggleCollision);
}
}
{
bool v = win->imTrackParallelEnabled.load();
if (ImGui::MenuItem("Parallel to track", nullptr, v)) {
win->imTrackParallelEnabled.store(!v);
pushCmd(UICmd::ToggleParallelLabels);
}
}
{
bool v = win->imTrackLabelAvoidTrack.load();
if (ImGui::MenuItem("Avoid track lines", nullptr, v)) {
win->imTrackLabelAvoidTrack.store(!v);
pushCmd(UICmd::ToggleAvoidTrackLabels);
}
}
{
bool v = win->imTrackMergeEnabled.load();
if (ImGui::MenuItem("Merge same-name labels", nullptr, v)) {
win->imTrackMergeEnabled.store(!v);
pushCmd(UICmd::ToggleMergeLabels);
}
}
ImGui::EndMenu();
}
// ── Zoom thresholds sub-menu ────────────────────────────────────────
if (ImGui::BeginMenu("Zoom thresholds")) {
ImGui::TextDisabled("Smaller = labels show closer in");
ImGui::Separator();
{
float v = win->imTrackLabelMergeZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Merge beyond##tl", &v, 50.f, 8000.f, "%.0f", ImGuiSliderFlags_Logarithmic))
win->imTrackLabelMergeZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetMergeZoom, win->imTrackLabelMergeZoom.load());
}
{
float v = win->imTrackLabelZoomLimit.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide beyond##tl", &v, 200.f, 8000.f, "%.0f", ImGuiSliderFlags_Logarithmic))
win->imTrackLabelZoomLimit.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetZoomLimit, win->imTrackLabelZoomLimit.load());
}
{
float v = win->imTrackIndustryZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Industry beyond##tl", &v, 200.f, 8000.f, "%.0f"))
win->imTrackIndustryZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetIndustryZoom, win->imTrackIndustryZoom.load());
}
{
float v = win->imTrackAllLabelsZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide all beyond##tl", &v, 200.f, 8000.f, "%.0f"))
win->imTrackAllLabelsZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetAllZoom, win->imTrackAllLabelsZoom.load());
}
ImGui::EndMenu();
}
// ── Utility tracks sub-menu ─────────────────────────────────────────
if (ImGui::BeginMenu("Utility tracks")) {
ImGui::TextDisabled("Toggle label categories");
ImGui::Separator();
{
bool v = win->imTrackUtilityRepair.load();
if (ImGui::MenuItem("Repair tracks", nullptr, v)) {
win->imTrackUtilityRepair.store(!v);
pushCmd(UICmd::ToggleUtilityRepairLabels);
}
}
{
bool v = win->imTrackUtilityDiesel.load();
if (ImGui::MenuItem("Diesel stands", nullptr, v)) {
win->imTrackUtilityDiesel.store(!v);
pushCmd(UICmd::ToggleUtilityDieselLabels);
}
}
{
bool v = win->imTrackUtilityLoader.load();
if (ImGui::MenuItem("Coal loaders", nullptr, v)) {
win->imTrackUtilityLoader.store(!v);
pushCmd(UICmd::ToggleUtilityLoaderLabels);
}
}
{
bool v = win->imTrackUtilityInterchange.load();
if (ImGui::MenuItem("Interchanges", nullptr, v)) {
win->imTrackUtilityInterchange.store(!v);
pushCmd(UICmd::ToggleUtilityInterchangeLabels);
}
}
ImGui::Separator();
{
float v = win->imTrackUtilityZoom.load();
ImGui::SetNextItemWidth(110.f);
if (ImGui::SliderFloat("Hide beyond##tu", &v, 50.f, 8000.f, "%.0f"))
win->imTrackUtilityZoom.store(v);
if (ImGui::IsItemDeactivatedAfterEdit())
pushCmd(UICmd::TrackLabelSetUtilityZoom, win->imTrackUtilityZoom.load());
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
ImGui::Separator();
// Theme preset picker
if (ImGui::BeginMenu("Theme")) {
int cur = g_currentThemePreset.load();
@ -1043,6 +1406,12 @@ void Renderer_Present(PopoutWindow* win) {
BuildMapUI(win, ImVec2(0.f, 0.f), (float)w, (float)h,
/*reserveResizeGrip=*/false, /*showPopOutBtn=*/false, theme);
// Track labels for the popout. The map is a fullscreen blit (not an ImGui
// Image), so (0,0)→(w,h) IS the image rect. Foreground drawlist renders above
// all ImGui windows so labels are always readable.
DrawTrackLabels(win, ImGui::GetForegroundDrawList(),
ImVec2(0.f, 0.f), ImVec2((float)w, (float)h));
ImGui::Render();
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
win->imWantMouse.store(ImGui::GetIO().WantCaptureMouse);
@ -1254,6 +1623,10 @@ void Renderer_PresentOverlay() {
ImGui::Image((ImTextureID)g_ovMapSRV.Get(), sz, uv0, uv1, tint);
if (hasAlpha) ImGui::PushStyleVar(ImGuiStyleVar_Alpha, effectiveAlpha);
// Track name labels overlaid on the map image (C# pushes name+UV each frame)
if (stateWin)
DrawTrackLabels(stateWin, ImGui::GetWindowDrawList(), imgPos, sz);
// Visible resize-grip indicator. ImGui draws its own grip during
// Begin() — behind our opaque map image, so invisible. We draw one
// on top in the corner only while the window is focused (the dark

View file

@ -355,6 +355,94 @@ void RRPOPOUT_SetOverlayMapAlpha(float alpha) {
Overlay_SetMapAlpha(alpha);
}
// Push industry track name labels for the map.
// namesBlobW: UTF-16 track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all span anchor positions.
// anchorStarts/anchorCounts: per-label index + count into the anchor arrays.
// count=0 clears everything (zoomed out or map inactive).
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabels(int windowHandle, const wchar_t* namesBlobW,
const float* us, const float* vs,
const float* anchorUs, const float* anchorVs,
const int* anchorStarts, const int* anchorCounts,
const float* angles, const float* scales,
int count) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
std::vector<PopoutWindow::ImTrackLabel> tmpLabels;
std::vector<float> tmpAnchorUs, tmpAnchorVs;
if (namesBlobW && count > 0) {
tmpLabels.reserve(count);
const wchar_t* p = namesBlobW;
for (int i = 0; i < count; ++i) {
const wchar_t* end = p;
while (*end && *end != L'\n') ++end;
PopoutWindow::ImTrackLabel lbl{};
WideCharToMultiByte(CP_UTF8, 0, p, (int)(end - p),
lbl.name, (int)sizeof(lbl.name) - 1, nullptr, nullptr);
lbl.u = us[i]; lbl.v = vs[i];
lbl.angle = angles[i];
lbl.scale = scales ? scales[i] : 1.0f;
lbl.anchorStart = anchorStarts[i];
lbl.anchorCount = anchorCounts[i];
tmpLabels.push_back(lbl);
p = (*end == L'\n') ? end + 1 : end;
}
int totalAnchors = anchorStarts[count - 1] + anchorCounts[count - 1];
tmpAnchorUs.assign(anchorUs, anchorUs + totalAnchors);
tmpAnchorVs.assign(anchorVs, anchorVs + totalAnchors);
}
std::lock_guard<std::mutex> lk(win->imTrackLabelMutex);
win->imTrackLabels = std::move(tmpLabels);
win->imAnchorUs = std::move(tmpAnchorUs);
win->imAnchorVs = std::move(tmpAnchorVs);
}
// Enable or disable track label rendering for this window. Default: enabled.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (win) win->imTrackLabelsEnabled.store(enabled);
}
// Seed all track-label style settings at once. Call on map activate and on any change.
extern "C" __declspec(dllexport)
void RRPOPOUT_SetTrackLabelStyle(int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled,
bool parallelEnabled, bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin) {
PopoutWindow* win = GetPopoutWindow(windowHandle);
if (!win) return;
win->imTrackLabelFontSize.store(fontSize);
win->imTrackLabelLineThickness.store(lineThickness);
win->imTrackLabelZoomLimit.store(zoomLimit);
win->imTrackLeaderLinesEnabled.store(leaderLinesEnabled);
win->imTrackCollisionEnabled.store(collisionEnabled);
win->imTrackParallelEnabled.store(parallelEnabled);
win->imTrackMergeEnabled.store(mergeEnabled);
win->imTrackLabelMergeZoom.store(mergeZoom);
win->imTrackIndustryZoom.store(industryZoom);
win->imTrackUtilityRepair.store(utilityRepairEnabled);
win->imTrackUtilityDiesel.store(utilityDieselEnabled);
win->imTrackUtilityLoader.store(utilityLoaderEnabled);
win->imTrackUtilityInterchange.store(utilityInterchangeEnabled);
win->imTrackUtilityZoom.store(utilityZoom);
win->imTrackAllLabelsZoom.store(allLabelsZoom);
win->imTrackLabelAvoidTrack.store(avoidTrack);
win->imTrackLabelFontSizeMin.store(fontSizeMin);
}
// ---------------------------------------------------------------------------
// Plugin_Initialize / Plugin_Shutdown (called from dllmain.cpp)
// ---------------------------------------------------------------------------

View file

@ -105,6 +105,43 @@ struct PopoutWindow {
std::atomic<float> imMETrackThick {1.25f};
std::atomic<float> imMECrossingSc {0.3f};
// -----------------------------------------------------------------------
// Track name labels (industry track names + viewport UVs, pushed from C# each frame).
// C# projects TrackSpan centroids through the map camera, culls off-screen entries,
// and pushes (name, centroid_uv, anchors). Native runs per-frame collision avoidance
// (AABB push in pixel space) then draws leader lines to each anchor before the text.
// -----------------------------------------------------------------------
struct ImTrackLabel {
char name[64];
float u, v; // centroid UV — starting position for the layout solver
float angle; // track direction in screen space (degrees; 0=right, +ve=CW)
float scale; // font size multiplier (1.0 = track label, 1.5 = industry label)
int anchorStart; // index into imAnchorUs/Vs
int anchorCount; // 1 for single span; >1 for merged same-name cluster
};
std::vector<ImTrackLabel> imTrackLabels;
std::vector<float> imAnchorUs; // flat anchor UV arrays, indexed by anchorStart
std::vector<float> imAnchorVs;
std::mutex imTrackLabelMutex;
std::atomic<bool> imTrackLabelsEnabled {true};
std::atomic<float> imTrackLabelFontSize {14.f};
std::atomic<float> imTrackLabelLineThickness {1.5f};
std::atomic<float> imTrackLabelZoomLimit {3000.f};
std::atomic<bool> imTrackLeaderLinesEnabled {true};
std::atomic<bool> imTrackCollisionEnabled {true};
std::atomic<bool> imTrackParallelEnabled {false};
std::atomic<bool> imTrackMergeEnabled {true};
std::atomic<float> imTrackLabelMergeZoom {150.f};
std::atomic<float> imTrackIndustryZoom {200.f};
std::atomic<bool> imTrackUtilityRepair {true};
std::atomic<bool> imTrackUtilityDiesel {true};
std::atomic<bool> imTrackUtilityLoader {true};
std::atomic<bool> imTrackUtilityInterchange {true};
std::atomic<float> imTrackUtilityZoom {200.f};
std::atomic<float> imTrackAllLabelsZoom {8000.f};
std::atomic<bool> imTrackLabelAvoidTrack {false};
std::atomic<float> imTrackLabelFontSizeMin {8.f};
// Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW.
std::atomic<int> lastWinX {-1}, lastWinY {-1};
std::atomic<int> lastWinW {900}, lastWinH {700};

View file

@ -1,4 +1,9 @@
using System.Collections;
using System.Collections.Generic;
using System.Text;
using Helpers; // WorldTransformer
using Model.Ops; // IndustryComponent
using Track; // TrackSpan
using HarmonyLib;
using S3.Modules.Popout; // NativeLoader + Native + PanelFinder + MapEnhancerBridge
using UI; // GameInput
@ -173,6 +178,40 @@ internal sealed class UiHost : MonoBehaviour
private bool _meDirty;
private string _lastStatus = "";
// Track name labels: rebuilt every 5 s from IndustryComponent list.
// Stored in simulation ("game") space so GameToWorld() is applied each frame
// (WorldTransformer offset can shift between rebuilds).
// trackDir: game-space direction of the track, used to compute per-frame screen angle.
// anchors[]: individual span positions in a merged cluster (for leader lines).
private IndustryComponent[]? _industryComponents;
// Tracks the merge state used for the last rebuild so we can detect transitions
// caused by the auto-merge zoom threshold crossing and force a fresh rebuild.
private bool _effectiveMergeEnabled = true;
private readonly List<(Vector3 centroid, Vector3 trackDir, Vector3[] anchors, string name, int utilityType)> _trackSpanLabels = new();
private readonly List<(Vector3 centroid, string name, int utilityType)> _industryLabels = new();
private float _spawnPointTimer = 99f; // force rebuild on first tick
private int _lastLabelCount = -1;
private readonly StringBuilder _labelNames = new();
// Per-label world-space offset from centroid, computed by ComputeWorldSpaceOffsets()
// during rebuild. Applied in PushTrackLabels so labels stay locked to their tracks
// regardless of camera rotation or pan — the offset is rotation-invariant (world XZ).
private Vector3[] _labelWorldOffsets = System.Array.Empty<Vector3>();
private float[] _labelUs = System.Array.Empty<float>();
private float[] _labelVs = System.Array.Empty<float>();
private float[] _labelAngles = System.Array.Empty<float>(); // per-label screen-space angle (degrees)
private float[] _labelScales = System.Array.Empty<float>(); // per-label font size multiplier
// Flat anchor arrays: all span anchor UVs packed end-to-end across all visible labels.
// _anchorStarts[i] / _anchorCounts[i] index into _anchorUs/Vs for label i.
private float[] _anchorUs = System.Array.Empty<float>();
private float[] _anchorVs = System.Array.Empty<float>();
private int[] _anchorStarts = System.Array.Empty<int>();
private int[] _anchorCounts = System.Array.Empty<int>();
private int _totalAnchorCount = 0; // sum of anchors across all cached labels (array size bound)
private const float kMergeDistGame = 250f; // sim units (≈ft); merge same-name spans within this radius
private static readonly float[] s_emptyFloat = System.Array.Empty<float>();
private static readonly int[] s_emptyInt = System.Array.Empty<int>();
private void Start()
{
if (!NativeLoader.EnsureLoaded())
@ -334,6 +373,7 @@ internal sealed class UiHost : MonoBehaviour
if (_mapActive && _mapCamera != null)
{
UpdateMapStatus();
PushTrackLabels();
RefreshMenuLists();
ApplyFollowAndSync();
int cn = Native.RRPOPOUT_PollInputEvents(_overlayHandle, s_inputBuf, kMaxInput);
@ -596,6 +636,99 @@ internal sealed class UiHost : MonoBehaviour
PopoutModule.Settings.eotdSizeScale = Mathf.Clamp(e.y, 1.0f, 10.0f);
PopoutModule.Persist();
break;
case UICmd.ToggleTrackLabels:
PopoutModule.Settings.trackLabelsEnabled = !PopoutModule.Settings.trackLabelsEnabled;
PopoutModule.Persist();
Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled);
if (!PopoutModule.Settings.trackLabelsEnabled)
{
// Clear immediately so no stale labels are shown while disabled
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
break;
case UICmd.TrackLabelSetFontSize:
PopoutModule.Settings.trackLabelFontSize = Mathf.Clamp(e.y, 8f, 24f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.TrackLabelSetLineThick:
PopoutModule.Settings.trackLabelLineThickness = Mathf.Clamp(e.y, 1f, 4f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.TrackLabelSetZoomLimit:
PopoutModule.Settings.trackLabelZoomLimit = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleLeaderLines:
PopoutModule.Settings.trackLeaderLinesEnabled = !PopoutModule.Settings.trackLeaderLinesEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleCollision:
PopoutModule.Settings.trackCollisionEnabled = !PopoutModule.Settings.trackCollisionEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleParallelLabels:
PopoutModule.Settings.trackLabelParallel = !PopoutModule.Settings.trackLabelParallel;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
break;
case UICmd.ToggleMergeLabels:
PopoutModule.Settings.trackLabelMergeEnabled = !PopoutModule.Settings.trackLabelMergeEnabled;
PopoutModule.Persist();
SeedTrackLabelStyle(_overlayHandle);
_spawnPointTimer = 99f;
break;
case UICmd.TrackLabelSetMergeZoom:
PopoutModule.Settings.trackLabelMergeZoom = Mathf.Clamp(e.y, 50f, 8000f);
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetIndustryZoom:
PopoutModule.Settings.trackIndustryLabelZoom = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
break;
case UICmd.ToggleUtilityRepairLabels:
PopoutModule.Settings.trackUtilityRepairEnabled = !PopoutModule.Settings.trackUtilityRepairEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityDieselLabels:
PopoutModule.Settings.trackUtilityDieselEnabled = !PopoutModule.Settings.trackUtilityDieselEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityLoaderLabels:
PopoutModule.Settings.trackUtilityLoaderEnabled = !PopoutModule.Settings.trackUtilityLoaderEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.ToggleUtilityInterchangeLabels:
PopoutModule.Settings.trackUtilityInterchangeEnabled = !PopoutModule.Settings.trackUtilityInterchangeEnabled;
PopoutModule.Persist();
_spawnPointTimer = 99f;
break;
case UICmd.TrackLabelSetUtilityZoom:
PopoutModule.Settings.trackUtilityZoomLimit = Mathf.Clamp(e.y, 50f, 8000f);
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetAllZoom:
PopoutModule.Settings.trackAllLabelsZoomLimit = Mathf.Clamp(e.y, 200f, 8000f);
PopoutModule.Persist();
break;
case UICmd.ToggleAvoidTrackLabels:
PopoutModule.Settings.trackLabelAvoidTrack = !PopoutModule.Settings.trackLabelAvoidTrack;
PopoutModule.Persist();
break;
case UICmd.TrackLabelSetFontSizeMin:
PopoutModule.Settings.trackLabelFontSizeMin = Mathf.Clamp(e.y, 4f, PopoutModule.Settings.trackLabelFontSize);
PopoutModule.Persist();
break;
}
}
@ -731,6 +864,11 @@ internal sealed class UiHost : MonoBehaviour
Native.RRPOPOUT_SetPanDisablesFollow(_overlayHandle, PopoutModule.Settings.panDisablesFollow);
Native.RRPOPOUT_SetRightClickRecenter(_overlayHandle, PopoutModule.Settings.rightClickRecenter);
SeedIconCullingState(_overlayHandle);
Native.RRPOPOUT_SetTrackLabelsEnabled(_overlayHandle, PopoutModule.Settings.trackLabelsEnabled);
SeedTrackLabelStyle(_overlayHandle);
_spawnPointTimer = 99f; // force IC/span rebuild on next PushTrackLabels
_trackSpanLabels.Clear();
_lastLabelCount = -1;
Native.RRPOPOUT_SetOverlayMapBgAlpha(PopoutModule.Settings.overlayMapBgAlpha);
var bgc = MapThemes.GetMapBgColor((MapTheme)PopoutModule.Settings.mapTheme);
bgc.a *= PopoutModule.Settings.overlayMapBgAlpha;
@ -793,6 +931,537 @@ internal sealed class UiHost : MonoBehaviour
_mapActive = false;
}
// Returns the midpoint and local direction of the straightest segment near the
// arc-length centre of a track-span polyline. Score = straightness² × proximity,
// so a clearly straight segment beats a curved one even if it's off-centre.
private static (Vector3 pos, Vector3 dir) FindStraightestNearMiddle(IList<Vector3> pts)
{
int n = pts.Count;
Vector3 overallDir = (pts[n - 1] - pts[0]).normalized;
if (n == 2) return ((pts[0] + pts[1]) * 0.5f, overallDir);
float totalLen = 0f;
for (int k = 1; k < n; k++) totalLen += Vector3.Distance(pts[k], pts[k - 1]);
if (totalLen < 0.01f) return (pts[n / 2], overallDir);
float midLen = totalLen * 0.5f;
float bestScore = -1f;
Vector3 bestPos = pts[n / 2];
Vector3 bestDir = overallDir;
float cumLen = 0f;
for (int k = 1; k < n; k++)
{
float segLen = Vector3.Distance(pts[k], pts[k - 1]);
if (segLen < 0.01f) { cumLen += segLen; continue; }
float segMidLen = cumLen + segLen * 0.5f;
cumLen += segLen;
Vector3 segDir = (pts[k] - pts[k - 1]) / segLen;
float straight = Mathf.Abs(Vector3.Dot(segDir, overallDir)); // 1=parallel, 0=perpendicular
float distFromMid = Mathf.Abs(segMidLen - midLen) / midLen; // 0=at mid, 1=at end
float score = straight * straight * (1f - distFromMid * 0.5f);
if (score > bestScore)
{
bestScore = score;
bestPos = (pts[k] + pts[k - 1]) * 0.5f;
bestDir = segDir;
}
}
return (bestPos, bestDir);
}
// Rebuilds the flat (centroid, trackDir, anchors, name) list every 5 s.
// trackDir: average game-space direction of all spans in the cluster, sign-aligned
// so anti-parallel spans don't cancel (span direction is arbitrary in the graph).
private void RebuildTrackSpanLabels()
{
_industryComponents = Object.FindObjectsOfType<IndustryComponent>();
// 1. Collect (pos, dir, name, utilityType) per visible span.
// A TrackSpan can be referenced by more than one IndustryComponent; deduplicate
// by instance so we never emit the same physical track twice.
// Interchange names are normalized ("East Whittier Interchange to X" → "East Whittier
// Interchange") before clustering, so all variants merge into one label.
var raw = new List<(Vector3 pos, Vector3 dir, string name, int utilityType)>();
var seenSpans = new HashSet<TrackSpan>();
var settings = PopoutModule.Settings;
foreach (var ic in _industryComponents)
{
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
string[] names = ExpandSpanNames(ic);
for (int si = 0; si < ic.trackSpans.Length; si++)
{
var span = ic.trackSpans[si];
if (!seenSpans.Add(span)) continue; // already added via another IC
var pts = span.GetPoints() as IList<Vector3>;
Vector3 pos, dir;
if (pts != null && pts.Count >= 2)
(pos, dir) = FindStraightestNearMiddle(pts);
else
{
pos = span.GetCenterPoint();
dir = Vector3.right;
}
string spanName = NormalizeSpanName(si < names.Length ? names[si] : ic.DisplayName);
int utType = GetUtilityType(spanName);
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
raw.Add((pos, dir, spanName, utType));
}
}
// 2a. Per-track mode: one label per span, no clustering.
_trackSpanLabels.Clear();
if (!_effectiveMergeEnabled)
{
foreach (var (pos, dir, name, utType) in raw)
_trackSpanLabels.Add((pos, dir, new[] { pos }, name, utType));
_totalAnchorCount = _trackSpanLabels.Count;
RebuildIndustryLabels();
ComputeWorldSpaceOffsets();
return;
}
// 2b. Group by name; greedy single-linkage cluster within kMergeDistGame.
var grouped = new Dictionary<string, List<(Vector3 pos, Vector3 dir, int utilityType)>>();
foreach (var (pos, dir, name, utType) in raw)
{
if (!grouped.TryGetValue(name, out var list)) grouped[name] = list = new();
list.Add((pos, dir, utType));
}
foreach (var (name, entries) in grouped)
{
var assigned = new bool[entries.Count];
for (int i = 0; i < entries.Count; i++)
{
if (assigned[i]) continue;
var cluster = new List<(Vector3 pos, Vector3 dir, int utilityType)> { entries[i] };
assigned[i] = true;
bool added;
do {
added = false;
for (int j = i + 1; j < entries.Count; j++)
{
if (assigned[j]) continue;
foreach (var (cp, _, _) in cluster)
if (Vector3.Distance(entries[j].pos, cp) < kMergeDistGame)
{ cluster.Add(entries[j]); assigned[j] = true; added = true; break; }
}
} while (added);
int clusterUtType = entries[0].utilityType;
Vector3 centroid = Vector3.zero;
Vector3 refDir = cluster[0].dir;
Vector3 avgDir = Vector3.zero;
var anchorPositions = new Vector3[cluster.Count];
for (int k = 0; k < cluster.Count; k++)
{
centroid += cluster[k].pos;
anchorPositions[k] = cluster[k].pos;
// Align each dir with the reference so anti-parallel spans don't cancel
var d = cluster[k].dir;
avgDir += Vector3.Dot(d, refDir) >= 0f ? d : -d;
}
centroid /= cluster.Count;
_trackSpanLabels.Add((centroid, avgDir.normalized, anchorPositions, name, clusterUtType));
}
}
_totalAnchorCount = 0;
foreach (var (_, _, anchors, _, _) in _trackSpanLabels)
_totalAnchorCount += anchors.Length;
RebuildIndustryLabels();
ComputeWorldSpaceOffsets();
}
// Builds one label per distinct industry from all visible IndustryComponents.
// Groups ICs by their stripped base name (e.g., "Whittier Saw Mill S01/S02" →
// "Whittier Saw Mill") and averages their span positions into a single centroid.
// Called at the end of RebuildTrackSpanLabels so _industryComponents is already set.
private void RebuildIndustryLabels()
{
_industryLabels.Clear();
var byName = new Dictionary<string, (Vector3 sum, int count, int utilityType)>();
var settings = PopoutModule.Settings;
foreach (var ic in _industryComponents)
{
if (ic == null || !ic.IsVisible || ic.trackSpans.Length == 0) continue;
string industryName = GetIndustryName(ic);
int utType = GetUtilityType(industryName);
if (utType == 1 && !settings.trackUtilityRepairEnabled) continue;
if (utType == 2 && !settings.trackUtilityDieselEnabled) continue;
if (utType == 3 && !settings.trackUtilityLoaderEnabled) continue;
if (utType == 4 && !settings.trackUtilityInterchangeEnabled) continue;
Vector3 centroid = Vector3.zero;
int n = 0;
foreach (var span in ic.trackSpans)
{
var pts = span.GetPoints() as IList<Vector3>;
centroid += pts != null && pts.Count >= 2
? FindStraightestNearMiddle(pts).pos
: span.GetCenterPoint();
n++;
}
if (n == 0) continue;
centroid /= n;
if (byName.TryGetValue(industryName, out var entry))
byName[industryName] = (entry.sum + centroid, entry.count + 1, utType);
else
byName[industryName] = (centroid, 1, utType);
}
foreach (var (name, (sum, count, utType)) in byName)
_industryLabels.Add((sum / count, name, utType));
}
// Spreads overlapping labels in world-space XZ so each label has a stable offset
// from its track centroid. By baking the offset into the projected UV (rather than
// letting the native solver run in screen-space), label positions become invariant
// to camera rotation and pan — they always sit in the same world direction relative
// to their track regardless of what the map is doing.
private void ComputeWorldSpaceOffsets()
{
int n = _trackSpanLabels.Count;
if (n == 0) { _labelWorldOffsets = System.Array.Empty<Vector3>(); return; }
// Pixel→world conversion using current camera state.
float screenH = _mapCamera != null && _mapCamera.pixelHeight > 0
? _mapCamera.pixelHeight : 1080f;
float worldH = _mapCamera != null ? _mapCamera.orthographicSize * 2f : 200f;
float wpp = worldH / screenH; // world units per screen pixel
var s = PopoutModule.Settings;
float fH = s.trackLabelFontSize * wpp; // font height in world units
float cW = fH * 0.55f; // approx char width (ProggyClean ~55%)
float pad = 3f * wpp; // 3 px padding
float gap = 4f * wpp; // extra clearance between label and track
var cx = new float[n];
var cz = new float[n];
var hw = new float[n];
var hh = new float[n];
for (int i = 0; i < n; i++)
{
var (centroid, trackDir, _, name, _) = _trackSpanLabels[i];
hw[i] = name.Length * cW * 0.5f + pad;
hh[i] = fH * 0.5f + pad;
// Perpendicular to track in XZ (90° CCW: (-z, x)), normalised.
float lx = trackDir.x, lz = trackDir.z;
float len = Mathf.Sqrt(lx * lx + lz * lz);
if (len > 0.001f) { lx /= len; lz /= len; }
float px = -lz, pz = lx;
// Place label above the track (in the world-perpendicular direction).
float initOff = hh[i] + gap;
cx[i] = centroid.x + px * initOff;
cz[i] = centroid.z + pz * initOff;
}
// Iterative AABB spread — same algorithm as the native solver, run in world XZ.
const int kMaxIter = 40;
for (int iter = 0; iter < kMaxIter; iter++)
{
bool anyOverlap = false;
for (int i = 0; i < n; i++)
{
for (int j = i + 1; j < n; j++)
{
float ox = (hw[i] + hw[j]) - Mathf.Abs(cx[i] - cx[j]);
float oz = (hh[i] + hh[j]) - Mathf.Abs(cz[i] - cz[j]);
if (ox <= 0f || oz <= 0f) continue;
anyOverlap = true;
float pushX = 0f, pushZ = 0f;
if (ox < oz)
pushX = ox * 0.55f * (cx[i] < cx[j] ? -1f : 1f);
else
pushZ = oz * 0.55f * (cz[i] < cz[j] ? -1f : 1f);
cx[i] += pushX; cz[i] += pushZ;
cx[j] -= pushX; cz[j] -= pushZ;
}
}
if (!anyOverlap) break;
}
if (_labelWorldOffsets.Length < n)
_labelWorldOffsets = new Vector3[n];
for (int i = 0; i < n; i++)
{
var (centroid, _, _, _, _) = _trackSpanLabels[i];
_labelWorldOffsets[i] = new Vector3(cx[i] - centroid.x, 0f, cz[i] - centroid.z);
}
}
// Extracts the base industry name from an IC's display name, then normalizes it.
// Multi-span: "Whittier Saw Mill S01/S02/S03" → "Whittier Saw Mill".
// Single-span with track code: "Whittier Saw Mill UFR1" → "Whittier Saw Mill".
// Interchange: "East Whittier Interchange to Atlantic..." → "East Whittier Interchange".
// Single-span without strippable suffix: "East Whittier Coal Loader" → unchanged.
private static string GetIndustryName(IndustryComponent ic)
{
string displayName = ic.DisplayName;
if (ic.trackSpans.Length > 1 && displayName.Contains('/'))
{
string first = displayName.Split('/')[0].Trim();
int sp = first.LastIndexOf(' ');
string baseName = sp >= 0 ? first.Substring(0, sp) : first;
return NormalizeSpanName(baseName);
}
int slash = displayName.IndexOf('/');
string name = slash >= 0 ? displayName.Substring(0, slash).Trim() : displayName;
name = NormalizeSpanName(name);
int lastSp = name.LastIndexOf(' ');
if (lastSp >= 0 && IsTrackCode(name.Substring(lastSp + 1)))
return name.Substring(0, lastSp);
return name;
}
// Strips the " to <Business>" part from interchange track names so all variants
// of the same interchange cluster under one name.
// "East Whittier Interchange to Atlantic Locomotive Works" → "East Whittier Interchange"
private static string NormalizeSpanName(string name)
{
int idx = name.IndexOf(" Interchange to ", System.StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
return name.Substring(0, idx + " Interchange".Length);
return name;
}
// Returns the utility category for a (normalized) span name.
// 0=regular, 1=repair, 2=diesel, 3=loader, 4=interchange.
private static int GetUtilityType(string name)
{
if (name.IndexOf(" Interchange", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
name.StartsWith("Interchange", System.StringComparison.OrdinalIgnoreCase))
return 4;
if (name.EndsWith(" Repair Track", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Repair", System.StringComparison.OrdinalIgnoreCase))
return 1;
if (name.EndsWith(" Diesel Stand", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Diesel", System.StringComparison.OrdinalIgnoreCase))
return 2;
if (name.EndsWith(" Coal Loader", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Loader", System.StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(" Coaling Tower", System.StringComparison.OrdinalIgnoreCase))
return 3;
return 0;
}
// Returns true when s looks like a per-track identifier: 1-3 leading letters
// followed by 0-3 digits (e.g., S01, R1, PH1, WS1, C3, MP2, UFR1).
private static bool IsTrackCode(string s)
{
if (s.Length == 0 || s.Length > 4) return false;
int i = 0;
while (i < s.Length && char.IsLetter(s[i])) i++;
if (i == 0 || i > 3) return false;
while (i < s.Length && char.IsDigit(s[i])) i++;
return i == s.Length;
}
// Expands a component's display name by "/": "Base A/B/C" with 3 spans →
// ["Base A", "Base B", "Base C"]. Falls back to the full name if counts mismatch.
private static string[] ExpandSpanNames(IndustryComponent ic)
{
if (ic.trackSpans.Length <= 1) return new[] { ic.DisplayName };
string[] parts = ic.DisplayName.Split('/');
if (parts.Length != ic.trackSpans.Length) return new[] { ic.DisplayName };
// Extract the prefix from the first part (everything before the last space).
string first = parts[0].Trim();
int sp = first.LastIndexOf(' ');
string prefix = sp >= 0 ? first.Substring(0, sp + 1) : "";
var result = new string[parts.Length];
result[0] = first;
for (int i = 1; i < parts.Length; i++)
result[i] = prefix + parts[i].Trim();
return result;
}
// Per-frame: projects cached (centroid, anchors, name) tuples through the map camera
// and pushes the visible subset to native. Native runs AABB collision avoidance and
// draws leader lines. All positions are in simulation space; GameToWorld() applied here.
private void PushTrackLabels()
{
if (_mapCamera == null) return;
// Recompute effective merge: manual toggle OR auto-merge when zoomed out past threshold.
var s = PopoutModule.Settings;
bool shouldMerge = s.trackLabelMergeEnabled ||
(_mapCamera.orthographicSize > s.trackLabelMergeZoom);
if (shouldMerge != _effectiveMergeEnabled)
{
_effectiveMergeEnabled = shouldMerge;
_spawnPointTimer = 99f; // zoom crossed the threshold — rebuild with new merge state
}
_spawnPointTimer += Time.deltaTime;
if (_spawnPointTimer >= 5f || _industryComponents == null)
{
RebuildTrackSpanLabels();
_spawnPointTimer = 0f;
}
// Grow per-label arrays to cover both stages; done here (before the zoom branch)
// so Stage 3 industry label push can write into them safely.
int maxLabels = System.Math.Max(_trackSpanLabels.Count, _industryLabels.Count);
if (_labelUs.Length < maxLabels)
{
_labelUs = new float[maxLabels];
_labelVs = new float[maxLabels];
_labelAngles = new float[maxLabels];
_labelScales = new float[maxLabels];
_anchorStarts = new int [maxLabels];
_anchorCounts = new int [maxLabels];
}
if (_anchorUs.Length < _totalAnchorCount)
{
_anchorUs = new float[_totalAnchorCount];
_anchorVs = new float[_totalAnchorCount];
}
// Global cutoff: beyond this zoom all labels (incl. Stage 3) are suppressed.
if (_mapCamera.orthographicSize > s.trackAllLabelsZoomLimit)
{
if (_lastLabelCount != 0)
{
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
return;
}
// Stage 3: beyond the industry-label threshold — one big label per industry.
if (_mapCamera.orthographicSize > s.trackIndustryLabelZoom)
{
_labelNames.Clear();
int icCount = 0;
foreach (var (centroid, name, _) in _industryLabels)
{
Vector3 vp = _mapCamera.WorldToViewportPoint(centroid.GameToWorld());
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
vp.y < -0.05f || vp.y > 1.05f) continue;
if (icCount > 0) _labelNames.Append('\n');
_labelNames.Append(name);
_labelUs[icCount] = vp.x;
_labelVs[icCount] = vp.y;
_labelAngles[icCount] = 0f; // horizontal — labels an area, not a track
_labelScales[icCount] = 1.5f; // bigger than per-track labels
_anchorStarts[icCount] = 0;
_anchorCounts[icCount] = 0; // no leader lines at this zoom
icCount++;
}
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(),
_labelUs, _labelVs,
s_emptyFloat, s_emptyFloat,
_anchorStarts, _anchorCounts,
_labelAngles, _labelScales, icCount);
_lastLabelCount = icCount;
return;
}
// Dead zone: track labels are hidden but industry labels haven't kicked in yet.
if (_mapCamera.orthographicSize > s.trackLabelZoomLimit)
{
if (_lastLabelCount != 0)
{
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, "",
s_emptyFloat, s_emptyFloat, s_emptyFloat, s_emptyFloat,
s_emptyInt, s_emptyInt, s_emptyFloat, s_emptyFloat, 0);
_lastLabelCount = 0;
}
return;
}
// Stage 1 / Stage 2: per-track or merged labels.
_labelNames.Clear();
int si = 0; // index into _trackSpanLabels / _labelWorldOffsets
int count = 0;
int anchorOffset = 0;
foreach (var (centroid, trackDir, anchors, name, utilityType) in _trackSpanLabels)
{
// Utility labels (repair, diesel, loader, interchange) have their own zoom limit
// and hide before the main track labels when the map is zoomed out.
if (utilityType != 0 && _mapCamera.orthographicSize > s.trackUtilityZoomLimit) { si++; continue; }
// Apply world-space spread offset computed at rebuild time. The offset is in
// game-space XZ and is rotation-invariant, so labels stay locked to their tracks
// regardless of camera rotation or pan.
Vector3 labelCenter = si < _labelWorldOffsets.Length
? centroid + _labelWorldOffsets[si]
: centroid;
Vector3 vp = _mapCamera.WorldToViewportPoint(labelCenter.GameToWorld());
if (vp.z < 0f || vp.x < -0.05f || vp.x > 1.05f ||
vp.y < -0.05f || vp.y > 1.05f) { si++; continue; }
if (count > 0) _labelNames.Append('\n');
_labelNames.Append(name);
_labelUs[count] = vp.x;
_labelVs[count] = vp.y;
// Scale label size inversely with zoom so text appears constant-size relative
// to tracks. Reference zoom = half the hide threshold (full size at that level).
// Clamped to [trackLabelFontSizeMin, trackLabelFontSize] in pixel space.
float refZoom = Mathf.Max(s.trackLabelZoomLimit * 0.5f, 1f);
float targetPx = s.trackLabelFontSize * (refZoom / _mapCamera.orthographicSize);
float clampedPx = Mathf.Clamp(targetPx, s.trackLabelFontSizeMin, s.trackLabelFontSize);
_labelScales[count] = clampedPx / s.trackLabelFontSize;
_anchorStarts[count] = anchorOffset;
_anchorCounts[count] = anchors.Length;
// Compute screen-space angle for parallel-label rotation.
// Project two points along the track direction through the camera;
// screen Y is flipped relative to viewport Y, so negate the dy term.
Vector3 p0v = _mapCamera.WorldToViewportPoint((centroid - trackDir * 20f).GameToWorld());
Vector3 p1v = _mapCamera.WorldToViewportPoint((centroid + trackDir * 20f).GameToWorld());
float adx = p1v.x - p0v.x;
float ady = -(p1v.y - p0v.y); // viewport Y up → screen Y down
// Viewport UV is normalized; text is rendered in screen pixels.
// Dividing ady by aspect converts Y from "fraction of height" to
// "fraction of width" so atan2 operates in uniform pixel units.
float aspect = _mapCamera.aspect > 0f ? _mapCamera.aspect : 1f;
float angle = Mathf.Atan2(ady / aspect, adx) * Mathf.Rad2Deg;
// Keep text readable: clamp to (-90°, 90°] so it never renders upside-down.
if (angle > 90f) angle -= 180f;
else if (angle < -90f) angle += 180f;
_labelAngles[count] = angle;
foreach (var ap in anchors)
{
Vector3 av = _mapCamera.WorldToViewportPoint(ap.GameToWorld());
_anchorUs[anchorOffset] = av.x;
_anchorVs[anchorOffset] = av.y;
anchorOffset++;
}
si++;
count++;
}
Native.RRPOPOUT_SetTrackLabels(_overlayHandle, _labelNames.ToString(),
_labelUs, _labelVs,
_anchorUs, _anchorVs,
_anchorStarts, _anchorCounts,
_labelAngles, _labelScales, count);
_lastLabelCount = count;
}
private static void SeedIconCullingState(int handle)
{
var s = PopoutModule.Settings;
@ -800,4 +1469,20 @@ internal sealed class UiHost : MonoBehaviour
s.iconCullingEnabled, s.iconCullingThreshold, s.hideMuLocos, s.locoBoostedScale,
s.eotdEnabled, s.eotdOnlyWhenCulled, s.eotdSizeScale);
}
private static void SeedTrackLabelStyle(int handle)
{
var s = PopoutModule.Settings;
Native.RRPOPOUT_SetTrackLabelStyle(handle,
s.trackLabelFontSize, s.trackLabelLineThickness, s.trackLabelZoomLimit,
s.trackLeaderLinesEnabled, s.trackCollisionEnabled, s.trackLabelParallel,
s.trackLabelMergeEnabled, s.trackLabelMergeZoom,
s.trackIndustryLabelZoom,
s.trackUtilityRepairEnabled, s.trackUtilityDieselEnabled,
s.trackUtilityLoaderEnabled, s.trackUtilityInterchangeEnabled,
s.trackUtilityZoomLimit,
s.trackAllLabelsZoomLimit,
s.trackLabelAvoidTrack,
s.trackLabelFontSizeMin);
}
}

View file

@ -29,6 +29,7 @@ public static class Main
// Modules are registered here. Each module loads its own settings in its
// constructor. Order here is display order in the settings panel.
_registry.Register(new Modules.PhysicsOptimizer.PhysicsOptimizerModule());
_registry.Register(new Modules.MeshLod.MeshLodModule());
_registry.Register(new Modules.Popout.PopoutModule());
_registry.EnableConfigured();

View file

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Linq;
using HarmonyLib;
using Model;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodInjector
{
static MeshLodSettings _settings = new();
static readonly List<WeakReference<Car>> _trackedCars = new();
// Debounced auto-refresh: set by MarkDirty, consumed by CheckAutoRefresh (called from Update).
static float _pendingRefreshAt = -1f;
const float RefreshDebounce = 0.5f;
// ── Lifecycle ─────────────────────────────────────────────────────────────
public static void Init(MeshLodSettings settings)
{
_settings = settings;
Log.Info($"MeshLOD settings: frac1={settings.Lod1RendererFraction:F2} frac2={settings.Lod2RendererFraction:F2} " +
$"d1={settings.Lod1Distance}m d2={settings.Lod2Distance}m d3={settings.Lod3Distance}m " +
$"locos={settings.ApplyToLocomotives} freight={settings.ApplyToFreightCars} " +
$"lodBias={QualitySettings.lodBias:F2}");
}
public static void ClearTracking()
{
_trackedCars.Clear();
_pendingRefreshAt = -1f;
}
// Called from the UI whenever any setting changes.
// Triggers RefreshAll after RefreshDebounce seconds with no further changes,
// so slider drags don't spam scene rebuilds.
public static void MarkDirty() =>
_pendingRefreshAt = Time.realtimeSinceStartup + RefreshDebounce;
// Called every Update by MeshLodHost.
public static void CheckAutoRefresh()
{
if (_pendingRefreshAt < 0f) return;
if (Time.realtimeSinceStartup < _pendingRefreshAt) return;
_pendingRefreshAt = -1f;
RefreshAll();
}
// ── Harmony entry point ───────────────────────────────────────────────────
public static void OnModelLoaded(Car car)
{
if (car?.BodyTransform == null) return;
if (car.BodyTransform.GetComponent<LODGroup>() != null) return;
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.ApplyToLocomotives) return;
if (!isLoco && !_settings.ApplyToFreightCars) return;
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) return;
try
{
ApplyLods(car, renderers);
_trackedCars.Add(new WeakReference<Car>(car));
}
catch (Exception ex) { Log.Warn($"MeshLOD: apply failed for {car.DisplayName}: {ex.Message}"); }
}
// ── Explicit refresh ──────────────────────────────────────────────────────
public static void RefreshAll()
{
int refreshed = 0, skipped = 0;
foreach (WeakReference<Car> wr in _trackedCars.ToArray())
{
if (!wr.TryGetTarget(out Car? car) || car == null) continue;
if (car.BodyTransform == null) { skipped++; continue; }
RemoveLods(car);
bool isLoco = car is BaseLocomotive;
if ( isLoco && !_settings.ApplyToLocomotives) { skipped++; continue; }
if (!isLoco && !_settings.ApplyToFreightCars) { skipped++; continue; }
MeshRenderer[] renderers = CollectRenderers(car.BodyTransform);
if (renderers.Length == 0) { skipped++; continue; }
try { ApplyLods(car, renderers); refreshed++; }
catch (Exception ex) { Log.Warn($"MeshLOD: refresh failed for {car.DisplayName}: {ex.Message}"); }
}
_trackedCars.RemoveAll(wr => !wr.TryGetTarget(out _));
Log.Info($"MeshLOD: refreshed {refreshed} car(s), skipped {skipped}");
}
// ── Renderer collection ───────────────────────────────────────────────────
static MeshRenderer[] CollectRenderers(Transform bodyRoot)
{
// Build exclusion set: renderers already claimed by the game's own child LODGroups
// (coupler-lods, gauge objects, etc.) must not be given to our outer LODGroup.
var alreadyClaimed = new HashSet<Renderer>();
foreach (LODGroup existing in bodyRoot.GetComponentsInChildren<LODGroup>())
foreach (LOD lod in existing.GetLODs())
foreach (Renderer r in lod.renderers)
if (r != null) alreadyClaimed.Add(r);
var list = new List<MeshRenderer>();
foreach (MeshRenderer mr in bodyRoot.GetComponentsInChildren<MeshRenderer>())
{
if (alreadyClaimed.Contains(mr)) continue;
if (IsBBoxChild(mr.transform)) continue;
if (mr.GetComponent<MeshFilter>()?.sharedMesh == null) continue;
list.Add(mr);
}
return list.ToArray();
}
// Our proxy box GO is named "S3_BBox"; skip renderers that live inside it.
static bool IsBBoxChild(Transform t) => t.parent?.name == "S3_BBox";
// ── LOD application ───────────────────────────────────────────────────────
static void ApplyLods(Car car, MeshRenderer[] allRenderers)
{
Transform bodyRoot = car.BodyTransform;
// Sort by world-bounds volume descending: largest = car body / trucks (structural),
// smallest = bolts, grab irons, rivets (detail).
MeshRenderer[] sorted = allRenderers
.OrderByDescending(r => BoundsVolume(r.bounds))
.ToArray();
// LOD1: minor cull — keep the top Lod1RendererFraction of renderers
int lod1Keep = Mathf.Max(1, Mathf.RoundToInt(sorted.Length * _settings.Lod1RendererFraction));
// LOD2: major cull — keep only the top Lod2RendererFraction (must be ≤ lod1Keep)
int lod2Keep = Mathf.Clamp(Mathf.RoundToInt(sorted.Length * _settings.Lod2RendererFraction), 1, lod1Keep);
Renderer[] lod0Rend = allRenderers.Cast<Renderer>().ToArray();
Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast<Renderer>().ToArray();
Renderer[] lod2Rend = sorted.Take(lod2Keep).Cast<Renderer>().ToArray();
// LOD3: 12-triangle proxy box derived from the largest renderer's mesh-local AABB.
Bounds localBounds = ComputeMeshLocalBounds(sorted[0], bodyRoot);
MeshRenderer bboxMr = CreateBoundingBoxRenderer(bodyRoot, localBounds,
sorted[0].sharedMaterial,
sorted[0].gameObject.layer);
Renderer[] lod3Rend = new[] { (Renderer)bboxMr };
// Convert distance settings → screenRelativeTransitionHeight.
// Multiply by lodBias so that transitions fire at the specified metre distance
// even when QualitySettings.lodBias ≠ 1. (Without this, transitions fire at
// d × lodBias instead of d — Railroader uses lodBias=2.5.)
float sphereDiameter = Mathf.Max(localBounds.extents.magnitude * 2f, 0.5f);
float fov = Camera.main != null ? Camera.main.fieldOfView : 60f;
float invFovFactor = 2f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
float lodBias = Mathf.Max(QualitySettings.lodBias, 0.01f);
float k = sphereDiameter * lodBias / invFovFactor;
float lod1H = Mathf.Clamp(k / _settings.Lod1Distance, 0.001f, 0.999f);
float lod2H = Mathf.Clamp(k / _settings.Lod2Distance, 0.001f, lod1H * 0.9f);
float lod3H = Mathf.Clamp(k / _settings.Lod3Distance, 0.001f, lod2H * 0.9f);
var lodGroup = bodyRoot.gameObject.AddComponent<LODGroup>();
lodGroup.SetLODs(new LOD[]
{
new(lod1H, lod0Rend), // full detail
new(lod2H, lod1Rend), // minor cull
new(lod3H, lod2Rend), // structural only
new(0f, lod3Rend), // proxy box
});
lodGroup.RecalculateBounds();
Log.Info($"MeshLOD: {car.DisplayName} " +
$"lod0={lod0Rend.Length} lod1={lod1Keep} lod2={lod2Keep} lod3=bbox " +
$"sphere={sphereDiameter:F1}m bias={lodBias:F2} " +
$"thresh={lod1H:F3}/{lod2H:F3}/{lod3H:F3}");
}
// Tears down our LODGroup and proxy box, re-enabling any renderers that the
// LODGroup had disabled during a LOD1/LOD2/LOD3 transition.
static void RemoveLods(Car car)
{
if (car?.BodyTransform == null) return;
LODGroup? lg = car.BodyTransform.GetComponent<LODGroup>();
if (lg != null)
{
LOD[] lods = lg.GetLODs();
if (lods.Length > 0)
foreach (Renderer r in lods[0].renderers)
if (r != null) r.enabled = true;
UnityEngine.Object.Destroy(lg);
}
Transform? bbox = car.BodyTransform.Find("S3_BBox");
if (bbox != null) UnityEngine.Object.Destroy(bbox.gameObject);
}
// ── Bounds helpers ────────────────────────────────────────────────────────
static Bounds ComputeMeshLocalBounds(MeshRenderer mr, Transform bodyRoot)
{
MeshFilter? mf = mr.GetComponent<MeshFilter>();
if (mf?.sharedMesh != null)
{
try
{
Bounds mb = mf.sharedMesh.bounds;
Matrix4x4 m2b = bodyRoot.worldToLocalMatrix * mr.transform.localToWorldMatrix;
Vector3 c = mb.center, e = mb.extents;
bool init = false;
Bounds local = default;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = m2b.MultiplyPoint3x4(c + new Vector3(e.x * sx, e.y * sy, e.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
return local;
}
catch { /* fall through */ }
}
return WorldToLocalBounds(new[] { mr }, bodyRoot);
}
static Bounds WorldToLocalBounds(MeshRenderer[] renderers, Transform bodyRoot)
{
Matrix4x4 w2l = bodyRoot.worldToLocalMatrix;
bool init = false;
Bounds local = default;
foreach (MeshRenderer mr in renderers)
{
Bounds wb = mr.bounds;
Vector3 c = wb.center, ex = wb.extents;
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
for (int sz = -1; sz <= 1; sz += 2)
{
Vector3 lp = w2l.MultiplyPoint3x4(c + new Vector3(ex.x * sx, ex.y * sy, ex.z * sz));
if (!init) { local = new Bounds(lp, Vector3.zero); init = true; }
else local.Encapsulate(lp);
}
}
return local;
}
// ── Proxy box mesh ────────────────────────────────────────────────────────
static MeshRenderer CreateBoundingBoxRenderer(Transform parent, Bounds localBounds,
Material mat, int layer)
{
var go = new GameObject("S3_BBox");
go.transform.SetParent(parent, worldPositionStays: false);
go.layer = layer;
Vector3 c = localBounds.center, e = localBounds.extents;
var verts = new[]
{
c + new Vector3(-e.x,-e.y,-e.z), c + new Vector3( e.x,-e.y,-e.z),
c + new Vector3( e.x, e.y,-e.z), c + new Vector3(-e.x, e.y,-e.z),
c + new Vector3(-e.x,-e.y, e.z), c + new Vector3( e.x,-e.y, e.z),
c + new Vector3( e.x, e.y, e.z), c + new Vector3(-e.x, e.y, e.z),
};
var tris = new[]
{
0,2,1, 0,3,2, 4,5,6, 4,6,7,
0,1,5, 0,5,4, 2,3,7, 2,7,6,
0,4,7, 0,7,3, 1,2,6, 1,6,5,
};
var mesh = new Mesh { name = "S3_BBox" };
mesh.SetVertices(verts);
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
mesh.RecalculateBounds();
mesh.UploadMeshData(markNoLongerReadable: true);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var mr = go.AddComponent<MeshRenderer>();
mr.sharedMaterial = mat;
return mr;
}
static float BoundsVolume(Bounds b) { Vector3 s = b.size; return s.x * s.y * s.z; }
}
// ── Harmony patch ─────────────────────────────────────────────────────────────
[HarmonyPatch(typeof(Car), "HandleModelsLoaded")]
static class HandleModelsLoadedLodPatch
{
static void Postfix(Car __instance) => MeshLodInjector.OnModelLoaded(__instance);
}

View file

@ -0,0 +1,65 @@
using HarmonyLib;
using S3.Core;
using UnityEngine;
namespace S3.Modules.MeshLod;
public sealed class MeshLodModule : IModule
{
private const string SettingsFile = "S3.meshlod.json";
public static MeshLodSettings Settings { get; private set; } = new();
private static Harmony? _harmony;
private static GameObject? _hostGo;
public MeshLodModule() => Settings = SettingsStore.Load<MeshLodSettings>(SettingsFile);
public string Id => "meshlod";
public string DisplayName => "Mesh LOD";
public string Description =>
"Reduces rendering triangle count for rolling stock and locomotives at distance " +
"by progressively hiding small-detail renderers (bolts, grab irons, rivets) and " +
"replacing very distant cars with a single bounding-box proxy mesh.";
public bool Enabled
{
get => Settings.enabled;
set => Settings.enabled = value;
}
public void OnEnable()
{
MeshLodInjector.Init(Settings);
_harmony = new Harmony("S3.meshlod");
var applied = _harmony.CreateClassProcessor(typeof(HandleModelsLoadedLodPatch)).Patch();
Log.Info($"MeshLOD: patched {applied?.Count ?? 0} method(s)");
// Lightweight MonoBehaviour solely to drive the auto-refresh debounce timer.
_hostGo = new GameObject("[S3] MeshLodHost");
_hostGo.AddComponent<MeshLodHost>();
UnityEngine.Object.DontDestroyOnLoad(_hostGo);
}
public void OnDisable()
{
_harmony?.UnpatchAll("S3.meshlod");
_harmony = null;
MeshLodInjector.ClearTracking();
if (_hostGo != null) UnityEngine.Object.Destroy(_hostGo);
_hostGo = null;
}
public void SaveSettings() => Persist();
internal static void Persist() => SettingsStore.Save(SettingsFile, Settings);
public void DrawSettings() => MeshLodSettingsUI.Draw();
}
// Drives the debounce timer. Exists only while the module is enabled.
sealed class MeshLodHost : MonoBehaviour
{
void Update() => MeshLodInjector.CheckAutoRefresh();
}

View file

@ -0,0 +1,22 @@
using System;
namespace S3.Modules.MeshLod;
[Serializable]
public class MeshLodSettings
{
public bool enabled = false;
// Fraction of renderers kept at each progressive cull level (sorted by bounds volume desc).
public float Lod1RendererFraction = 0.50f; // minor cull: remove tiny details
public float Lod2RendererFraction = 0.15f; // major cull: structural geometry only
// Distance in metres for each LOD transition. Converted to Unity's
// screenRelativeTransitionHeight at apply time, corrected for lodBias.
public float Lod1Distance = 80f; // LOD0 → LOD1 (full detail → minor cull)
public float Lod2Distance = 200f; // LOD1 → LOD2 (minor cull → structural)
public float Lod3Distance = 600f; // LOD2 → LOD3 (structural → proxy box)
public bool ApplyToLocomotives = true;
public bool ApplyToFreightCars = true;
}

View file

@ -0,0 +1,120 @@
using UnityEngine;
namespace S3.Modules.MeshLod;
static class MeshLodSettingsUI
{
public static void Draw()
{
MeshLodSettings s = MeshLodModule.Settings;
bool changed = false;
GUILayout.BeginVertical();
GUILayout.Label("<b>Mesh LOD</b> — 4-level renderer culling for distant rolling stock");
GUILayout.Space(4f);
GUILayout.Label(
" Renderers are ranked by bounding-box volume (largest = car body, trucks).\n" +
" Each LOD level keeps a progressively smaller subset of the highest-ranked renderers.",
GUI.skin.label);
// ── Apply to ──────────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Apply to</b>");
GUILayout.Space(4f);
bool newLocos = GUILayout.Toggle(s.ApplyToLocomotives, " Locomotives");
if (newLocos != s.ApplyToLocomotives) { s.ApplyToLocomotives = newLocos; changed = true; }
bool newCars = GUILayout.Toggle(s.ApplyToFreightCars, " Freight / passenger cars");
if (newCars != s.ApplyToFreightCars) { s.ApplyToFreightCars = newCars; changed = true; }
// ── Renderer fractions ────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>Detail cull fractions</b> (fraction of renderers to keep per LOD level)");
GUILayout.Space(4f);
int ex60 = 60; // example car with 60 renderers
GUILayout.Label(
$" LOD1 (minor cull): {s.Lod1RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.Lod1RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF1 = GUILayout.HorizontalSlider(s.Lod1RendererFraction, 0.15f, 0.80f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF1 - s.Lod1RendererFraction) > 0.005f)
{
s.Lod1RendererFraction = Mathf.Round(newF1 * 20f) / 20f; // snap to 5% steps
if (s.Lod1RendererFraction <= s.Lod2RendererFraction)
s.Lod2RendererFraction = Mathf.Max(0.05f, s.Lod1RendererFraction - 0.10f);
changed = true;
}
GUILayout.Space(4f);
GUILayout.Label(
$" LOD2 (structural): {s.Lod2RendererFraction * 100f:F0}% kept " +
$"— {Mathf.Max(1, Mathf.RoundToInt(ex60 * s.Lod2RendererFraction))}/{ex60} for a 60-renderer car",
GUI.skin.label);
GUILayout.BeginHorizontal();
GUILayout.Space(4f);
float newF2 = GUILayout.HorizontalSlider(s.Lod2RendererFraction, 0.05f, 0.40f, GUILayout.Width(220f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newF2 - s.Lod2RendererFraction) > 0.005f)
{
s.Lod2RendererFraction = Mathf.Round(newF2 * 20f) / 20f;
if (s.Lod2RendererFraction >= s.Lod1RendererFraction)
s.Lod1RendererFraction = Mathf.Min(0.80f, s.Lod2RendererFraction + 0.10f);
changed = true;
}
// ── Transition distances ──────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label("<b>LOD transition distances</b> (metres from camera)");
GUILayout.Space(4f);
GUILayout.Label(
" Converted to Unity screen-height thresholds at runtime using the car's actual\n" +
" bounding sphere and camera FOV, corrected for the game's LOD quality bias (2.5×).",
GUI.skin.label);
GUILayout.Space(4f);
DrawDistanceSlider(ref s.Lod1Distance, "LOD0→LOD1 (minor cull)", 30f, 500f, ref changed);
GUILayout.Space(2f);
DrawDistanceSlider(ref s.Lod2Distance, "LOD1→LOD2 (structural)", s.Lod1Distance + 20f, 1000f, ref changed);
if (s.Lod2Distance <= s.Lod1Distance) { s.Lod2Distance = s.Lod1Distance + 50f; changed = true; }
GUILayout.Space(2f);
DrawDistanceSlider(ref s.Lod3Distance, "LOD2→LOD3 (proxy box) ", s.Lod2Distance + 20f, 1500f, ref changed);
if (s.Lod3Distance <= s.Lod2Distance) { s.Lod3Distance = s.Lod2Distance + 100f; changed = true; }
// ── Manual refresh ────────────────────────────────────────────────────
GUILayout.Space(10f);
GUILayout.Label(" Settings auto-refresh loaded cars ~0.5 s after the last change.", GUI.skin.label);
GUILayout.Space(4f);
if (GUILayout.Button("Refresh Now", GUILayout.Width(120f)))
{
MeshLodModule.Persist();
MeshLodInjector.RefreshAll();
}
GUILayout.EndVertical();
if (changed)
{
MeshLodModule.Persist();
MeshLodInjector.MarkDirty();
}
}
static void DrawDistanceSlider(ref float value, string label, float min, float max, ref bool changed)
{
GUILayout.BeginHorizontal();
GUILayout.Label($" {label}: {value:F0} m", GUILayout.Width(270f));
float newVal = GUILayout.HorizontalSlider(value, min, max, GUILayout.Width(200f));
GUILayout.EndHorizontal();
if (Mathf.Abs(newVal - value) > 1f)
{
value = Mathf.Round(newVal / 10f) * 10f;
changed = true;
}
}
}

View file

@ -54,6 +54,24 @@ namespace S3.Modules.Popout {
ToggleEotd = 27,
ToggleEotdOnlyWhenCulled = 28,
SetEotdSizeScale = 29, // y = [1.0, 10.0]
ToggleTrackLabels = 30, // toggle industry track name labels on the map
TrackLabelSetFontSize = 31, // y = font size px [8, 24]
TrackLabelSetLineThick = 32, // y = leader line thickness [1, 4]
TrackLabelSetZoomLimit = 33, // y = orthographicSize zoom-out limit [200, 8000]
ToggleLeaderLines = 34, // toggle leader lines from label to track anchor
ToggleCollision = 35, // toggle collision-avoidance label spread
ToggleParallelLabels = 36, // rotate labels to run parallel to their track
ToggleMergeLabels = 37, // merge same-name nearby spans into one label
TrackLabelSetMergeZoom = 38, // y = orthographicSize threshold for auto-merge
TrackLabelSetIndustryZoom = 39, // y = orthographicSize threshold for Stage 3 industry labels
ToggleUtilityRepairLabels = 40, // toggle repair-track labels
ToggleUtilityDieselLabels = 41, // toggle diesel-stand labels
ToggleUtilityLoaderLabels = 42, // toggle coal-loader labels
ToggleUtilityInterchangeLabels = 43, // toggle interchange labels
TrackLabelSetUtilityZoom = 44, // y = orthographicSize beyond which utility labels hide
TrackLabelSetAllZoom = 45, // y = orthographicSize beyond which ALL labels hide
ToggleAvoidTrackLabels = 46, // push labels off their own track line
TrackLabelSetFontSizeMin = 47, // y = minimum font size px [4, max]; labels auto-scale with zoom
}
// Must match MapThemeData in native/include/shared_types.h exactly (36 floats = 144 bytes).
@ -252,5 +270,41 @@ namespace S3.Modules.Popout {
int windowHandle, uint flags,
float flareScale, float junctionScale,
float trackThickness, float crossingScale);
// Push industry track name labels for the map.
// namesBlob: track names joined by '\n'.
// us/vs: centroid UV per label (C# projects TrackSpan centroid through the map camera).
// anchorUs/anchorVs: flat UV arrays for all anchor span positions across all labels.
// anchorStarts/anchorCounts: per-label start index and count into the anchor arrays.
// count=0 clears everything (call when zoomed out or map inactive).
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
public static extern void RRPOPOUT_SetTrackLabels(
int windowHandle, string namesBlob,
[In] float[] us, [In] float[] vs,
[In] float[] anchorUs, [In] float[] anchorVs,
[In] int[] anchorStarts, [In] int[] anchorCounts,
[In] float[] angles, // screen-space angle per label (degrees; 0=right, +ve=CW in screen space)
[In] float[] scales, // font size multiplier per label (1.0=track label, 1.5=industry label)
int count);
// Enable or disable track label rendering for this window. Default: enabled.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelsEnabled(int windowHandle, bool enabled);
// Seed all track-label style settings at once. Called on map activate and
// whenever any of the settings change via the gear menu.
[DllImport(Dll, CallingConvention = CallingConvention.Cdecl)]
public static extern void RRPOPOUT_SetTrackLabelStyle(
int windowHandle,
float fontSize, float lineThickness, float zoomLimit,
bool leaderLinesEnabled, bool collisionEnabled, bool parallelEnabled,
bool mergeEnabled, float mergeZoom,
float industryZoom,
bool utilityRepairEnabled, bool utilityDieselEnabled,
bool utilityLoaderEnabled, bool utilityInterchangeEnabled,
float utilityZoom,
float allLabelsZoom,
bool avoidTrack,
float fontSizeMin);
}
}

View file

@ -54,6 +54,28 @@ public class PopoutSettings
// Dot size: 1 (tiny) to 10 (large). Applied as orthographicSize * value * 0.002.
public float eotdSizeScale = 8f;
// Track name labels — shown on the map at each industry track, hidden when zoomed out.
public bool trackLabelsEnabled = true;
public float trackLabelFontSize = 12f; // px; range 8-32
public float trackLabelLineThickness = 1.5f; // px; range 1-4
public float trackLabelZoomLimit = 200f; // orthographicSize beyond which labels hide
public bool trackLeaderLinesEnabled = true; // draw lines from label to each track anchor
public bool trackCollisionEnabled = true; // spread overlapping labels apart
public bool trackLabelParallel = false; // rotate labels parallel to their track
public bool trackLabelMergeEnabled = true; // merge same-name nearby spans into one label
public float trackLabelMergeZoom = 150f; // auto-merge when orthographicSize exceeds this
public float trackIndustryLabelZoom = 200f; // Stage 3: show industry labels beyond this zoom
// Utility track filters — per-type visibility toggle + shared zoom limit.
// Interchange tracks are also name-normalized (strip " to <Business>" suffix).
public bool trackUtilityRepairEnabled = true;
public bool trackUtilityDieselEnabled = true;
public bool trackUtilityLoaderEnabled = true;
public bool trackUtilityInterchangeEnabled = true;
public float trackUtilityZoomLimit = 200f; // utility tracks hide before this zoom
public float trackAllLabelsZoomLimit = 8000f; // all labels (incl. industry) hide beyond this zoom
public bool trackLabelAvoidTrack = false; // push labels perpendicular so they don't cover track lines
public float trackLabelFontSizeMin = 10f; // smallest pixel size (at zoom limit); auto-scales between this and trackLabelFontSize
// Custom theme colors — edited live in the Settings color picker.
// Initialized to S3 Dark so first-launch looks reasonable before the user tunes it.
public MapThemeData customTheme = new MapThemeData {