<Placement> turns its <Placeable> children into things people move by hand:
- tap a thing to pick it up (a marker appears under it);
- drag it along the floor;
- twist two fingers to turn it;
- tap empty space to put it down.
Drags that start elsewhere still turn the camera (with <CameraRig orbit>). A drag that starts on the marker moves the picked-up thing, so thin things (a towel, a frame) are easy to grab.
The layout is plain data: each thing has an id, a position and a turn, passed in as props and reported back through onChange. Keep it with useLayout, which adds undo, redo and saving between runs.
A room to furnish#
A complete screen: a sofa, a coffee table with a lamp on it, a rug, and a frame on the wall. Undo and redo sit under the room.
import {
Box,
CameraRig,
Cone,
Cylinder,
Lamp,
type LayoutItem,
Merged,
type PlaceOn,
Placeable,
Placement,
Scene,
useLayout,
} from "@apricot/react";
import type { ReactNode } from "react";
import { Button, StyleSheet, View } from "react-native";
type Kind = "sofa" | "table" | "lamp" | "rug" | "frame";
interface Piece extends LayoutItem {
kind: Kind;
}
// Each piece stands on its origin and faces +z. Hung things have their back at z = 0.
const KINDS: Record<Kind, { on?: PlaceOn; surface?: boolean; solid?: boolean; make: () => ReactNode }> = {
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" />
</Merged>
),
},
table: {
surface: true, // things with on="surface" can stand on it
make: () => (
<Merged>
<Box size={[0.9, 0.05, 0.5]} position={[0, 0.425, 0]} color="#e5c595" />
<Cylinder radius={0.03} height={0.4} position={[-0.38, 0.2, -0.18]} color="#8d5c3d" />
<Cylinder radius={0.03} height={0.4} position={[0.38, 0.2, -0.18]} color="#8d5c3d" />
<Cylinder radius={0.03} height={0.4} position={[-0.38, 0.2, 0.18]} color="#8d5c3d" />
<Cylinder radius={0.03} height={0.4} position={[0.38, 0.2, 0.18]} color="#8d5c3d" />
</Merged>
),
},
lamp: {
on: "surface", // on a table, or on the floor
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]} glow={0.08} />
</>
),
},
rug: {
solid: false, // things can stand over it
make: () => <Box size={[1.8, 0.01, 1.2]} position={[0, 0.005, 0]} color="#cfc3cb" />,
},
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>
),
},
};
const START: Piece[] = [
{ id: "rug", kind: "rug", position: [0, 0, 0.2], turn: 0 },
{ id: "sofa", kind: "sofa", position: [0, 0, -1.0], turn: 0 },
{ id: "table", kind: "table", position: [0, 0, 0.2], turn: 0 },
{ id: "lamp", kind: "lamp", position: [0.25, 0.45, 0.2], turn: 0 }, // y = the table's top
{ id: "frame", kind: "frame", position: [0, 1.5, -1.5], turn: 0 }, // on the back wall
];
function Room() {
return (
<>
<Box size={[4, 0.1, 3]} position={[0, -0.05, 0]} color="#ebc79c" />
<Box size={[4, 2.6, 0.1]} position={[0, 1.3, -1.55]} color="#faf1e8" />
<Box size={[0.1, 2.6, 3]} position={[-2.05, 1.3, 0]} color="#faf1e8" />
</>
);
}
export function FurnishRoom() {
const layout = useLayout(START, { save: "my-room" });
return (
<View style={{ flex: 1 }}>
<Scene style={StyleSheet.absoluteFill} look="soft" shadows lamps={1}>
<Room />
<Placement
bounds={[-2, -1.5, 2, 1.5]} // the walls: [minX, minZ, maxX, maxZ]
wallHeight={2.6}
snap={{ grid: 0.05, turn: 15, wall: 0.1 }}
onChange={layout.place}
>
{layout.loaded &&
layout.items.map((p) => {
const kind = KINDS[p.kind];
return (
<Placeable
key={p.id}
id={p.id}
position={p.position}
turn={p.turn}
on={kind.on}
surface={kind.surface}
solid={kind.solid}
>
{kind.make()}
</Placeable>
);
})}
</Placement>
<CameraRig
subject={null}
view={{ azimuth: 35, elevation: 35, distance: 8, target: [0, 0.6, 0] }}
orbit={{ turn: 40, elevation: [12, 70], zoom: [0.5, 1.4] }}
inset={{ bottom: 100 }}
/>
</Scene>
<View style={{ position: "absolute", bottom: 40, left: 0, right: 0, flexDirection: "row", justifyContent: "center" }}>
<Button title="Undo" disabled={!layout.canUndo} onPress={layout.undo} />
<Button title="Redo" disabled={!layout.canRedo} onPress={layout.redo} />
</View>
</View>
);
}What this shows:
- Where things go is set per thing with
on:"floor"(default),"surface"(stands on things markedsurface, or on the floor),"wall"(hangs on the walls roundbounds),"water"(floats, see Ground and water). - Things on a surface move with it. Drag the table and the lamp comes along; both new places are reported.
- Solid things don't overlap. A dragged sofa stops against the table and slides along it.
solid={false}(a rug) lets things over it. snap.wallpulls furniture flush against a wall when it comes this close (metres).<Merged>draws each piece's plain shapes as one mesh: one draw call per piece. See Performance.layout.loaded: withsave, wait until the kept layout has been read before drawing the pieces, so they don't jump.
Building pieces#
Each piece is whatever you put inside its <Placeable>: shapes, a <Merged> group of shapes, or a <Model>.
- Build it standing on its origin (its lowest point at y = 0, centred on x and z) and facing +z.
- Build things for walls with their back at z = 0, facing +z (into the room).
- A piece is measured from what it draws: its footprint is its bounding box, turned with it.
- A
<Model>works the same; usegroundto stand it on its lowest point. It is measured once it has loaded. Until then it has no size, so nothing is checked and it is in no one's way.
import { Placeable } from "@apricot/react";
import { Model } from "@apricot/react/model";
export const Armchair = () => (
<Placeable id="armchair" position={[0.75, 0, -0.35]} turn={330}>
<Model src={require("./assets/armchair.glb")} ground />
</Placeable>
);Positions and turns#
| Field | Meaning |
|---|---|
position |
[x, y, z] in metres. For floor things, y is the floor's level (0). For things on a surface, y is the surface's top. For hung things, [x or z along the wall, height, the wall's x or z] |
turn |
Degrees round the vertical axis. On a wall, set by the wall: 0 on the back wall (min z), 90 on the min-x wall, 180 on the front wall (max z), 270 on the max-x wall |
The walls are the four sides of bounds, wallHeight tall. A thing can be dragged onto any wall the camera sees from inside the room.
Snapping#
snap={{ grid, turn, wall }}: positions snap to a grid (metres), turns to steps of turn (degrees), and furniture moves flush to a wall within wall metres. All default to 0 (free).
When things meet: collide#
| Mode | A moved thing that meets another |
|---|---|
"block" (default) |
Stops against it and slides along it |
"warn" |
Passes through it, with the marker in its blocked colour. Let go there, it goes back to where it last fitted |
"off" |
Ignores it |
"warn" feels freer on a small phone screen; "block" never lets a layout go wrong.
Your own rules: canPlace#
canPlace(id, place, check) is called while a thing moves or turns. Return false where it may not go: with "block" it stays where it last fitted; otherwise the marker turns red and, let go there, it goes back.
import { Placement, type Place, type PlaceCheck } from "@apricot/react";
import type { ReactNode } from "react";
// Keep the doorway (x between -0.5 and 0.5, near the front wall) clear.
const inDoorway = ({ position: [x, , z] }: Place) => Math.abs(x) < 0.5 && z > 1.0;
export const RoomRules = ({ children }: { children: ReactNode }) => (
<Placement
bounds={[-2, -1.5, 2, 1.5]}
collide="warn"
canPlace={(id, place: Place, { standingOn }: PlaceCheck) => standingOn !== null || !inDoorway(place)}
>
{children}
</Placement>
);check (PlaceCheck) tells you what the thing would stand on (standingOn, an id or null), what it would overlap (overlapping), where it goes (on), the area it would stand in and the no-go zones it touches (see Ground and water).
A panel for the picked-up thing#
Control the selection to show your own UI for it: selected and onSelect. onFitChange says whether it fits where it is, to show a hint. The ref (PlacementHandle) turns things by code and lists what stands on them.
import { Box, CameraRig, type LayoutItem, Placeable, Placement, type PlacementHandle, Scene, useLayout } from "@apricot/react";
import { useRef, useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
const START: LayoutItem[] = [
{ id: "crate-1", position: [-0.6, 0, 0], turn: 0 },
{ id: "crate-2", position: [0.6, 0, 0], turn: 0 },
];
export function PiecePanel() {
const layout = useLayout(START);
const placement = useRef<PlacementHandle>(null);
const [selected, setSelected] = useState<string | null>(null);
const [fits, setFits] = useState(true);
// Undone away: nothing picked up.
if (selected !== null && !layout.items.some((p) => p.id === selected)) setSelected(null);
return (
<View style={{ flex: 1 }}>
<Scene style={StyleSheet.absoluteFill} look="soft" shadows>
<Box size={[4, 0.1, 3]} position={[0, -0.05, 0]} color="#ebc79c" />
<Placement
ref={placement}
bounds={[-2, -1.5, 2, 1.5]}
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.items.map((p) => (
<Placeable key={p.id} id={p.id} position={p.position} turn={p.turn}>
<Box size={[0.6, 0.5, 0.4]} position={[0, 0.25, 0]} color="#c98b5a" />
</Placeable>
))}
</Placement>
<CameraRig subject={null} view={{ azimuth: 30, elevation: 40, distance: 7 }} orbit={{ turn: 45 }} />
</Scene>
{selected ? (
<View style={{ position: "absolute", bottom: 40, left: 16, right: 16, gap: 8 }}>
<Text>{fits ? "Drag to move, twist to turn" : "Doesn't fit here: let go and it goes back"}</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap" }}>
<Button title="Turn left" onPress={() => placement.current?.turnBy(selected, 45)} />
<Button title="Turn right" onPress={() => placement.current?.turnBy(selected, -45)} />
<Button
title="Remove"
onPress={() => {
// What stands on it goes too, in one undo step.
layout.remove([selected, ...(placement.current?.standingOn(selected) ?? [])]);
setSelected(null);
}}
/>
<Button title="Undo" disabled={!layout.canUndo} onPress={layout.undo} />
<Button title="Done" onPress={() => setSelected(null)} />
</View>
</View>
) : null}
</View>
);
}turnBy(id, degrees) turns with the same checks as a twist (snapped to snap.turn) and returns false if it could not. Things hung on walls don't turn.
The marker#
marker styles the patch under the picked-up thing (behind it, on a wall): a colour, false for none, or { color, blocked, opacity, margin, radius, outline }. A large radius makes a pill or a disc; outline draws only a ring of that width.
Keeping the layout: useLayout#
useLayout(initial, options) holds a list of LayoutItems ({ id, position, turn } plus your own fields) with undo and redo.
const layout = useLayout(START, { save: "living-room", version: 1 });
<Placement onChange={layout.place}>…</Placement>| Returns | Use |
|---|---|
items |
The layout now. Draw a <Placeable> per item |
place |
Pass to <Placement onChange>. The moves of one gesture (a desk with what stands on it) are one undo step |
add(item), update(id, change), remove(ids), set(items) |
Change the layout; each is one step. remove takes one id or several |
undo(), redo(), canUndo, canRedo |
History, up to history steps (default 100) |
newId(prefix) |
A free id: chair-1, chair-2… |
loaded |
With save: true once the kept layout has been read. Draw items from then on |
With save, the layout is written a moment after each change and read back at the next launch:
- on a phone, as a JSON file in the app's documents (needs
expo-file-system); - on the web, in local storage;
- anywhere else you like, with
storage: { read(name), write(name, text) }.
Raise version when your items change shape: a layout kept by another version is ignored and the screen starts from initial.
Adding a thing and picking it up at once:
const add = (kind: Kind) => {
const id = layout.newId(kind);
layout.add({ id, kind, position: [0.3, 0, 0.5], turn: 0 });
setSelected(id);
};Performance#
- Wrap the room and its
<Placement>in a<Bundle>: still things are recorded once and replayed, which halves the CPU time per frame on Android. Dragged things inside it still move. - Use
<Merged>for pieces made of many shapes. - While a thing moves it casts no shadow, so the shadow map is redrawn when it is picked up and put down, not every frame.
- Draw each piece in a
memocomponent, so moving one does not re-render the others.
See Performance and the room builder recipe.
Related#
- Placement reference.
- Ground and water: areas, no-go zones, snap points and floating things.
- Light a room: daylight through a window, lamps that come on at dusk.