From 9189aac68744ba8757213923a3be702a6fe2f0aa Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 10 Jun 2026 18:56:29 -0700 Subject: [PATCH] Improve railroad visibility: own-rail contrast color and thickness setting Local-player rails previously rendered in the white focused-border color from the palette, making them hard to see on light territory. Rails now use a dedicated local rail color: white normally, flipped to black when the territory backdrop is too light for white to read against (patterns average their primary/secondary brightness). Also add a railThickness render setting (0.5-3, default 1), exposed in the Graphics Settings modal and the debug GUI, and persisted via GraphicsOverrides. In the medium-zoom LOD, rails are now drawn as screen-space anti-aliased lines around each tile's rail centerline, accumulated from the 3x3 neighborhood so thick lines spill cleanly into neighboring tiles; detailed mode scales its sub-grid band widths. - PlayerView: compute railColor() (white/black by backdrop brightness) - RailroadPass/shader: uLocalPlayerID, uLocalRailColor, uRailThickness - render-settings.json, RenderSettings, GraphicsOverrides, RenderOverrides: new railroad.railThickness knob - GraphicsSettingsModal: "Train track thickness" slider (+ en.json keys) - tests: schema + apply coverage for railroad overrides --- resources/lang/en.json | 2 + src/client/WebGLFrameBuilder.ts | 7 +- .../hud/layers/GraphicsSettingsModal.ts | 42 ++++++ src/client/render/gl/GameView.ts | 4 + src/client/render/gl/GraphicsOverrides.ts | 1 + src/client/render/gl/RenderOverrides.ts | 3 + src/client/render/gl/RenderSettings.ts | 2 + src/client/render/gl/Renderer.ts | 5 + src/client/render/gl/debug/Layout.ts | 9 ++ src/client/render/gl/passes/RailroadPass.ts | 35 +++++ src/client/render/gl/render-settings.json | 3 +- .../gl/shaders/railroad/railroad.frag.glsl | 121 ++++++++++++------ src/client/view/PlayerView.ts | 16 +++ tests/GraphicsOverrides.test.ts | 47 +++++++ 14 files changed, 253 insertions(+), 44 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 5c0be6cf4..3e725c55f 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -958,6 +958,8 @@ "territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)", "rail_distance_label": "Train track draw distance", "rail_distance_desc": "How far zoomed out train tracks remain visible", + "rail_thickness_label": "Train track thickness", + "rail_thickness_desc": "How wide train tracks are drawn", "section_effects": "Effects", "reset_label": "Reset to defaults", "reset_desc": "Clear all graphics overrides" diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 72aec8b3b..7ce81389a 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -109,10 +109,15 @@ export class WebGLFrameBuilder { } private syncLocalPlayer(gameView: GameView): void { - const sid = gameView.myPlayer()?.smallID() ?? 0; + const me = gameView.myPlayer(); + const sid = me?.smallID() ?? 0; if (sid === this.localPlayerSmallID) return; this.localPlayerSmallID = sid; this.view.setLocalPlayerID(sid); + if (me) { + const rail = me.railColor().toRgb(); + this.view.setLocalRailColor(rail.r / 255, rail.g / 255, rail.b / 255); + } } /** diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 4cad92c4e..5ffd316c1 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -46,6 +46,10 @@ const RAIL_ZOOM_MIN = 0; const RAIL_ZOOM_MAX = 10; const RAIL_ZOOM_STEP = 0.1; +const RAIL_THICKNESS_MIN = 0.5; +const RAIL_THICKNESS_MAX = 3; +const RAIL_THICKNESS_STEP = 0.1; + export class ShowGraphicsSettingsModalEvent { constructor( public readonly isVisible: boolean = true, @@ -222,6 +226,13 @@ export class GraphicsSettingsModal extends LitElement implements Controller { ); } + private currentRailThickness(): number { + return ( + this.userSettings.graphicsOverrides().railroad?.railThickness ?? + renderDefaults.railroad.railThickness + ); + } + private onHighlightFillChange(event: Event) { const value = parseFloat((event.target as HTMLInputElement).value); this.patchMapOverlay({ highlightFillBrighten: value }); @@ -253,6 +264,11 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchRailroad({ railMinZoom: RAIL_ZOOM_MAX - drawDistance }); } + private onRailThicknessChange(event: Event) { + const value = parseFloat((event.target as HTMLInputElement).value); + this.patchRailroad({ railThickness: value }); + } + private currentClassicIcons(): boolean { return ( this.userSettings.graphicsOverrides().structure?.classicIcons ?? false @@ -322,6 +338,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const territorySat = this.currentTerritorySat(); const territoryAlpha = this.currentTerritoryAlpha(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); + const railThickness = this.currentRailThickness(); return html`
+
+
+
+ ${translateText("graphics_setting.rail_thickness_label")} +
+
+ ${translateText("graphics_setting.rail_thickness_desc")} +
+ +
+
+ ${railThickness.toFixed(1)} +
+
+
diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts index 9b6938480..19073ab91 100644 --- a/src/client/render/gl/GameView.ts +++ b/src/client/render/gl/GameView.ts @@ -384,6 +384,10 @@ export class GameView { setLocalPlayerID(id: number): void { this.renderer?.setLocalPlayerID(id); } + /** Rail color for the local player (0–1 RGB). */ + setLocalRailColor(r: number, g: number, b: number): void { + this.renderer?.setLocalRailColor(r, g, b); + } setAltView(active: boolean): void { this.renderer?.setAltView(active); } diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 9f6ff26b5..518f9583e 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -26,6 +26,7 @@ export const GraphicsOverridesSchema = z railroad: z .object({ railMinZoom: z.number(), + railThickness: z.number(), }) .partial(), passEnabled: z diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index e44d5f3f4..505a97188 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -45,6 +45,9 @@ export function applyGraphicsOverrides( if (overrides.railroad?.railMinZoom !== undefined) { settings.railroad.railMinZoom = overrides.railroad.railMinZoom; } + if (overrides.railroad?.railThickness !== undefined) { + settings.railroad.railThickness = overrides.railroad.railThickness; + } if (overrides.passEnabled?.fx !== undefined) { settings.passEnabled.fx = overrides.passEnabled.fx; } diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 09ac2964e..984d176fb 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -112,6 +112,8 @@ export interface RenderSettings { railFadeRange: number; railDetailZoom: number; railAlpha: number; + /** Track width multiplier (1 = default width). */ + railThickness: number; }; structure: { iconSize: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 8ea85c237..27ca7c1fd 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -1026,6 +1026,11 @@ export class GPURenderer { this.samRadiusPass.setLocalPlayer(id); this.affiliationPalette.setLocalPlayer(id); this.unitPass.setLocalPlayer(id); + this.railroadPass.setLocalPlayer(id); + } + + setLocalRailColor(r: number, g: number, b: number): void { + this.railroadPass.setLocalRailColor(r, g, b); } setSAMRadiusVisible(visible: boolean): void { diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index ef4c7d813..49df28965 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -201,6 +201,15 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { "Detail Zoom", ), slider(s.railroad, "railAlpha", d.railroad, 0, 1, 0.01, "Alpha"), + slider( + s.railroad, + "railThickness", + d.railroad, + 0.5, + 3, + 0.1, + "Thickness", + ), ]), ]), diff --git a/src/client/render/gl/passes/RailroadPass.ts b/src/client/render/gl/passes/RailroadPass.ts index 517c7f47c..b590684b3 100644 --- a/src/client/render/gl/passes/RailroadPass.ts +++ b/src/client/render/gl/passes/RailroadPass.ts @@ -99,7 +99,10 @@ export class RailroadPass { private uRailDetailZoom: WebGLUniformLocation; private uRailAlpha: WebGLUniformLocation; private uRailFade: WebGLUniformLocation; + private uRailThickness: WebGLUniformLocation; private uGhostOwnerID: WebGLUniformLocation; + private uLocalPlayerID: WebGLUniformLocation; + private uLocalRailColor: WebGLUniformLocation; private mapW: number; private mapH: number; @@ -112,6 +115,9 @@ export class RailroadPass { private ghostRailDirty = false; private ghostOwnerID = 0; + private localPlayerID = 0; + private localRailColor: [number, number, number] = [0.75, 0.75, 0.75]; + constructor( private gl: WebGL2RenderingContext, mapW: number, @@ -147,7 +153,19 @@ export class RailroadPass { )!; this.uRailAlpha = gl.getUniformLocation(this.program, "uRailAlpha")!; this.uRailFade = gl.getUniformLocation(this.program, "uRailFade")!; + this.uRailThickness = gl.getUniformLocation( + this.program, + "uRailThickness", + )!; this.uGhostOwnerID = gl.getUniformLocation(this.program, "uGhostOwnerID")!; + this.uLocalPlayerID = gl.getUniformLocation( + this.program, + "uLocalPlayerID", + )!; + this.uLocalRailColor = gl.getUniformLocation( + this.program, + "uLocalRailColor", + )!; // Texture unit bindings + ghost defaults gl.useProgram(this.program); @@ -199,6 +217,15 @@ export class RailroadPass { this.railroadDirty = true; } + setLocalPlayer(smallID: number): void { + this.localPlayerID = smallID; + } + + /** Rail color for the local player (0–1 RGB). */ + setLocalRailColor(r: number, g: number, b: number): void { + this.localRailColor = [r, g, b]; + } + /** * Sub-upload terrain bytes for tiles that changed (water-nuke conversions). * Keeps the R8UI water-detection texture in sync with the simulation. @@ -321,7 +348,15 @@ export class RailroadPass { gl.uniform1f(this.uRailDetailZoom, rs.railDetailZoom); gl.uniform1f(this.uRailAlpha, rs.railAlpha); gl.uniform1f(this.uRailFade, fade); + gl.uniform1f(this.uRailThickness, rs.railThickness); gl.uniform1f(this.uGhostOwnerID, this.ghostOwnerID); + gl.uniform1f(this.uLocalPlayerID, this.localPlayerID); + gl.uniform3f( + this.uLocalRailColor, + this.localRailColor[0], + this.localRailColor[1], + this.localRailColor[2], + ); // Bind textures: 0=railroad, 1=tile, 2=palette, 3=terrain, 4=ghostRail gl.activeTexture(gl.TEXTURE0); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 392b44346..7b757fe34 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -106,7 +106,8 @@ "railMinZoom": 4, "railFadeRange": 2, "railDetailZoom": 6, - "railAlpha": 1 + "railAlpha": 1, + "railThickness": 1 }, "structure": { "iconSize": 50, diff --git a/src/client/render/gl/shaders/railroad/railroad.frag.glsl b/src/client/render/gl/shaders/railroad/railroad.frag.glsl index e89a06cd1..5afc3877a 100644 --- a/src/client/render/gl/shaders/railroad/railroad.frag.glsl +++ b/src/client/render/gl/shaders/railroad/railroad.frag.glsl @@ -13,7 +13,10 @@ uniform float uZoom; uniform float uRailDetailZoom; uniform float uRailAlpha; uniform float uRailFade; // Zoom-based fade multiplier (0..1) +uniform float uRailThickness; // Track width multiplier (1 = default) uniform float uGhostOwnerID; // Player smallID for ghost rail color +uniform float uLocalPlayerID; // Local player smallID (0 = none) +uniform vec3 uLocalRailColor; // Rail color for the local player's rails in vec2 vWorldPos; out vec4 fragColor; @@ -54,35 +57,55 @@ bool isBridgePixel(uint rt, ivec2 lp) { return false; } -// Compute rail pixel coverage for a given rail type at fractional tile position. -// Returns 0.0 for miss, 1.0 for hit (detailed mode), or AA coverage (line mode). -float railCoverage(uint rt, vec2 f) { +// Detailed-mode coverage: 3x3 sub-grid with cross-ties, rail band width +// scaled by uRailThickness (clamped so the two bands never overlap fully). +float railDetailCoverage(uint rt, vec2 f) { if (rt == 0u) return 0.0; - - if (uZoom >= uRailDetailZoom) { - // Detailed mode: 3x3 sub-grid with cross-ties - float T = 1.0 / 3.0; - float T2 = 2.0 / 3.0; - bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2); - bool hit = false; - if (rt == 1u) { - hit = (f.x < T) || (f.x >= T2) || center; - } else if (rt == 2u) { - hit = (f.y < T) || (f.y >= T2) || center; - } else if (rt == 3u) { - hit = (f.y < T) || (f.x < T) || center; - } else if (rt == 4u) { - hit = (f.y < T) || (f.x >= T2) || center; - } else if (rt == 5u) { - hit = (f.y >= T2) || (f.x < T) || center; - } else if (rt == 6u) { - hit = (f.y >= T2) || (f.x >= T2) || center; - } - return hit ? 1.0 : 0.0; - } else { - // Simplified mode: fill entire tile (tiles are small at this zoom) - return 1.0; + float T = 1.0 / 3.0; + float T2 = 2.0 / 3.0; + float w = min(T * uRailThickness, 0.5); + bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2); + bool hit = false; + if (rt == 1u) { + hit = (f.x < w) || (f.x >= 1.0 - w) || center; + } else if (rt == 2u) { + hit = (f.y < w) || (f.y >= 1.0 - w) || center; + } else if (rt == 3u) { + hit = (f.y < w) || (f.x < w) || center; + } else if (rt == 4u) { + hit = (f.y < w) || (f.x >= 1.0 - w) || center; + } else if (rt == 5u) { + hit = (f.y >= 1.0 - w) || (f.x < w) || center; + } else if (rt == 6u) { + hit = (f.y >= 1.0 - w) || (f.x >= 1.0 - w) || center; } + return hit ? 1.0 : 0.0; +} + +float segDist(vec2 p, vec2 a, vec2 b) { + vec2 ab = b - a; + float t = clamp(dot(p - a, ab) / dot(ab, ab), 0.0, 1.0); + return length(p - a - ab * t); +} + +// Distance from tile-local point p to the rail centerline of type rt. +// Straights span the tile; corners are two half-segments meeting at center. +float railLineDist(uint rt, vec2 p) { + if (rt == 1u) return segDist(p, vec2(0.5, 0.0), vec2(0.5, 1.0)); + if (rt == 2u) return segDist(p, vec2(0.0, 0.5), vec2(1.0, 0.5)); + vec2 c = vec2(0.5); + vec2 e1 = vec2(0.5, (rt == 3u || rt == 4u) ? 0.0 : 1.0); // top or bottom edge + vec2 e2 = vec2((rt == 3u || rt == 5u) ? 0.0 : 1.0, 0.5); // left or right edge + return min(segDist(p, c, e1), segDist(p, c, e2)); +} + +// Line-mode coverage: screen-space anti-aliased line of width uRailThickness +// (in tiles) around the rail centerline of tile-local point p. +float railLineCoverage(uint rt, vec2 p) { + if (rt == 0u || rt > 6u) return 0.0; + float halfW = 0.5 * uRailThickness; + float aa = 0.5 / uZoom; // ~1 screen pixel in tile units + return 1.0 - smoothstep(halfW - aa, halfW + aa, railLineDist(rt, p)); } void main() { @@ -95,29 +118,34 @@ void main() { uint ghostRailType = texelFetch(uGhostRailTex, tc, 0).r; vec2 f = fract(vWorldPos); - // Compute coverage for real and ghost rails - float realCov = railCoverage(railType, f); + bool detailMode = uZoom >= uRailDetailZoom; + + // Compute coverage for real and ghost rails. In line mode, real coverage is + // accumulated from the 3x3 neighborhood below so thick lines can spill into + // neighboring tiles. + float realCov = detailMode ? railDetailCoverage(railType, f) : 0.0; // Ghost only renders where there is no real rail (values 1-6 = ghost path) // Value 7 = highlight marker (existing rail turns green) float ghostCov = (ghostRailType >= 1u && ghostRailType <= 6u && railType == 0u) - ? railCoverage(ghostRailType, f) + ? (detailMode ? railDetailCoverage(ghostRailType, f) : railLineCoverage(ghostRailType, f)) : 0.0; bool highlighted = (ghostRailType == 7u && railType != 0u); - bool hitRail = (realCov * uRailAlpha > 0.001); - bool hitGhost = (ghostCov * uRailAlpha > 0.001); - - // --- Bridge: check 3x3 neighborhood for water+rail tiles --- + // --- 3x3 neighborhood: bridges (both modes) + line coverage (line mode) --- bool hitBridge = false; ivec2 fp = ivec2(floor(vWorldPos * 2.0)); // fragment pos in game's 2x-pixel grid - for (int dy = -1; dy <= 1 && !hitBridge; dy++) { - for (int dx = -1; dx <= 1 && !hitBridge; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { ivec2 ntc = tc + ivec2(dx, dy); if (ntc.x < 0 || ntc.y < 0 || ntc.x >= int(uMapSize.x) || ntc.y >= int(uMapSize.y)) continue; uint nRail = texelFetch(uRailroadTex, ntc, 0).r; if (nRail == 0u) continue; + if (!detailMode) { + realCov = max(realCov, railLineCoverage(nRail, vWorldPos - vec2(ntc))); + } + if (hitBridge) continue; uint nTerr = texelFetch(uTerrainTex, ntc, 0).r; if ((nTerr & 0x80u) != 0u) continue; // land tile, no bridge ivec2 lp = fp - ntc * 2; @@ -125,6 +153,9 @@ void main() { } } + bool hitRail = (realCov * uRailAlpha > 0.001); + bool hitGhost = (ghostCov * uRailAlpha > 0.001); + if (!hitBridge && !hitRail && !hitGhost) discard; // --- Color output --- @@ -134,9 +165,13 @@ void main() { float railAlpha = uRailAlpha * realCov; uint tileRaw = texelFetch(uTileTex, tc, 0).r; uint owner = tileRaw & uint(OWNER_MASK); - vec3 railColor = owner != 0u - ? texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb - : vec3(0.75); + // Local rails use uLocalRailColor (white, or black over light territory) + // instead of the palette border row. + vec3 railColor = owner == 0u + ? vec3(0.75) + : (owner == uint(uLocalPlayerID) + ? uLocalRailColor + : texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb); // Overlapping railroad highlight — green tint if (highlighted) railColor = vec3(0.2, 0.85, 0.3); if (hitBridge) { @@ -146,9 +181,11 @@ void main() { } } else if (hitGhost) { float ghostAlpha = uRailAlpha * ghostCov * 0.5; - vec3 ghostColor = uGhostOwnerID > 0.0 - ? texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb - : vec3(0.75); + vec3 ghostColor = uGhostOwnerID <= 0.0 + ? vec3(0.75) + : (uGhostOwnerID == uLocalPlayerID + ? uLocalRailColor + : texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb); fragColor = vec4(ghostColor, ghostAlpha * uRailFade); } else { fragColor = vec4(bridgeColor, uRailFade); diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 456582098..0a34a7c96 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -107,6 +107,7 @@ export class PlayerView { private _territoryColor: Colord; private _borderColor: Colord; + private _railColor: Colord; // Update here to include structure light and dark colors private _structureColors: { light: Colord; dark: Colord }; @@ -173,6 +174,17 @@ export class PlayerView { maybeFocusedBorderColor.toHex(), ); + // Rail color (only used for the local player's rails): white for + // visibility, flipped to black when the territory is too light for white + // to read against it. Patterns paint both colors, so average them. + const railBackdropBrightness = pattern + ? (this._territoryColor.brightness() + this._borderColor.brightness()) / 2 + : this._territoryColor.brightness(); + this._railColor = + railBackdropBrightness > 0.8 + ? colord("rgb(0,0,0)") + : theme.focusedBorderColor(); + const baseRgb = this._borderColor.toRgb(); this._borderColorNeutral = this._borderColor; @@ -253,6 +265,10 @@ export class PlayerView { return this._structureColors; } + railColor(): Colord { + return this._railColor; + } + /** * Border color for a tile: * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). diff --git a/tests/GraphicsOverrides.test.ts b/tests/GraphicsOverrides.test.ts index 570033f5c..a92ea9b0a 100644 --- a/tests/GraphicsOverrides.test.ts +++ b/tests/GraphicsOverrides.test.ts @@ -54,6 +54,18 @@ describe("GraphicsOverridesSchema", () => { } }); + test("accepts partial railroad overrides", () => { + const cases = [ + { railroad: {} }, + { railroad: { railMinZoom: 2 } }, + { railroad: { railThickness: 1.5 } }, + { railroad: { railMinZoom: 0, railThickness: 3 } }, + ]; + for (const c of cases) { + expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true); + } + }); + test("rejects wrong field types", () => { expect( GraphicsOverridesSchema.safeParse({ name: { nameScaleFactor: "big" } }) @@ -72,6 +84,16 @@ describe("GraphicsOverridesSchema", () => { mapOverlay: { territorySaturation: "full" }, }).success, ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + railroad: { railMinZoom: "far" }, + }).success, + ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + railroad: { railThickness: "wide" }, + }).success, + ).toBe(false); }); }); @@ -214,6 +236,31 @@ describe("applyGraphicsOverrides", () => { expect(mo.territoryDefenseDarken).toBe(defaults.territoryDefenseDarken); }); + test("applies railMinZoom override (including 0)", () => { + expect(gen({ railroad: { railMinZoom: 7 } }).railroad.railMinZoom).toBe(7); + expect(gen({ railroad: { railMinZoom: 0 } }).railroad.railMinZoom).toBe(0); + }); + + test("applies railThickness override (including values below 1)", () => { + expect( + gen({ railroad: { railThickness: 2.5 } }).railroad.railThickness, + ).toBe(2.5); + expect( + gen({ railroad: { railThickness: 0.5 } }).railroad.railThickness, + ).toBe(0.5); + }); + + test("railroad override leaves other railroad fields at defaults", () => { + const defaults = createRenderSettings().railroad; + const r = gen({ railroad: { railThickness: 2 } }).railroad; + expect(r.railMinZoom).toBe(defaults.railMinZoom); + expect(r.railFadeRange).toBe(defaults.railFadeRange); + expect(r.railDetailZoom).toBe(defaults.railDetailZoom); + expect(r.railAlpha).toBe(defaults.railAlpha); + const z = gen({ railroad: { railMinZoom: 1 } }).railroad; + expect(z.railThickness).toBe(defaults.railThickness); + }); + test("classicIcons + name overrides compose independently", () => { const s = gen({ name: { darkNames: true, nameScaleFactor: 0.9 },