@jgengine/shell 0.15.0 → 0.17.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.
- package/CHANGELOG.md +62 -0
- package/dist/Shell3dPresentation.js +1 -1
- package/dist/environment/EnvironmentScene.js +1 -1
- package/dist/presentationResolve.d.ts +2 -0
- package/dist/presentationResolve.js +1 -0
- package/dist/render/modelLoad.d.ts +24 -0
- package/dist/render/modelLoad.js +46 -0
- package/dist/scene/CityRenderer.js +28 -54
- package/dist/structures/BuildingKitBatch.d.ts +13 -0
- package/dist/structures/BuildingKitBatch.js +78 -0
- package/dist/structures/GeneratedBuilding.d.ts +19 -3
- package/dist/structures/GeneratedBuilding.js +13 -81
- package/dist/structures/buildingKitFit.d.ts +59 -0
- package/dist/structures/buildingKitFit.js +188 -0
- package/dist/structures/buildingKitRegistry.d.ts +9 -0
- package/dist/structures/buildingKitRegistry.js +26 -0
- package/dist/weather/DustField.d.ts +41 -0
- package/dist/weather/DustField.js +129 -0
- package/dist/weather/WeatherLayer.d.ts +8 -2
- package/dist/weather/WeatherLayer.js +5 -2
- package/dist/weather/index.d.ts +1 -0
- package/dist/weather/index.js +1 -0
- package/dist/weather/weatherMath.d.ts +4 -0
- package/dist/weather/weatherMath.js +4 -0
- package/dist/world/WorldHud.d.ts +6 -2
- package/dist/world/WorldHud.js +4 -4
- package/dist/world/worldBarSamples.d.ts +15 -3
- package/dist/world/worldBarSamples.js +34 -3
- package/package.json +4 -4
- package/skills/jgengine/api.md +38 -5
- package/skills/jgengine/capabilities.md +24 -0
- package/skills/jgengine-ui/api.md +33 -12
- package/skills/jgengine-ui/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +27 -2
- package/skills/jgengine-world/capabilities.md +24 -0
- 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
|
|
65
|
+
const { boxes, models } = useMemo(() => {
|
|
134
66
|
const visible = visibleKinds === undefined ? null : new Set(visibleKinds);
|
|
135
|
-
return
|
|
136
|
-
}, [buildings, visibleKinds]);
|
|
137
|
-
if (
|
|
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 (
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/weather/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/weather/index.js
CHANGED