← All examples

LIGHT, MATERIAL, MOTION

Drag it into the light.

Five geometries in five material kinds under a sky light, a warm point light and a spot that follows your selection. Drag a piece across the floor, right-click to change its finish, double-click to send it home. Drag the space around them to orbit, right-drag to pan and scroll to zoom toward the pointer; with the scene focused, the arrow keys pan (and turn with shift), and Frame the pieces fits the camera to their measured bounds. The camera lives in the Model, moved by gesture Messages. Toggle shadows and filmic tone mapping, and switch between perspective, orthographic and a split view with a plan from above. The monitor at the back is a render texture: its own little scene showing your selection close up. The tag over your selection is ordinary HTML from the view, placed at a point on the piece every frame.

Drags as data.

A press on a piece captures the pointer, so every move arrives as a typed Message carrying a world-space ray, even off the canvas. The update meets that ray with the floor plane and moves the piece; the scene eases it there. There are no refs and no per-frame callbacks: the Model holds where everything is.

Explore the example source →

See how it works.

The source running this example. Expand a file to follow the Model, Messages and view.

model.ts
import { SceneSchema } from '@opsydyn/effect-three';
import { Option, Schema } from 'effect';

import { homeCamera } from './cameras';

const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);

/** The pieces on the studio floor. */
export const Piece = Schema.Literals(['torus', 'capsule', 'cone', 'ball', 'crate']);
export type Piece = typeof Piece.Type;
export const pieces: ReadonlyArray<Piece> = Piece.literals;

/** Surface finishes, each a different three.js material kind. */
export const Finish = Schema.Literals(['matte', 'glossy', 'metal', 'lacquer', 'flat']);
export type Finish = typeof Finish.Type;
export const finishes: ReadonlyArray<Finish> = Finish.literals;

export const Placement = Schema.Struct({
  piece: Piece,
  x: Schema.Number,
  z: Schema.Number,
  finish: Finish,
});
export type Placement = typeof Placement.Type;

/** A drag in progress: the piece and where on the floor it was grabbed, relative to it. */
export const Grab = Schema.Struct({
  piece: Piece,
  offsetX: Schema.Number,
  offsetZ: Schema.Number,
});
export type Grab = typeof Grab.Type;

export const Lens = Schema.Literals(['perspective', 'orthographic', 'split']);
export type Lens = typeof Lens.Type;

export const Shadows = Schema.Literals(['soft', 'off']);
export type Shadows = typeof Shadows.Type;

export const Tone = Schema.Literals(['none', 'aces']);
export type Tone = typeof Tone.Type;

export const Model = Schema.Struct({
  placements: Schema.Array(Placement),
  selected: Schema.Option(Piece),
  hovered: Schema.Option(Piece),
  grab: Schema.Option(Grab),
  shadows: Shadows,
  tone: Tone,
  lens: Lens,
  /** The camera, orbited by gestures on the canvas. */
  camera: SceneSchema.Camera,
  /** The stage's size in CSS pixels, as gestures last reported it, for arrow keys. */
  stage: Schema.Struct({ width: Schema.Number, height: Schema.Number }),
  /** Each piece's world bounding box, as the scene last measured it. */
  bounds: Schema.Array(Schema.Struct({ piece: Piece, min: Vec3, max: Vec3 })),
});
export type Model = typeof Model.Type;

/** Where each piece starts, and returns to on a double-click. */
export const home = {
  torus: { x: -2.4, z: -0.6 },
  capsule: { x: -1.1, z: 0.8 },
  cone: { x: 0.3, z: -0.9 },
  ball: { x: 1.5, z: 0.7 },
  crate: { x: 2.6, z: -0.4 },
} satisfies Record<Piece, { readonly x: number; readonly z: number }>;

const startingFinish = {
  torus: 'lacquer',
  capsule: 'glossy',
  cone: 'matte',
  ball: 'metal',
  crate: 'flat',
} satisfies Record<Piece, Finish>;

export const init = () => ({
  model: {
    placements: pieces.map((piece) => ({ piece, ...home[piece], finish: startingFinish[piece] })),
    selected: Option.none(),
    hovered: Option.none(),
    grab: Option.none(),
    shadows: 'soft',
    tone: 'none',
    lens: 'perspective',
    camera: homeCamera.perspective,
    stage: { width: 960, height: 540 },
    bounds: [],
  } satisfies Model,
});

/** The floor is a square from -limit to +limit on x and z. */
export const limit = 3.6;
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';

export const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);

export const Message = defineMessageUnion({
  HoveredPiece: { key: Schema.String },
  LeftPiece: { key: Schema.String },
  ClickedPiece: { key: Schema.String },
  DoubleClickedPiece: { key: Schema.String },
  OpenedPieceMenu: { key: Schema.String },
  PressedPiece: { key: Schema.String, origin: Vec3, direction: Vec3 },
  DraggedPiece: { key: Schema.String, origin: Vec3, direction: Vec3 },
  DroppedPiece: {},
  ClickedEmptySpace: {},
  ClickedShadows: {},
  ClickedToneMapping: {},
  ClickedLens: {},
  /** A drag, wheel or pinch on the canvas: it orbits the camera. */
  MadeGesture: { gesture: SceneSchema.Gesture },
  PressedArrow: {
    key: Schema.Literals(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']),
    turning: Schema.Boolean,
  },
  MeasuredPiece: { key: Schema.String, min: Vec3, max: Vec3 },
  ClickedFrame: {},
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import {
  type Box,
  fitCamera,
  type Gesture,
  noModifiers,
  orbit,
  orbitKey,
  pointOnPlane,
  type Vec3,
} from '@opsydyn/effect-three';
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { homeCamera, studioOrbit, viewKey } from './cameras';
import { Message } from './message';
import {
  finishes,
  type Grab,
  home,
  type Lens,
  limit,
  type Model,
  type Piece,
  pieces,
  type Placement,
  type Shadows,
  type Tone,
} from './model';

type AppReturn = Return<Model, Message>;

const floor = { normal: [0, 1, 0], constant: 0 } as const;

export const pieceKey = (piece: Piece): string => `piece:${piece}`;

/** Reads a piece back from its scene key. */
export const pieceOf = (key: string): Option.Option<Piece> =>
  Option.fromNullishOr(pieces.find((piece) => pieceKey(piece) === key));

const clamp = (value: number): number => Math.max(-limit, Math.min(limit, value));

/** Applies `change` to the placement of `piece`, leaving the others alone. */
const onPiece =
  (piece: Piece, change: (placement: Placement) => Placement) =>
  (placement: Placement): Placement =>
    Option.match(
      Option.liftPredicate(placement, (candidate) => candidate.piece === piece),
      { onNone: () => placement, onSome: change },
    );

const placeAt = (piece: Piece, x: number, z: number) =>
  onPiece(piece, (placement) => ({ ...placement, x: clamp(x), z: clamp(z) }));

const withPlacements = (model: Model, change: (placement: Placement) => Placement): Model => ({
  ...model,
  placements: model.placements.map(change),
});

const nextFinish = (placement: Placement): Placement => ({
  ...placement,
  finish: finishes[(finishes.indexOf(placement.finish) + 1) % finishes.length] ?? 'matte',
});

const placementOf = (model: Model, piece: Piece): Option.Option<Placement> =>
  Option.fromNullishOr(model.placements.find((placement) => placement.piece === piece));

/** Takes hold of a piece where the press ray meets the floor, so a drag moves it from there. */
const press = (model: Model, piece: Piece, origin: Vec3, direction: Vec3): Model =>
  Option.match(Option.fromNullishOr(pointOnPlane({ origin, direction }, floor)), {
    onNone: () => model,
    onSome: (point) => ({ ...model, grab: grabFor(model, piece, point) }),
  });

/** The grab for this drag: the current one for `piece`, or a new one from where it was taken. */
const grabFor = (model: Model, piece: Piece, point: Vec3): Option.Option<Grab> =>
  Option.orElse(
    Option.filter(model.grab, (grab) => grab.piece === piece),
    () =>
      Option.map(placementOf(model, piece), (placement) => ({
        piece,
        offsetX: point[0] - placement.x,
        offsetZ: point[2] - placement.z,
      })),
  );

/** Places the grabbed piece so the point it was grabbed by sits under the pointer. */
const moveGrabbed =
  (model: Model, piece: Piece, point: Vec3) =>
  (grab: Grab): Model => {
    const moved = withPlacements(
      model,
      placeAt(piece, point[0] - grab.offsetX, point[2] - grab.offsetZ),
    );
    return { ...moved, grab: Option.some(grab), selected: Option.some(piece) };
  };

const dragTo =
  (model: Model, piece: Piece) =>
  (point: Vec3): Model =>
    Option.match(grabFor(model, piece, point), {
      onNone: () => model,
      onSome: moveGrabbed(model, piece, point),
    });

/** Moves a dragged piece to where the drag ray meets the floor. */
const drag = (model: Model, piece: Piece, origin: Vec3, direction: Vec3): Model =>
  Option.match(Option.fromNullishOr(pointOnPlane({ origin, direction }, floor)), {
    onNone: () => model,
    onSome: dragTo(model, piece),
  });

const withPiece = (key: string, model: Model, apply: (piece: Piece) => Model): AppReturn => {
  const next = Option.match(pieceOf(key), { onNone: () => model, onSome: apply });
  return { model: next };
};

/** Lenses cycle: perspective, orthographic, then a split of both. */
const otherLens = {
  perspective: 'orthographic',
  orthographic: 'split',
  split: 'perspective',
} satisfies Record<Lens, Lens>;
const otherShadows = { soft: 'off', off: 'soft' } satisfies Record<Shadows, Shadows>;
const otherTone = { none: 'aces', aces: 'none' } satisfies Record<Tone, Tone>;

/**
 * Whether a gesture moves the camera: not one that began on a piece (that drags the
 * piece), and not one over the plan, which stays put.
 */
const orbits = (gesture: Gesture): boolean =>
  !('key' in gesture && gesture.key !== undefined) && gesture.view !== viewKey.plan;

/** The camera after a gesture, when the gesture is one that orbits. */
const orbitBy = (model: Model, gesture: Gesture): Model =>
  Option.match(Option.liftPredicate(gesture, orbits), {
    onNone: () => model,
    onSome: (orbiting) => ({ ...model, camera: orbit(model.camera, orbiting, studioOrbit) }),
  });

/** Gestures carry the stage's size: keep it, for arrow keys. */
const withStage = (model: Model, gesture: Gesture): Model => ({
  ...model,
  stage: { width: gesture.width, height: gesture.height },
});

/** A piece's newly measured box, replacing its last. */
const measured = (model: Model, key: string, min: Vec3, max: Vec3): Model =>
  Option.match(pieceOf(key), {
    onNone: () => model,
    onSome: (piece) => ({
      ...model,
      bounds: [...model.bounds.filter((box) => box.piece !== piece), { piece, min, max }],
    }),
  });

/** Per axis, `pick` (Math.min or Math.max) across the points. */
const across = (points: ReadonlyArray<Vec3>, pick: (...values: Array<number>) => number): Vec3 => [
  pick(...points.map((point) => point[0])),
  pick(...points.map((point) => point[1])),
  pick(...points.map((point) => point[2])),
];

/** The box around every piece, or nothing before they are measured. */
const allPieces = (model: Model): Option.Option<Box> =>
  Option.liftPredicate(model.bounds, (bounds) => bounds.length > 0).pipe(
    Option.map((bounds) => ({
      min: across(
        bounds.map((box) => box.min),
        Math.min,
      ),
      max: across(
        bounds.map((box) => box.max),
        Math.max,
      ),
    })),
  );

/** The camera framing every piece, as Drei's Bounds fits, eased by its damping. */
const framed = (model: Model): Model =>
  Option.match(allPieces(model), {
    onNone: () => model,
    onSome: (box) => ({ ...model, camera: fitCamera(model.camera, box, model.stage) }),
  });

/** The next lens, from its own starting camera. */
const nextLens = (model: Model): Model => {
  const lens = otherLens[model.lens];
  return { ...model, lens, camera: homeCamera[lens] };
};

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    HoveredPiece: ({ key }) =>
      withPiece(key, model, (piece) => modifyFields(model, { hovered: () => Option.some(piece) })),
    LeftPiece: ({ key }) =>
      withPiece(key, model, (piece) =>
        modifyFields(model, {
          hovered: (hovered) => Option.filter(hovered, (current) => current !== piece),
        }),
      ),
    ClickedPiece: ({ key }) =>
      withPiece(key, model, (piece) => modifyFields(model, { selected: () => Option.some(piece) })),
    DoubleClickedPiece: ({ key }) =>
      withPiece(key, model, (piece) =>
        withPlacements(model, placeAt(piece, home[piece].x, home[piece].z)),
      ),
    OpenedPieceMenu: ({ key }) =>
      withPiece(key, model, (piece) => withPlacements(model, onPiece(piece, nextFinish))),
    PressedPiece: ({ key, origin, direction }) =>
      withPiece(key, model, (piece) =>
        press(modifyFields(model, { grab: () => Option.none() }), piece, origin, direction),
      ),
    DraggedPiece: ({ key, origin, direction }) =>
      withPiece(key, model, (piece) => drag(model, piece, origin, direction)),
    DroppedPiece: () => ({ model: modifyFields(model, { grab: () => Option.none() }) }),
    ClickedEmptySpace: () => ({ model: modifyFields(model, { selected: () => Option.none() }) }),
    ClickedShadows: () => ({
      model: modifyFields(model, { shadows: (shadows) => otherShadows[shadows] }),
    }),
    ClickedToneMapping: () => ({ model: modifyFields(model, { tone: (tone) => otherTone[tone] }) }),
    ClickedLens: () => ({ model: nextLens(model) }),
    MadeGesture: ({ gesture }) => ({ model: orbitBy(withStage(model, gesture), gesture) }),
    PressedArrow: ({ key, turning }) => ({
      model: {
        ...model,
        camera: orbitKey(
          model.camera,
          key,
          { ...noModifiers, shift: turning },
          model.stage,
          studioOrbit,
        ),
      },
    }),
    MeasuredPiece: ({ key, min, max }) => ({ model: measured(model, key, min, max) }),
    ClickedFrame: () => ({ model: framed(model) }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/**
 * The studio: five pieces on a floor, each in its own material kind, lit by a sky light,
 * a warm point light and a spot light that follows the selected piece and casts shadows.
 * A monitor at the back shows the selected piece close up, through a render texture, and
 * a room environment gives the glossy and metal finishes something to reflect.
 */
import {
  basic,
  type Camera,
  capsule,
  roundedBox,
  cone,
  ease,
  environment,
  fixed,
  hemisphereLight,
  lambert,
  type MaterialRef,
  mesh,
  perspectiveCamera,
  phong,
  physical,
  plane,
  pointLight,
  renderSettings,
  renderTexture,
  scene,
  type SceneNode,
  sharedGeometry,
  sphere,
  spotLight,
  standard,
  torus,
  type Vec3,
  type View,
  view,
} from '@opsydyn/effect-three';
import { Option } from 'effect';

import { lighten } from '../bars/colours';
import { paintColour } from '../palette';
import { planLens, viewKey } from './cameras';
import { type Finish, type Lens, limit, type Model, type Piece, type Placement } from './model';
import { pieceKey } from './update';

const glide = ease({ seconds: 0.18, easing: 'easeOutCubic' });

const colour = {
  torus: paintColour.coral,
  capsule: paintColour.blue,
  cone: paintColour.gold,
  ball: paintColour.periwinkle,
  crate: paintColour.mint,
} satisfies Record<Piece, number>;

/** Height of each piece's centre when it rests on the floor. */
const restingHeight = {
  torus: 0.63,
  capsule: 0.62,
  cone: 0.5,
  ball: 0.5,
  crate: 0.4,
} satisfies Record<Piece, number>;

/** A quarter-ish turn on the crate so it reads as a cube, not a square. */
const turn = {
  torus: [0, 0, 0],
  capsule: [0, 0, 0],
  cone: [0, 0, 0],
  ball: [0, 0, 0],
  crate: [0, 0.5, 0],
} satisfies Record<Piece, Vec3>;

/** Each finish is a different three.js material kind. */
const finishMaterial = {
  matte: (color: number, glow: number) => lambert({ color, emissive: glow }),
  glossy: (color: number, glow: number) =>
    phong({ color, emissive: glow, specular: 0x444444, shininess: 80 }),
  // Fully metallic: it reflects the room environment the studio is lit by.
  metal: (color: number, glow: number) =>
    standard({ color, emissive: glow, metalness: 1, roughness: 0.25 }),
  lacquer: (color: number, glow: number) =>
    physical({ color, emissive: glow, roughness: 0.45, clearcoat: 1, clearcoatRoughness: 0.05 }),
  flat: (color: number, glow: number) => basic({ color: lighten(color, glow / 0xffffff) }),
} satisfies Record<Finish, (color: number, glow: number) => MaterialRef>;

const isHovered = (model: Model, piece: Piece): boolean =>
  Option.isSome(Option.filter(model.hovered, (hovered) => hovered === piece));

/** A faint glow on the hovered piece; none otherwise. */
const glowOf = (model: Model, piece: Piece): number =>
  Option.match(
    Option.liftPredicate(piece, (candidate) => isHovered(model, candidate)),
    {
      onNone: () => 0x000000,
      onSome: () => 0x2a2a2a,
    },
  );

const pieceNode = (model: Model, placement: Placement): SceneNode => {
  const material = finishMaterial[placement.finish](
    colour[placement.piece],
    glowOf(model, placement.piece),
  );
  return mesh({
    key: pieceKey(placement.piece),
    position: [placement.x, restingHeight[placement.piece], placement.z],
    rotation: fixed(turn[placement.piece]),
    transition: glide,
    geometry: sharedGeometry(placement.piece),
    material,
    interactive: true,
    castShadow: true,
  });
};

/** Where the spot light aims: the selected piece, or the middle of the floor. */
const spotTarget = (model: Model): Vec3 =>
  Option.getOrElse(
    Option.flatMap(model.selected, (piece) =>
      Option.map(
        Option.fromNullishOr(model.placements.find((placement) => placement.piece === piece)),
        (placement): Vec3 => [placement.x, 0, placement.z],
      ),
    ),
    (): Vec3 => [0, 0, 0],
  );

/** The split lens: the orbiting view on the left, a plan from above on the right. */
const splitViews = (camera: Camera): ReadonlyArray<View> => [
  view({ key: viewKey.front, camera, rect: { width: 0.5 } }),
  view({ key: viewKey.plan, camera: planLens, rect: { x: 0.5, width: 0.5 } }),
];

const noViews: ReadonlyArray<View> = [];

const viewsFor = {
  perspective: () => noViews,
  orthographic: () => noViews,
  split: splitViews,
} satisfies Record<Lens, (camera: Camera) => ReadonlyArray<View>>;

/** The selected piece alone, turned to show its finish; nothing when none is selected. */
const closeUp = (model: Model): ReadonlyArray<SceneNode> =>
  Option.match(
    Option.flatMap(model.selected, (piece) =>
      Option.fromNullishOr(model.placements.find((placement) => placement.piece === piece)),
    ),
    {
      onNone: () => [],
      onSome: (placement) => [
        mesh({
          key: 'close-up',
          rotation: fixed([0.35, 0.6, 0]),
          geometry: sharedGeometry(placement.piece),
          material: finishMaterial[placement.finish](colour[placement.piece], 0x000000),
        }),
      ],
    },
  );

/**
 * The monitor's picture: its own little scene, drawn into the `monitor` texture only when
 * the selection or its finish changes.
 */
const monitorPicture = (model: Model) =>
  renderTexture({
    id: 'monitor',
    width: 384,
    height: 240,
    camera: perspectiveCamera({ fov: 35, position: [0, 0.5, 2.6], lookAt: [0, 0, 0] }),
    background: 0x1d2128,
    children: [
      hemisphereLight({
        key: 'monitor-sky',
        color: 0xffffff,
        groundColor: 0x55524c,
        intensity: 1.4,
      }),
      pointLight({ key: 'monitor-key', intensity: 8, position: [1.5, 1.5, 2] }),
      ...closeUp(model),
    ],
  });

export const sceneFor = (model: Model) =>
  scene({
    camera: model.camera,
    gestures: 'scene',
    // Each piece's box, for framing them all.
    measure: model.placements.map((placement) => pieceKey(placement.piece)),
    views: viewsFor[model.lens](model.camera),
    render: renderSettings({ shadows: model.shadows, toneMapping: model.tone }),
    // three's RoomEnvironment: soft studio reflections without an image file.
    environment: environment({ environmentIntensity: 0.35 }),
    renderTextures: [monitorPicture(model)],
    resources: {
      geometries: [
        { id: 'torus', geometry: torus({ radius: 0.45, tube: 0.18, tubularSegments: 64 }) },
        { id: 'capsule', geometry: capsule({ radius: 0.32, height: 0.6, radialSegments: 24 }) },
        { id: 'cone', geometry: cone({ radius: 0.45, height: 1 }) },
        { id: 'ball', geometry: sphere({ radius: 0.5 }) },
        // Drei's RoundedBox: a crate with softened edges, which catch the light.
        {
          id: 'crate',
          geometry: roundedBox({ width: 0.8, height: 0.8, depth: 0.8, radius: 0.06 }),
        },
      ],
    },
    children: [
      hemisphereLight({ key: 'sky', color: 0xffffff, groundColor: 0x8d8a83, intensity: 1.1 }),
      pointLight({ key: 'rim', color: 0xffd2a0, intensity: 14, position: [-3, 2.5, -2.5] }),
      spotLight({
        key: 'spot',
        intensity: 90,
        position: [2.5, 6, 3.5],
        target: spotTarget(model),
        angle: 0.65,
        penumbra: 0.5,
        castShadow: true,
        shadowMapSize: 1024,
        shadowBias: -0.0005,
      }),
      mesh({
        key: 'floor',
        rotation: fixed([-Math.PI / 2, 0, 0]),
        geometry: plane({ width: limit * 2 + 1.2, height: limit * 2 + 1.2 }),
        material: standard({ color: 0xd9d6cf, roughness: 0.95 }),
        receiveShadow: true,
      }),
      mesh({
        key: 'monitor',
        position: [0, 1.15, -limit - 0.4],
        geometry: plane({ width: 3.2, height: 2 }),
        material: basic({ map: 'monitor' }),
      }),
      ...model.placements.map((placement) => pieceNode(model, placement)),
    ],
  });
view.ts
import { overlay } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Option } from 'effect';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';

import { sceneMount } from '../environment';
import { Message } from './message';
import type { Finish, Lens, Model, Piece, Placement, Shadows, Tone } from './model';
import { sceneFor } from './scene';
import { pieceKey } from './update';

const pieceName = {
  torus: 'Torus',
  capsule: 'Capsule',
  cone: 'Cone',
  ball: 'Ball',
  crate: 'Crate',
} satisfies Record<Piece, string>;

/** Each finish and the three.js material kind behind it. */
const finishName = {
  matte: 'matte (Lambert)',
  glossy: 'glossy (Phong)',
  metal: 'metal (standard)',
  lacquer: 'lacquer (physical, clearcoat)',
  flat: 'flat (basic, unlit)',
} satisfies Record<Finish, string>;

/** The button names the lens it switches to. */
const lensLabel = {
  perspective: 'Orthographic camera',
  orthographic: 'Split view',
  split: 'Perspective camera',
} satisfies Record<Lens, string>;

const shadowsOn = { soft: true, off: false } satisfies Record<Shadows, boolean>;
const filmicOn = { aces: true, none: false } satisfies Record<Tone, boolean>;

const toggle = (h: HtmlBuilder<Message>, label: string, on: boolean, message: Message): Html =>
  h.button(
    [h.Class('e3-button'), h.Type('button'), h.AriaPressed(String(on)), h.OnClick(message)],
    [label],
  );

const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
  h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);

const selectedPlacement = (model: Model): Option.Option<Placement> =>
  Option.flatMap(model.selected, (piece) =>
    Option.fromNullishOr(model.placements.find((placement) => placement.piece === piece)),
  );

/** A tag floating over the selected piece: its name and finish, from the Model. */
const tagFor = (h: HtmlBuilder<Message>) => (placement: Placement) => ({
  overlay: overlay({
    key: 'selected-tag',
    node: pieceKey(placement.piece),
    position: [0, 0.9, 0],
    center: true,
  }),
  content: h.span([h.Class('e3-tag')], [`${pieceName[placement.piece]} · ${placement.finish}`]),
  pointerEvents: 'none' as const,
});

const tags = (model: Model, h: HtmlBuilder<Message>) =>
  Option.toArray(Option.map(selectedPlacement(model), tagFor(h)));

const arrows = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] as const;

/** Arrow keys move the camera while the stage has focus (shift turns); others pass. */
const arrowKey = (key: string, modifiers: { readonly shiftKey: boolean }): Option.Option<Message> =>
  Option.map(Option.fromNullishOr(arrows.find((arrow) => arrow === key)), (arrow) =>
    Message.PressedArrow({ key: arrow, turning: modifiers.shiftKey }),
  );

const readout = (model: Model): string =>
  Option.match(selectedPlacement(model), {
    onNone: () =>
      'Drag a piece across the floor, or the space around it to orbit. Click one to light it.',
    onSome: (placement) =>
      `${pieceName[placement.piece]}: ${finishName[placement.finish]}. Right-click to change the finish, double-click to send it home.`,
  });

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Studio — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      h.div(
        [
          h.Tabindex(0),
          h.AriaLabel('Studio scene. Arrow keys pan the camera; with shift they turn it.'),
          h.OnKeyDownPreventDefault(arrowKey),
        ],
        [
          EffectThreeFoldkit.view(
            {
              scene: sceneFor(model),
              mount: sceneMount,
              className: 'e3-stage',
              onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
              onPointerEnter: ({ key }) => Message.HoveredPiece({ key }),
              onPointerLeave: ({ key }) => Message.LeftPiece({ key }),
              onClick: ({ key }) => Message.ClickedPiece({ key }),
              onDoubleClick: ({ key }) => Message.DoubleClickedPiece({ key }),
              onContextMenu: ({ key }) => Message.OpenedPieceMenu({ key }),
              onPointerDown: ({ key, ray }) =>
                Message.PressedPiece({ key, origin: ray.origin, direction: ray.direction }),
              onDrag: ({ key, ray }) =>
                Message.DraggedPiece({ key, origin: ray.origin, direction: ray.direction }),
              onDragEnd: () => Message.DroppedPiece(),
              onPointerMissed: () => Message.ClickedEmptySpace(),
              onGesture: (gesture) => Message.MadeGesture({ gesture }),
              onMeasured: ({ key, min, max }) => Message.MeasuredPiece({ key, min, max }),
              overlays: tags(model, h),
            },
            h,
          ),
        ],
      ),
      h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
      h.div(
        [h.Class('e3-controls')],
        [
          toggle(h, 'Shadows', shadowsOn[model.shadows], Message.ClickedShadows()),
          toggle(h, 'Filmic tone mapping', filmicOn[model.tone], Message.ClickedToneMapping()),
          button(h, lensLabel[model.lens], Message.ClickedLens()),
          button(h, 'Frame the pieces', Message.ClickedFrame()),
        ],
      ),
      h.p(
        [h.Class('e3-note')],
        [
          'Every piece is a different geometry and material kind. A press captures the pointer, ',
          'so a drag follows it anywhere; the drag ray meets the floor plane to place the piece.',
        ],
      ),
    ],
  ),
});