component

Roof

Flat or gable roof.

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

Tune every prop in the workshop →

Add it

npx @runek/cli add roof

Pulls @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.

Use it

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

<Roof position={[0, 0, 0]} />

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

{ "type": "Roof", "props": { "position": [0, 0, 0] } }

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.
seed number Accepted for the component contract; this component has no seeded variation yet.
size [number, number] [8, 8] [width, depth] in units. The roof rests with its base at the component origin.
style "flat" | "gable" "gable"
peak number 1.6 Ridge height for a gable roof, in units.
thickness number 0.18 Thickness, in units.
overhang number 0.3
ends boolean true Cap the triangular gable ends so the attic isn’t open to the outside.
color color palette roof Defaults to the world palette’s roof slot.
endColor color palette wall Gable end caps; defaults to the world palette’s wall slot.

Migrate

v0.11.0 → v0.12.0. No signature break, but gable roofs now cap their open triangular ends with wall-colored prisms (the attic used to show the sky). The old look is one prop away.

v0.11.0
<Roof style="gable" />
// v0.11.0: gable ends rendered open
v0.12.0
<Roof style="gable" />
// v0.12.0: ends are capped by default

<Roof style="gable" ends={false} />
// opt back into the open look

Registry manifest

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

Browse the whole catalog in the gallery →.

Source (Roof.tsx)
import { RigidBody } from '@react-three/rapier'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo } from 'react'
import { ExtrudeGeometry, Shape } from 'three'

export type RoofStyle = 'flat' | 'gable'

export interface RoofProps {
  position?: Vec3
  rotation?: Vec3
  /** Accepted for the component contract; this component has no seeded variation yet. */
  seed?: number
  /** `[width, depth]` in units. The roof rests with its base at the component origin. */
  size?: [number, number]
  style?: RoofStyle
  /** Ridge height for a gable roof, in units. */
  peak?: number
  thickness?: number
  overhang?: number
  /** Cap the triangular gable ends so the attic isn't open to the outside. */
  ends?: boolean
  /** Defaults to the world palette's `roof` slot. */
  color?: string
  /** Gable end caps; defaults to the world palette's `wall` slot. */
  endColor?: string
}

export function Roof({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  size = [8, 8],
  style = 'gable',
  peak = 1.6,
  thickness = 0.18,
  overhang = 0.3,
  ends = true,
  color,
  endColor,
}: RoofProps) {
  const { unit, palette } = useWorld()
  const roofColor = color ?? palette.roof
  const w = size[0] * unit + overhang * 2 * unit
  const d = size[1] * unit + overhang * 2 * unit
  const t = thickness * unit
  const ridge = peak * unit
  const gabled = style === 'gable'

  // Gable end cap: a triangular prism spanning the un-overhung depth, apex at the
  // ridge. The base sits inside the roof's eave overhang, so it lands on the walls.
  const innerHalfD = (size[1] / 2) * unit
  const endGeometry = useMemo(() => {
    if (!gabled || !ends) return null
    const shape = new Shape()
    shape.moveTo(-innerHalfD, 0)
    shape.lineTo(innerHalfD, 0)
    shape.lineTo(0, ridge)
    shape.closePath()
    return new ExtrudeGeometry(shape, { depth: t, bevelEnabled: false })
  }, [gabled, ends, innerHalfD, ridge, t])

  if (!gabled) {
    return (
      <RigidBody type="fixed" colliders="cuboid" position={position} rotation={rotation}>
        <mesh castShadow receiveShadow position={[0, t / 2, 0]}>
          <boxGeometry args={[w, t, d]} />
          <meshStandardMaterial color={roofColor} />
        </mesh>
      </RigidBody>
    )
  }

  const slope = Math.hypot(d / 2, ridge)
  const angle = Math.atan2(ridge, d / 2)
  const capX = (size[0] / 2) * unit

  return (
    <RigidBody type="fixed" colliders="cuboid" position={position} rotation={rotation}>
      <mesh castShadow receiveShadow position={[0, ridge / 2, d / 4]} rotation={[angle, 0, 0]}>
        <boxGeometry args={[w, t, slope]} />
        <meshStandardMaterial color={roofColor} />
      </mesh>
      <mesh castShadow receiveShadow position={[0, ridge / 2, -d / 4]} rotation={[-angle, 0, 0]}>
        <boxGeometry args={[w, t, slope]} />
        <meshStandardMaterial color={roofColor} />
      </mesh>
      {endGeometry &&
        [capX, -capX].map((x) => (
          <mesh
            key={x}
            castShadow
            receiveShadow
            geometry={endGeometry}
            position={[x - t / 2, 0, 0]}
            rotation={[0, Math.PI / 2, 0]}
          >
            <meshStandardMaterial color={endColor ?? palette.wall} />
          </mesh>
        ))}
    </RigidBody>
  )
}