@jgengine/shell 0.15.0-next.0 → 0.16.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 (38) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Shell3dPresentation.js +1 -1
  3. package/dist/audio/synthEngine.js +12 -2
  4. package/dist/environment/EnvironmentScene.js +1 -1
  5. package/dist/presentationResolve.d.ts +2 -0
  6. package/dist/presentationResolve.js +1 -0
  7. package/dist/render/modelLoad.d.ts +24 -0
  8. package/dist/render/modelLoad.js +46 -0
  9. package/dist/scene/CityRenderer.js +28 -54
  10. package/dist/structures/BuildingKitBatch.d.ts +13 -0
  11. package/dist/structures/BuildingKitBatch.js +78 -0
  12. package/dist/structures/GeneratedBuilding.d.ts +19 -3
  13. package/dist/structures/GeneratedBuilding.js +13 -81
  14. package/dist/structures/buildingKitFit.d.ts +59 -0
  15. package/dist/structures/buildingKitFit.js +188 -0
  16. package/dist/structures/buildingKitRegistry.d.ts +9 -0
  17. package/dist/structures/buildingKitRegistry.js +26 -0
  18. package/dist/weather/DustField.d.ts +41 -0
  19. package/dist/weather/DustField.js +129 -0
  20. package/dist/weather/WeatherLayer.d.ts +8 -2
  21. package/dist/weather/WeatherLayer.js +5 -2
  22. package/dist/weather/index.d.ts +1 -0
  23. package/dist/weather/index.js +1 -0
  24. package/dist/weather/weatherMath.d.ts +4 -0
  25. package/dist/weather/weatherMath.js +4 -0
  26. package/dist/world/WorldHud.d.ts +6 -2
  27. package/dist/world/WorldHud.js +4 -4
  28. package/dist/world/worldBarSamples.d.ts +15 -3
  29. package/dist/world/worldBarSamples.js +34 -3
  30. package/package.json +4 -4
  31. package/skills/jgengine/api.md +15 -3
  32. package/skills/jgengine/capabilities.md +12 -0
  33. package/skills/jgengine-ui/api.md +33 -12
  34. package/skills/jgengine-ui/capabilities.md +12 -0
  35. package/skills/jgengine-world/SKILL.md +5 -0
  36. package/skills/jgengine-world/api.md +40 -2
  37. package/skills/jgengine-world/capabilities.md +16 -0
  38. package/skills/jgengine-world/recipes/building-kit.md +78 -0
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useLayoutEffect, useMemo, useRef } from "react";
2
+ import { Suspense, useLayoutEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { useDisposable } from "../render/useDisposable.js";
5
+ import { BuildingKitBatch } from "./BuildingKitBatch.js";
6
+ import { bucketBuildingParts, normalFor, outwardOffset } from "./buildingKitFit.js";
5
7
  const DEFAULT_PALETTE = {
6
8
  wall: "#83766a",
7
9
  window: "#8ecae6",
@@ -19,26 +21,6 @@ const DEFAULT_PALETTE = {
19
21
  function colorFor(kind, palette) {
20
22
  return palette?.[kind] ?? DEFAULT_PALETTE[kind];
21
23
  }
22
- function normalFor(facade) {
23
- if (facade === "front")
24
- return [0, 1];
25
- if (facade === "back")
26
- return [0, -1];
27
- if (facade === "left")
28
- return [-1, 0];
29
- if (facade === "right")
30
- return [1, 0];
31
- return [0, 0];
32
- }
33
- function outwardOffset(part) {
34
- if (part.kind === "wall" || part.kind === "roof" || part.kind === "corner")
35
- return 0;
36
- if (part.kind === "window" || part.kind === "shutter" || part.kind === "storefront")
37
- return 0.025;
38
- if (part.kind === "awning" || part.kind === "storeSign")
39
- return 0.09;
40
- return 0.12;
41
- }
42
24
  function materialFor(part, palette) {
43
25
  const color = colorFor(part.kind, palette);
44
26
  if (part.kind === "window" || part.kind === "storefront") {
@@ -65,56 +47,6 @@ function batchMaterialFor(kind, palette) {
65
47
  }
66
48
  return new THREE.MeshStandardMaterial({ color, roughness: 0.88, metalness: 0 });
67
49
  }
68
- const CLOTHESLINE_ROW_OFFSETS = [-0.09, 0.09];
69
- function bucketPartMatrices(buildings, visible) {
70
- const dummy = new THREE.Object3D();
71
- const buckets = new Map();
72
- for (const placement of buildings) {
73
- const [ox, oy, oz] = placement.position ?? [0, 0, 0];
74
- // Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
75
- // dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
76
- const yaw = placement.rotationY ?? 0;
77
- const [px0, pz0] = placement.pivot ?? [0, 0];
78
- const cos = Math.cos(yaw);
79
- const sin = Math.sin(yaw);
80
- for (const part of placement.building.parts) {
81
- if (visible !== null && !visible.has(part.kind))
82
- continue;
83
- const [nx, nz] = normalFor(part.facade);
84
- const offset = outwardOffset(part);
85
- // Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
86
- const lx = part.position[0] - px0 + nx * offset;
87
- const lz = part.position[2] - pz0 + nz * offset;
88
- const px = ox + px0 + lx * cos + lz * sin;
89
- const py = oy + part.position[1];
90
- const pz = oz + pz0 - lx * sin + lz * cos;
91
- const partYaw = part.rotationY + yaw;
92
- let bucket = buckets.get(part.kind);
93
- if (bucket === undefined) {
94
- bucket = [];
95
- buckets.set(part.kind, bucket);
96
- }
97
- if (part.kind === "clothesline") {
98
- const rowSin = Math.sin(partYaw);
99
- const rowCos = Math.cos(partYaw);
100
- for (const row of CLOTHESLINE_ROW_OFFSETS) {
101
- dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
102
- dummy.rotation.set(0, partYaw, 0);
103
- dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
104
- dummy.updateMatrix();
105
- bucket.push(dummy.matrix.clone());
106
- }
107
- continue;
108
- }
109
- dummy.position.set(px, py, pz);
110
- dummy.rotation.set(0, partYaw, 0);
111
- dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
112
- dummy.updateMatrix();
113
- bucket.push(dummy.matrix.clone());
114
- }
115
- }
116
- return buckets;
117
- }
118
50
  function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
119
51
  const meshRef = useRef(null);
120
52
  const material = useDisposable(() => batchMaterialFor(kind, palette), [kind, palette]);
@@ -128,15 +60,15 @@ function BuildingKindBatch({ kind, matrices, palette, geometry, }) {
128
60
  }, [matrices, material]);
129
61
  return (_jsx("instancedMesh", { ref: meshRef, args: [geometry, material, matrices.length], castShadow: true, receiveShadow: true }, matrices.length));
130
62
  }
131
- export function InstancedBuildings({ buildings, palette, visibleKinds }) {
63
+ export function InstancedBuildings({ buildings, palette, kit, resolveModelUrl, visibleKinds, }) {
132
64
  const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
133
- const buckets = useMemo(() => {
65
+ const { boxes, models } = useMemo(() => {
134
66
  const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
135
- return bucketPartMatrices(buildings, visible);
136
- }, [buildings, visibleKinds]);
137
- if (buckets.size === 0)
67
+ return bucketBuildingParts(buildings, visible, kit, resolveModelUrl);
68
+ }, [buildings, visibleKinds, kit, resolveModelUrl]);
69
+ if (boxes.size === 0 && models.size === 0)
138
70
  return null;
139
- return (_jsx("group", { children: [...buckets.entries()].map(([kind, matrices]) => (_jsx(BuildingKindBatch, { kind: kind, matrices: matrices, palette: palette, geometry: geometry }, kind))) }));
71
+ return (_jsxs("group", { children: [[...boxes.entries()].map(([kind, matrices]) => (_jsx(BuildingKindBatch, { kind: kind, matrices: matrices, palette: palette, geometry: geometry }, kind))), [...models.entries()].map(([url, instances]) => (_jsx(Suspense, { fallback: null, children: _jsx(BuildingKitBatch, { url: url, instances: instances }) }, url)))] }));
140
72
  }
141
73
  function BlockMesh({ part, palette }) {
142
74
  const [nx, nz] = normalFor(part.facade);
@@ -161,7 +93,7 @@ export function BuildingBlock({ part, palette }) {
161
93
  }
162
94
  return _jsx(BlockMesh, { part: part, palette: palette });
163
95
  }
164
- export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
96
+ export function GeneratedBuilding({ building, palette, partRenderer, kit, resolveModelUrl, visibleKinds, }) {
165
97
  const { kitParts, batched } = useMemo(() => {
166
98
  const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
167
99
  const kitRendered = [];
@@ -169,7 +101,7 @@ export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
169
101
  for (const part of building.parts) {
170
102
  if (visible !== null && !visible.has(part.kind))
171
103
  continue;
172
- const rendered = kit?.renderPart?.(part);
104
+ const rendered = partRenderer?.renderPart?.(part);
173
105
  if (rendered !== undefined) {
174
106
  kitRendered.push({ id: part.id, node: rendered });
175
107
  continue;
@@ -178,6 +110,6 @@ export function GeneratedBuilding({ building, palette, kit, visibleKinds }) {
178
110
  }
179
111
  const placements = batchedParts.length === 0 ? [] : [{ building: { id: building.id, parts: batchedParts } }];
180
112
  return { kitParts: kitRendered, batched: placements };
181
- }, [building, kit, visibleKinds]);
182
- return (_jsxs("group", { name: building.id, children: [kitParts.map((entry) => (_jsx("group", { children: entry.node }, entry.id))), _jsx(InstancedBuildings, { buildings: batched, palette: palette })] }));
113
+ }, [building, partRenderer, visibleKinds]);
114
+ return (_jsxs("group", { name: building.id, children: [kitParts.map((entry) => (_jsx("group", { children: entry.node }, entry.id))), _jsx(InstancedBuildings, { buildings: batched, palette: palette, kit: kit, resolveModelUrl: resolveModelUrl })] }));
183
115
  }
@@ -0,0 +1,59 @@
1
+ import * as THREE from "three";
2
+ import type { BuildingKit, BuildingKitFit, BuildingKitPart } from "@jgengine/core/world/buildingKit";
3
+ import type { BuildingFacade, BuildingPartKind, BuildingPartPlacement, InstancedBuildingPlacement } from "./GeneratedBuilding.js";
4
+ /**
5
+ * One kit-bound part resolved to the world slot the fallback box would have filled, carrying the
6
+ * binding that replaces it. `slotScale` is the slot box in world units; `yaw` already includes the
7
+ * whole-building rotation.
8
+ * @internal
9
+ */
10
+ export interface BuildingKitInstance {
11
+ position: readonly [number, number, number];
12
+ yaw: number;
13
+ slotScale: readonly [number, number, number];
14
+ part: BuildingKitPart;
15
+ fit: BuildingKitFit;
16
+ }
17
+ /** A kit model's native extents, measured once per URL from the harvested root. @internal */
18
+ export interface BuildingKitModelBounds {
19
+ size: readonly [number, number, number];
20
+ center: readonly [number, number, number];
21
+ }
22
+ /** Box-path matrices per part kind plus kit-bound instances per resolved model URL. @internal */
23
+ export interface BuildingPartBuckets {
24
+ boxes: Map<BuildingPartKind, THREE.Matrix4[]>;
25
+ models: Map<string, BuildingKitInstance[]>;
26
+ }
27
+ /** Outward XZ normal of a facade; `roof` has none. @internal */
28
+ export declare function normalFor(facade: BuildingFacade): readonly [number, number];
29
+ /** How far a part sits proud of its facade, so decor never z-fights the wall behind it. @internal */
30
+ export declare function outwardOffset(part: BuildingPartPlacement): number;
31
+ /**
32
+ * Splits every placed part into the two render paths: parts a kit binds to a model land in a bucket
33
+ * keyed by their resolved URL, everything else keeps today's per-kind box matrices. With no kit every
34
+ * part resolves to `box`, so the box path is untouched.
35
+ * @internal
36
+ */
37
+ export declare function bucketBuildingParts(buildings: readonly InstancedBuildingPlacement[], visible: ReadonlySet<BuildingPartKind> | null, kit?: BuildingKit, resolveModelUrl?: (model: string) => string): BuildingPartBuckets;
38
+ /**
39
+ * Whether a model needs a quarter-turn about Y to run along its slot. Modular kits disagree on which
40
+ * horizontal axis a panel is authored along — within one pack a wall may run along Z while a door
41
+ * runs along X — so compare which horizontal axis is longer on each and turn when they disagree.
42
+ * @internal
43
+ */
44
+ export declare function buildingKitQuarterTurn(fit: BuildingKitFit, slotScale: readonly [number, number, number], nativeSize: readonly [number, number, number], autoOrient?: boolean): boolean;
45
+ /**
46
+ * Maps a model's native extents onto the slot box per {@link BuildingKitFit}. Degenerate or
47
+ * non-finite native extents fall back to a ratio of 1 on that axis rather than blowing the model up.
48
+ * @internal
49
+ */
50
+ export declare function buildingKitFitScale(fit: BuildingKitFit, slotScale: readonly [number, number, number], nativeSize: readonly [number, number, number]): [number, number, number];
51
+ /** Measures the harvested model root once so every instance of that URL shares one fit basis. @internal */
52
+ export declare function measureBuildingKitModel(root: THREE.Object3D): BuildingKitModelBounds;
53
+ /**
54
+ * Composes one kit instance's world matrix: fit scale times the binding's `scale`, the binding's
55
+ * Euler XYZ rotation on top of the slot's facade yaw, the binding's slot-local `offset` rotated into
56
+ * world space by that total yaw, and a recentre so the model's bounds center lands on the slot center.
57
+ * @internal
58
+ */
59
+ export declare function composeBuildingKitMatrix(instance: BuildingKitInstance, bounds: BuildingKitModelBounds, target?: THREE.Matrix4): THREE.Matrix4;
@@ -0,0 +1,188 @@
1
+ import * as THREE from "three";
2
+ import { resolveBuildingKitPart } from "@jgengine/core/world/buildingKit";
3
+ const CLOTHESLINE_ROW_OFFSETS = [-0.09, 0.09];
4
+ /** Outward XZ normal of a facade; `roof` has none. @internal */
5
+ export function normalFor(facade) {
6
+ if (facade === "front")
7
+ return [0, 1];
8
+ if (facade === "back")
9
+ return [0, -1];
10
+ if (facade === "left")
11
+ return [-1, 0];
12
+ if (facade === "right")
13
+ return [1, 0];
14
+ return [0, 0];
15
+ }
16
+ /** How far a part sits proud of its facade, so decor never z-fights the wall behind it. @internal */
17
+ export function outwardOffset(part) {
18
+ if (part.kind === "wall" || part.kind === "roof" || part.kind === "corner")
19
+ return 0;
20
+ if (part.kind === "window" || part.kind === "shutter" || part.kind === "storefront")
21
+ return 0.025;
22
+ if (part.kind === "awning" || part.kind === "storeSign")
23
+ return 0.09;
24
+ return 0.12;
25
+ }
26
+ /**
27
+ * Splits every placed part into the two render paths: parts a kit binds to a model land in a bucket
28
+ * keyed by their resolved URL, everything else keeps today's per-kind box matrices. With no kit every
29
+ * part resolves to `box`, so the box path is untouched.
30
+ * @internal
31
+ */
32
+ export function bucketBuildingParts(buildings, visible, kit, resolveModelUrl) {
33
+ const dummy = new THREE.Object3D();
34
+ const boxes = new Map();
35
+ const models = new Map();
36
+ for (const placement of buildings) {
37
+ const [ox, oy, oz] = placement.position ?? [0, 0, 0];
38
+ // Building yaw turns the whole massing about its center. Engine Y-rotation convention (matches
39
+ // dummy.rotation.y below): a local vector (x, z) maps to (x·cos + z·sin, −x·sin + z·cos).
40
+ const yaw = placement.rotationY ?? 0;
41
+ const [px0, pz0] = placement.pivot ?? [0, 0];
42
+ const cos = Math.cos(yaw);
43
+ const sin = Math.sin(yaw);
44
+ for (const part of placement.building.parts) {
45
+ if (visible !== null && !visible.has(part.kind))
46
+ continue;
47
+ const binding = resolveBuildingKitPart(kit, part.kind, part.kit);
48
+ if (binding.type === "omit")
49
+ continue;
50
+ const [nx, nz] = normalFor(part.facade);
51
+ const offset = outwardOffset(part);
52
+ // Local offset from the pivot (part position + facade-normal nudge), then rotated by the yaw.
53
+ const lx = part.position[0] - px0 + nx * offset;
54
+ const lz = part.position[2] - pz0 + nz * offset;
55
+ const px = ox + px0 + lx * cos + lz * sin;
56
+ const py = oy + part.position[1];
57
+ const pz = oz + pz0 - lx * sin + lz * cos;
58
+ const partYaw = part.rotationY + yaw;
59
+ if (binding.type === "model") {
60
+ const url = resolveModelUrl?.(binding.part.model) ?? binding.part.model;
61
+ const instance = {
62
+ position: [px, py, pz],
63
+ yaw: partYaw,
64
+ slotScale: part.scale,
65
+ part: binding.part,
66
+ fit: binding.fit,
67
+ };
68
+ const bucket = models.get(url);
69
+ if (bucket === undefined)
70
+ models.set(url, [instance]);
71
+ else
72
+ bucket.push(instance);
73
+ continue;
74
+ }
75
+ let bucket = boxes.get(part.kind);
76
+ if (bucket === undefined) {
77
+ bucket = [];
78
+ boxes.set(part.kind, bucket);
79
+ }
80
+ if (part.kind === "clothesline") {
81
+ const rowSin = Math.sin(partYaw);
82
+ const rowCos = Math.cos(partYaw);
83
+ for (const row of CLOTHESLINE_ROW_OFFSETS) {
84
+ dummy.position.set(px + row * rowSin, py, pz + row * rowCos);
85
+ dummy.rotation.set(0, partYaw, 0);
86
+ dummy.scale.set(part.scale[0], Math.max(part.scale[1], 0.025), 0.025);
87
+ dummy.updateMatrix();
88
+ bucket.push(dummy.matrix.clone());
89
+ }
90
+ continue;
91
+ }
92
+ dummy.position.set(px, py, pz);
93
+ dummy.rotation.set(0, partYaw, 0);
94
+ dummy.scale.set(part.scale[0], part.scale[1], part.scale[2]);
95
+ dummy.updateMatrix();
96
+ bucket.push(dummy.matrix.clone());
97
+ }
98
+ }
99
+ return { boxes, models };
100
+ }
101
+ function ratio(slot, native) {
102
+ if (!Number.isFinite(slot) || !Number.isFinite(native) || Math.abs(native) < 1e-6)
103
+ return 1;
104
+ return slot / native;
105
+ }
106
+ /**
107
+ * Whether a model needs a quarter-turn about Y to run along its slot. Modular kits disagree on which
108
+ * horizontal axis a panel is authored along — within one pack a wall may run along Z while a door
109
+ * runs along X — so compare which horizontal axis is longer on each and turn when they disagree.
110
+ * @internal
111
+ */
112
+ export function buildingKitQuarterTurn(fit, slotScale, nativeSize, autoOrient = true) {
113
+ if (!autoOrient || fit === "native")
114
+ return false;
115
+ const [sx, , sz] = slotScale;
116
+ const [nx, , nz] = nativeSize;
117
+ if (![sx, sz, nx, nz].every((value) => Number.isFinite(value)))
118
+ return false;
119
+ if (Math.abs(nx - nz) < 1e-6 || Math.abs(sx - sz) < 1e-6)
120
+ return false;
121
+ return sx >= sz !== nx >= nz;
122
+ }
123
+ /**
124
+ * Maps a model's native extents onto the slot box per {@link BuildingKitFit}. Degenerate or
125
+ * non-finite native extents fall back to a ratio of 1 on that axis rather than blowing the model up.
126
+ * @internal
127
+ */
128
+ export function buildingKitFitScale(fit, slotScale, nativeSize) {
129
+ if (fit === "native")
130
+ return [1, 1, 1];
131
+ const rx = ratio(slotScale[0], nativeSize[0]);
132
+ const ry = ratio(slotScale[1], nativeSize[1]);
133
+ const rz = ratio(slotScale[2], nativeSize[2]);
134
+ if (fit === "stretch")
135
+ return [rx, ry, rz];
136
+ const uniform = fit === "cover" ? Math.max(rx, ry, rz) : Math.min(rx, ry, rz);
137
+ return [uniform, uniform, uniform];
138
+ }
139
+ /** Measures the harvested model root once so every instance of that URL shares one fit basis. @internal */
140
+ export function measureBuildingKitModel(root) {
141
+ const box = new THREE.Box3().setFromObject(root);
142
+ if (box.isEmpty())
143
+ return { size: [0, 0, 0], center: [0, 0, 0] };
144
+ return {
145
+ size: [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z],
146
+ center: [(box.min.x + box.max.x) / 2, (box.min.y + box.max.y) / 2, (box.min.z + box.max.z) / 2],
147
+ };
148
+ }
149
+ const UP = new THREE.Vector3(0, 1, 0);
150
+ const scratchScale = new THREE.Vector3();
151
+ const scratchPosition = new THREE.Vector3();
152
+ const scratchCenter = new THREE.Vector3();
153
+ const scratchQuat = new THREE.Quaternion();
154
+ const scratchYaw = new THREE.Quaternion();
155
+ const scratchEuler = new THREE.Euler();
156
+ /**
157
+ * Composes one kit instance's world matrix: fit scale times the binding's `scale`, the binding's
158
+ * Euler XYZ rotation on top of the slot's facade yaw, the binding's slot-local `offset` rotated into
159
+ * world space by that total yaw, and a recentre so the model's bounds center lands on the slot center.
160
+ * @internal
161
+ */
162
+ export function composeBuildingKitMatrix(instance, bounds, target = new THREE.Matrix4()) {
163
+ const turn = buildingKitQuarterTurn(instance.fit, instance.slotScale, bounds.size, instance.part.autoOrient);
164
+ // The turn swaps which native axis faces the slot's width, so fit against the swapped extents.
165
+ const native = turn
166
+ ? [bounds.size[2], bounds.size[1], bounds.size[0]]
167
+ : bounds.size;
168
+ const fit = buildingKitFitScale(instance.fit, instance.slotScale, native);
169
+ const mul = instance.part.scale ?? [1, 1, 1];
170
+ const fitted = turn
171
+ ? [fit[2] * mul[0], fit[1] * mul[1], fit[0] * mul[2]]
172
+ : [fit[0] * mul[0], fit[1] * mul[1], fit[2] * mul[2]];
173
+ scratchScale.set(fitted[0], fitted[1], fitted[2]);
174
+ const rotation = instance.part.rotation;
175
+ const turnYaw = turn ? Math.PI / 2 : 0;
176
+ scratchEuler.set(rotation?.[0] ?? 0, (rotation?.[1] ?? 0) + turnYaw, rotation?.[2] ?? 0, "XYZ");
177
+ scratchQuat.setFromEuler(scratchEuler);
178
+ scratchQuat.premultiply(scratchYaw.setFromAxisAngle(UP, instance.yaw));
179
+ const offset = instance.part.offset ?? [0, 0, 0];
180
+ const totalYaw = instance.yaw + (rotation?.[1] ?? 0) + turnYaw;
181
+ const cos = Math.cos(totalYaw);
182
+ const sin = Math.sin(totalYaw);
183
+ scratchCenter
184
+ .set(bounds.center[0] * scratchScale.x, bounds.center[1] * scratchScale.y, bounds.center[2] * scratchScale.z)
185
+ .applyQuaternion(scratchQuat);
186
+ scratchPosition.set(instance.position[0] + offset[0] * cos + offset[2] * sin - scratchCenter.x, instance.position[1] + offset[1] - scratchCenter.y, instance.position[2] - offset[0] * sin + offset[2] * cos - scratchCenter.z);
187
+ return target.compose(scratchPosition, scratchQuat, scratchScale);
188
+ }
@@ -0,0 +1,9 @@
1
+ import type { BuildingKit } from "@jgengine/core/world/buildingKit";
2
+ /** Registers a kit under its own `id`, replacing any kit already registered under that name. */
3
+ export declare function registerBuildingKit(kit: BuildingKit): void;
4
+ /** The kit registered under `id`, or `undefined` for an unset or unknown name (renders blocks). */
5
+ export declare function getBuildingKit(id: string | undefined): BuildingKit | undefined;
6
+ /** Every registered kit id — the editor's kit picker and diagnostics read this. */
7
+ export declare function listBuildingKits(): string[];
8
+ /** Drops every registration. For tests and hot-reload teardown. */
9
+ export declare function clearBuildingKits(): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Kits a game has bound, keyed by `BuildingKit.id`.
3
+ *
4
+ * Scene documents name a kit rather than embedding it, so an authored city stays portable and free
5
+ * of model paths while the game keeps ownership of its art. Registration is the game's job — the
6
+ * engine ships no kits.
7
+ */
8
+ const kits = new Map();
9
+ /** Registers a kit under its own `id`, replacing any kit already registered under that name. */
10
+ export function registerBuildingKit(kit) {
11
+ kits.set(kit.id, kit);
12
+ }
13
+ /** The kit registered under `id`, or `undefined` for an unset or unknown name (renders blocks). */
14
+ export function getBuildingKit(id) {
15
+ if (id === undefined || id === "")
16
+ return undefined;
17
+ return kits.get(id);
18
+ }
19
+ /** Every registered kit id — the editor's kit picker and diagnostics read this. */
20
+ export function listBuildingKits() {
21
+ return [...kits.keys()].sort();
22
+ }
23
+ /** Drops every registration. For tests and hot-reload teardown. */
24
+ export function clearBuildingKits() {
25
+ kits.clear();
26
+ }
@@ -0,0 +1,41 @@
1
+ import * as THREE from "three";
2
+ import { type WeatherVector } from "./weatherUniforms.js";
3
+ /**
4
+ * Wind-borne particulate: dust, sand, ash, pollen, spores. Unlike rain and snow this is not falling
5
+ * — motion is dominated by horizontal wind with a slow vertical bob, and density thins with height
6
+ * so the air near the ground carries the most grit. Any arid, volcanic, or blown-out world needs it.
7
+ */
8
+ export interface DustFieldProps {
9
+ count?: number;
10
+ density?: number;
11
+ budget?: number;
12
+ volume?: WeatherVector;
13
+ wind?: WeatherVector;
14
+ origin?: WeatherVector;
15
+ followCamera?: boolean;
16
+ /** Horizontal drift added to `wind`, in world units per second. */
17
+ speed?: number;
18
+ size?: number;
19
+ /** Amplitude of the slow vertical bob that keeps motes from travelling on rails. */
20
+ sway?: number;
21
+ opacity?: number;
22
+ color?: THREE.ColorRepresentation;
23
+ /**
24
+ * How fast density falls off with height across the volume, 0..1. At 0 the volume is filled
25
+ * evenly; at 1 nearly everything hugs the ground. Default 0.65.
26
+ */
27
+ groundBias?: number;
28
+ timeScale?: number;
29
+ seed?: number;
30
+ renderOrder?: number;
31
+ frustumCulled?: boolean;
32
+ }
33
+ /**
34
+ * Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself
35
+ * should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a
36
+ * silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and
37
+ * snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
38
+ *
39
+ * @capability weather-dust airborne particulate that turns still air into visible wind
40
+ */
41
+ export declare function DustField({ count, density, budget, volume, wind, origin, followCamera, speed, size, sway, opacity, color, groundBias, timeScale, seed, renderOrder, frustumCulled, }: DustFieldProps): import("react").JSX.Element;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { useDisposable } from "../render/useDisposable.js";
5
+ import { createWeatherQuadGeometry } from "./weatherGeometry.js";
6
+ import { DEFAULT_DUST_COUNT, DEFAULT_DUST_DENSITY, resolveWeatherInstanceCount } from "./weatherMath.js";
7
+ import { useWeatherUniformSet } from "./weatherUniforms.js";
8
+ const DEFAULT_VOLUME = [70, 22, 70];
9
+ const DEFAULT_ORIGIN = [0, 0, 0];
10
+ const DEFAULT_DUST_COLOR = "#c8a878";
11
+ /**
12
+ * Renders wind-borne particulate as camera-facing motes. Reach for it whenever the air itself
13
+ * should read as weather — a desert gale, ash off a volcano, pollen in a meadow, grain dust in a
14
+ * silo. Compose it through {@link WeatherLayer}'s `"dust"` mode (or `dustAlways` alongside rain and
15
+ * snow) rather than mounting it directly, so it shares one wind and time source with the other layers.
16
+ *
17
+ * @capability weather-dust airborne particulate that turns still air into visible wind
18
+ */
19
+ export function DustField({ count = DEFAULT_DUST_COUNT, density = DEFAULT_DUST_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 2.4, size = 0.16, sway = 0.5, opacity = 0.3, color = DEFAULT_DUST_COLOR, groundBias = 0.65, timeScale, seed = 51407, renderOrder = 10, frustumCulled, }) {
20
+ const { camera } = useThree();
21
+ const shared = useWeatherUniformSet({ wind, timeScale });
22
+ const geometry = useDisposable(() => createWeatherQuadGeometry(count, seed), [count, seed]);
23
+ const material = useDisposable(() => {
24
+ const uniforms = {
25
+ uTime: shared.time,
26
+ uWind: shared.wind,
27
+ uAnchor: { value: new THREE.Vector3() },
28
+ uVolume: { value: new THREE.Vector3() },
29
+ uSpeed: { value: speed },
30
+ uSize: { value: size },
31
+ uSway: { value: sway },
32
+ uOpacity: { value: opacity },
33
+ uColor: { value: new THREE.Color(color) },
34
+ uGroundBias: { value: groundBias },
35
+ };
36
+ return new THREE.ShaderMaterial({
37
+ uniforms,
38
+ transparent: true,
39
+ depthWrite: false,
40
+ blending: THREE.NormalBlending,
41
+ vertexShader: `
42
+ uniform float uTime;
43
+ uniform vec3 uWind;
44
+ uniform vec3 uAnchor;
45
+ uniform vec3 uVolume;
46
+ uniform float uSpeed;
47
+ uniform float uSize;
48
+ uniform float uSway;
49
+ uniform float uGroundBias;
50
+
51
+ attribute vec3 aSpawn;
52
+ attribute float aDrift;
53
+
54
+ varying vec2 vUv;
55
+ varying float vDrift;
56
+ varying float vFade;
57
+
58
+ void main() {
59
+ vUv = uv;
60
+ vDrift = aDrift;
61
+
62
+ // Bias the spawn height toward the ground: pow() on a uniform [0,1) packs samples low
63
+ // without needing a second attribute or a reseed.
64
+ vec3 spawn = aSpawn;
65
+ spawn.y = pow(spawn.y, 1.0 + uGroundBias * 4.0);
66
+
67
+ vec3 origin = uAnchor - vec3(uVolume.x * 0.5, uVolume.y * 0.12, uVolume.z * 0.5);
68
+ float phase = aDrift * 6.28318530718;
69
+ // Faster motes ride higher in the gust, the way real airborne grit sorts itself.
70
+ float carry = uSpeed * (0.55 + 0.9 * aDrift);
71
+ vec3 bob = vec3(
72
+ sin(uTime * 0.43 + phase) * 0.35,
73
+ sin(uTime * 0.67 + phase * 1.9) + sin(uTime * 0.31 + phase) * 0.4,
74
+ cos(uTime * 0.39 + phase * 1.3) * 0.35
75
+ ) * uSway * (0.4 + 0.6 * aDrift);
76
+ vec3 travel = normalize(vec3(uWind.x, 0.0, uWind.z) + vec3(0.0001, 0.0, 0.0)) * carry;
77
+ vec3 local = spawn * uVolume + (uWind + travel) * uTime + bob;
78
+ vec3 worldCenter = mod(local - origin, uVolume) + origin;
79
+
80
+ // Fade at the top of the volume so recycled motes do not pop into view against the sky.
81
+ float height = (worldCenter.y - origin.y) / max(uVolume.y, 0.001);
82
+ vFade = 1.0 - smoothstep(0.55, 1.0, height);
83
+
84
+ float moteSize = uSize * (0.4 + 1.3 * aDrift);
85
+ vec3 right = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
86
+ vec3 up = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
87
+ vec3 world = worldCenter + right * position.x * moteSize + up * position.y * moteSize;
88
+
89
+ gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
90
+ }
91
+ `,
92
+ fragmentShader: `
93
+ uniform float uOpacity;
94
+ uniform vec3 uColor;
95
+
96
+ varying vec2 vUv;
97
+ varying float vDrift;
98
+ varying float vFade;
99
+
100
+ void main() {
101
+ float distanceFromCenter = length(vUv - 0.5) * 2.0;
102
+ // No hard core: dust is diffuse, and a bright centre reads as snow.
103
+ float puff = smoothstep(1.0, 0.05, distanceFromCenter);
104
+ float alpha = puff * puff * uOpacity * (0.35 + 0.65 * vDrift) * vFade;
105
+ if (alpha < 0.001) discard;
106
+ gl_FragColor = vec4(uColor, alpha);
107
+ }
108
+ `,
109
+ });
110
+ }, [color, groundBias, opacity, shared, size, speed, sway]);
111
+ useFrame(() => {
112
+ const anchor = followCamera ? camera.position : null;
113
+ const uniforms = material.uniforms;
114
+ const target = uniforms.uAnchor.value;
115
+ if (anchor !== null)
116
+ target.copy(anchor);
117
+ else
118
+ target.set(origin[0], origin[1], origin[2]);
119
+ uniforms.uVolume.value.set(volume[0], volume[1], volume[2]);
120
+ uniforms.uSpeed.value = speed;
121
+ uniforms.uSize.value = size;
122
+ uniforms.uSway.value = sway;
123
+ uniforms.uOpacity.value = opacity;
124
+ uniforms.uGroundBias.value = groundBias;
125
+ uniforms.uColor.value.set(color);
126
+ geometry.instanceCount = resolveWeatherInstanceCount(count, density, budget);
127
+ });
128
+ return (_jsx("mesh", { geometry: geometry, material: material, frustumCulled: frustumCulled ?? !followCamera, renderOrder: renderOrder }));
129
+ }
@@ -1,8 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
+ import { type DustFieldProps } from "./DustField.js";
2
3
  import { type RainFieldProps } from "./RainField.js";
3
4
  import { type SnowFieldProps } from "./SnowField.js";
4
5
  import { type WeatherVector } from "./weatherUniforms.js";
5
- export type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed";
6
+ /** `"mixed"` runs rain and snow together; `"dust"` is airborne particulate and composes with either via `dust`. */
7
+ export type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed" | "dust";
6
8
  export interface WeatherLayerProps {
7
9
  mode?: WeatherLayerMode;
8
10
  intensity?: number;
@@ -11,7 +13,11 @@ export interface WeatherLayerProps {
11
13
  timeScale?: number;
12
14
  rain?: Omit<RainFieldProps, "wind" | "lightning" | "timeScale"> | false;
13
15
  snow?: Omit<SnowFieldProps, "wind" | "timeScale"> | false;
16
+ /** Airborne particulate. Drawn in `"dust"` mode, and alongside rain/snow whenever `dustAlways` is set. */
17
+ dust?: Omit<DustFieldProps, "wind" | "timeScale"> | false;
18
+ /** Keep dust in the air in every mode — a world that is dusty whatever else the sky is doing. */
19
+ dustAlways?: boolean;
14
20
  enabled?: boolean;
15
21
  children?: ReactNode;
16
22
  }
17
- export declare function WeatherLayer({ mode, intensity, wind, lightning, timeScale, rain, snow, enabled, children, }: WeatherLayerProps): import("react").JSX.Element | null;
23
+ export declare function WeatherLayer({ mode, intensity, wind, lightning, timeScale, rain, snow, dust, dustAlways, enabled, children, }: WeatherLayerProps): import("react").JSX.Element | null;
@@ -1,16 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DustField } from "./DustField.js";
2
3
  import { RainField } from "./RainField.js";
3
4
  import { SnowField } from "./SnowField.js";
4
5
  import { WeatherUniformProvider } from "./weatherUniforms.js";
5
6
  function resolveLayerDensity(value, fallback, intensity) {
6
7
  return (value ?? fallback) * intensity;
7
8
  }
8
- export function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }) {
9
+ export function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, dust, dustAlways = false, enabled = true, children, }) {
9
10
  if (!enabled)
10
11
  return null;
11
12
  const rainProps = rain === false ? null : (rain ?? {});
12
13
  const snowProps = snow === false ? null : (snow ?? {});
13
14
  const showRain = rainProps !== null && (mode === "rain" || mode === "mixed");
14
15
  const showSnow = snowProps !== null && (mode === "snow" || mode === "mixed");
15
- return (_jsxs(WeatherUniformProvider, { wind: wind, lightning: lightning, timeScale: timeScale, children: [showRain ? (_jsx(RainField, { ...rainProps, density: resolveLayerDensity(rainProps.density, 0.45, intensity) })) : null, showSnow ? (_jsx(SnowField, { ...snowProps, density: resolveLayerDensity(snowProps.density, 0.5, intensity) })) : null, children] }));
16
+ const dustProps = dust === false ? null : (dust ?? {});
17
+ const showDust = dustProps !== null && (mode === "dust" || dustAlways);
18
+ return (_jsxs(WeatherUniformProvider, { wind: wind, lightning: lightning, timeScale: timeScale, children: [showRain ? (_jsx(RainField, { ...rainProps, density: resolveLayerDensity(rainProps.density, 0.45, intensity) })) : null, showSnow ? (_jsx(SnowField, { ...snowProps, density: resolveLayerDensity(snowProps.density, 0.5, intensity) })) : null, showDust ? (_jsx(DustField, { ...dustProps, density: resolveLayerDensity(dustProps.density, 0.4, intensity) })) : null, children] }));
16
19
  }
@@ -1,3 +1,4 @@
1
+ export { DustField, type DustFieldProps } from "./DustField.js";
1
2
  export { FireSpreadLayer, type FireSpreadLayerProps } from "./FireSpreadLayer.js";
2
3
  export { LightningStrike, type LightningStrikeProps } from "./LightningStrike.js";
3
4
  export { RainField, type RainFieldProps } from "./RainField.js";
@@ -1,3 +1,4 @@
1
+ export { DustField } from "./DustField.js";
1
2
  export { FireSpreadLayer } from "./FireSpreadLayer.js";
2
3
  export { LightningStrike } from "./LightningStrike.js";
3
4
  export { RainField } from "./RainField.js";