// Prevailing wind — a Meep example for the fluid simulation + global effector +
// solid obstacles.
//
// One GlobalFluidEffector blows a steady breeze across a tree plantation: every
// fluid face in the volume relaxes toward the same ambient wind each tick, so
// the whole field leans coherently. The wind slowly wheels and gusts, and the
// forest sways with it.
//
// Standing in the field are a long WINDBREAK wall and two BOULDERS. They are
// FluidObstacles — the FluidObstacleSystem voxelizes each collider into the
// fluid's solid mask every tick, so the breeze genuinely stops at them and has
// to flow around. Trees in the wall's lee stay upright in a sheltered pocket
// while the open forest leans; the boulders trail their own small calm wakes.
//
// The fluid pieces and who does what:
//
// - FluidComponent the volume: a velocity grid + where it sits in the
// world. No Transform on its entity → static.
// - GlobalFluidEffector the atmosphere: position-free. Each tick it relaxes
// EVERY unpinned face toward `wind` at rate `drag`.
// We steer `wind` per-frame in §7.
// - FluidObstacle the wall marker: rides a (Collider, RigidBody,
// Transform) body. FluidObstacleSystem stamps the
// collider into every overlapping field's solid mask.
// - FluidSystem the pump: steps every field on the fixed timestep.
//
// Trees don't participate — they only READ: sampleVelocityAtWorld() at the
// canopy, smoothed through a spring, tilts the cone. Pinned faces inside and
// beside the obstacles never receive the wind, and the projection deflects the
// flow around them — that deflected, slackened flow in the lee is the shelter.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap render · physics · obstacle · fluid systems
// §3 Ground
// §4 The fluid volume
// §5 The trees cone on a stick × a grid
// §6 The wind + the shelter the global effector · the wall + boulders
// §7 Per-frame steer the wind, bend the trees, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { AmbientOcclusionPostProcessEffect } from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
// Rigid-body plumbing — obstacles ARE physics bodies (the same tuple the
// solver simulates), so the obstacle system can read their colliders and
// velocities. Here every body is Static, so the solver just holds them.
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { ColliderObserverSystem } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderObserverSystem.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import { FluidComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidComponent.js";
import { FluidEffectorsComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js";
import { FluidSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidSystem.js";
import { FluidObstacle } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidObstacle.js";
import { FluidObstacleSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidObstacleSystem.js";
import { AdvectionScheme } from "@woosh/meep-engine/src/engine/physics/fluid/FluidSimulator.js";
import { GlobalFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/GlobalFluidEffector.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
// The plantation: TREES_X × TREES_Z cones on sticks. The wind blows mainly
// along +X, so the wall's shelter falls on the +X side of it.
const TREES_X = 18;
const TREES_Z = 12;
const TREE_SPACING = 1.6;
const TRUNK_HEIGHT = 1.1;
const CANOPY_HEIGHT = 1.4;
const CANOPY_RADIUS = 0.55;
// Trees sample the fluid at mid-canopy.
const SAMPLE_Y = TRUNK_HEIGHT + CANOPY_HEIGHT * 0.5;
// The fluid volume.
const CELL_SIZE = 0.6;
const FIELD_SIZE_X = 32; // metres; the prevailing wind runs along +X
const FIELD_SIZE_Y = 6;
const FIELD_SIZE_Z = 22;
// The wind. `wind` is the ambient velocity the air relaxes toward (world
// units/s); `drag` is the relaxation rate (1/s) — higher re-asserts the breeze
// faster after the obstacles slacken it, lower lets the sheltered pockets
// deepen. We steer the wind in §7: its heading wheels slowly and its speed
// gusts, so the forest is never still and the lee shifts with the heading.
const WIND_SPEED = 6; // base ambient speed (world units/s)
const WIND_DRAG = 1.1; // relaxation rate toward `wind` (1/s)
const WIND_SWING = 0.34; // rad — how far the heading wheels off +X
const WIND_SWING_PERIOD = 16; // seconds for one full heading sweep
const WIND_GUST = 0.7; // ± fraction the speed gusts by
const WIND_GUST_PERIOD = 3.5; // seconds per gust cycle
// The windbreak wall: thin across the flow, long across the lanes, tall enough
// to be solid at canopy height. Sat upwind of centre so a broad sheltered lee
// falls across the middle of the forest.
const WALL_X = -5;
const WALL_W = 0.7; // X — thin, across the flow
const WALL_D = 7.0; // Z — long, the windbreak span
const WALL_H = 3.8; // Y — spans canopy height
// Two boulders downwind of the wall, off to the sides, each trailing a small
// calm wake of its own. [x, z, radius].
const BOULDERS = [
[5.5, 3.0, 1.5],
[8.5, -3.5, 1.3],
];
// Grow each voxel footprint half a cell so a thin wall still stamps an airtight
// solid (see FluidObstacle.inflation).
const OBSTACLE_INFLATION = CELL_SIZE * 0.5;
// Tree response: target bend = BEND_GAIN × sampled velocity (grid cells/s),
// capped at MAX_BEND. Each canopy is an underdamped spring (ω, ζ), so gusts
// ring it rather than just leaning it.
const BEND_GAIN = 0.055;
const MAX_BEND = 0.6; // rad — about 34°
const SWAY_OMEGA = 6; // rad/s — canopy natural frequency
const SWAY_ZETA = 0.4; // damping ratio; < 1 ⇒ wobbles past zero
const TRUNK_COLOR = 0x6b4f35;
const CANOPY_COLOR = 0x3f9460;
const WALL_COLOR = 0x4a4640;
const BOULDER_COLOR = 0x6b7079;
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
//
// System registration ORDER matters. The obstacle system must run AFTER the
// physics system (to read this tick's body poses/velocities) and BEFORE the
// fluid system (so the simulation step sees this tick's walls). Engine systems
// run in registration order, so we add them physics → obstacle → fluid.
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
// Rigid bodies + the observer that wires colliders onto them. Our
// obstacles are Static, but they still need the full body tuple.
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
// Voxelizes obstacle colliders into every fluid field's solid mask.
config.addSystem(new FluidObstacleSystem());
// Steps every fluid field on the fixed timestep.
config.addSystem(new FluidSystem());
// Screen-space ambient occlusion — grounds the cones, the wall and the
// boulders visually, cheap contact shadows between canopies.
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 1.2, 0),
distance: 28,
pitch: 0.9,
yaw: 0, // looking straight down the rows, matching wake-field
cameraFarDistance: 300,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §3 Ground ──────────────────────────────────────────────────────────────
{
const t = new Transform();
t.position.set(0, -0.25, 0);
new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(FIELD_SIZE_X + 10, 0.5, FIELD_SIZE_Z + 8),
new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }),
))
.build(ecd);
}
// ─── §4 The fluid volume ────────────────────────────────────────────────────
//
// One FluidComponent = one velocity grid + its world placement. The entity has
// NO Transform, so the placement is static: we set `origin` (the world position
// of cell (0,0,0)'s centre) once and the system never re-anchors it.
const fluid = new FluidComponent();
const RES_X = Math.round(FIELD_SIZE_X / CELL_SIZE) + 1;
const RES_Y = Math.round(FIELD_SIZE_Y / CELL_SIZE) + 1;
const RES_Z = Math.round(FIELD_SIZE_Z / CELL_SIZE) + 1;
fluid.field.setResolution(RES_X, RES_Y, RES_Z);
fluid.field.build();
fluid.cell_size = CELL_SIZE;
fluid.origin = [-FIELD_SIZE_X / 2, 0, -FIELD_SIZE_Z / 2];
new Entity().add(fluid).build(ecd);
const fluidSystem = engine.entityManager.getSystem(FluidSystem);
const sim = fluidSystem.simulator;
// The global effector's `drag` already relaxes the whole field, so the solver's
// own velocity_damping stays low — just enough to settle numerical noise. With
// drag carrying the calming, the obstacle wakes persist long enough to read.
sim.velocity_damping = 0.2;
sim.vorticity_confinement = 0;
// First-order semi-Lagrangian transport smears small structure away, keeping
// the steady wind clean rather than churning.
sim.advection_scheme = AdvectionScheme.SEMI_LAGRANGIAN;
// ─── §5 The trees ───────────────────────────────────────────────────────────
//
// Cone on a stick. The trunk is static; the canopy cone is its own entity whose
// geometry is shifted so the cone's BASE sits at the entity origin — tilting
// the transform then pivots the canopy around the top of the trunk.
const trunkGeometry = new THREE.CylinderGeometry(0.06, 0.09, TRUNK_HEIGHT, 6);
trunkGeometry.translate(0, TRUNK_HEIGHT / 2, 0);
const canopyGeometry = new THREE.ConeGeometry(CANOPY_RADIUS, CANOPY_HEIGHT, 8);
canopyGeometry.translate(0, CANOPY_HEIGHT / 2, 0);
const trunkMaterial = new THREE.MeshStandardMaterial({ color: TRUNK_COLOR, roughness: 0.9 });
const canopyMaterial = new THREE.MeshStandardMaterial({ color: CANOPY_COLOR, roughness: 0.8 });
// Per-tree bookkeeping for §7: world position, the canopy transform we tilt,
// and the smoothed bend/velocity state.
const trees = [];
for (let ix = 0; ix < TREES_X; ix++) {
for (let iz = 0; iz < TREES_Z; iz++) {
const x = (ix - (TREES_X - 1) / 2) * TREE_SPACING;
const z = (iz - (TREES_Z - 1) / 2) * TREE_SPACING;
const trunkTransform = new Transform();
trunkTransform.position.set(x, 0, z);
new Entity()
.add(trunkTransform)
.add(ShadedGeometry.from(trunkGeometry, trunkMaterial))
.build(ecd);
const canopyTransform = new Transform();
canopyTransform.position.set(x, TRUNK_HEIGHT, z);
new Entity()
.add(canopyTransform)
.add(ShadedGeometry.from(canopyGeometry, canopyMaterial))
.build(ecd);
// bx/bz: current bend vector (rad) · ux/uz: bend velocity (rad/s)
trees.push({ x, z, rotation: canopyTransform.rotation, bx: 0, bz: 0, ux: 0, uz: 0 });
}
}
// ─── §6 The wind + the shelter ──────────────────────────────────────────────
//
// THE WIND. One GlobalFluidEffector, position-free, in a FluidEffectorsComponent
// on a Transform-less entity. `wind` + `drag` makes the air relax toward the
// ambient breeze every tick (the fixed point is exactly `wind`), so any
// disturbance — like the obstacle wakes — decays back to the prevailing flow.
// We rewrite `wind` per-frame in §7 to wheel and gust it.
const wind = new GlobalFluidEffector();
wind.wind = [WIND_SPEED, 0, 0];
wind.drag = WIND_DRAG;
const effectors = new FluidEffectorsComponent();
effectors.addEffector(wind);
new Entity().add(effectors).build(ecd);
// THE WINDBREAK WALL. A Static rigid body — Collider (a box), RigidBody,
// Transform — tagged with FluidObstacle so the obstacle system voxelizes it
// into the field's solid mask every tick. The render box matches the collider.
{
const transform = new Transform();
transform.position.set(WALL_X, WALL_H / 2, 0);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from_size(WALL_W, WALL_H, WALL_D);
const obstacle = new FluidObstacle();
obstacle.inflation = OBSTACLE_INFLATION;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(obstacle)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(WALL_W, WALL_H, WALL_D),
new THREE.MeshStandardMaterial({ color: WALL_COLOR, roughness: 0.95 }),
))
.build(ecd);
}
// THE BOULDERS. Static sphere bodies, also FluidObstacles — same recipe, a
// SphereShape3D collider instead of a box.
const boulderMaterial = new THREE.MeshStandardMaterial({ color: BOULDER_COLOR, roughness: 0.9 });
for (let i = 0; i < BOULDERS.length; i++) {
const [x, z, r] = BOULDERS[i];
const transform = new Transform();
transform.position.set(x, r, z);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = SphereShape3D.from(r);
const obstacle = new FluidObstacle();
obstacle.inflation = OBSTACLE_INFLATION;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(obstacle)
.add(ShadedGeometry.from(
new THREE.SphereGeometry(r, 24, 18),
boulderMaterial,
))
.build(ecd);
}
// ─── §7 Per-frame: steer the wind, bend the trees, HUD ──────────────────────
document.getElementById("trees").textContent = String(trees.length);
document.getElementById("cells").textContent = String(RES_X * RES_Y * RES_Z);
const fpsEl = document.getElementById("fps");
const SWING_OMEGA = (2 * Math.PI) / WIND_SWING_PERIOD;
const GUST_OMEGA = (2 * Math.PI) / WIND_GUST_PERIOD;
const sample = new Float32Array(3);
const bendAxis = new Vector3();
let elapsed = 0;
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = Math.min((nowMs - lastFrameMs) / 1000, 0.1);
lastFrameMs = nowMs;
elapsed += dt;
// — Steer the wind. The heading wheels ±WIND_SWING off +X and the speed
// gusts ±WIND_GUST, both on slow sinusoids. Writing `wind` here means the
// next fixedUpdate relaxes the whole field toward the new breeze — frame-
// rate independent, because the effector integrates the EXACT ODE solution.
const heading = WIND_SWING * Math.sin(SWING_OMEGA * elapsed);
const speed = WIND_SPEED * (1 + WIND_GUST * Math.sin(GUST_OMEGA * elapsed));
wind.wind[0] = speed * Math.cos(heading);
wind.wind[2] = speed * Math.sin(heading);
// — Bend the trees. Each samples the fluid velocity at mid-canopy (in
// grid-cells/s — the field's native unit, deliberately not rescaled),
// maps it to a target bend vector, and integrates a damped spring toward
// it (semi-implicit Euler). The tilt axis is ⟂ to the bend so the canopy
// leans downwind; the spring's overshoot is the sway. Trees in the wall's
// lee sample a slackened field and barely lean — the visible shelter.
const w2 = SWAY_OMEGA * SWAY_OMEGA;
const friction = 2 * SWAY_ZETA * SWAY_OMEGA;
for (let i = 0; i < trees.length; i++) {
const tree = trees[i];
fluid.sampleVelocityAtWorld(sample, tree.x, SAMPLE_Y, tree.z);
let tx = sample[0] * BEND_GAIN;
let tz = sample[2] * BEND_GAIN;
const tm = Math.sqrt(tx * tx + tz * tz);
if (tm > MAX_BEND) {
tx *= MAX_BEND / tm;
tz *= MAX_BEND / tm;
}
tree.ux += (w2 * (tx - tree.bx) - friction * tree.ux) * dt;
tree.uz += (w2 * (tz - tree.bz) - friction * tree.uz) * dt;
tree.bx += tree.ux * dt;
tree.bz += tree.uz * dt;
const angle = Math.sqrt(tree.bx * tree.bx + tree.bz * tree.bz);
if (angle < 1e-3) {
tree.rotation.set(0, 0, 0, 1);
continue;
}
bendAxis.set(tree.bz / angle, 0, -tree.bx / angle);
tree.rotation.fromAxisAngle(bendAxis, Math.min(angle, MAX_BEND * 1.4));
}
// — HUD
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prevailing wind · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 180px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #6fd08c; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 440px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">trees</span><span class="value" id="trees">--</span></div>
<div><span class="label">fluid cells</span><span class="value" id="cells">--</span></div>
</div>
<div class="panel legend">
A <strong>global wind</strong> effector blows a steady, wheeling breeze
across the fluid volume; every tree samples the velocity field and leans
with it. A long <strong>windbreak wall</strong> and two
<strong>boulders</strong> are fluid obstacles — the air flows around them,
so the trees in their <strong>lee</strong> stay upright in a sheltered
pocket while the open forest sways. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Prevailing wind",
"description": "A global wind effector blows a steady, wheeling breeze across a tree plantation; every canopy samples the velocity field and leans, while a windbreak wall and two boulders act as fluid obstacles that shelter a calm pocket in their lee.",
"category": "Physics",
"status": "live",
"order": 9,
"tags": ["physics", "fluid", "global", "wind", "obstacle", "vegetation", "ecs"],
"sourceHint": "examples-src/prevailing-wind/",
"demoUrl": "/examples/prevailing-wind/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/prevailing-wind",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A global wind effector blows a wheeling breeze across a tree plantation; a windbreak wall and boulders act as fluid obstacles sheltering a calm lee.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.163.2",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Prevailing wind
One `GlobalFluidEffector` blows a steady breeze across an 18×12 tree
plantation: every fluid face in the `FluidComponent` volume relaxes toward the
same ambient `wind` each tick, so the whole field leans coherently. The wind
slowly wheels and gusts (steered per-frame), and every tree samples the fluid
velocity at canopy height and sways with it.
A long **windbreak wall** and two **boulders** stand in the field. They are
`FluidObstacle`s — the `FluidObstacleSystem` voxelizes each collider into the
fluid's solid mask every tick, so the breeze stops at them and flows around.
Trees in the wall's lee stay upright in a sheltered pocket while the open
forest leans; the boulders trail their own small calm wakes.
What it demonstrates:
- `GlobalFluidEffector` in its `wind` + `drag` regime — position-free, it
relaxes every unpinned face toward the ambient wind at `drag` per second
(the fixed point is exactly `wind`), so obstacle wakes decay back into the
prevailing flow. Pinned faces (inside and beside the solids) are skipped, so
the projection deflects the flow around the obstacles — that slackened,
deflected flow in the lee is the shelter.
- Steering a global wind by rewriting `wind` per-frame; the effector integrates
the exact ODE solution, so the result is frame-rate independent.
- `FluidObstacle` + `FluidObstacleSystem` with two shapes — a `BoxShape3D`
windbreak and `SphereShape3D` boulders — tagged on `BodyKind.Static` bodies.
- System registration ORDER: `PhysicsSystem` → `FluidObstacleSystem` →
`FluidSystem`, so the obstacle voxelization sees this tick's poses and the
fluid step sees this tick's walls. (`inflation = cell_size / 2` keeps the
thin wall airtight.)
- `velocity_damping` left low because the effector's own `drag` carries the
calming — so the obstacle wakes persist long enough to read.
- `sampleVelocityAtWorld()` as a cheap read-side API: vegetation sway driven
straight off the simulation, with no coupling back into it.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/prevailing-wind/`.
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = resolve(__dirname, "../../public/examples/prevailing-wind");
export default defineConfig({
base: "./",
build: {
outDir,
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const src = resolve(__dirname, "thumbnail.png");
if (existsSync(src)) {
mkdirSync(outDir, { recursive: true });
copyFileSync(src, resolve(outDir, "thumbnail.png"));
}
},
},
],
});