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.
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#
useLayoutholds the pieces with undo and redo, and keeps them under"room-builder".layout.placegoes toonChange; 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.onFitChangedrives 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).PieceViewis 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(andlayout.loadedis always true).