component

CeilingLight

A ceiling fitting hanging from its origin: a flush dome, a pendant shade on a cord, or an aimed spot can. 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 ceilinglight

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

Use it

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

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

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

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

Props

Prop Type Default Description
kind "flush" | "pendant" | "spot" Seeded when unset.
shade "dome" | "cone" | "drum" | "globe" Pendant only; seeded when unset.
drop number 0.6 Pendant cord length from the ceiling to the shade, in units.
size number 0.36 Shade size across, in units.
color color Canopy, cord, and can; defaults to the palette’s metal.
shadeColor color The shade. Seeded when unset.
lightColor color "#ffe2b8" The light and the glowing bulb.
intensity number 12 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/ceilinglight.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add ceilinglight fetches.

Browse the whole catalog in the gallery →.

Source (CeilingLight.tsx)
import { pick, range, rng, sub, useSun, useWorld, type WorldComponentProps } from '@runek/core'
import { useMemo } from 'react'
import { Object3D } from 'three'

/** Close to the ceiling (`flush`), hanging on a cord (`pendant`), or an aimed can (`spot`). */
export type CeilingLightKind = 'flush' | 'pendant' | 'spot'
/** The pendant's shade. */
export type CeilingLightShade = 'dome' | 'cone' | 'drum' | 'globe'

export interface CeilingLightProps extends WorldComponentProps {
  /** Seeded when unset. */
  kind?: CeilingLightKind
  /** Pendant only; seeded when unset. */
  shade?: CeilingLightShade
  /** Pendant cord length from the ceiling to the shade, in units. */
  drop?: number
  /** Shade size across, in units. */
  size?: number
  /** Canopy, cord, and can; defaults to the palette's `metal`. */
  color?: string
  /** The shade. Seeded when unset. */
  shadeColor?: 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
}

const KINDS: CeilingLightKind[] = ['flush', 'pendant', 'pendant', 'spot']
const SHADES: CeilingLightShade[] = ['dome', 'cone', 'drum', 'globe']
const SHADE_COLORS = ['#e9e4d8', '#2f3a35', '#b8743f', '#d9c27a', '#1f2a44']

/**
 * A ceiling fitting, its origin at the ceiling with the fitting hanging below (local -Y): a
 * frosted `flush` dome, a `pendant` shade on a cord, or a `spot` can throwing a cone of light
 * down. `nightOnly` switches it on with the world's dusk. Decorative: no collider.
 */
export function CeilingLight({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  seed = 1,
  kind,
  shade,
  drop = 0.6,
  size = 0.36,
  color,
  shadeColor,
  lightColor = '#ffe2b8',
  intensity = 12,
  on = true,
  nightOnly = false,
}: CeilingLightProps) {
  const { unit, palette } = useWorld()
  const sun = useSun()
  const lit = on && (!nightOnly || !sun.day)
  const metal = color ?? palette.metal
  const R = (size / 2) * unit

  const look = useMemo(() => {
    const r = rng(sub(seed, 0))
    return {
      kind: kind ?? pick(r, KINDS),
      shade: shade ?? pick(r, SHADES),
      shadeColor: shadeColor ?? pick(r, SHADE_COLORS),
      tilt: range(r, -0.25, 0.25),
    }
  }, [seed, kind, shade, shadeColor])

  const target = useMemo(() => new Object3D(), [])
  const bulb = (
    <meshStandardMaterial
      color="#fff6e0"
      emissive={lightColor}
      emissiveIntensity={lit ? 2 : 0}
      toneMapped={!lit}
    />
  )
  const canopy = (
    <mesh position={[0, -0.015 * unit, 0]}>
      <cylinderGeometry args={[0.06 * unit, 0.07 * unit, 0.03 * unit, 16]} />
      <meshStandardMaterial color={metal} metalness={0.5} roughness={0.4} />
    </mesh>
  )

  if (look.kind === 'flush') {
    return (
      <group position={position} rotation={rotation}>
        <mesh position={[0, -0.01 * unit, 0]}>
          <cylinderGeometry args={[R * 1.05, R * 1.05, 0.02 * unit, 28]} />
          <meshStandardMaterial color={metal} metalness={0.5} roughness={0.4} />
        </mesh>
        <mesh position={[0, -0.02 * unit, 0]} scale={[1, 0.45, 1]} rotation={[Math.PI, 0, 0]}>
          <sphereGeometry args={[R, 28, 12, 0, Math.PI * 2, 0, Math.PI / 2]} />
          {bulb}
        </mesh>
        {lit && (
          <pointLight
            position={[0, -R * 0.6, 0]}
            color={lightColor}
            intensity={intensity}
            distance={10 * unit}
            decay={2}
          />
        )}
      </group>
    )
  }

  if (look.kind === 'spot') {
    const canL = 0.14 * unit
    return (
      <group position={position} rotation={rotation}>
        {canopy}
        <group position={[0, -0.04 * unit, 0]} rotation={[look.tilt, 0, 0]}>
          <mesh position={[0, -canL / 2, 0]} castShadow>
            <cylinderGeometry args={[0.05 * unit, 0.045 * unit, canL, 18, 1, true]} />
            <meshStandardMaterial color={metal} metalness={0.5} roughness={0.4} side={2} />
          </mesh>
          <mesh position={[0, -canL + 0.01 * unit, 0]} rotation={[Math.PI / 2, 0, 0]}>
            <circleGeometry args={[0.04 * unit, 18]} />
            {bulb}
          </mesh>
          <primitive object={target} position={[0, -3 * unit, 0]} />
          {lit && (
            <spotLight
              position={[0, -canL, 0]}
              target={target}
              color={lightColor}
              intensity={intensity * 2}
              distance={12 * unit}
              angle={0.5}
              penumbra={0.6}
              decay={2}
            />
          )}
        </group>
      </group>
    )
  }

  const D = drop * unit
  const dome = look.shade === 'dome'
  const shadeH = dome ? R : (look.shade === 'drum' ? 0.7 : 0.8) * R * 2
  return (
    <group position={position} rotation={rotation}>
      {canopy}
      <mesh position={[0, -D / 2, 0]}>
        <cylinderGeometry args={[0.003 * unit, 0.003 * unit, D, 5]} />
        <meshStandardMaterial color={metal} />
      </mesh>
      <group position={[0, -D, 0]}>
        {look.shade === 'globe' ? (
          <mesh position={[0, -R, 0]}>
            <sphereGeometry args={[R, 24, 16]} />
            <meshStandardMaterial
              color={look.shadeColor}
              emissive={lightColor}
              emissiveIntensity={lit ? 0.9 : 0}
              transparent
              opacity={0.92}
            />
          </mesh>
        ) : (
          <mesh position={[0, dome ? -R : -shadeH / 2, 0]} castShadow>
            {dome ? (
              <sphereGeometry args={[R, 24, 10, 0, Math.PI * 2, 0, Math.PI / 2]} />
            ) : look.shade === 'cone' ? (
              <cylinderGeometry args={[R * 0.12, R, shadeH, 24, 1, true]} />
            ) : (
              <cylinderGeometry args={[R, R, shadeH, 24, 1, true]} />
            )}
            <meshStandardMaterial
              color={look.shadeColor}
              roughness={0.5}
              metalness={0.2}
              side={2}
            />
          </mesh>
        )}
        {look.shade !== 'globe' && (
          <mesh position={[0, -shadeH * 0.8, 0]}>
            <sphereGeometry args={[0.035 * unit, 12, 8]} />
            {bulb}
          </mesh>
        )}
        {lit && (
          <pointLight
            position={[0, -shadeH, 0]}
            color={lightColor}
            intensity={intensity}
            distance={10 * unit}
            decay={2}
          />
        )}
      </group>
    </group>
  )
}