# Recipe: room builder

> A complete furnish-a-room screen in one file. Pick up, drag and turn furniture, stand things on tables, hang frames on walls, add and remove pieces, undo and redo, keep the layout between launches, and change the time of day with lamps that come on at dusk.

A cosy room to furnish by hand, the pattern behind the Apricot playground's room builder:

- tap a piece to pick it up, drag it, twist two fingers to turn it, tap the floor to put it down;
- a panel shows the picked-up piece with turn, remove, undo and done buttons;
- with nothing picked up, the panel adds pieces, undoes, redoes and resets, and sets the time of day;
- the layout is kept between launches.

**Copy `RoomBuilder.tsx` into your app.** It needs only `@apricot/react` (and `expo-file-system` to keep the layout on a phone). Replace the shape-built pieces with your own `<Model>`s when you have them.

```tsx title="RoomBuilder.tsx"
import {
  Box,
  Bundle,
  CameraRig,
  Cone,
  Cylinder,
  Daylight,
  Lamp,
  type LayoutItem,
  LightBeam,
  Merged,
  type Place,
  type PlaceOn,
  Placeable,
  Placement,
  type PlacementHandle,
  Scene,
  useLayout,
  WindowPane,
} from "@apricot/react";
import { memo, type ReactNode, useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";

// The room: 4 × 3 m, 2.6 m high, a window in the right (+x) wall.
const W = 2;
const D = 1.5;
const H = 2.6;
const T = 0.1;
const BOUNDS = [-W, -D, W, D] as const;
const WINDOW = { y: 1.4, z: 0.2, width: 1, height: 1.2 };

// The pieces. Each stands on its origin and faces +z; hung things have their back at z = 0.
type Kind = "sofa" | "table" | "lamp" | "plant" | "rug" | "frame";
interface Spec {
  name: string;
  on?: PlaceOn;
  surface?: boolean;
  solid?: boolean;
  make: () => ReactNode;
}
const CATALOGUE: Record<Kind, Spec> = {
  sofa: {
    name: "Sofa",
    make: () => (
      <Merged>
        <Box size={[1.6, 0.4, 0.8]} position={[0, 0.2, 0]} color="#ee9c9b" />
        <Box size={[1.6, 0.5, 0.2]} position={[0, 0.65, -0.3]} color="#e48a8a" />
        <Box size={[0.2, 0.3, 0.8]} position={[-0.7, 0.55, 0]} color="#e48a8a" />
        <Box size={[0.2, 0.3, 0.8]} position={[0.7, 0.55, 0]} color="#e48a8a" />
      </Merged>
    ),
  },
  table: {
    name: "Table",
    surface: true,
    make: () => (
      <Merged>
        <Box size={[0.9, 0.05, 0.5]} position={[0, 0.425, 0]} color="#e5c595" />
        {[-0.38, 0.38].flatMap((x) =>
          [-0.18, 0.18].map((z) => (
            <Cylinder key={`${x},${z}`} radius={0.03} height={0.4} position={[x, 0.2, z]} color="#8d5c3d" />
          )),
        )}
      </Merged>
    ),
  },
  lamp: {
    name: "Lamp",
    on: "surface",
    make: () => (
      <>
        <Merged>
          <Cylinder radius={0.05} height={0.25} position={[0, 0.125, 0]} color="#f2c8a1" />
          <Cone radius={0.12} height={0.14} position={[0, 0.3, 0]} color="#fff3e0" />
        </Merged>
        <Lamp position={[0, 0.28, 0]} on="dark" glow={0.1} />
      </>
    ),
  },
  plant: {
    name: "Plant",
    on: "surface",
    make: () => (
      <Merged>
        <Cylinder radius={0.09} radiusTop={0.11} height={0.18} position={[0, 0.09, 0]} color="#f5f2ee" />
        <Cone radius={0.16} height={0.4} position={[0, 0.38, 0]} color="#97ba80" />
      </Merged>
    ),
  },
  rug: {
    name: "Rug",
    solid: false,
    make: () => <Box size={[1.8, 0.01, 1.2]} position={[0, 0.005, 0]} color="#cfc3cb" />,
  },
  frame: {
    name: "Frame",
    on: "wall",
    make: () => (
      <Merged>
        <Box size={[0.5, 0.4, 0.03]} position={[0, 0, 0.015]} color="#fbf7f1" />
        <Box size={[0.4, 0.3, 0.01]} position={[0, 0, 0.035]} color="#a2bbcc" />
      </Merged>
    ),
  },
};

interface Piece extends LayoutItem {
  kind: Kind;
}

const START: Piece[] = [
  { id: "rug", kind: "rug", position: [-0.3, 0, 0.2], turn: 0 },
  { id: "sofa", kind: "sofa", position: [-0.3, 0, -1.05], turn: 0 },
  { id: "table", kind: "table", position: [-0.3, 0, 0.2], turn: 0 },
  { id: "lamp", kind: "lamp", position: [-0.05, 0.45, 0.2], turn: 0 },
  { id: "plant", kind: "plant", position: [1.6, 0, -1.1], turn: 0 },
  { id: "frame", kind: "frame", position: [-0.3, 1.6, -D], turn: 0 },
];

const startPlace = (kind: Kind): Place =>
  CATALOGUE[kind].on === "wall" ? { position: [0.6, 1.4, -D], turn: 0 } : { position: [0.5, 0, 0.6], turn: 0 };

const TIMES = [
  { label: "Morning", hour: 8.5 },
  { label: "Afternoon", hour: 16.5 },
  { label: "Sunset", hour: 18.6 },
  { label: "Night", hour: 22 },
];

function Shell() {
  const top = WINDOW.y + WINDOW.height / 2;
  const bottom = WINDOW.y - WINDOW.height / 2;
  const front = WINDOW.z + WINDOW.width / 2;
  const back = WINDOW.z - WINDOW.width / 2;
  return (
    <>
      <Merged>
        <Box size={[2 * W, 0.1, 2 * D]} position={[0, -0.05, 0]} color="#ebc79c" />
        <Box size={[2 * W, H, T]} position={[0, H / 2, -D - T / 2]} color="#faf1e8" />
        <Box size={[T, bottom, 2 * D]} position={[W + T / 2, bottom / 2, 0]} color="#faf1e8" />
        <Box size={[T, H - top, 2 * D]} position={[W + T / 2, (H + top) / 2, 0]} color="#faf1e8" />
        <Box size={[T, WINDOW.height, back + D]} position={[W + T / 2, WINDOW.y, (back - D) / 2]} color="#faf1e8" />
        <Box size={[T, WINDOW.height, D - front]} position={[W + T / 2, WINDOW.y, (front + D) / 2]} color="#faf1e8" />
      </Merged>
      {/* Not drawn: they keep the sun out, so it only comes in by the window. */}
      <Box size={[2 * W + 0.4, T, 2 * D + 0.4]} position={[0, H + T / 2, 0]} shadowOnly />
      <Box size={[2 * W + 0.4, H, T]} position={[0, H / 2, D + T / 2]} shadowOnly />
      <Box size={[T, H, 2 * D + 0.4]} position={[-W - T / 2, H / 2, 0]} shadowOnly />
      <WindowPane size={[WINDOW.width, WINDOW.height]} position={[W + T / 2, WINDOW.y, WINDOW.z]} rotation={[0, Math.PI / 2, 0]} />
    </>
  );
}

// One piece: rendered again only when it moved, not when another did.
const PieceView = memo(function PieceView({ piece }: { piece: Piece }) {
  const spec = CATALOGUE[piece.kind];
  return (
    <Placeable id={piece.id} position={piece.position} turn={piece.turn} on={spec.on} surface={spec.surface} solid={spec.solid}>
      {spec.make()}
    </Placeable>
  );
});

export function RoomBuilder() {
  // Raise `version` when Piece changes shape: older kept layouts are then ignored.
  const layout = useLayout(START, { save: "room-builder", version: 1 });
  const placement = useRef<PlacementHandle>(null);
  const [selected, setSelected] = useState<string | null>(null);
  const [fits, setFits] = useState(true);
  const [time, setTime] = useState(1);
  // Undone away: nothing picked up.
  if (selected !== null && !layout.items.some((p) => p.id === selected)) setSelected(null);
  const picked = layout.items.find((p) => p.id === selected) ?? null;

  const add = (kind: Kind) => {
    const id = layout.newId(kind);
    layout.add({ id, kind, ...startPlace(kind) });
    setSelected(id);
  };
  const remove = () => {
    if (!picked) return;
    // What stands on it goes too, in one undo step.
    layout.remove([picked.id, ...(placement.current?.standingOn(picked.id) ?? [])]);
    setSelected(null);
  };

  return (
    <View style={styles.screen}>
      <Scene
        style={StyleSheet.absoluteFill}
        look="none"
        toneMapping="neutral"
        shadows={{ softness: 3, darkness: 0.94 }}
        occlusion={{ radius: 0.5, strength: 1 }}
        lamps={2}
      >
        {/* The window faces west: afternoon sun comes in. Indoors the sun needs to be stronger. */}
        <Daylight time={TIMES[time].hour} north={Math.PI} strength={{ sun: 4 }} />
        {/* Still things recorded once and replayed. See-through things go inside too. */}
        <Bundle>
          <LightBeam
            sun
            from={[W, WINDOW.y, WINDOW.z]}
            size={[WINDOW.width, WINDOW.height]}
            facing={[-1, 0, 0]}
            bounds={BOUNDS}
            intensity={5}
          />
          <Shell />
          <Placement
            ref={placement}
            bounds={BOUNDS}
            wallHeight={H}
            snap={{ grid: 0.05, turn: 15, wall: 0.12 }}
            collide="warn"
            selected={selected}
            onSelect={(id) => {
              setSelected(id);
              setFits(true);
            }}
            onChange={layout.place}
            onFitChange={(_, now) => setFits(now)}
            marker={{ color: "#3ccf6e", blocked: "#ff5a4f", radius: 0.08 }}
          >
            {layout.loaded && layout.items.map((p) => <PieceView key={p.id} piece={p} />)}
          </Placement>
        </Bundle>
        <CameraRig
          subject={null}
          view={{ azimuth: -36, elevation: 32, distance: 9, target: [0, 0.9, 0], fov: 30 }}
          orbit={{ turn: 40, elevation: [12, 70], zoom: [0.5, 1.4] }}
          inset={{ bottom: 170 }}
        />
      </Scene>

      <View style={styles.panel}>
        {picked ? (
          <>
            <Text style={styles.title}>{CATALOGUE[picked.kind].name}</Text>
            <Text style={[styles.sub, !fits && styles.warn]}>
              {fits ? "Drag to move · twist two fingers to turn · tap the floor to put it down" : "Doesn't fit here: let go and it goes back"}
            </Text>
            <View style={styles.row}>
              {CATALOGUE[picked.kind].on !== "wall" ? (
                <>
                  <Chip label="⟲ 45°" onPress={() => placement.current?.turnBy(picked.id, 45)} />
                  <Chip label="⟳ 45°" onPress={() => placement.current?.turnBy(picked.id, -45)} />
                </>
              ) : null}
              <Chip label="Remove" onPress={remove} />
              <Chip label="Done" strong onPress={() => setSelected(null)} />
            </View>
          </>
        ) : (
          <>
            <View style={styles.row}>
              {TIMES.map((t, i) => (
                <Chip key={t.label} label={t.label} strong={i === time} onPress={() => setTime(i)} />
              ))}
            </View>
            <Text style={styles.title}>Tap a piece to pick it up</Text>
            <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
              <Chip label="↶ Undo" disabled={!layout.canUndo} onPress={layout.undo} />
              <Chip label="↷ Redo" disabled={!layout.canRedo} onPress={layout.redo} />
              {(Object.keys(CATALOGUE) as Kind[]).map((kind) => (
                <Chip key={kind} label={`+ ${CATALOGUE[kind].name}`} onPress={() => add(kind)} />
              ))}
              <Chip label="Reset" onPress={() => layout.set(START)} />
            </ScrollView>
          </>
        )}
      </View>
    </View>
  );
}

function Chip({ label, onPress, strong, disabled }: { label: string; onPress: () => void; strong?: boolean; disabled?: boolean }) {
  return (
    <Pressable onPress={onPress} disabled={disabled} style={[styles.chip, strong && styles.strong, disabled && styles.disabled]}>
      <Text style={[styles.chipText, strong && styles.strongText]}>{label}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, backgroundColor: "#dccbc4" },
  panel: {
    position: "absolute",
    left: 0,
    right: 0,
    bottom: 0,
    padding: 16,
    paddingBottom: 32,
    gap: 6,
    backgroundColor: "rgba(255,250,246,0.94)",
    borderTopLeftRadius: 18,
    borderTopRightRadius: 18,
  },
  title: { fontSize: 17, fontWeight: "700", color: "#3d2b24" },
  sub: { fontSize: 13, color: "#7a6259" },
  warn: { color: "#d2453a" },
  row: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
  chip: { paddingVertical: 9, paddingHorizontal: 14, borderRadius: 999, backgroundColor: "#f1e3da" },
  chipText: { fontSize: 14, fontWeight: "600", color: "#5b4036" },
  strong: { backgroundColor: "#e98a6a" },
  strongText: { color: "#ffffff" },
  disabled: { opacity: 0.4 },
});
```

## How it fits together

- **`useLayout`** holds the pieces with undo and redo, and keeps them under `"room-builder"`. `layout.place` goes to `onChange`; a desk moved with what stands on it is one undo step.
- **`<Placement>`** handles the gestures. `collide="warn"` lets pieces pass through each other with a red marker; let go there, they go back. `onFitChange` drives the hint.
- **`<Bundle>`** records the room once and replays it each frame, which roughly halves the CPU time per frame on Android. Pieces inside it still move.
- **`<Merged>`** draws each piece's plain shapes in one call.
- **`<Daylight>`** sets the hour; the `<LightBeam sun>` and `<WindowPane>` follow it, and `<Lamp on="dark">` comes on at dusk (`lamps={2}`: up to two light the room at once).
- **`PieceView` is memoised**, so moving one piece doesn't re-render the others.

## Variations

- **Real furniture:** replace a `make()` with `<Model src={require("./assets/sofa.glb")} ground />`. Models are measured when they load.
- **Stop against things instead:** `collide="block"`, so pieces slide along each other and never overlap.
- **Rules:** `canPlace={(id, place, check) => …}` to keep a doorway clear, or plants only on surfaces.
- **No saving:** drop `save` (and `layout.loaded` is always true).

## Related

- [Placing things by hand](https://matthiasbrandolin.dev/apricot/guides/placement).
- [Light a room](https://matthiasbrandolin.dev/apricot/guides/rooms).
- [Placement reference](https://matthiasbrandolin.dev/apricot/reference/placement).
