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/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; +} 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",