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:
parent
c918dd7867
commit
c5e75ad54a
13 changed files with 1785 additions and 0 deletions
BIN
img/map/map_track_and_industry_labels/3-stages-demo.mp4
Normal file
BIN
img/map/map_track_and_industry_labels/3-stages-demo.mp4
Normal file
Binary file not shown.
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
300
src/Modules/MeshLod/MeshLodInjector.cs
Normal file
300
src/Modules/MeshLod/MeshLodInjector.cs
Normal 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);
|
||||
}
|
||||
65
src/Modules/MeshLod/MeshLodModule.cs
Normal file
65
src/Modules/MeshLod/MeshLodModule.cs
Normal 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();
|
||||
}
|
||||
22
src/Modules/MeshLod/MeshLodSettings.cs
Normal file
22
src/Modules/MeshLod/MeshLodSettings.cs
Normal 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;
|
||||
}
|
||||
120
src/Modules/MeshLod/MeshLodSettingsUI.cs
Normal file
120
src/Modules/MeshLod/MeshLodSettingsUI.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue