// Path display — a Meep example for PathDisplay + PathDisplaySystem.
//
// Four paths, four ways to draw them (far to near):
//
// 1 ENTITY MARKERS a blueprint-stamped start cube, sphere
// waypoints, and an arrowhead cone at the end
// 2 STACKED SPECS one zigzag path, TWO tube specs: a wide
// translucent sheath over a thin solid core
// 3 TUBE, animated path_mask lit marching dashes (round caps) on a wave —
// the mask is rewritten every frame, re-tessellated
// 4 TUBE, standard material a smooth Catmull-Rom wave, lit and shadowed
//
// The pieces:
//
// - `Path` (from the navigation package) is the curve itself: a point list
// plus an interpolation mode (Linear or CatmullRom). It's the same
// component path-following agents consume — PathDisplay just draws it.
// - `PathDisplay` holds a list of `PathDisplaySpec`s — (type, style) pairs.
// One path can carry several specs at once (lane 2).
// - `PathDisplaySystem` watches Path + PathDisplay entities and builds the
// geometry. After mutating a style, send `PathEvents.Changed` to the
// entity and the display rebuilds (lane 3 does this every frame).
// - Entity-marker displays stamp `Mesh` entities, which want `MeshSystem`
// + the GLTF loader registered alongside `PathDisplaySystem`.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap
// §3 Ground
// §4 Path builders the curves
// §5 The four lanes one display style each
// §6 Per-frame march the dashes, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { GLTFAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFAssetLoader.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { EntityBlueprint } from "@woosh/meep-engine/src/engine/ecs/EntityBlueprint.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 Mesh from "@woosh/meep-engine/src/engine/graphics/ecs/mesh/Mesh.js";
import { MeshSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh/MeshSystem.js";
import Path from "@woosh/meep-engine/src/engine/navigation/ecs/components/Path.js";
import { PathEvents } from "@woosh/meep-engine/src/engine/navigation/ecs/components/PathEvents.js";
import { InterpolationType } from "@woosh/meep-engine/src/engine/navigation/ecs/components/InterpolationType.js";
import { PathDisplay } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplay.js";
import { PathDisplaySpec } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplaySpec.js";
import { PathDisplaySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplaySystem.js";
import { PathDisplayType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplayType.js";
import { TubePathStyle } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/TubePathStyle.js";
import { TubeMaterialType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/TubeMaterialType.js";
import { StandardMaterialDefinition } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/StandardMaterialDefinition.js";
import { BasicMaterialDefinition } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/BasicMaterialDefinition.js";
import { CapType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/CapType.js";
import { EntityPathStyle } from "@woosh/meep-engine/src/engine/graphics/ecs/path/entity/EntityPathStyle.js";
import { EntityPathMarkerDefinition } from "@woosh/meep-engine/src/engine/graphics/ecs/path/entity/EntityPathMarkerDefinition.js";
import { clamp01 } from "@woosh/meep-engine/src/core/math/clamp01.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
const LANE_LENGTH = 16; // each path runs this far along X
const LANE_SPACING = 3.2; // metres between lanes, along Z
const PATH_Y = 1.0; // hover height
const DASH_COUNT = 9; // marching dashes on lane 4
const DASH_FILL = 0.5; // fraction of each dash period that is "on"
const DASH_SPEED = 0.12; // mask offset per second
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
// Builds tube / marker geometry for Path + PathDisplay entities.
config.addSystem(new PathDisplaySystem(engine));
// Entity-marker displays stamp Mesh entities from blueprints.
config.addSystem(new MeshSystem(engine));
const gltf = new GLTFAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltf);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltf);
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 0.6, 0),
distance: 21,
pitch: 0.7,
yaw: 0,
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(Mesh);
// ─── §3 Ground ──────────────────────────────────────────────────────────────
{
const t = new Transform();
t.position.set(0, -0.25, 0);
new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(LANE_LENGTH + 6, 0.5, 4 * LANE_SPACING + 4),
new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }),
))
.build(ecd);
}
// ─── §4 Path builders ───────────────────────────────────────────────────────
//
// A Path is just points + an interpolation mode. CatmullRom turns sparse
// control points into a smooth spline at display time; Linear keeps the
// corners. These are the same components a PathFollowingSystem agent would
// walk — the display layer is purely additive.
function makePath(points, interpolation) {
const p = new Path();
p.interpolation = interpolation;
p.setPositionsFromVectorArray(points);
return p;
}
function wave(z, amplitude, cycles, n = 9) {
const pts = [];
for (let i = 0; i < n; i++) {
const f = i / (n - 1);
pts.push(new Vector3(
(f - 0.5) * LANE_LENGTH,
PATH_Y,
z + Math.sin(f * Math.PI * cycles) * amplitude,
));
}
return pts;
}
function zigzag(z, amplitude, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const f = i / steps;
pts.push(new Vector3(
(f - 0.5) * LANE_LENGTH,
PATH_Y,
z + (i % 2 === 0 ? -amplitude : amplitude),
));
}
return pts;
}
// ─── §5 The five lanes ──────────────────────────────────────────────────────
// Lane 1 (far) — ENTITY MARKERS: a cube at the start, spheres along the way,
// an arrowhead cone at the end. Each marker is stamped from an
// EntityBlueprint (Transform + Mesh templates — anything serializable works);
// `align` orients the marker's +Z along the travel direction, which is what
// points the cone forward.
{
const markerBlueprint = (url) => {
const bp = new EntityBlueprint();
bp.addJSON(Transform, new Transform().toJSON());
bp.addJSON(Mesh, { url, castShadow: true, receiveShadow: true });
return bp;
};
const style = new EntityPathStyle();
style.marker_start = EntityPathMarkerDefinition.from({ blueprint: markerBlueprint("./models/marker-start.glb") });
style.marker_main = EntityPathMarkerDefinition.from({ blueprint: markerBlueprint("./models/marker-mid.glb") });
style.marker_end = EntityPathMarkerDefinition.from({ blueprint: markerBlueprint("./models/marker-end.glb") });
style.spacing = 1.4; // one waypoint sphere every ~1.4 m
new Entity()
.add(makePath(wave(-1.5 * LANE_SPACING, 0.8, 1.5), InterpolationType.CatmullRom))
.add(PathDisplay.from(PathDisplaySpec.from(PathDisplayType.Entity, style)))
.build(ecd);
}
// Lane 2 — STACKED SPECS on one path: a hard zigzag (Linear interpolation —
// note the corners survive) drawn TWICE: a wide translucent sheath with a
// thin solid core inside. PathDisplay.specs is a list; this is its point.
{
const core = new TubePathStyle();
core.color.setRGB(1.0, 0.55, 0.15);
core.material_type = TubeMaterialType.Basic;
core.material = new BasicMaterialDefinition();
core.width = 0.12;
core.resolution = 4; // Linear path — no curvature to chase
core.cap_type = CapType.Round;
core.cast_shadow = true; // the solid core casts; the translucent
core.receive_shadow = true; // sheath is left shadowless on purpose
const sheath = new TubePathStyle();
sheath.color.setRGB(1.0, 0.55, 0.15);
sheath.opacity = 0.22; // translucent over-tube
sheath.material_type = TubeMaterialType.Basic;
sheath.material = new BasicMaterialDefinition();
sheath.width = 0.45;
sheath.resolution = 4;
sheath.cap_type = CapType.Round;
new Entity()
.add(makePath(zigzag(-0.5 * LANE_SPACING, 0.9, 8), InterpolationType.Linear))
.add(PathDisplay.from(
PathDisplaySpec.from(PathDisplayType.Tube, sheath),
PathDisplaySpec.from(PathDisplayType.Tube, core),
))
.build(ecd);
}
// Lane 3 — TUBE with an ANIMATED path_mask: marching dashes. The mask is a
// flat list of [start, end] fractions along the path; rewrite it and send
// PathEvents.Changed and the tube re-tessellates to match. §6 does that
// every frame.
const dashStyle = new TubePathStyle();
dashStyle.color.setRGB(0.31, 0.94, 0.66);
dashStyle.material_type = TubeMaterialType.Standard; // lit — the round dash
dashStyle.material = new StandardMaterialDefinition(); // caps need shading to
dashStyle.material.roughness = 0.5; // read as solid pills
dashStyle.material.metalness = 0.1;
dashStyle.width = 0.3;
dashStyle.resolution = 32;
dashStyle.cap_type = CapType.Round;
dashStyle.cast_shadow = true;
dashStyle.receive_shadow = true;
// NOTE: keep the Entity INSTANCE — build() returns the entity id, but
// sendEvent() (used in §6 to trigger re-tessellation) lives on the instance.
const dashEntity = new Entity()
.add(makePath(wave(0.5 * LANE_SPACING, 1.2, 3), InterpolationType.CatmullRom))
.add(PathDisplay.from(PathDisplaySpec.from(PathDisplayType.Tube, dashStyle)));
dashEntity.build(ecd);
// Lane 4 (near) — TUBE with a standard (lit) material: smooth wave, shadows.
{
const style = new TubePathStyle();
style.color.setRGB(0.78, 0.82, 0.88);
style.material_type = TubeMaterialType.Standard;
style.material = new StandardMaterialDefinition();
style.material.roughness = 0.45;
style.material.metalness = 0.25;
style.width = 0.45; // tube diameter, world units
style.resolution = 24; // spline samples per segment
style.cap_type = CapType.Round;
style.cast_shadow = true;
style.receive_shadow = true;
new Entity()
.add(makePath(wave(1.5 * LANE_SPACING, 1.0, 2), InterpolationType.CatmullRom))
.add(PathDisplay.from(PathDisplaySpec.from(PathDisplayType.Tube, style)))
.build(ecd);
}
// ─── §6 Per-frame: march the dashes, HUD ────────────────────────────────────
document.getElementById("lanes").textContent = "4";
const fpsEl = document.getElementById("fps");
let time = 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;
time += dt;
// Rebuild lane 4's mask: DASH_COUNT windows, each DASH_FILL of a period
// wide, all sliding along the path and wrapping at the ends.
const mask = [];
const period = 1 / DASH_COUNT;
const offset = (time * DASH_SPEED) % period;
for (let i = 0; i < DASH_COUNT + 1; i++) {
const start = i * period + offset - period;
const end = start + period * DASH_FILL;
if (end <= 0 || start >= 1) continue;
mask.push(clamp01(start), clamp01(end));
}
dashStyle.path_mask = mask;
dashEntity.sendEvent(PathEvents.Changed); // ask the system to re-tessellate
// 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>Path display · 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: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 460px;
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">lanes</span><span class="value" id="lanes">--</span></div>
</div>
<div class="panel legend">
Four paths drawn by <strong>PathDisplay</strong>: blueprint-stamped
<strong>entity markers</strong> (cube start, sphere waypoints, arrowhead end);
a zigzag with <strong>two stacked tube specs</strong> — translucent sheath over
a solid core; a wave of <strong>marching dashes</strong> (an animated, lit
<code>path_mask</code>); and a lit, shadow-casting <strong>tube</strong>.
Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Path display",
"description": "Four paths, four display styles: blueprint-stamped entity markers, stacked tube specs, a wave of lit marching dashes animated through path_mask, and a lit shadow-casting tube.",
"category": "Rendering",
"status": "live",
"order": 7,
"tags": ["rendering", "path", "tube", "spline", "navigation", "ecs"],
"sourceHint": "examples-src/path-display/",
"demoUrl": "/examples/path-display/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/path-display",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Four paths, four display styles: entity markers, stacked tube specs, a wave of animated marching dashes via path_mask, and a lit tube.",
"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"
}
}
# Path display
Four paths, four ways to draw them with `PathDisplay` + `PathDisplaySystem`:
1. **Entity markers** — a cube at the start, sphere waypoints every ~1.4 m,
and an arrowhead cone at the end, each stamped from an `EntityBlueprint`
(Transform + Mesh templates pointing at three tiny bundled GLBs). `align`
orients each marker's +Z along the travel direction — that's what points
the cone forward.
2. **Stacked specs** — one zigzag path (Linear interpolation, corners intact)
carrying TWO tube `PathDisplaySpec`s: a wide translucent sheath over a
thin solid core.
3. **Tube, animated `path_mask`** — a wave of lit, round-capped marching
dashes. The mask is a flat list of `[start, end]` fractions along the path;
the demo rewrites it every frame and sends `PathEvents.Changed` so the
system re-tessellates.
4. **Tube, standard material** — a Catmull-Rom wave rendered as a lit,
shadow-casting tube (`TubePathStyle` with `StandardMaterialDefinition`,
round caps).
What it demonstrates:
- `Path` (from the navigation package) is the curve: points + interpolation
mode. It's the same component path-following agents consume — the display
layer is purely additive.
- `PathDisplay.from(...specs)` — (type, style) pairs; one path can carry any
number of displays.
- `TubePathStyle` knobs: width, spline resolution, round / flat / no caps,
cast/receive shadow, Basic / Standard / Matcap materials, and `path_mask`
for partial or dashed display.
- Live mutation: change a style, send `PathEvents.Changed` **to the Entity
instance** (`build()` returns the entity id — keep the instance around),
and the geometry rebuilds.
Wiring note: entity-marker displays stamp `Mesh` entities, so register
`MeshSystem` + the GLTF loaders alongside `PathDisplaySystem`.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/path-display/`.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
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 thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/path-display");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/path-display"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});