component

Path

A meandering ribbon trail from seeded waypoints; decorative, laid just over the ground.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add path

Pulls @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Path } from './runek/Path'

<Path position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Path", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

Prop Type Default Description
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
length number 12 Length along local Z, in units.
width number 1.4 Width, in units.
meander number 1.2 Lateral meander amplitude, in units.
rise number 0 Total height climbed from the near end (local −Z) to the far end (+Z), in units. For a trail that gains height as it winds; the ribbon rises linearly along its length.
heights number[] Explicit elevation profile, in units: evenly spaced samples from the near end (local −Z) to the far end (+Z), linearly interpolated along the ribbon. Overrides rise. Author it from the terrain the trail crosses so the ribbon hugs the ground it climbs.
color color palette ground Defaults to the world palette’s ground slot.
segments number 48
seed number 1 Seed for the deterministic variation: same seed, same result.

Registry manifest

registry manifest /r/components/path.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add path fetches.

Browse the whole catalog in the gallery →.

Source (Path.tsx)
import { rng, useWorld, type Vec3 } from '@runek/core'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'

export interface PathProps {
  position?: Vec3
  rotation?: Vec3
  /** Length along local Z, in units. */
  length?: number
  width?: number
  /** Lateral meander amplitude, in units. */
  meander?: number
  /** Total height climbed from the near end (local −Z) to the far end (+Z), in units. For a
   *  trail that gains height as it winds; the ribbon rises linearly along its length. */
  rise?: number
  /** Explicit elevation profile, in units: evenly spaced samples from the near end (local −Z)
   *  to the far end (+Z), linearly interpolated along the ribbon. Overrides `rise`. Author it
   *  from the terrain the trail crosses so the ribbon hugs the ground it climbs. */
  heights?: number[]
  /** Defaults to the world palette's `ground` slot. */
  color?: string
  segments?: number
  seed?: number
}

/** A meandering ribbon trail laid flat in the XZ plane, draped just above the
 *  ground. Decorative: you walk on the terrain beneath it, so it owns no collider. */
export function Path({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  length = 12,
  width = 1.4,
  meander = 1.2,
  rise = 0,
  heights,
  color,
  segments = 48,
  seed = 1,
}: PathProps) {
  const { unit, palette } = useWorld()
  const pathColor = color ?? palette.ground
  const L = length * unit
  const W = width * unit
  const amp = meander * unit
  const Rise = rise * unit

  const geometry = useMemo(() => {
    const next = rng(seed)
    const p1 = next() * Math.PI * 2
    const p2 = next() * Math.PI * 2
    const f1 = 1.2 + next() * 0.6
    const f2 = 2.6 + next() * 1.0
    const centerX = (t: number) =>
      amp * (Math.sin(t * Math.PI * f1 + p1) * 0.6 + Math.sin(t * Math.PI * f2 + p2) * 0.4)
    const profile = heights && heights.length > 1 ? heights : null
    const elevation = (t: number) => {
      if (!profile) return t * Rise
      const s = t * (profile.length - 1)
      const i = Math.min(profile.length - 2, Math.floor(s))
      return (profile[i] + (profile[i + 1] - profile[i]) * (s - i)) * unit
    }

    const positions: number[] = []
    const indices: number[] = []
    for (let i = 0; i <= segments; i++) {
      const t = i / segments
      const z = -L / 2 + t * L
      const cx = centerX(t)
      const dt = 1 / segments
      const tx = centerX(Math.min(1, t + dt)) - cx
      const tz = dt * L
      const tl = Math.hypot(tx, tz) || 1
      const nx = -tz / tl
      const nz = tx / tl
      const y = elevation(t)
      positions.push(cx + (nx * W) / 2, y, z + (nz * W) / 2)
      positions.push(cx - (nx * W) / 2, y, z - (nz * W) / 2)
    }
    for (let i = 0; i < segments; i++) {
      const a = i * 2
      indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3)
    }
    const g = new THREE.BufferGeometry()
    g.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
    g.setIndex(indices)
    g.computeVertexNormals()
    return g
  }, [L, W, amp, Rise, heights, unit, segments, seed])

  useEffect(() => () => geometry.dispose(), [geometry])

  return (
    <mesh
      geometry={geometry}
      position={[position[0], position[1] + 0.02 * unit, position[2]]}
      rotation={rotation}
      receiveShadow
    >
      <meshStandardMaterial color={pathColor} roughness={1} side={THREE.DoubleSide} />
    </mesh>
  )
}