WEBXR
Step into the room.
A small room you can furnish with ferns, cacti and tulips. On the page, choose a kind, click the floor to plant and drag to look around. With a headset, Enter VR stands you in it: glide with the left stick, turn with the right, and point a controller at the floor to plant, with a tips label riding your left hand. Enter AR and the furniture goes so your own room shows through: each plant is a WebXR anchor that stays where you put it, and the planes and meshes the device detects are drawn from Messages, each by a group following its tracked space.
Tracked things as data.
The Model holds the plants, where the player stands, and what the session reported finding. Groups follow the head, hands, controllers, anchors, planes and meshes without a Message per frame; what changes comes back as one. Because the room is changed only by Messages, an agent can furnish it too: in development, the Foldkit DevTools MCP server sends it PlacedPlant, decoded against the room's schema.
See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
model.ts
import { glide, perspectiveCamera, SceneSchema, xr } from '@opsydyn/effect-three';
import { Schema } from 'effect';
/** Whether the browser can present a mode, and whether it is. */
export const Support = Schema.Literals(['checking', 'unavailable', 'ready']);
export type Support = typeof Support.Type;
/** Not presenting, or presenting in VR or AR. */
export const Session = Schema.Literals(['none', 'entering', 'immersive-vr', 'immersive-ar']);
export type Session = typeof Session.Type;
export const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);
/** The plants there are to place. */
export const PlantKind = Schema.Literals(['fern', 'cactus', 'tulip']);
export type PlantKind = typeof PlantKind.Type;
/** A plant someone placed: on the page or in VR on the room's floor; in AR, at an anchor. */
export const Plant = Schema.Struct({ id: Schema.String, at: Vec3, kind: PlantKind });
export type Plant = typeof Plant.Type;
export const Model = Schema.Struct({
assetBase: Schema.String,
vr: Support,
ar: Support,
session: Session,
plants: Schema.Array(Plant),
nextPlant: Schema.Number,
/** What a click on the floor plants. */
kind: PlantKind,
/** What an AR session has detected, as it last reported. */
planes: Schema.Array(SceneSchema.XrPlane),
meshes: Schema.Array(SceneSchema.XrMesh),
/** Where the player stands: gliding moves them, and the Model keeps where. */
player: SceneSchema.Xr,
camera: SceneSchema.Camera,
});
export type Model = typeof Model.Type;
export const mostPlants = 12;
export const homeCamera = perspectiveCamera({
fov: 50,
position: [2.6, 2.3, 4.6],
lookAt: [0, 0.8, 0],
damping: 0.08,
});
export const initialModel = (assetBase: string): Model => ({
assetBase,
vr: 'checking',
ar: 'checking',
session: 'none',
plants: [{ id: 'plant-0', at: [-0.9, 0, 0.9], kind: 'fern' }],
nextPlant: 1,
kind: 'fern',
planes: [],
meshes: [],
// In front of the sofa, facing the room's back wall.
player: xr({ origin: [0, 0, 2.2], glide: glide(), hitTest: true, planes: true, meshes: true }),
camera: homeCamera,
});
message.ts
import { SceneSchema } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
import { PlantKind, Vec3 } from './model';
export const Mode = Schema.Literals(['immersive-vr', 'immersive-ar']);
export type Mode = typeof Mode.Type;
export const Message = defineMessageUnion({
CheckedSupport: { mode: Mode, supported: Schema.Boolean },
ClickedEnter: { mode: Mode },
ClickedExit: {},
FailedEnter: {},
ChangedSession: { mode: Schema.NullOr(Mode) },
/** A click (or a controller's select) on the floor: plant the chosen kind there. */
ClickedFloor: { point: Vec3 },
ChoseKind: { kind: PlantKind },
/**
* A plant placed at a point on the floor (`y` is ignored): what a click becomes, and
* what an agent or a script sends to arrange the room.
*/
PlacedPlant: { at: Vec3, kind: PlantKind },
ClickedPlant: { id: Schema.String },
ClickedClear: {},
/** In AR, a select whose ray met a real surface. */
SelectedSurface: { surface: SceneSchema.XrSurfaceDetail },
DetectedPlanes: { detected: SceneSchema.XrPlanesDetail },
DetectedMeshes: { detected: SceneSchema.XrMeshesDetail },
Glided: { moved: SceneSchema.XrMovedDetail },
MadeGesture: { gesture: SceneSchema.Gesture },
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { glided, type Gesture, orbit, orbitOptions } from '@opsydyn/effect-three';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { CheckSupport, Enter, Exit } from './command';
import { Message } from './message';
import { initialModel, type Model, mostPlants, type PlantKind } from './model';
type AppReturn = Return<Model, Message>;
/** A plant of `kind` at `point`, the oldest going once there are too many. */
const planted = (
model: Model,
point: readonly [number, number, number],
kind: PlantKind,
): Model => ({
...model,
plants: [...model.plants, { id: `plant-${model.nextPlant}`, at: point, kind }].slice(-mostPlants),
nextPlant: model.nextPlant + 1,
});
/** On the floor, within the room's walls. */
const onFloor = (at: readonly [number, number, number]): readonly [number, number, number] => [
Math.min(2.8, Math.max(-2.8, at[0])),
0,
Math.min(2.8, Math.max(-2.8, at[2])),
];
const support = { true: 'ready', false: 'unavailable' } as const;
const orbitLimits = orbitOptions({
minDistance: 2,
maxDistance: 9,
maxPolarAngle: 1.45,
minAzimuthAngle: -0.4,
maxAzimuthAngle: 1.6,
});
/** Look around, unless the gesture began on something to click. */
const orbited = (model: Model, gesture: Gesture): Model =>
'key' in gesture && gesture.key !== undefined
? model
: { ...model, camera: orbit(model.camera, gesture, orbitLimits) };
export const init = (props: { readonly assetBase?: string } = {}) => ({
model: initialModel(props.assetBase ?? '/models/'),
commands: [CheckSupport({ mode: 'immersive-vr' }), CheckSupport({ mode: 'immersive-ar' })],
});
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
CheckedSupport: ({ mode, supported }) => ({
model:
mode === 'immersive-vr'
? { ...model, vr: support[`${supported}`] }
: { ...model, ar: support[`${supported}`] },
}),
ClickedEnter: ({ mode }) => ({
model: { ...model, session: 'entering' },
commands: [Enter({ mode })],
}),
ClickedExit: () => ({ model, commands: [Exit()] }),
FailedEnter: () => ({ model: { ...model, session: 'none' } }),
// The session's end arrives whoever ends it; what was detected goes with it.
ChangedSession: ({ mode }) => ({
model:
mode === null
? { ...model, session: 'none', planes: [], meshes: [] }
: { ...model, session: mode },
}),
ClickedFloor: ({ point }) => ({ model: planted(model, onFloor(point), model.kind) }),
ChoseKind: ({ kind }) => ({ model: { ...model, kind } }),
PlacedPlant: ({ at, kind }) => ({ model: planted(model, onFloor(at), kind) }),
ClickedPlant: ({ id }) => ({
model: modifyFields(model, { plants: (plants) => plants.filter((plant) => plant.id !== id) }),
}),
ClickedClear: () => ({ model: { ...model, plants: [] } }),
SelectedSurface: ({ surface }) => ({ model: planted(model, surface.point, model.kind) }),
DetectedPlanes: ({ detected }) => ({ model: { ...model, planes: detected.planes } }),
DetectedMeshes: ({ detected }) => ({ model: { ...model, meshes: detected.meshes } }),
Glided: ({ moved }) => ({
model: modifyFields(model, { player: (player) => glided(player, moved) }),
}),
MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
ReceivedSceneLifecycle: () => ({ model }),
});
command.ts
/** Asking the browser about VR and AR, and entering and leaving them. */
import { Effect } from 'effect';
import { Command } from 'foldkit';
import { environment } from '../labelledEnvironment';
import { Message, Mode } from './message';
export const CheckSupport = Command.define('CheckSupport', {
args: { mode: Mode },
messages: [Message.CheckedSupport],
execute: ({ mode }) =>
Effect.suspend(() => environment.xr.supported(mode)).pipe(
Effect.map((supported) => Message.CheckedSupport({ mode, supported })),
),
});
/** Run from the click on "Enter": browsers start sessions only from a user gesture. */
export const Enter = Command.define('Enter', {
args: { mode: Mode },
messages: [Message.ChangedSession, Message.FailedEnter],
execute: ({ mode }) =>
Effect.suspend(() => environment.xr.enter(mode)).pipe(
Effect.match({
onSuccess: () => Message.ChangedSession({ mode }),
onFailure: () => Message.FailedEnter(),
}),
),
});
export const Exit = Command.define('Exit', {
messages: [Message.ChangedSession],
execute: Effect.suspend(() => environment.xr.exit).pipe(
Effect.map(() => Message.ChangedSession({ mode: null })),
),
});
scene.ts
/**
* A small room, presentable in VR and AR. On the page and in VR it is furniture you can
* plant things around; in AR the furniture goes, your own room shows through, and what
* the session finds there is described as data:
*
* - each plant placed in AR is an anchor (`xr({ anchors })`), and a group following
* `anchorSpace(id)` keeps the plant where you put it as the session learns the room;
* - detected planes and meshes come back as Messages; groups following `planeSpace(id)`
* and `meshSpace(id)` draw them, a mesh with its lent geometry (`xrMesh(id)`);
* - a tips label follows your left hand or controller (`jointSpace`, `controllerSpace`).
*/
import {
basic,
box,
circle,
cone,
controllerSpace,
cylinder,
directionalLight,
environment,
fixed,
group,
hemisphereLight,
jointSpace,
label,
meshSpace,
mesh,
plane,
planeSpace,
pointLight,
renderSettings,
scene,
type SceneNode,
sphere,
standard,
type Vec3,
xrAnchor,
anchorSpace,
xrMesh,
type XrPlane,
} from '@opsydyn/effect-three';
import { paintColour } from '../palette';
import type { Model, Plant, PlantKind } from './model';
const piece = (
key: string,
position: Vec3,
size: Vec3,
colour: number,
roughness = 0.8,
): SceneNode =>
mesh({
key,
position,
geometry: box({ width: size[0], height: size[1], depth: size[2] }),
material: standard({ color: colour, roughness }),
castShadow: true,
receiveShadow: true,
});
const sofaColour = 0x5d6b8c;
const wood = 0x9a6b45;
/** The room: floor, two walls with a window, a sofa, a table on a rug, a lamp and a shelf. */
const room: ReadonlyArray<SceneNode> = [
mesh({
key: 'floor',
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width: 6, height: 6 }),
material: standard({ color: 0xc8a27a, roughness: 0.7 }),
receiveShadow: true,
interactive: true,
}),
mesh({
key: 'rug',
position: [0.3, 0.004, -1.1],
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: circle({ radius: 1.35, segments: 48 }),
material: standard({ color: 0xe9e1d3, roughness: 1 }),
receiveShadow: true,
interactive: true,
}),
piece('wall-back', [0, 1.4, -3], [6, 2.8, 0.1], 0xe8e2d8, 0.95),
piece('wall-left', [-3, 1.4, 0], [0.1, 2.8, 6], 0xded6ca, 0.95),
mesh({
key: 'window',
position: [1.6, 1.6, -2.94],
geometry: plane({ width: 1.4, height: 1.1 }),
material: standard({ color: 0xbcd4ff, emissive: 0xbcd4ff, emissiveIntensity: 0.9 }),
}),
piece('sofa-seat', [0.3, 0.22, -2.35], [2.2, 0.44, 0.9], sofaColour),
piece('sofa-back', [0.3, 0.62, -2.75], [2.2, 0.56, 0.22], sofaColour),
piece('sofa-arm-left', [-0.85, 0.42, -2.35], [0.2, 0.42, 0.9], sofaColour),
piece('sofa-arm-right', [1.45, 0.42, -2.35], [0.2, 0.42, 0.9], sofaColour),
piece('cushion', [-0.25, 0.55, -2.5], [0.42, 0.34, 0.14], paintColour.coral, 1),
piece('table-top', [0.3, 0.42, -1.1], [1.1, 0.05, 0.6], wood, 0.45),
...[-0.45, 0.45].flatMap((x) =>
[-0.22, 0.22].map((z) =>
piece(`table-leg:${x}:${z}`, [0.3 + x, 0.2, -1.1 + z], [0.05, 0.4, 0.05], wood, 0.45),
),
),
// The lamp: a pole, a glowing shade, and its light.
mesh({
key: 'lamp-pole',
position: [-2.1, 0.75, -2.3],
geometry: cylinder({ radiusTop: 0.025, radiusBottom: 0.025, height: 1.5 }),
material: standard({ color: 0x2b2e35, metalness: 0.6, roughness: 0.4 }),
castShadow: true,
}),
mesh({
key: 'lamp-shade',
position: [-2.1, 1.55, -2.3],
geometry: cylinder({ radiusTop: 0.16, radiusBottom: 0.24, height: 0.3, radialSegments: 24 }),
material: standard({ color: 0xffe2b0, emissive: 0xffc56b, emissiveIntensity: 1.2 }),
}),
pointLight({ key: 'lamp', color: 0xffc98a, intensity: 6, position: [-2.1, 1.45, -2.3] }),
// A shelf of books on the left wall.
piece('shelf', [-2.82, 1.3, -0.6], [0.3, 0.04, 1.4], wood, 0.5),
...[0x3d79fa, 0xf5ce60, 0x80d7bb, 0xfa8b79, 0xb38bf5, 0x2b2e35].map((colour, index) =>
piece(
`book:${index}`,
[-2.8, 1.47, -1.15 + index * 0.2],
[0.22, 0.3 - (index % 3) * 0.04, 0.16],
colour,
0.7,
),
),
];
const pot = (id: string): SceneNode =>
mesh({
key: `${id}:pot`,
position: [0, 0.11, 0],
geometry: cylinder({ radiusTop: 0.11, radiusBottom: 0.08, height: 0.22, radialSegments: 20 }),
material: standard({ color: paintColour.coral, roughness: 0.6 }),
castShadow: true,
interactive: true,
});
const bloom = (id: string, position: Vec3, colour: number): SceneNode =>
mesh({
key: `${id}:bloom`,
position,
geometry: sphere({ radius: 0.04 }),
material: standard({ color: colour, emissive: colour, emissiveIntensity: 0.4 }),
});
/** What grows in each kind's pot. */
const growth = {
fern: (id) => [
mesh({
key: `${id}:leaves`,
position: [0, 0.38, 0],
geometry: cone({ radius: 0.16, height: 0.4, radialSegments: 12 }),
material: standard({ color: 0x4f9a6b, roughness: 0.7, flatShading: true }),
castShadow: true,
interactive: true,
}),
bloom(id, [0.05, 0.6, 0.02], paintColour.gold),
],
cactus: (id) => [
mesh({
key: `${id}:stem`,
position: [0, 0.42, 0],
geometry: cylinder({ radiusTop: 0.06, radiusBottom: 0.07, height: 0.42, radialSegments: 10 }),
material: standard({ color: 0x5f8f4e, roughness: 0.8, flatShading: true }),
castShadow: true,
interactive: true,
}),
mesh({
key: `${id}:arm`,
position: [0.1, 0.46, 0],
geometry: cylinder({
radiusTop: 0.035,
radiusBottom: 0.035,
height: 0.16,
radialSegments: 8,
}),
material: standard({ color: 0x5f8f4e, roughness: 0.8, flatShading: true }),
castShadow: true,
interactive: true,
}),
bloom(id, [0, 0.65, 0], paintColour.coral),
],
tulip: (id) => [
mesh({
key: `${id}:stem`,
position: [0, 0.38, 0],
geometry: cylinder({
radiusTop: 0.012,
radiusBottom: 0.012,
height: 0.34,
radialSegments: 6,
}),
material: standard({ color: 0x4f9a6b, roughness: 0.7 }),
interactive: true,
}),
mesh({
key: `${id}:flower`,
position: [0, 0.6, 0],
geometry: sphere({ radius: 0.08 }),
material: standard({ color: 0xe0566b, roughness: 0.5 }),
castShadow: true,
interactive: true,
}),
],
} satisfies Record<PlantKind, (id: string) => ReadonlyArray<SceneNode>>;
/** A potted plant of its kind, at the origin of whatever holds it. */
const plantParts = (plant: Plant): ReadonlyArray<SceneNode> => [
pot(plant.id),
...growth[plant.kind](plant.id),
];
/** In AR a plant rides its anchor; elsewhere it stands where it was put. */
const plantNode = (model: Model, plant: Plant): SceneNode =>
group({
key: plant.id,
position: plant.at,
follow: model.session === 'immersive-ar' ? anchorSpace(plant.id) : null,
children: plantParts(plant),
});
/** A detected plane, drawn flat over its outline's bounds, with what the device took it for. */
const planeNode = (detected: XrPlane): SceneNode => {
const xs = detected.polygon.map((point) => point[0]);
const zs = detected.polygon.map((point) => point[2]);
const width = Math.max(0.05, Math.max(...xs) - Math.min(...xs));
const depth = Math.max(0.05, Math.max(...zs) - Math.min(...zs));
const centre: Vec3 = [
(Math.max(...xs) + Math.min(...xs)) / 2,
0.002,
(Math.max(...zs) + Math.min(...zs)) / 2,
];
return group({
key: `plane:${detected.id}`,
follow: planeSpace(detected.id),
children: [
mesh({
key: `plane:${detected.id}:surface`,
position: centre,
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width, height: depth }),
material: basic({ color: paintColour.mint, opacity: 0.25, transparent: true }),
}),
...(detected.label === ''
? []
: [
label({
key: `plane:${detected.id}:label`,
text: detected.label,
position: [centre[0], 0.08, centre[2]],
fontSize: 0.06,
color: 0xffffff,
outlineWidth: 0.004,
outlineColor: 0x1f3b33,
}),
]),
],
});
};
/** Tips that ride the left hand, or the left controller: whichever is tracked shows. */
const tips = (model: Model): ReadonlyArray<SceneNode> => {
const text =
model.session === 'immersive-ar'
? 'Point at a surface and pull the trigger (or pinch) to plant.'
: 'Left stick glides, right stick turns. Point at the floor and pull the trigger to plant.';
const tip = (key: string) =>
label({
key,
text,
font: `${model.assetBase}inter-latin-400-normal.woff`,
position: [0, 0.09, 0],
fontSize: 0.016,
maxWidth: 0.16,
color: 0xffffff,
outlineWidth: 0.0015,
outlineColor: 0x1d2433,
});
return [
group({
key: 'tips:hand',
follow: jointSpace('left', 'wrist'),
children: [tip('tips:hand:text')],
}),
group({
key: 'tips:controller',
follow: controllerSpace('left'),
children: [tip('tips:controller:text')],
}),
];
};
export const sceneFor = (model: Model) => {
const inAr = model.session === 'immersive-ar';
return scene({
camera: model.camera,
gestures: 'scene',
xr: {
...model.player,
// Anchors only where the room is the real one.
anchors: inAr
? model.plants.map((plant) => xrAnchor({ id: plant.id, position: plant.at }))
: [],
},
environment: inAr ? null : environment({ environmentIntensity: 0.45 }),
render: renderSettings({ shadows: 'soft', toneMapping: 'aces', exposure: 1.05 }),
children: [
hemisphereLight({ key: 'sky', color: 0xeef3ff, groundColor: 0x6b5a48, intensity: 0.8 }),
directionalLight({
key: 'sun',
position: [3, 4.5, 2],
intensity: 1.4,
castShadow: true,
shadowArea: 8,
}),
...(inAr ? [] : room),
...model.plants.map((plant) => plantNode(model, plant)),
...model.planes.map(planeNode),
...model.meshes.map((detected) =>
group({
key: `mesh:${detected.id}`,
follow: meshSpace(detected.id),
children: [
mesh({
key: `mesh:${detected.id}:shape`,
geometry: xrMesh(detected.id),
material: basic({ color: paintColour.periwinkle, wireframe: true }),
}),
],
}),
),
...tips(model),
],
});
};
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../labelledEnvironment';
import { Message, type Mode } from './message';
import { type Model, PlantKind } from './model';
import { sceneFor } from './scene';
const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);
const kindButton = (model: Model, h: HtmlBuilder<Message>, kind: PlantKind): Html =>
h.button(
[
h.Class('e3-button'),
h.Type('button'),
h.AriaPressed(String(model.kind === kind)),
h.OnClick(Message.ChoseKind({ kind })),
],
[kind],
);
/** The way in, for each mode the browser can present, or the way out. */
const sessionButtons = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> => {
if (model.session === 'entering') return [button(h, 'Entering…', Message.ClickedExit())];
if (model.session !== 'none') return [button(h, 'Exit', Message.ClickedExit())];
const offers: ReadonlyArray<readonly [Mode, string, Model['vr']]> = [
['immersive-vr', 'Enter VR', model.vr],
['immersive-ar', 'Enter AR', model.ar],
];
return offers
.filter(([, , support]) => support === 'ready')
.map(([mode, label]) => button(h, label, Message.ClickedEnter({ mode })));
};
/** The plant a hit names: its pot or leaves are `<plant>:<part>`. */
const plantOf = (key: string): string | undefined =>
key.startsWith('plant-') ? key.split(':')[0] : undefined;
const clicked = (key: string, point: readonly [number, number, number]): Message => {
const plant = plantOf(key);
if (plant !== undefined) return Message.ClickedPlant({ id: plant });
return Message.ClickedFloor({ point });
};
const readout = (model: Model): string => {
const plants = `${model.plants.length} plant${model.plants.length === 1 ? '' : 's'}`;
const found =
model.session === 'immersive-ar'
? ` The session has found ${model.planes.length} planes and ${model.meshes.length} meshes.`
: '';
const xr =
model.vr === 'ready' || model.ar === 'ready'
? ''
: ' This browser has no headset to present to: the room works here as it is.';
return `${plants}. Click the floor to plant, a plant to take it away; drag to look around.${found}${xr}`;
};
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'XR room — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage e3-stage-tall',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ key, point }) => clicked(key, point),
onGesture: (gesture) => Message.MadeGesture({ gesture }),
onXRSession: ({ mode }) => Message.ChangedSession({ mode }),
onXRSurface: (surface) => Message.SelectedSurface({ surface }),
onXRPlanes: (detected) => Message.DetectedPlanes({ detected }),
onXRMeshes: (detected) => Message.DetectedMeshes({ detected }),
onXRMoved: (moved) => Message.Glided({ moved }),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
h.div(
[h.Class('e3-controls')],
[
...PlantKind.literals.map((kind) => kindButton(model, h, kind)),
...sessionButtons(model, h),
button(h, 'Clear plants', Message.ClickedClear()),
],
),
h.p(
[h.Class('e3-note')],
[
'In VR the sticks glide you round the room and a tips label rides your left hand. In AR ',
'the furniture goes and your room shows through: each plant you place is a WebXR anchor, ',
'and the planes and meshes the device detects are drawn from Messages, each by a group ',
'following its tracked space. On the page, click the floor to plant. In development an ',
'agent can arrange the room too: the Foldkit DevTools MCP server sends it PlacedPlant.',
],
),
],
),
});