@jgengine/shell 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/GamePlayerShell.js +18 -3
  3. package/dist/GameUiPreview.js +2 -1
  4. package/dist/Shell3dPresentation.js +13 -8
  5. package/dist/ShellChrome.d.ts +1 -1
  6. package/dist/ShellChrome.js +4 -2
  7. package/dist/ShellHudPresentation.js +2 -1
  8. package/dist/ShellNotices.d.ts +26 -0
  9. package/dist/ShellNotices.js +51 -0
  10. package/dist/audio/AudioComponents.d.ts +3 -1
  11. package/dist/audio/AudioComponents.js +5 -2
  12. package/dist/audio/listenerAnchor.d.ts +15 -0
  13. package/dist/audio/listenerAnchor.js +17 -0
  14. package/dist/audio/playableAudio.d.ts +9 -0
  15. package/dist/audio/playableAudio.js +11 -0
  16. package/dist/commandSink.d.ts +1 -1
  17. package/dist/commandSink.js +8 -3
  18. package/dist/defineGame.js +3 -1
  19. package/dist/devtools/DevtoolsOverlay.js +1 -0
  20. package/dist/devtools/agentBridge.d.ts +21 -0
  21. package/dist/devtools/agentBridge.js +95 -3
  22. package/dist/devtools/devtoolsReports.d.ts +32 -0
  23. package/dist/devtools/devtoolsReports.js +26 -0
  24. package/dist/devtools/simStepper.d.ts +21 -0
  25. package/dist/devtools/simStepper.js +37 -0
  26. package/dist/drivers/FrameDriver.js +26 -14
  27. package/dist/drivers/HudOnlyDriver.js +16 -5
  28. package/dist/environment/EnvironmentScene.js +19 -7
  29. package/dist/environment/RoadJunctions.d.ts +3 -1
  30. package/dist/environment/RoadJunctions.js +9 -2
  31. package/dist/environment/RoadRibbons.d.ts +3 -1
  32. package/dist/environment/RoadRibbons.js +16 -3
  33. package/dist/environment/terrainGroundColor.js +3 -3
  34. package/dist/materialOverride.js +4 -2
  35. package/dist/playSurfaceFocus.d.ts +16 -0
  36. package/dist/playSurfaceFocus.js +42 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -1
  38. package/dist/postfx/PostProcessing.js +14 -6
  39. package/dist/registry.d.ts +5 -1
  40. package/dist/render/CascadedShadows.d.ts +2 -0
  41. package/dist/render/CascadedShadows.js +4 -0
  42. package/dist/render/EntityPreview.js +1 -1
  43. package/dist/render/EnvironmentLighting.d.ts +26 -7
  44. package/dist/render/EnvironmentLighting.js +40 -27
  45. package/dist/render/SceneLighting.js +2 -2
  46. package/dist/render/modelLoad.d.ts +7 -0
  47. package/dist/render/modelLoad.js +13 -0
  48. package/dist/render/modelRender.d.ts +1 -0
  49. package/dist/render/modelRender.js +3 -0
  50. package/dist/render/modelSurface.d.ts +39 -0
  51. package/dist/render/modelSurface.js +206 -0
  52. package/dist/render/useModelInstance.d.ts +1 -1
  53. package/dist/render/useModelInstance.js +2 -2
  54. package/dist/scatter/scatterModels.js +3 -1
  55. package/dist/scene/AuthoredScene.d.ts +11 -3
  56. package/dist/scene/AuthoredScene.js +17 -9
  57. package/dist/scene/CityRenderer.js +142 -25
  58. package/dist/scene/cityJunctionSurface.d.ts +7 -0
  59. package/dist/scene/cityJunctionSurface.js +34 -0
  60. package/dist/settings/settingsController.js +1 -1
  61. package/dist/terrain/groundEdgeFade.d.ts +67 -0
  62. package/dist/terrain/groundEdgeFade.js +234 -0
  63. package/dist/terrain/index.d.ts +5 -0
  64. package/dist/terrain/index.js +5 -0
  65. package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
  66. package/dist/terrain/surfaceRibbonMaterial.js +227 -0
  67. package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
  68. package/dist/terrain/terrainDetailMaterial.js +149 -63
  69. package/dist/terrain/terrainPaintColor.d.ts +12 -3
  70. package/dist/terrain/terrainPaintColor.js +46 -1
  71. package/dist/terrain/terrainQuality.d.ts +5 -0
  72. package/dist/terrain/terrainQuality.js +7 -0
  73. package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
  74. package/dist/terrain/terrainSurfaceShader.js +266 -0
  75. package/dist/terrain/terrainSurfaces.d.ts +59 -0
  76. package/dist/terrain/terrainSurfaces.js +199 -0
  77. package/dist/world/StaticObjectModels.d.ts +1 -1
  78. package/dist/world/StaticObjectModels.js +1 -1
  79. package/dist/world/WorldScene.js +1 -1
  80. package/dist/world/staticObjectBatches.d.ts +5 -1
  81. package/dist/world/staticObjectBatches.js +9 -5
  82. package/dist/world/worldBarSamples.d.ts +13 -1
  83. package/dist/world/worldBarSamples.js +40 -8
  84. package/package.json +5 -5
  85. package/skills/jgengine/api.md +18 -18
  86. package/skills/jgengine-ui/SKILL.md +4 -0
  87. package/skills/jgengine-ui/api.md +254 -37
  88. package/skills/jgengine-ui/capabilities.md +58 -0
  89. package/skills/jgengine-ui/reference-primitives.md +4 -4
  90. package/skills/jgengine-world/api.md +27 -14
  91. package/skills/jgengine-world/capabilities.md +4 -0
  92. package/skills/jgengine-world/reference.md +27 -8
@@ -11,10 +11,15 @@ export function localCommandSink(ctx) {
11
11
  /** Sends commands to the authoritative host over the transport; the result replicates back through world sync.
12
12
  * @internal
13
13
  */
14
- export function remoteCommandSink(backend, serverId) {
14
+ export function remoteCommandSink(backend, serverId, events) {
15
15
  return {
16
16
  run(name, input) {
17
- void backend.transport.runCommand({ serverId, command: name, input }).catch(() => undefined);
17
+ void backend.transport.runCommand({ serverId, command: name, input }).then((result) => {
18
+ // Host refusals reach the local notice feed the same way local ones do.
19
+ if (!result.ok && result.message !== undefined) {
20
+ events?.emit("command.refused", { command: name, reason: result.reason, message: result.message, actor: null });
21
+ }
22
+ }).catch(() => undefined);
18
23
  },
19
24
  };
20
25
  }
@@ -25,7 +30,7 @@ export function remoteCommandSink(backend, serverId) {
25
30
  */
26
31
  export function resolveCommandSink(ctx, opts) {
27
32
  if (opts.serverAuthoritative && opts.backend !== null && opts.serverId !== null) {
28
- return remoteCommandSink(opts.backend, opts.serverId);
33
+ return remoteCommandSink(opts.backend, opts.serverId, ctx.game.events);
29
34
  }
30
35
  if (opts.serverAuthoritative)
31
36
  return { run() { } };
@@ -55,7 +55,7 @@ const emptyUi = () => null;
55
55
  * @capability define-game single public game-authoring path — compose systems, world, presentation, and authored scene in one definition
56
56
  */
57
57
  export function defineGame(config) {
58
- const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, sceneFlockModels, scenePathKinds, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, gamepad, localPlayers, viewports, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, settings, objectStyles, devtools, capture, platforms, hudFit, ...engineFields } = config;
58
+ const { content, loop, GameUI, environment, camera, multiplayer, editorLayers, editorCatalogs, scenePlacement, sceneScatterModels, sceneFlockModels, scenePathKinds, WorldOverlay, viewmodel, renderEntity, renderObject, entitySprites, entityModels, objectModels, hotbarSelection, prompts, pointer, touch, gamepad, localPlayers, viewports, orientation, worldHealthBars, nameplates, presentationEffects, audio, entitySounds, objectSounds, worldItem, collision, movement, lighting: authoredLighting, environmentSource, backdrop, look, postProcessing, shadows, graphics, presentation, settings, notices, objectStyles, devtools, capture, platforms, hudFit, hudTheme, ...engineFields } = config;
59
59
  const game = defineEngineGame({
60
60
  ...engineFields,
61
61
  ...(editorLayers === undefined ? {} : { authoredDocument: editorLayers }),
@@ -133,10 +133,12 @@ export function defineGame(config) {
133
133
  graphics,
134
134
  presentation,
135
135
  settings,
136
+ notices,
136
137
  objectStyles,
137
138
  devtools,
138
139
  capture,
139
140
  platforms,
140
141
  hudFit,
142
+ hudTheme,
141
143
  };
142
144
  }
@@ -20,6 +20,7 @@ export function DevtoolsRendererProbe() {
20
20
  useEffect(() => devtools.probes.register("heavyModels", () => heavyModels()), []);
21
21
  const gl = useThree((state) => state.gl);
22
22
  const lastSampleAt = useRef(0);
23
+ useEffect(() => devtools.probes.register("shaderPrograms", () => gl.info.programs?.length ?? 0), [gl]);
23
24
  useEffect(() => {
24
25
  // three resets gl.info after every render() call, so with a multi-pass
25
26
  // composer the counters only ever hold the last fullscreen pass. Own the
@@ -1,3 +1,4 @@
1
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
1
2
  import type { PlayableGame } from "../registry.js";
2
3
  /** One RPC call into the agent bridge: a verb name plus its verb-specific fields. */
3
4
  export type AgentBridgeRequest = {
@@ -11,6 +12,24 @@ export type AgentBridgeResponse = {
11
12
  ok: false;
12
13
  error: string;
13
14
  };
15
+ /**
16
+ * @internal
17
+ * The local player's live pose for `player_pose` / `debug_snapshot`, so a drive can measure movement
18
+ * (position deltas between two RPCs) instead of judging it from screenshots. `null` before the game
19
+ * context exists or before the player entity spawns.
20
+ */
21
+ export declare function playerPoseReport(ctx: GameContext | null): {
22
+ userId: string;
23
+ position: readonly [number, number, number];
24
+ rotationY: number;
25
+ } | null;
26
+ /**
27
+ * @internal
28
+ * `time_scale` verb: sets the game clock's global timescale (`ctx.time.setTimescale`) so a headless
29
+ * drive can fast-forward timers, economies and day cycles that never finish at capture frame rates.
30
+ * Large scales take large steps — use it for clocks, not to judge movement. Omit `scale` to read.
31
+ */
32
+ export declare function applyTimeScale(ctx: Pick<GameContext, "time">, scale: unknown): AgentBridgeResponse;
14
33
  type EditorHost = {
15
34
  gameId: string;
16
35
  handle(request: AgentBridgeRequest): unknown;
@@ -43,5 +62,7 @@ export declare function installAgentBridge(options: {
43
62
  devtoolsEnabled: boolean;
44
63
  isDevtoolsOpen: () => boolean;
45
64
  setDevtoolsOpen: (open: boolean) => void;
65
+ /** Live game context, once mounted; feeds `player_pose`. */
66
+ getContext?: () => GameContext | null;
46
67
  }): () => void;
47
68
  export {};
@@ -2,7 +2,39 @@ import { devtools } from "@jgengine/core/devtools/devtools";
2
2
  import { getSaveEndpoint } from "@jgengine/core/devtools/saveEndpoint";
3
3
  import { isHudAnchor, listActiveHudLayouts, } from "@jgengine/core/ui/hudLayout";
4
4
  import { listHudPanelTypes, resolvePanelResize, resizePanelSize, } from "@jgengine/core/ui/hudDocument";
5
- import { buildFullReport, buildLeanReport } from "./devtoolsReports.js";
5
+ import { buildFullReport, buildLeanReport, summarizeLeanReport } from "./devtoolsReports.js";
6
+ import { advanceSimulation } from "./simStepper.js";
7
+ /**
8
+ * @internal
9
+ * The local player's live pose for `player_pose` / `debug_snapshot`, so a drive can measure movement
10
+ * (position deltas between two RPCs) instead of judging it from screenshots. `null` before the game
11
+ * context exists or before the player entity spawns.
12
+ */
13
+ export function playerPoseReport(ctx) {
14
+ if (ctx === null)
15
+ return null;
16
+ const entity = ctx.scene.entity.get(ctx.player.userId);
17
+ if (entity === null || entity === undefined)
18
+ return null;
19
+ const [x, y, z] = entity.position;
20
+ const round = (value) => Math.round(value * 1000) / 1000;
21
+ return { userId: ctx.player.userId, position: [round(x), round(y), round(z)], rotationY: round(entity.rotationY ?? 0) };
22
+ }
23
+ /**
24
+ * @internal
25
+ * `time_scale` verb: sets the game clock's global timescale (`ctx.time.setTimescale`) so a headless
26
+ * drive can fast-forward timers, economies and day cycles that never finish at capture frame rates.
27
+ * Large scales take large steps — use it for clocks, not to judge movement. Omit `scale` to read.
28
+ */
29
+ export function applyTimeScale(ctx, scale) {
30
+ if (scale !== undefined) {
31
+ if (typeof scale !== "number" || !Number.isFinite(scale) || scale < 0 || scale > 120) {
32
+ return { ok: false, error: "time_scale needs { scale } between 0 and 120 (1 = normal)" };
33
+ }
34
+ ctx.time.setTimescale(scale);
35
+ }
36
+ return { ok: true, timescale: ctx.time.timescale(), now: Math.round(ctx.time.now() * 1000) / 1000 };
37
+ }
6
38
  /**
7
39
  * @internal
8
40
  * Resolves true once `isLive()` reports a mounted editor host, polling every `intervalMs` up to
@@ -91,6 +123,7 @@ function panelLayoutSnapshot(placement, extras) {
91
123
  */
92
124
  export function installAgentBridge(options) {
93
125
  const { playable, devtoolsEnabled, isDevtoolsOpen, setDevtoolsOpen } = options;
126
+ const getContext = options.getContext ?? (() => null);
94
127
  const handle = (request) => {
95
128
  const method = typeof request?.method === "string" ? request.method : "";
96
129
  switch (method) {
@@ -115,8 +148,67 @@ export function installAgentBridge(options) {
115
148
  setDevtoolsOpen(open);
116
149
  return { ok: true, open };
117
150
  }
118
- case "debug_snapshot":
119
- return { ok: true, snapshot: buildLeanReport(playable) };
151
+ case "debug_summary":
152
+ case "debug_snapshot": {
153
+ const ctx = getContext();
154
+ const lean = buildLeanReport(playable);
155
+ if (method === "debug_summary" || request.detail === "summary") {
156
+ const entities = ctx === null ? null : ctx.scene.entity.ids().length;
157
+ return { ok: true, summary: { ...summarizeLeanReport(lean, entities), player: playerPoseReport(ctx) } };
158
+ }
159
+ return { ok: true, snapshot: { ...lean, player: playerPoseReport(ctx) } };
160
+ }
161
+ case "player_pose": {
162
+ const pose = playerPoseReport(getContext());
163
+ return pose === null ? { ok: false, error: "no local player entity yet" } : { ok: true, ...pose };
164
+ }
165
+ case "time_scale": {
166
+ const ctx = getContext();
167
+ if (ctx === null)
168
+ return { ok: false, error: "no game context yet" };
169
+ return applyTimeScale(ctx, request.scale);
170
+ }
171
+ case "sim_advance": {
172
+ const seconds = request.seconds;
173
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0 || seconds > 120) {
174
+ return { ok: false, error: "sim_advance needs { seconds } in (0, 120]" };
175
+ }
176
+ return advanceSimulation(seconds).then((frames) => {
177
+ if (frames === null)
178
+ return { ok: false, error: "no sim driver mounted" };
179
+ const ctx = getContext();
180
+ return { ok: true, frames, now: ctx === null ? null : Math.round(ctx.time.now() * 1000) / 1000 };
181
+ });
182
+ }
183
+ case "notice_push": {
184
+ const ctx = getContext();
185
+ if (ctx === null)
186
+ return { ok: false, error: "no game context yet" };
187
+ const tone = request.tone;
188
+ if (typeof request.text !== "string" || (tone !== undefined && tone !== "info" && tone !== "success" && tone !== "warn" && tone !== "deny")) {
189
+ return { ok: false, error: "notice_push needs { text, tone?: info|success|warn|deny, ttlMs? }" };
190
+ }
191
+ const ttlMs = typeof request.ttlMs === "number" && request.ttlMs > 0 ? request.ttlMs : undefined;
192
+ const notice = ctx.game.notices.push({ text: request.text, ...(tone === undefined ? {} : { tone }), ...(ttlMs === undefined ? {} : { ttlMs }) });
193
+ return { ok: true, id: notice.id, count: notice.count };
194
+ }
195
+ case "notice_list": {
196
+ const ctx = getContext();
197
+ if (ctx === null)
198
+ return { ok: false, error: "no game context yet" };
199
+ return { ok: true, notices: ctx.game.notices.list(), rendered: document.querySelectorAll("[data-jg-notice]").length, now: Date.now() };
200
+ }
201
+ case "run_command": {
202
+ const ctx = getContext();
203
+ if (ctx === null)
204
+ return { ok: false, error: "no game context yet" };
205
+ if (typeof request.name !== "string")
206
+ return { ok: false, error: "run_command needs { name, input? }" };
207
+ const result = ctx.game.commands.run(request.name, request.input ?? {});
208
+ return result.status === "rejected"
209
+ ? { ok: true, status: result.status, reason: result.reason, ...(result.message === undefined ? {} : { message: result.message }) }
210
+ : { ok: true, status: result.status };
211
+ }
120
212
  case "debug_report":
121
213
  return { ok: true, report: buildFullReport(playable) };
122
214
  case "debug_perf_reset":
@@ -66,3 +66,35 @@ export declare function buildLeanReport(playable: PlayableGame): {
66
66
  export declare function buildFullReport(playable: PlayableGame): DevtoolsSnapshot & {
67
67
  game: string;
68
68
  };
69
+ /** Compact `debug_summary` payload: the numbers an agent needs to judge a run, about 1 KB. */
70
+ export interface DebugSummary {
71
+ game: string;
72
+ fps: number | null;
73
+ frameMs: {
74
+ avg: number;
75
+ p95: number;
76
+ } | null;
77
+ simMs: number | null;
78
+ outsideSimMs: number | null;
79
+ drawCalls: number | null;
80
+ triangles: number | null;
81
+ entities: number | null;
82
+ longFrames: number;
83
+ topCulprit: {
84
+ culprit: string;
85
+ count: number;
86
+ } | null;
87
+ topPhase: {
88
+ name: string;
89
+ avgMs: number;
90
+ } | null;
91
+ warnings: number;
92
+ lastWarnings: string[];
93
+ why: string | null;
94
+ }
95
+ /**
96
+ * Reduce a lean report to {@link DebugSummary}: no per-frame probe arrays, no control lists, at most
97
+ * three warning lines of 160 characters. `debug_snapshot` with `detail: "full"` keeps everything.
98
+ * @internal
99
+ */
100
+ export declare function summarizeLeanReport(lean: ReturnType<typeof buildLeanReport>, entities: number | null): DebugSummary;
@@ -98,3 +98,29 @@ export function buildLeanReport(playable) {
98
98
  export function buildFullReport(playable) {
99
99
  return { game: playable.game.name, ...devtools.snapshot() };
100
100
  }
101
+ /**
102
+ * Reduce a lean report to {@link DebugSummary}: no per-frame probe arrays, no control lists, at most
103
+ * three warning lines of 160 characters. `debug_snapshot` with `detail: "full"` keeps everything.
104
+ * @internal
105
+ */
106
+ export function summarizeLeanReport(lean, entities) {
107
+ const frame = lean.frame;
108
+ const logs = lean.logs ?? [];
109
+ const phase = frame?.phases[0];
110
+ return {
111
+ game: lean.game,
112
+ fps: frame?.fps ?? null,
113
+ frameMs: frame === null ? null : { avg: frame.avgFrameMs, p95: frame.p95FrameMs },
114
+ simMs: frame?.avgSimMs ?? null,
115
+ outsideSimMs: frame?.avgOutsideMs ?? null,
116
+ drawCalls: lean.render?.drawCalls ?? null,
117
+ triangles: lean.render?.triangles ?? null,
118
+ entities,
119
+ longFrames: lean.longFrameSummary?.count ?? 0,
120
+ topCulprit: lean.longFrameSummary?.culprits[0] ?? null,
121
+ topPhase: phase === undefined ? null : { name: phase.name, avgMs: phase.avgMs },
122
+ warnings: logs.length,
123
+ lastWarnings: logs.slice(-3).map((entry) => `${entry.level}: ${entry.message}`.slice(0, 160)),
124
+ why: lean.why === null ? null : lean.why.split(/(?<=[.:])\s/)[0].slice(0, 200),
125
+ };
126
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The mounted sim driver's per-frame step, registered so the agent bridge can fast-forward the
3
+ * simulation without rendering (`sim_advance`). Headless software rendering runs at well under
4
+ * 1 fps, so timers, waves and economies otherwise never complete inside a drive capture.
5
+ * @internal
6
+ */
7
+ export type SimStep = (rawDt: number) => void;
8
+ /** Register the active driver's step; returns the unregister. Last registration wins. @internal */
9
+ export declare function registerSimStepper(step: SimStep): () => void;
10
+ /**
11
+ * Step the registered simulation `seconds` of real time in `step`-second frames (each frame runs
12
+ * the full driver step: input, movement, `onTick`, behaviors, bound actions). Frames run in
13
+ * batches of {@link FRAMES_PER_YIELD} with a macrotask between batches, so React commits what the
14
+ * sim spawned along the way — models for entities created mid-advance mount as they would in play,
15
+ * instead of one burst the renderer never catches up with. Capped at 2400 frames per call.
16
+ * Resolves to the frames run, or `null` when no driver is mounted.
17
+ * @internal
18
+ */
19
+ export declare function advanceSimulation(seconds: number, step?: number, yieldToHost?: () => Promise<void>): Promise<number | null>;
20
+ /** Simulated frames between yields to the host (ten game seconds at 0.05 s frames): each yield can cost a full render frame, which headless software rendering spends seconds on. @internal */
21
+ export declare const FRAMES_PER_YIELD = 200;
@@ -0,0 +1,37 @@
1
+ let current = null;
2
+ /** Register the active driver's step; returns the unregister. Last registration wins. @internal */
3
+ export function registerSimStepper(step) {
4
+ current = step;
5
+ return () => {
6
+ if (current === step)
7
+ current = null;
8
+ };
9
+ }
10
+ /**
11
+ * Step the registered simulation `seconds` of real time in `step`-second frames (each frame runs
12
+ * the full driver step: input, movement, `onTick`, behaviors, bound actions). Frames run in
13
+ * batches of {@link FRAMES_PER_YIELD} with a macrotask between batches, so React commits what the
14
+ * sim spawned along the way — models for entities created mid-advance mount as they would in play,
15
+ * instead of one burst the renderer never catches up with. Capped at 2400 frames per call.
16
+ * Resolves to the frames run, or `null` when no driver is mounted.
17
+ * @internal
18
+ */
19
+ export async function advanceSimulation(seconds, step = 0.05, yieldToHost = defaultYield) {
20
+ if (current === null)
21
+ return null;
22
+ const frame = Math.min(0.05, Math.max(0.001, step));
23
+ const frames = Math.min(2400, Math.max(0, Math.ceil(seconds / frame)));
24
+ for (let index = 0; index < frames; index += 1) {
25
+ if (current === null)
26
+ return index;
27
+ current(frame);
28
+ if ((index + 1) % FRAMES_PER_YIELD === 0 && index + 1 < frames)
29
+ await yieldToHost();
30
+ }
31
+ return frames;
32
+ }
33
+ /** Simulated frames between yields to the host (ten game seconds at 0.05 s frames): each yield can cost a full render frame, which headless software rendering spends seconds on. @internal */
34
+ export const FRAMES_PER_YIELD = 200;
35
+ function defaultYield() {
36
+ return new Promise((resolve) => setTimeout(resolve, 0));
37
+ }
@@ -1,5 +1,5 @@
1
1
  import { useFrame } from "@react-three/fiber";
2
- import { useMemo, useRef } from "react";
2
+ import { useEffect, useMemo, useRef } from "react";
3
3
  import { RESERVED_INPUT_ACTIONS, dispatchBoundAction, heldActionsFor, shouldFireBoundAction } from "../boundActionDispatch.js";
4
4
  import { executeHotbarSlot, findHotbarSlotActions, hotbarIdFor } from "../hotbarActions.js";
5
5
  import { pointerAimFor } from "../shellPointer.js";
@@ -17,6 +17,7 @@ import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
17
17
  import { playControlsActive } from "@jgengine/core/game/controlGate";
18
18
  import { devtools } from "@jgengine/core/devtools/devtools";
19
19
  import { collisionDebug } from "../devtools/collisionDebug.js";
20
+ import { registerSimStepper } from "../devtools/simStepper.js";
20
21
  import { GAME_SIM_FRAME_PRIORITY } from "../camera/index.js";
21
22
  import { NO_ACTIONS } from "../shellConstants.js";
22
23
  import { createPredictionBuffer } from "@jgengine/core/runtime/prediction";
@@ -79,17 +80,10 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
79
80
  ctx.scene.entity.setPose(playerId, { position: current });
80
81
  }, [ctx]);
81
82
  authoritativeFrameRef.current = serverAuthoritative ? predictionHandler : null;
82
- useFrame((_state, rawDt) => {
83
- if (poster) {
84
- if (posterDoneRef.current)
85
- return;
86
- posterElapsedRef.current += Math.min(rawDt, 0.05);
87
- if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) {
88
- posterDoneRef.current = true;
89
- onPosterSettled();
90
- return;
91
- }
92
- }
83
+ // One frame of simulation — input, movement, onTick, behaviors, bound actions — shared by the
84
+ // render loop and the agent bridge's render-free `sim_advance`.
85
+ /** Returns false when play is gated (menu, pause) and nothing advanced. */
86
+ const simulate = (rawDt) => {
93
87
  const sendInput = () => {
94
88
  if (!serverAuthoritative)
95
89
  return;
@@ -109,9 +103,8 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
109
103
  ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true });
110
104
  ctx.input.publishAnalog(null);
111
105
  sendInput();
112
- return;
106
+ return false;
113
107
  }
114
- const simStart = performance.now();
115
108
  try {
116
109
  let endPhase = devtools.profile.begin("time+input");
117
110
  const dt = Math.min(rawDt, 0.05);
@@ -280,6 +273,25 @@ export function FrameDriver({ ctx, playable, tracker, yawRef, pitchRef, primaryC
280
273
  onRuntimeError(error, "tick");
281
274
  }
282
275
  }
276
+ return true;
277
+ };
278
+ const simulateRef = useRef(simulate);
279
+ simulateRef.current = simulate;
280
+ useEffect(() => (poster ? undefined : registerSimStepper((rawDt) => simulateRef.current(rawDt))), [poster]);
281
+ useFrame((_state, rawDt) => {
282
+ if (poster) {
283
+ if (posterDoneRef.current)
284
+ return;
285
+ posterElapsedRef.current += Math.min(rawDt, 0.05);
286
+ if (posterElapsedRef.current >= POSTER_SETTLE_SECONDS) {
287
+ posterDoneRef.current = true;
288
+ onPosterSettled();
289
+ return;
290
+ }
291
+ }
292
+ const simStart = performance.now();
293
+ if (!simulate(rawDt))
294
+ return;
283
295
  devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
284
296
  }, GAME_SIM_FRAME_PRIORITY);
285
297
  return null;
@@ -7,6 +7,7 @@ import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
7
7
  import { resolveCommandSink } from "../commandSink.js";
8
8
  import { inputFramesEqual, resolveInputSink } from "../inputSink.js";
9
9
  import { EMPTY_RESERVED, NO_ACTIONS } from "../shellConstants.js";
10
+ import { registerSimStepper } from "../devtools/simStepper.js";
10
11
  export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, }) {
11
12
  const hasReportedTickError = useRef(false);
12
13
  const repeatFiredAtRef = useRef(new Map());
@@ -22,6 +23,14 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
22
23
  lastFrameRef.current = now;
23
24
  if (last === null)
24
25
  return;
26
+ const rawDt = (now - last) / 1000;
27
+ const simStart = performance.now();
28
+ if (!simulate(rawDt))
29
+ return;
30
+ devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
31
+ };
32
+ /** One frame of simulation; false when play is gated and nothing advanced. Shared with `sim_advance`. */
33
+ const simulate = (rawDt) => {
25
34
  const sendInput = () => {
26
35
  if (!serverAuthoritative || serverIdRef.current === null)
27
36
  return;
@@ -35,10 +44,8 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
35
44
  ctx.input.publish(heldActionsFor(tracker, NO_ACTIONS), { reset: true });
36
45
  ctx.input.publishAnalog(null);
37
46
  sendInput();
38
- return;
47
+ return false;
39
48
  }
40
- const rawDt = (now - last) / 1000;
41
- const simStart = performance.now();
42
49
  try {
43
50
  let endPhase = devtools.profile.begin("time+input");
44
51
  const dt = Math.min(rawDt, 0.05);
@@ -74,10 +81,14 @@ export function HudOnlyDriver({ ctx, multiplayer, serverIdRef, playable, tracker
74
81
  onRuntimeError(error, "tick");
75
82
  }
76
83
  }
77
- devtools.frame.record({ frameMs: rawDt * 1000, simMs: performance.now() - simStart });
84
+ return true;
78
85
  };
79
86
  frameId = requestAnimationFrame(tick);
80
- return () => cancelAnimationFrame(frameId);
87
+ const unregister = registerSimStepper((rawDt) => { simulate(rawDt); });
88
+ return () => {
89
+ cancelAnimationFrame(frameId);
90
+ unregister();
91
+ };
81
92
  }, [ctx, playable, tracker, pointerAxisRef, gateRef, onRuntimeError, inputActions, multiplayer, serverIdRef, serverAuthoritative]);
82
93
  return null;
83
94
  }
@@ -20,6 +20,8 @@ import { InstancedBuildings } from "../structures/GeneratedBuilding.js";
20
20
  import { GrassField } from "../terrain/GrassField.js";
21
21
  import { CarvedTerrain } from "../terrain/CarvedTerrain.js";
22
22
  import { createTerrainDetailMaterial } from "../terrain/terrainDetailMaterial.js";
23
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
24
+ import { createTerrainPaintTextures, packTerrainPaintWeights } from "../terrain/terrainSurfaces.js";
23
25
  import { Ocean } from "../water/Ocean.js";
24
26
  import { RainField } from "../weather/RainField.js";
25
27
  import { SnowField } from "../weather/SnowField.js";
@@ -39,11 +41,20 @@ function TexturedTerrainGround({ detail, layout, }) {
39
41
  texture.needsUpdate = true;
40
42
  }
41
43
  }, [textures]);
42
- const material = useDisposable(() => createTerrainDetailMaterial(detail, textures).material, [detail, textures]);
43
- return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, paintColorAt: layout.paintColorAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
44
+ const quality = useTerrainQuality();
45
+ const material = useDisposable(() => createTerrainDetailMaterial(detail, textures, { paint: layout.paint, quality }).material, [detail, textures, layout.paint, quality]);
46
+ return (_jsxs(_Fragment, { children: [_jsx(PresentationRecoveredDraw, {}), _jsx(CarvedTerrain, { field: layout.field, size: layout.size, segments: layout.segments, colors: layout.colors, heightRange: layout.heightRange, paletteAt: layout.paletteAt, center: layout.center, roughness: 0.94, surfaceMaterial: material })] }));
44
47
  }
45
48
  function TerrainGround({ terrain, field, center, sculpt, }) {
46
- const paintColorAt = useMemo(() => sculpt === undefined ? undefined : createTerrainPaintColorSampler(editableTerrainFromSnapshot(sculpt)), [sculpt]);
49
+ const detailEnabled = terrain.detail !== false;
50
+ const paint = useMemo(() => {
51
+ if (!detailEnabled || sculpt === undefined)
52
+ return undefined;
53
+ const weights = packTerrainPaintWeights(editableTerrainFromSnapshot(sculpt));
54
+ return weights === undefined ? undefined : createTerrainPaintTextures(weights);
55
+ }, [detailEnabled, sculpt]);
56
+ useEffect(() => () => paint?.dispose(), [paint]);
57
+ const paintColorAt = useMemo(() => detailEnabled || sculpt === undefined ? undefined : createTerrainPaintColorSampler(editableTerrainFromSnapshot(sculpt)), [detailEnabled, sculpt]);
47
58
  const palette = useMemo(() => resolveTerrainPalette(terrain), [terrain]);
48
59
  const paletteAt = useMemo(() => {
49
60
  const hasRegions = terrain.materialRegions !== undefined && terrain.materialRegions.length > 0;
@@ -66,13 +77,14 @@ function TerrainGround({ terrain, field, center, sculpt, }) {
66
77
  const swing = terrain.height * 1.2;
67
78
  return [base - swing, base + swing];
68
79
  }, [terrain.baseHeight, terrain.height]);
69
- const resolvedDetail = useMemo(() => (terrain.detail === undefined ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
80
+ const resolvedDetail = useMemo(() => (terrain.detail === false ? undefined : resolveTerrainDetail(terrain.detail, terrain.waterLevel)), [terrain.detail, terrain.waterLevel]);
81
+ const quality = useTerrainQuality();
70
82
  const proceduralMaterial = useMemo(() => resolvedDetail === undefined || resolvedDetail.material !== undefined
71
83
  ? undefined
72
- : createTerrainDetailMaterial(resolvedDetail).material, [resolvedDetail]);
84
+ : createTerrainDetailMaterial(resolvedDetail, undefined, { paint, quality }).material, [resolvedDetail, paint, quality]);
73
85
  useEffect(() => () => proceduralMaterial?.dispose(), [proceduralMaterial]);
74
86
  if (resolvedDetail?.material !== undefined) {
75
- return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paintColorAt, center } }) }));
87
+ return (_jsx(PresentationAssetBoundary, { children: _jsx(TexturedTerrainGround, { detail: resolvedDetail, layout: { field, size, segments: terrain.segments, colors, heightRange, paletteAt, paint, center } }) }));
76
88
  }
77
89
  return (_jsx(CarvedTerrain, { field: field, size: size, segments: terrain.segments, colors: colors, heightRange: heightRange, paletteAt: paletteAt, paintColorAt: paintColorAt, center: center, roughness: 0.94, surfaceMaterial: proceduralMaterial }));
78
90
  }
@@ -163,5 +175,5 @@ export function EnvironmentScene({ feature }) {
163
175
  const roads = feature.roads ?? [];
164
176
  const junctions = useMemo(() => findRoadJunctions(roads), [roads]);
165
177
  const roadNetwork = useMemo(() => buildAuthoredRoadNetwork(roads, junctions, (x, z) => field.sampleHeight(x, z)), [roads, junctions, field]);
166
- return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field, sculpt: feature.sculpt }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin, sculpt: feature.sculpt }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, paths: roadNetwork.roadPaths[index] ?? [] }, `road-${index}`))), _jsx(RoadJunctions, { surfaces: roadNetwork.junctionSurfaces, junctions: junctions, field: field }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass, feature.sculpt) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
178
+ return (_jsxs(_Fragment, { children: [feature.terrain !== undefined ? _jsx(TerrainGround, { terrain: feature.terrain, field: field, sculpt: feature.sculpt }) : null, islands.map((entry, index) => (_jsx(TerrainGround, { terrain: entry, field: field, center: entry.origin, sculpt: feature.sculpt }, `island-${index}`))), water.map((ocean, index) => (_jsx(Water, { ocean: ocean }, `ocean-${index}`))), roads.map((entry, index) => (_jsx(RoadRibbons, { road: entry, field: field, paths: roadNetwork.roadPaths[index] ?? [] }, `road-${index}`))), _jsx(RoadJunctions, { surfaces: roadNetwork.junctionSurfaces, junctions: junctions, field: field, surfaceDetail: roads.some((road) => road.surface !== false) }), structures.map((entry, index) => (_jsx(Structures, { structures: entry, field: field }, `structures-${index}`))), pads.map((entry, index) => (_jsx(GroundPad, { pad: entry, field: field }, `pad-${index}`))), vegetation.map((grass, index) => (_jsx(Vegetation, { grass: grass, field: field, groundColor: vegetationGroundColor(feature.terrain, field, grass, feature.sculpt) }, `grass-${index}`))), feature.weather !== undefined && feature.weather.length > 0 ? (_jsx(Weather, { weather: feature.weather })) : null] }));
167
179
  }
@@ -9,8 +9,10 @@ import type { JunctionSurface } from "./RoadRibbons.js";
9
9
  * `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
10
10
  * @internal
11
11
  */
12
- export declare function RoadJunctions({ surfaces, junctions, field, }: {
12
+ export declare function RoadJunctions({ surfaces, junctions, field, surfaceDetail, }: {
13
13
  surfaces: readonly JunctionSurface[];
14
14
  junctions: readonly RoadJunction[];
15
15
  field: TerrainField;
16
+ /** Shade the welded surfaces with the asphalt procedural surface, matching the ribbons. Default true. */
17
+ surfaceDetail?: boolean;
16
18
  }): import("react").JSX.Element | null;
@@ -3,6 +3,13 @@ import { useMemo } from "react";
3
3
  import { BufferAttribute, BufferGeometry, DoubleSide } from "three";
4
4
  import { buildRoadRibbon, GROUND_DECAL_LAYERS } from "@jgengine/core/world/roads";
5
5
  import { junctionMarkings } from "@jgengine/core/world/streets";
6
+ import { useSurfaceRibbonMaterial } from "../terrain/surfaceRibbonMaterial.js";
7
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
8
+ function JunctionPatch({ geometry, color, surfaceDetail }) {
9
+ const quality = useTerrainQuality();
10
+ const material = useSurfaceRibbonMaterial(surfaceDetail ? { surface: "road", color, halfWidth: 1, quality } : null);
11
+ return (_jsx("mesh", { geometry: geometry, receiveShadow: true, ...(material === null ? {} : { material }), children: material === null ? _jsx("meshStandardMaterial", { color: color, roughness: 0.95, metalness: 0, side: DoubleSide }) : null }));
12
+ }
6
13
  /** Lane paint sits the table's road→marking gap above the welded junction surface (also polygonOffset). */
7
14
  const MARKING_LIFT = GROUND_DECAL_LAYERS.marking - GROUND_DECAL_LAYERS.road;
8
15
  function toGeometry(positions, indices) {
@@ -43,7 +50,7 @@ function mergedBars(bars, width, field, elevation) {
43
50
  * `polygonOffset`/`renderOrder` so they win the depth test on sloped ground.
44
51
  * @internal
45
52
  */
46
- export function RoadJunctions({ surfaces, junctions, field, }) {
53
+ export function RoadJunctions({ surfaces, junctions, field, surfaceDetail = true, }) {
47
54
  const patches = useMemo(() => surfaces
48
55
  .map((surface) => ({ color: surface.color, geometry: toGeometry(surface.ribbon.positions, surface.ribbon.indices) }))
49
56
  .filter((entry) => entry.geometry !== null), [surfaces]);
@@ -66,5 +73,5 @@ export function RoadJunctions({ surfaces, junctions, field, }) {
66
73
  }, [junctions, field]);
67
74
  if (patches.length === 0 && markings.stops === null && markings.crosswalks === null)
68
75
  return null;
69
- return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx("mesh", { geometry: patch.geometry, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: patch.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null] }));
76
+ return (_jsxs("group", { children: [patches.map((patch, index) => (_jsx(JunctionPatch, { geometry: patch.geometry, color: patch.color, surfaceDetail: surfaceDetail }, `junction-${index}`))), markings.stops !== null ? (_jsx("mesh", { geometry: markings.stops, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, markings.crosswalks !== null ? (_jsx("mesh", { geometry: markings.crosswalks, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: "#e6e9ee", roughness: 0.85, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null] }));
70
77
  }
@@ -40,7 +40,9 @@ export declare function buildAuthoredRoadNetwork(roads: readonly RoadEnvironment
40
40
  * by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
41
41
  * optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
42
42
  * junction boundary, every layer naturally ends there — no ribbon ploughs through the crossing and
43
- * the dashed line no longer needs exclusion circles to skip intersections.
43
+ * the dashed line no longer needs exclusion circles to skip intersections. The carriageway, curbs and
44
+ * sidewalks shade with the terrain's procedural surfaces (`road.surface`, default asphalt with tyre
45
+ * wear); `surface: false` keeps them flat.
44
46
  */
45
47
  export declare function RoadRibbons({ road, field, paths, }: {
46
48
  road: RoadEnvironmentDescriptor;
@@ -3,6 +3,8 @@ import { useMemo } from "react";
3
3
  import { BufferAttribute, BufferGeometry, Color, DoubleSide } from "three";
4
4
  import { buildJunctionSurface, buildRoadRibbon, dashSegments, GROUND_DECAL_LAYERS, trimPathAtJunctions, } from "@jgengine/core/world/roads";
5
5
  import { offsetPath, sidewalkWidthOf } from "@jgengine/core/world/streets";
6
+ import { resolvePathSurface, RIBBON_FRAME_ATTRIBUTE, ribbonSurfaceFrame, useSurfaceRibbonMaterial, } from "../terrain/surfaceRibbonMaterial.js";
7
+ import { useTerrainQuality } from "../terrain/terrainQuality.js";
6
8
  /**
7
9
  * Decal sub-layers as small lifts above each road's authored base `elevation`, spaced by the
8
10
  * single-owner {@link GROUND_DECAL_LAYERS} table so the edge strip and lane paint never z-fight the
@@ -47,7 +49,9 @@ function mergeRibbons(ribbons) {
47
49
  vertexOffset += ribbon.positions.length;
48
50
  indexOffset += ribbon.indices.length;
49
51
  }
50
- return toGeometry(positions, indices);
52
+ const geometry = toGeometry(positions, indices);
53
+ geometry?.setAttribute(RIBBON_FRAME_ATTRIBUTE, new BufferAttribute(ribbonSurfaceFrame(ribbons.map((r) => r.positions)), 3));
54
+ return geometry;
51
55
  }
52
56
  /** Convert a detected {@link RoadJunction} into the structural {@link RoadJunctionInput} the trimmer wants. @internal */
53
57
  export function roadJunctionInput(junction) {
@@ -150,7 +154,9 @@ export function buildAuthoredRoadNetwork(roads, junctions, sampleHeight) {
150
154
  * by {@link buildAuthoredRoadNetwork}): an asphalt ribbon, a curb/edge strip along each border, an
151
155
  * optional dashed centerline, and optional sidewalks. Because the sub-paths already stop at the
152
156
  * junction boundary, every layer naturally ends there — no ribbon ploughs through the crossing and
153
- * the dashed line no longer needs exclusion circles to skip intersections.
157
+ * the dashed line no longer needs exclusion circles to skip intersections. The carriageway, curbs and
158
+ * sidewalks shade with the terrain's procedural surfaces (`road.surface`, default asphalt with tyre
159
+ * wear); `surface: false` keeps them flat.
154
160
  */
155
161
  export function RoadRibbons({ road, field, paths, }) {
156
162
  const asphalt = useMemo(() => mergeRibbons(paths.map((path) => buildRoadRibbon(path, road.width, (x, z) => field.sampleHeight(x, z), { elevation: road.elevation }))), [paths, road.width, road.elevation, field]);
@@ -192,7 +198,14 @@ export function RoadRibbons({ road, field, paths, }) {
192
198
  }
193
199
  return mergeRibbons(ribbons);
194
200
  }, [paths, road, field]);
201
+ const quality = useTerrainQuality();
202
+ const detailed = road.surface !== false;
203
+ const style = detailed ? resolvePathSurface("road", road.width, { surface: road.surface ?? "road" }) : null;
204
+ const asphaltMaterial = useSurfaceRibbonMaterial(style === null ? null : { surface: style.surface, wear: style.wear, color: road.color, halfWidth: road.width / 2, quality });
205
+ const curbMaterial = useSurfaceRibbonMaterial(detailed ? { surface: "concrete", color: curbColor, halfWidth: road.width / 2, roughness: 0.9, quality } : null);
206
+ const sidewalkColor = road.sidewalk === false ? "#a7adb8" : road.sidewalk.color;
207
+ const sidewalkMaterial = useSurfaceRibbonMaterial(detailed && sidewalks !== null ? { surface: "pavement", color: sidewalkColor, halfWidth: road.width / 2, roughness: 0.9, scale: 1.6, quality } : null);
195
208
  if (asphalt === null)
196
209
  return null;
197
- return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) }), curbs !== null ? (_jsx("mesh", { geometry: curbs, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) })) : null, dashes !== null ? (_jsx("mesh", { geometry: dashes, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, sidewalks !== null ? (_jsx("mesh", { geometry: sidewalks, receiveShadow: true, children: _jsx("meshStandardMaterial", { color: road.sidewalk === false ? "#a7adb8" : road.sidewalk.color, roughness: 0.9, metalness: 0, side: DoubleSide }) })) : null] }));
210
+ return (_jsxs("group", { children: [_jsx("mesh", { geometry: asphalt, receiveShadow: true, ...(asphaltMaterial === null ? {} : { material: asphaltMaterial }), children: asphaltMaterial === null ? _jsx("meshStandardMaterial", { color: road.color, roughness: 0.95, metalness: 0, side: DoubleSide }) : null }), curbs !== null ? (_jsx("mesh", { geometry: curbs, receiveShadow: true, ...(curbMaterial === null ? {} : { material: curbMaterial }), children: curbMaterial === null ? _jsx("meshStandardMaterial", { color: curbColor, roughness: 0.9, metalness: 0, side: DoubleSide }) : null })) : null, dashes !== null ? (_jsx("mesh", { geometry: dashes, renderOrder: 1, children: _jsx("meshStandardMaterial", { color: road.markingColor, roughness: 0.8, metalness: 0, side: DoubleSide, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }) })) : null, sidewalks !== null ? (_jsx("mesh", { geometry: sidewalks, receiveShadow: true, ...(sidewalkMaterial === null ? {} : { material: sidewalkMaterial }), children: sidewalkMaterial === null ? _jsx("meshStandardMaterial", { color: sidewalkColor, roughness: 0.9, metalness: 0, side: DoubleSide }) : null })) : null] }));
198
211
  }