component

Chandelier

A hanging chandelier: a chain, a turned brass body, one or two rings of curved arms with candle bulbs, and optional glass drops. Instanced, with one shared light; can light only after dusk.

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

Tune every prop in the workshop →

Add it

npx @runek/cli add chandelier

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

Use it

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

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

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

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

Props

Prop Type Default Description
arms number Arms on the lower tier. Seeded (5 to 8) when unset.
tiers 1 | 2 One ring of arms, or two (the upper one smaller). Seeded when unset.
radius number 0.45 Reach of the lower arms from the center, in units.
drop number 0.4 Chain length from the ceiling to the body, in units.
crystals boolean Glass drops hanging from the arms. Seeded when unset.
color color Frame, arms, and cups; defaults to the palette’s accent (brass).
lightColor color "#ffd9a0" The light and the glowing bulb.
intensity number 22 Light strength.
on boolean true Switched on.
nightOnly boolean false Light only while the world’s sun is down.
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.
seed number 1 Seed for the deterministic variation: same seed, same result.

Registry manifest

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

Browse the whole catalog in the gallery →.

Source (Chandelier.tsx)
import { int, rng, sub, useSun, useWorld, type WorldComponentProps } from '@runek/core'
import { type ReactNode, useLayoutEffect, useMemo, useRef } from 'react'
import {
  CubicBezierCurve3,
  type InstancedMesh,
  LatheGeometry,
  Matrix4,
  Quaternion,
  TubeGeometry,
  Vector2,
  Vector3,
} from 'three'

export interface ChandelierProps extends WorldComponentProps {
  /** Arms on the lower tier. Seeded (5 to 8) when unset. */
  arms?: number
  /** One ring of arms, or two (the upper one smaller). Seeded when unset. */
  tiers?: 1 | 2
  /** Reach of the lower arms from the center, in units. */
  radius?: number
  /** Chain length from the ceiling to the body, in units. */
  drop?: number
  /** Glass drops hanging from the arms. Seeded when unset. */
  crystals?: boolean
  /** Frame, arms, and cups; defaults to the palette's `accent` (brass). */
  color?: string
  /** The light and the glowing bulb. */
  lightColor?: string
  /** Light strength. */
  intensity?: number
  /** Switched on. */
  on?: boolean
  /** Light only while the world's sun is down. */
  nightOnly?: boolean
}

interface Tier {
  y: number
  r: number
  arms: number
  offset: number
}

/** One draw call for every copy of a part: the matrices are set once per layout. */
function Instanced({
  matrices,
  children,
  castShadow,
}: {
  matrices: Matrix4[]
  children: ReactNode
  castShadow?: boolean
}) {
  const ref = useRef<InstancedMesh>(null)
  useLayoutEffect(() => {
    const mesh = ref.current
    if (!mesh) return
    matrices.forEach((m, i) => {
      mesh.setMatrixAt(i, m)
    })
    mesh.instanceMatrix.needsUpdate = true
  }, [matrices])
  return (
    <instancedMesh
      key={matrices.length}
      ref={ref}
      args={[undefined, undefined, matrices.length]}
      castShadow={castShadow}
    >
      {children}
    </instancedMesh>
  )
}

const UP = new Vector3(0, 1, 0)

/**
 * A hanging chandelier, its origin at the ceiling: a chain, a turned brass body, and one or two
 * rings of curved arms each ending in a cup and a candle bulb, with optional glass drops. Every
 * repeated part is instanced and the whole fitting shares one light, so a ballroom of them stays
 * cheap. `nightOnly` lights it with the world's dusk. Decorative: no collider.
 */
export function Chandelier({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  seed = 1,
  arms,
  tiers,
  radius = 0.45,
  drop = 0.4,
  crystals,
  color,
  lightColor = '#ffd9a0',
  intensity = 22,
  on = true,
  nightOnly = false,
}: ChandelierProps) {
  const { unit, palette } = useWorld()
  const sun = useSun()
  const lit = on && (!nightOnly || !sun.day)
  const brass = color ?? palette.accent
  const R = radius * unit
  const D = drop * unit
  const bodyH = R * 0.75

  const look = useMemo(() => {
    const r = rng(sub(seed, 0))
    const n = Math.max(3, Math.round(arms ?? int(r, 5, 8)))
    const t = tiers ?? (r() < 0.45 ? 2 : 1)
    const list: Tier[] = [{ y: -D - bodyH * 0.72, r: R, arms: n, offset: 0 }]
    if (t === 2)
      list.push({
        y: -D - bodyH * 0.3,
        r: R * 0.6,
        arms: Math.max(3, Math.round(n * 0.6)),
        offset: Math.PI / n,
      })
    return { tiers: list, crystals: crystals ?? r() < 0.6 }
  }, [seed, arms, tiers, crystals, R, D, bodyH])

  const parts = useMemo(() => {
    const arm: Matrix4[] = []
    const cup: Matrix4[] = []
    const bulb: Matrix4[] = []
    const drops: Matrix4[] = []
    const q = new Quaternion()
    const one = new Vector3(1, 1, 1)
    for (const tier of look.tiers) {
      for (let i = 0; i < tier.arms; i++) {
        const a = tier.offset + (i / tier.arms) * Math.PI * 2
        q.setFromAxisAngle(UP, -a)
        const s = tier.r
        arm.push(new Matrix4().compose(new Vector3(0, tier.y, 0), q, new Vector3(s, s, s)))
        const tip = new Vector3(Math.cos(a) * s, tier.y + 0.12 * s + 0.02 * unit, Math.sin(a) * s)
        cup.push(new Matrix4().compose(tip, q, one))
        bulb.push(new Matrix4().compose(tip.clone().add(new Vector3(0, 0.07 * unit, 0)), q, one))
        if (look.crystals) {
          // under the arm's lowest point (the curve at t = 0.5)
          const mid = new Vector3(
            Math.cos(a) * s * 0.62,
            tier.y - 0.27 * s - 0.045 * unit,
            Math.sin(a) * s * 0.62,
          )
          drops.push(new Matrix4().compose(mid, q, one))
        }
      }
    }
    return { arm, cup, bulb, drops }
  }, [look, unit])

  // Arms are built at unit reach and scaled per tier: they dip below the body and rise to the cup.
  const armGeometry = useMemo(() => {
    const curve = new CubicBezierCurve3(
      new Vector3(0.05, 0, 0),
      new Vector3(0.35, -0.4, 0),
      new Vector3(0.95, -0.35, 0),
      new Vector3(1, 0.12, 0),
    )
    return new TubeGeometry(curve, 24, 0.022, 6, false)
  }, [])

  const bodyGeometry = useMemo(() => {
    const k = bodyH
    const profile = [
      [0, 0],
      [0.02, 0],
      [0.03, 0.08],
      [0.09, 0.2],
      [0.12, 0.32],
      [0.05, 0.45],
      [0.04, 0.62],
      [0.08, 0.75],
      [0.03, 0.9],
      [0.015, 1],
      [0, 1],
    ].map(([x, y]) => new Vector2(x * k * 1.2, -y * k))
    return new LatheGeometry(profile, 20)
  }, [bodyH])

  const cupR = 0.035 * unit
  const metal = <meshStandardMaterial color={brass} metalness={0.75} roughness={0.3} />

  return (
    <group position={position} rotation={rotation}>
      <mesh position={[0, -0.015 * unit, 0]}>
        <cylinderGeometry args={[0.05 * unit, 0.08 * unit, 0.03 * unit, 20]} />
        {metal}
      </mesh>
      <mesh position={[0, -D / 2, 0]}>
        <cylinderGeometry args={[0.006 * unit, 0.006 * unit, D, 6]} />
        {metal}
      </mesh>
      <mesh position={[0, -D, 0]} geometry={bodyGeometry} castShadow>
        {metal}
      </mesh>
      <Instanced matrices={parts.arm} castShadow>
        <primitive object={armGeometry} attach="geometry" />
        {metal}
      </Instanced>
      <Instanced matrices={parts.cup}>
        <cylinderGeometry args={[cupR, cupR * 0.4, 0.04 * unit, 12]} />
        {metal}
      </Instanced>
      <Instanced matrices={parts.bulb}>
        <sphereGeometry args={[0.022 * unit, 10, 8]} />
        <meshStandardMaterial
          color="#fff3d6"
          emissive={lightColor}
          emissiveIntensity={lit ? 2.2 : 0}
          toneMapped={!lit}
        />
      </Instanced>
      {parts.drops.length > 0 && (
        <Instanced matrices={parts.drops}>
          <octahedronGeometry args={[0.03 * unit, 0]} />
          <meshPhysicalMaterial
            color="#e8f2ff"
            roughness={0.05}
            transmission={0.6}
            thickness={0.02 * unit}
            emissive={lightColor}
            emissiveIntensity={lit ? 0.25 : 0}
          />
        </Instanced>
      )}
      {lit && (
        <pointLight
          position={[0, look.tiers[0].y + 0.1 * R, 0]}
          color={lightColor}
          intensity={intensity}
          distance={14 * unit}
          decay={2}
        />
      )}
    </group>
  )
}