diff --git a/img/map/map_track_and_industry_labels/3-stages-demo.mp4 b/img/map/map_track_and_industry_labels/3-stages-demo.mp4 new file mode 100644 index 0000000..27b1e05 Binary files /dev/null and b/img/map/map_track_and_industry_labels/3-stages-demo.mp4 differ diff --git a/native/include/shared_types.h b/native/include/shared_types.h index 8fca52e..f5ed44b 100644 --- a/native/include/shared_types.h +++ b/native/include/shared_types.h @@ -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. diff --git a/native/src/d3d11_renderer.cpp b/native/src/d3d11_renderer.cpp index 78f09bc..6cf6ff8 100644 --- a/native/src/d3d11_renderer.cpp +++ b/native/src/d3d11_renderer.cpp @@ -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 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 boxes(n); + std::vector textSizes(n); + std::vector 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 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 diff --git a/native/src/exports.cpp b/native/src/exports.cpp index efc377a..8baeb4f 100644 --- a/native/src/exports.cpp +++ b/native/src/exports.cpp @@ -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 tmpLabels; + std::vector 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 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) // --------------------------------------------------------------------------- diff --git a/native/src/popout_window.h b/native/src/popout_window.h index e572b34..e6f3092 100644 --- a/native/src/popout_window.h +++ b/native/src/popout_window.h @@ -105,6 +105,43 @@ struct PopoutWindow { std::atomic imMETrackThick {1.25f}; std::atomic 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 imTrackLabels; + std::vector imAnchorUs; // flat anchor UV arrays, indexed by anchorStart + std::vector imAnchorVs; + std::mutex imTrackLabelMutex; + std::atomic imTrackLabelsEnabled {true}; + std::atomic imTrackLabelFontSize {14.f}; + std::atomic imTrackLabelLineThickness {1.5f}; + std::atomic imTrackLabelZoomLimit {3000.f}; + std::atomic imTrackLeaderLinesEnabled {true}; + std::atomic imTrackCollisionEnabled {true}; + std::atomic imTrackParallelEnabled {false}; + std::atomic imTrackMergeEnabled {true}; + std::atomic imTrackLabelMergeZoom {150.f}; + std::atomic imTrackIndustryZoom {200.f}; + std::atomic imTrackUtilityRepair {true}; + std::atomic imTrackUtilityDiesel {true}; + std::atomic imTrackUtilityLoader {true}; + std::atomic imTrackUtilityInterchange {true}; + std::atomic imTrackUtilityZoom {200.f}; + std::atomic imTrackAllLabelsZoom {8000.f}; + std::atomic imTrackLabelAvoidTrack {false}; + std::atomic imTrackLabelFontSizeMin {8.f}; + // Loaded geometry from the save file — consumed by MessagePumpThread before CreateWindowExW. std::atomic lastWinX {-1}, lastWinY {-1}; std::atomic lastWinW {900}, lastWinH {700}; diff --git a/src/Core/Ui/UiService.cs b/src/Core/Ui/UiService.cs index c5dc9b9..bf6db56 100644 --- a/src/Core/Ui/UiService.cs +++ b/src/Core/Ui/UiService.cs @@ -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(); + private float[] _labelUs = System.Array.Empty(); + private float[] _labelVs = System.Array.Empty(); + private float[] _labelAngles = System.Array.Empty(); // per-label screen-space angle (degrees) + private float[] _labelScales = System.Array.Empty(); // 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(); + private float[] _anchorVs = System.Array.Empty(); + private int[] _anchorStarts = System.Array.Empty(); + private int[] _anchorCounts = System.Array.Empty(); + 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(); + private static readonly int[] s_emptyInt = System.Array.Empty(); + 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 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(); + + // 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(); + 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 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>(); + 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(); + 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; + 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(); 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 " 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); + } } diff --git a/src/Main.cs b/src/Main.cs index 280db42..b7cc592 100644 --- a/src/Main.cs +++ b/src/Main.cs @@ -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(); diff --git a/src/Modules/MeshLod/MeshLodInjector.cs b/src/Modules/MeshLod/MeshLodInjector.cs new file mode 100644 index 0000000..af8b195 --- /dev/null +++ b/src/Modules/MeshLod/MeshLodInjector.cs @@ -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> _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() != 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)); + } + 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 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(); + foreach (LODGroup existing in bodyRoot.GetComponentsInChildren()) + foreach (LOD lod in existing.GetLODs()) + foreach (Renderer r in lod.renderers) + if (r != null) alreadyClaimed.Add(r); + + var list = new List(); + foreach (MeshRenderer mr in bodyRoot.GetComponentsInChildren()) + { + if (alreadyClaimed.Contains(mr)) continue; + if (IsBBoxChild(mr.transform)) continue; + if (mr.GetComponent()?.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().ToArray(); + Renderer[] lod1Rend = sorted.Take(lod1Keep).Cast().ToArray(); + Renderer[] lod2Rend = sorted.Take(lod2Keep).Cast().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.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(); + 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(); + 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().sharedMesh = mesh; + var mr = go.AddComponent(); + 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); +} diff --git a/src/Modules/MeshLod/MeshLodModule.cs b/src/Modules/MeshLod/MeshLodModule.cs new file mode 100644 index 0000000..34f5d88 --- /dev/null +++ b/src/Modules/MeshLod/MeshLodModule.cs @@ -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(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(); + 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(); +} diff --git a/src/Modules/MeshLod/MeshLodSettings.cs b/src/Modules/MeshLod/MeshLodSettings.cs new file mode 100644 index 0000000..4bcfc9e --- /dev/null +++ b/src/Modules/MeshLod/MeshLodSettings.cs @@ -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; +} diff --git a/src/Modules/MeshLod/MeshLodSettingsUI.cs b/src/Modules/MeshLod/MeshLodSettingsUI.cs new file mode 100644 index 0000000..aea097c --- /dev/null +++ b/src/Modules/MeshLod/MeshLodSettingsUI.cs @@ -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("Mesh LOD — 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("Apply to"); + 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("Detail cull fractions (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("LOD transition distances (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; + } + } +} diff --git a/src/Modules/Popout/NativeInterop.cs b/src/Modules/Popout/NativeInterop.cs index 1342889..80ed903 100644 --- a/src/Modules/Popout/NativeInterop.cs +++ b/src/Modules/Popout/NativeInterop.cs @@ -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); } } diff --git a/src/Modules/Popout/PopoutSettings.cs b/src/Modules/Popout/PopoutSettings.cs index 1d61dc3..1dac1f8 100644 --- a/src/Modules/Popout/PopoutSettings.cs +++ b/src/Modules/Popout/PopoutSettings.cs @@ -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 " 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 {