From 2b08b4e0bbb3cf27a9367af0df05db316b87596e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:55:14 +0000 Subject: [PATCH 1/4] Draw gravel-lane crossings as gravel, not asphalt slabs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- changes/gravel-junctions.md | 3 ++ packages/shell/src/scene/CityRenderer.tsx | 5 ++- .../src/scene/cityJunctionSurface.test.ts | 24 +++++++++++++ .../shell/src/scene/cityJunctionSurface.ts | 36 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 changes/gravel-junctions.md create mode 100644 packages/shell/src/scene/cityJunctionSurface.test.ts create mode 100644 packages/shell/src/scene/cityJunctionSurface.ts diff --git a/changes/gravel-junctions.md b/changes/gravel-junctions.md new file mode 100644 index 000000000..0967eb06a --- /dev/null +++ b/changes/gravel-junctions.md @@ -0,0 +1,3 @@ +### Fixed + +- City districts (`@jgengine/shell` `CityRenderer`) draw a crossing of gravel lanes as gravel instead of a dark asphalt slab; a crossing that touches any paved street stays asphalt. diff --git a/packages/shell/src/scene/CityRenderer.tsx b/packages/shell/src/scene/CityRenderer.tsx index 9e933be49..2e78cd548 100644 --- a/packages/shell/src/scene/CityRenderer.tsx +++ b/packages/shell/src/scene/CityRenderer.tsx @@ -56,6 +56,7 @@ import { InstancedBuildings, type InstancedBuildingPlacement } from "../structur import { getBuildingKit } from "../structures/buildingKitRegistry"; import { createCityWindowMaterial } from "./cityWindowMaterial"; import { registerSceneKindRenderer, type SceneKindRenderContext } from "./sceneKindRenderers"; +import { junctionSurface } from "./cityJunctionSurface"; const ASPHALT_COLOR = "#3a3b3d"; const GRAVEL_COLOR = "#93815f"; @@ -825,7 +826,9 @@ function OneCity({ object, context }: { object: SceneKindObject; context: SceneK const crossIndex = trimmed.junctionIndices[j]!; const scuff = (hashString(`cross:${crossIndex}`) % 512) / 512; wearColor.setRGB(0.76 + scuff * 0.18, 0.77 + scuff * 0.17, 0.8 + scuff * 0.16); - patches.addMesh(trimmed.junctions[j]!, wearColor); + const cross = resolved.intersections[crossIndex]; + if (cross !== undefined && junctionSurface(cross, resolved.streets) === "gravel") gravel.addMesh(trimmed.junctions[j]!); + else patches.addMesh(trimmed.junctions[j]!, wearColor); } for (const paint of buildIntersectionMarkings(trimmed, deckSampler, { mouthClearance: 1.25, diff --git a/packages/shell/src/scene/cityJunctionSurface.test.ts b/packages/shell/src/scene/cityJunctionSurface.test.ts new file mode 100644 index 000000000..6705a19a3 --- /dev/null +++ b/packages/shell/src/scene/cityJunctionSurface.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { junctionSurface } from "./cityJunctionSurface"; + +const street = (surface: "asphalt" | "gravel", points: [number, number][]) => ({ surface, width: 6, points }); +const cross = { x: 0, z: 0, radius: 4 }; + +describe("junctionSurface", () => { + test("a crossing of gravel lanes stays gravel", () => { + expect(junctionSurface(cross, [street("gravel", [[-20, 0], [20, 0]]), street("gravel", [[0, -20], [0, 20]])])).toBe("gravel"); + }); + + test("a gravel lane meeting a paved main is asphalt", () => { + expect(junctionSurface(cross, [street("asphalt", [[-20, 0], [20, 0]]), street("gravel", [[0, 0], [0, 20]])])).toBe("asphalt"); + }); + + test("distant paved streets do not pave a gravel crossing", () => { + const far = street("asphalt", [[-20, 40], [20, 40]]); + expect(junctionSurface(cross, [street("gravel", [[-20, 0], [20, 0]]), far])).toBe("gravel"); + }); + + test("a crossing no street reaches falls back to asphalt", () => { + expect(junctionSurface(cross, [street("gravel", [[-20, 50], [20, 50]])])).toBe("asphalt"); + }); +}); diff --git a/packages/shell/src/scene/cityJunctionSurface.ts b/packages/shell/src/scene/cityJunctionSurface.ts new file mode 100644 index 000000000..444ccb6ca --- /dev/null +++ b/packages/shell/src/scene/cityJunctionSurface.ts @@ -0,0 +1,36 @@ +import type { CityIntersection, CityStreet } from "@jgengine/core/world/cityKind"; + +/** + * Surface for a crossing's welded patch: gravel only when every street running through the crossing + * is gravel, so an unpaved hamlet's junctions stay gravel while a lane meeting a paved main stays asphalt. + * @internal + */ +export function junctionSurface( + cross: Pick, + streets: readonly Pick[], +): CityStreet["surface"] { + let touched = false; + for (const street of streets) { + const reach = cross.radius + street.width * 0.5 + 0.5; + if (distanceToPath(cross.x, cross.z, street.points) > reach) continue; + if (street.surface !== "gravel") return "asphalt"; + touched = true; + } + return touched ? "gravel" : "asphalt"; +} + +function distanceToPath(x: number, z: number, points: CityStreet["points"]): number { + if (points.length === 0) return Infinity; + if (points.length === 1) return Math.hypot(x - points[0]![0], z - points[0]![1]); + let best = Infinity; + for (let i = 1; i < points.length; i += 1) { + const [ax, az] = points[i - 1]!; + const [bx, bz] = points[i]!; + const dx = bx - ax; + const dz = bz - az; + const len2 = dx * dx + dz * dz; + const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((x - ax) * dx + (z - az) * dz) / len2)); + best = Math.min(best, Math.hypot(x - (ax + dx * t), z - (az + dz * t))); + } + return best; +} From 55676f274b20c2f55b84bbe07a5bac9c43e21c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:18:47 +0000 Subject: [PATCH 2/4] Give the settings boundary browser test a 20 s default timeout [skip changelog] The first test loads the full shell bundle in real Chromium; on a loaded CI runner it passes Bun's 5 s default and the remaining tests cascade. It failed this way on #1995, #1996, #1998 and #1999 while passing on beta and #1997. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- packages/shell/src/settingsBoundary.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shell/src/settingsBoundary.test.ts b/packages/shell/src/settingsBoundary.test.ts index 351e95bd9..dda88ac95 100644 --- a/packages/shell/src/settingsBoundary.test.ts +++ b/packages/shell/src/settingsBoundary.test.ts @@ -1,10 +1,14 @@ -import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { mkdir, rm, symlink } from "node:fs/promises"; import { resolve } from "node:path"; import { chromium, type Browser, type Page } from "playwright-core"; import { buildBrowserFixture, findChromeExecutable } from "../../../scripts/browser-lib"; +// Real Chromium: the first page load compiles the whole shell bundle, which overruns Bun's 5 s +// default on a loaded CI runner and then cascades into every later test in the file. +setDefaultTimeout(20_000); + const scratch = resolve(import.meta.dir, "../../../.scratch/settings-boundary-test"); let browser: Browser, page: Page, server: ReturnType; beforeAll(async () => { From dc3eab894d892dd2fe3886206e1ad398517425f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:21:10 +0000 Subject: [PATCH 3/4] Regenerate export manifest for cityJunctionSurface Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- scripts/export-manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index bead87ce2..689e17e50 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -922,6 +922,7 @@ "./scene/StudioStage", "./scene/WaterRenderer", "./scene/builtinSceneKindRenderers", + "./scene/cityJunctionSurface", "./scene/cityWindowMaterial", "./scene/sceneKindRenderers", "./settings/QuickControls", From a9704f0a157a064a461be86e1a5c10b2d5020744 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:25:20 +0000 Subject: [PATCH 4/4] Run CI tests with a 20 s default timeout instead of patching one file [skip changelog] After the settings boundary test got 20 s, the same runner timed out hudInputBoundary and the Rapier slope test instead: whichever cold-start browser/WASM suite runs first under load passes Bun's 5 s default. All pass locally. Set the default for the CI test command; tests with their own timeout keep it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RwsemkhoUGyeQkdkKFmUmw --- .github/workflows/ci.yml | 4 +++- packages/shell/src/settingsBoundary.test.ts | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e2cf6000..49c7817d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,9 @@ jobs: - run: bun run games:clone env: GAMES_CLONE_TOKEN: ${{ secrets.GAMES_CLONE_TOKEN }} - - run: bun test packages apps/dev scripts + # Browser and physics suites cold-start Chromium/WASM; on a loaded runner the first test in a + # file can pass Bun's 5 s default and cascade through the file. Tests with their own timeout keep it. + - run: bun test --timeout 20000 packages apps/dev scripts web-build: runs-on: ubuntu-latest diff --git a/packages/shell/src/settingsBoundary.test.ts b/packages/shell/src/settingsBoundary.test.ts index dda88ac95..351e95bd9 100644 --- a/packages/shell/src/settingsBoundary.test.ts +++ b/packages/shell/src/settingsBoundary.test.ts @@ -1,14 +1,10 @@ -import { afterAll, afterEach, beforeAll, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { mkdir, rm, symlink } from "node:fs/promises"; import { resolve } from "node:path"; import { chromium, type Browser, type Page } from "playwright-core"; import { buildBrowserFixture, findChromeExecutable } from "../../../scripts/browser-lib"; -// Real Chromium: the first page load compiles the whole shell bundle, which overruns Bun's 5 s -// default on a loaded CI runner and then cascades into every later test in the file. -setDefaultTimeout(20_000); - const scratch = resolve(import.meta.dir, "../../../.scratch/settings-boundary-test"); let browser: Browser, page: Page, server: ReturnType; beforeAll(async () => {