← All examples

EDITING

Move, turn, scale, undo.

Click something on the table and a transform gizmo appears on it: drag an arrow to move it along an axis, a square to move it in a plane, a ring to turn it, or switch to scale. Shift-drag across empty space to box several things, and the gizmo moves them all alike. Press an axis in the corner to look along it, or switch to Drei-style pivot controls, every handle at once. Measure the distance between any two points on the table, and edit the selected thing in an inspector generated from its schema. Snap to quarter units and fifteen degrees, work in the object's own axes, and undo or redo every move. Drag empty space to orbit.

A gizmo as a Foldkit submodel.

three's TransformControls listen to the canvas and move the object themselves. Here the gizmo's handles are scene nodes, a press and the drag rays after it are Messages, and each move comes back to the page as an OutMessage. The maths is three's, checked against its controls to six decimal places; the Model keeps every placement, so undo is a list. The selection box is a second submodel, after three's SelectionBox, and the axes in the corner a third, after Drei's GizmoHelper, drawn in the scene's HUD.

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 { History, perspectiveCamera, SceneSchema } from '@opsydyn/effect-three';
import {
  BoxSelect,
  MeasuringTape,
  PivotControls,
  TransformGizmo,
  ViewportGizmo,
} from '@opsydyn/effect-three-foldkit';
import { Option, Schema } from 'effect';

import { paintColour } from '../palette';

export const Kind = Schema.Literals(['crate', 'ball', 'cone', 'ring']);
export type Kind = typeof Kind.Type;

/** Something on the table, and where it is. */
export const Thing = Schema.Struct({
  key: Schema.String,
  kind: Kind,
  colour: SceneSchema.Rgb,
  placement: SceneSchema.Placement,
});
export type Thing = typeof Thing.Type;

export const Model = Schema.Struct({
  things: Schema.Array(Thing),
  /** What is selected; the gizmo sits on the first and moves the rest alike. */
  selected: Schema.Array(Schema.String),
  /** The transform gizmo: a Foldkit submodel. */
  gizmo: TransformGizmo.Model,
  /** Drei-style pivot controls, every handle at once: the other way to move things. */
  pivot: PivotControls.Model,
  /** Which of the two is on the selection. */
  style: Schema.Literals(['transform', 'pivot']),
  /** The selection box a shift-drag draws: a Foldkit submodel too. */
  box: BoxSelect.Model,
  /** The axes in the corner that turn the camera to look along one: a third. */
  view: ViewportGizmo.Model,
  /**
   * Whether the last press was on the viewport gizmo: its head turns away from under the
   * pointer, and letting go there is no click on nothing to clear the selection.
   */
  pressedView: Schema.Boolean,
  /** Whether clicks measure, with the tape (a fifth submodel), instead of selecting. */
  measuring: Schema.Boolean,
  tape: MeasuringTape.Model,
  /**
   * Whether the camera has orbited since the last press: a drag across the table that
   * ends on it is no click to place a tape end.
   */
  orbited: Schema.Boolean,
  /** Why the inspector's last edit was refused, as the schema says, if it was. */
  inspectorIssue: Schema.Option(Schema.String),
  camera: SceneSchema.Camera,
  /**
   * The things after every finished move, as a branching history: a move after an undo
   * starts a new branch and keeps the old one, for redo to take instead.
   */
  history: SceneSchema.History(Schema.Array(Thing)),
  /** The things when the current drag began. */
  before: Schema.Option(Schema.Array(Thing)),
  snapping: Schema.Boolean,
});
export type Model = typeof Model.Type;

const at = (x: number, z: number) => ({
  position: [x, 0.4, z] as const,
  rotation: [0, 0, 0] as const,
  scale: [1, 1, 1] as const,
});

export const startThings: ReadonlyArray<Thing> = [
  { key: 'crate', kind: 'crate', colour: paintColour.coral, placement: at(-1.5, 0) },
  { key: 'ball', kind: 'ball', colour: paintColour.blue, placement: at(0, -0.8) },
  { key: 'cone', kind: 'cone', colour: paintColour.gold, placement: at(1.5, 0.2) },
  { key: 'ring', kind: 'ring', colour: paintColour.mint, placement: at(0.2, 1.3) },
];

export const init = () => ({
  model: {
    things: startThings,
    selected: ['crate'],
    gizmo: TransformGizmo.init('gizmo', { size: 1.4 }),
    pivot: PivotControls.init('pivot', { scale: 0.9, depthTest: false }),
    style: 'transform' as const,
    box: BoxSelect.init(),
    view: ViewportGizmo.init('view'),
    pressedView: false,
    measuring: false,
    tape: MeasuringTape.init('tape'),
    orbited: false,
    inspectorIssue: Option.none(),
    camera: perspectiveCamera({
      fov: 45,
      position: [3.5, 3.2, 5.5],
      lookAt: [0, 0.3, 0],
      damping: 0.15,
    }),
    history: History.make<ReadonlyArray<Thing>>(startThings),
    before: Option.none(),
    snapping: false,
  } satisfies Model,
});
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 Message = defineMessageUnion({
  PressedScene: {
    key: Schema.String,
    point: SceneSchema.Vec3,
    origin: SceneSchema.Vec3,
    direction: SceneSchema.Vec3,
  },
  DraggedScene: { origin: SceneSchema.Vec3, direction: SceneSchema.Vec3 },
  ReleasedScene: {},
  ClickedThing: { key: Schema.String, point: SceneSchema.Vec3 },
  MovedOver: { point: SceneSchema.Vec3 },
  ClickedNothing: {},
  HoveredScene: { key: Schema.String },
  LeftScene: { key: Schema.String },
  MadeGesture: { gesture: SceneSchema.Gesture },
  EndedGesture: {},
  ChoseMode: { mode: Schema.Literals(['translate', 'rotate', 'scale']) },
  ClickedSpace: {},
  ClickedSnapping: {},
  ClickedPivot: {},
  ClickedMeasure: {},
  EditedThing: {
    key: Schema.String,
    path: Schema.Array(Schema.Union([Schema.String, Schema.Number])),
    value: Schema.Union([Schema.Number, Schema.String, Schema.Boolean]),
  },
  ClickedUndo: {},
  ClickedRedo: {},
  ClickedBranch: {},
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import {
  type FieldPath,
  type FieldValue,
  type Gesture,
  History,
  Inspector,
  movedAlike,
  orbit,
  type Placement,
  type Vec3,
} from '@opsydyn/effect-three';
import {
  BoxSelect,
  MeasuringTape,
  PivotControls,
  TransformGizmo,
  ViewportGizmo,
} from '@opsydyn/effect-three-foldkit';
import { Array as Arr, Option, Result } from 'effect';
import type { Return } from 'foldkit/update';

import { Message } from './message';
import { type Model, Thing } from './model';

type AppReturn = Return<Model, Message>;

/** The snaps a snapping gizmo takes: quarter units, fifteen degrees, quarter scales. */
const snaps = { translationSnap: 0.25, rotationSnap: Math.PI / 12, scaleSnap: 0.25 } as const;
const free = { translationSnap: null, rotationSnap: null, scaleSnap: null } as const;

const thingIn = (things: ReadonlyArray<Thing>, key: string): Option.Option<Thing> =>
  Arr.findFirst(things, (thing) => thing.key === key);

/** The first selected thing: the one the gizmo sits on. */
const selectedThing = (model: Model): Option.Option<Thing> =>
  Option.flatMap(Arr.head(model.selected), (key) => thingIn(model.things, key));

/** Where `self` goes when the gizmo put `moved` at `placement`: there, alike, or nowhere. */
const alike = (model: Model, moved: Thing, self: Thing, placement: Placement): Placement => {
  if (self.key === moved.key) return placement;
  if (!model.selected.includes(self.key)) return self.placement;
  return movedAlike(moved.placement, placement, self.placement);
};

/**
 * Each thing after the gizmo put the first selected at `placement`: that one there, the
 * rest of the selection moved alike from where they were when the drag began.
 */
const placedFrom =
  (model: Model, before: ReadonlyArray<Thing>, placement: Placement) =>
  (thing: Thing): Thing => {
    const first = Option.flatMap(Arr.head(model.selected), (key) => thingIn(before, key));
    const was = Option.all([first, thingIn(before, thing.key)]);
    return Option.match(was, {
      onNone: () => thing,
      onSome: ([moved, self]) => ({
        ...thing,
        placement: alike(model, moved, self, placement),
      }),
    });
  };

const placeSelected = (model: Model, placement: Placement): Model => {
  const before = Option.getOrElse(model.before, () => model.things);
  return { ...model, things: model.things.map(placedFrom(model, before, placement)) };
};

/** A finished move becomes a step of history, named for what moved. */
const recorded = (model: Model): Model => ({
  ...model,
  history: History.record(model.history, model.things, `Moved ${model.selected.join(', ')}`),
  before: Option.none(),
});

/** What the gizmo's OutMessage means here: move the selected thing, or record the move. */
const fromGizmo = (model: Model, out: TransformGizmo.OutMessage): Model =>
  TransformGizmo.OutMessage.match(out, {
    Moved: ({ placement }) => placeSelected(model, placement),
    Finished: ({ placement }) => recorded(placeSelected(model, placement)),
  });

/** Runs a gizmo Message, then what its OutMessage asks of the page. */
const withGizmo = (model: Model, message: TransformGizmo.Message): Model => {
  const result = TransformGizmo.update(model.gizmo, message);
  const next = { ...model, gizmo: result.model };
  return result.outMessage === undefined ? next : fromGizmo(next, result.outMessage);
};

/** What the pivot controls' OutMessage means here: the same as the gizmo's. */
const fromPivot = (model: Model, out: PivotControls.OutMessage): Model =>
  PivotControls.OutMessage.match(out, {
    Moved: ({ placement }) => placeSelected(model, placement),
    Finished: ({ placement }) => recorded(placeSelected(model, placement)),
  });

/** Runs a pivot Message, then what its OutMessage asks of the page. */
const withPivot = (model: Model, message: PivotControls.Message): Model => {
  const result = PivotControls.update(model.pivot, message);
  const next = { ...model, pivot: result.model };
  return result.outMessage === undefined ? next : fromPivot(next, result.outMessage);
};

/** A press on a pivot handle takes hold where it hit, remembering the things as they were. */
const pressPivot = (model: Model, key: string, point: Vec3): Model =>
  Option.match(Option.all([PivotControls.handleOf(model.pivot, key), selectedThing(model)]), {
    onNone: () => model,
    onSome: ([handle, thing]) =>
      withPivot(
        { ...model, before: Option.some(model.things) },
        PivotControls.Message.Pressed({ handle, point, placement: thing.placement }),
      ),
  });

/** Whether either way of moving things is held: the camera keeps still while it is. */
const holding = (model: Model): boolean =>
  TransformGizmo.held(model.gizmo) || PivotControls.held(model.pivot);

/** The viewport gizmo's Message, and the camera it turns to. */
const withView = (model: Model, message: ViewportGizmo.Message): Model => {
  const result = ViewportGizmo.update(model.view, message);
  const camera = result.outMessage?.camera ?? model.camera;
  return { ...model, view: result.model, camera };
};

/** A press on a gizmo handle takes hold, remembering the things as they were. */
const pressGizmo = (model: Model, key: string, origin: Vec3, direction: Vec3): Model =>
  Option.match(Option.all([TransformGizmo.handleOf(model.gizmo, key), selectedThing(model)]), {
    onNone: () => model,
    onSome: ([handle, thing]) =>
      withGizmo(
        { ...model, before: Option.some(model.things) },
        TransformGizmo.Message.Pressed({
          handle,
          origin,
          direction,
          placement: thing.placement,
          camera: model.camera,
        }),
      ),
  });

/** A press on the viewport gizmo turns the camera; anywhere else, it may take the gizmo. */
const press = (model: Model, key: string, point: Vec3, origin: Vec3, direction: Vec3): Model => {
  if (Option.isSome(ViewportGizmo.axisOf(model.view, key))) {
    return withView(
      { ...model, pressedView: true },
      ViewportGizmo.Message.Pressed({ key, camera: model.camera }),
    );
  }
  const pressed = { ...model, pressedView: false };
  return model.style === 'pivot'
    ? pressPivot(pressed, key, point)
    : pressGizmo(pressed, key, origin, direction);
};

/** A click on nothing clears the selection, unless it began on the viewport gizmo. */
const clickNothing = (model: Model): Model =>
  model.pressedView ? { ...model, pressedView: false } : { ...model, selected: [] };

/** A gesture that began on nothing, or on the table (which is picked while measuring). */
const offThings = (gesture: Gesture): boolean =>
  !('key' in gesture) || gesture.key === undefined || gesture.key === 'table';

/** The camera orbits on a gesture off the things, while no gizmo is held. */
const orbits = (model: Model, gesture: Gesture): boolean => !holding(model) && offThings(gesture);

/** The camera after a gesture that orbits; otherwise as it was. */
const orbitBy = (model: Model, gesture: Gesture): Model =>
  orbits(model, gesture)
    ? { ...model, camera: orbit(model.camera, gesture), orbited: true }
    : model;

/** The things with `edited` in place of the thing it was, recorded as a step. */
const withEdited = (model: Model, edited: Thing): Model => {
  const things = model.things.map((each) => (each.key === edited.key ? edited : each));
  return {
    ...model,
    things,
    history: History.record(model.history, things, `Edited ${edited.key}`),
    inspectorIssue: Option.none(),
  };
};

const refused = (model: Model, issue: string): Model => ({
  ...model,
  inspectorIssue: Option.some(issue),
});

/** An inspector edit to `thing`, checked by the `Thing` schema; refused, it says why. */
const applyEdit = (model: Model, thing: Thing, path: FieldPath, value: FieldValue): Model =>
  Result.match(Inspector.edit(Thing, thing, path, value), {
    onFailure: (issue) => refused(model, issue),
    onSuccess: (edited) => withEdited(model, edited),
  });

const editThing = (model: Model, key: string, path: FieldPath, value: FieldValue): Model =>
  Option.match(
    Arr.findFirst(model.things, (thing) => thing.key === key),
    {
      onNone: () => model,
      onSome: (thing) => applyEdit(model, thing, path, value),
    },
  );

/** The tape's Message; what it measures shows in the readout, from the Model. */
const withTape = (model: Model, message: MeasuringTape.Message): Model => ({
  ...model,
  tape: MeasuringTape.update(model.tape, message).model,
});

/** While measuring, the tape runs to the pointer. */
const moveOver = (model: Model, point: Vec3): Model =>
  model.measuring ? withTape(model, MeasuringTape.Message.Pointed({ point })) : model;

/** Measuring on or off: the tape starts empty either way. */
const toggleMeasuring = (model: Model): Model => ({
  ...withTape(model, MeasuringTape.Message.Cleared()),
  measuring: !model.measuring,
});

/** A shift-drag on nothing draws the selection box; any other gesture may orbit. */
const gesture = (model: Model, made: Gesture): Model =>
  made._tag === 'Drag' && BoxSelect.claims(model.box, made)
    ? { ...model, box: BoxSelect.update(model.box, BoxSelect.dragOf(made)).model }
    : orbitBy(model, made);

/** Letting go of the box selects what it holds. */
const endBox = (model: Model): Model => {
  const selectable = model.things.map(({ key, placement }) => ({ key, at: placement.position }));
  const result = BoxSelect.update(
    model.box,
    BoxSelect.Message.Ended({ camera: model.camera, selectable }),
  );
  const selected = result.outMessage?.keys ?? model.selected;
  return { ...model, box: result.model, selected };
};

/** The things as the history has them now. */
const travelled = (model: Model, history: Model['history']): Model => ({
  ...model,
  history,
  things: History.present(history),
});

const undo = (model: Model): Model => travelled(model, History.undo(model.history));

const redo = (model: Model): Model => travelled(model, History.redo(model.history));

/** Redo takes the next branch from here, round to the first after the last. */
const nextBranch = (model: Model): Model => {
  const choices = History.branches(model.history);
  const taken = choices.findIndex((entry) => entry.id === History.presentEntry(model.history).next);
  const next = choices[(taken + 1) % Math.max(choices.length, 1)];
  return next === undefined
    ? model
    : { ...model, history: History.chooseBranch(model.history, next.id) };
};

const isThing = (model: Model, key: string): boolean =>
  model.things.some((thing) => thing.key === key);

/** Selects a thing that is clicked; a click on a gizmo handle changes nothing. */
const select = (model: Model, key: string): Model =>
  isThing(model, key)
    ? { ...model, selected: [key], pressedView: false }
    : { ...model, pressedView: false };

/** While measuring, a click (not the end of an orbit) places a tape end where it hit. */
const clickThing = (model: Model, key: string, point: Vec3): Model => {
  if (!model.measuring) return select(model, key);
  return model.orbited ? model : withTape(model, MeasuringTape.Message.Placed({ point }));
};

/** A drag or release goes to the gizmo only while it is held. */
const whileHeld = (model: Model, message: TransformGizmo.Message): Model =>
  TransformGizmo.held(model.gizmo) ? withGizmo(model, message) : model;

/** A drag goes to the pivot controls while they are held, else to the gizmo. */
const drag = (model: Model, origin: Vec3, direction: Vec3): Model =>
  PivotControls.held(model.pivot)
    ? withPivot(model, PivotControls.Message.Dragged({ origin, direction, snap: model.snapping }))
    : whileHeld(model, TransformGizmo.Message.Dragged({ origin, direction }));

const release = (model: Model): Model =>
  PivotControls.held(model.pivot)
    ? withPivot(model, PivotControls.Message.Released())
    : whileHeld(model, TransformGizmo.Message.Released());

/** Over a pivot handle: highlighted. */
const hoverPivot = (model: Model, key: string): Model =>
  Option.match(PivotControls.handleOf(model.pivot, key), {
    onNone: () => model,
    onSome: (handle) => withPivot(model, PivotControls.Message.Hovered({ handle })),
  });

const leavePivot = (model: Model, key: string): Model =>
  Option.isSome(PivotControls.handleOf(model.pivot, key))
    ? withPivot(model, PivotControls.Message.Hovered({ handle: null }))
    : model;

const otherStyle = { transform: 'pivot', pivot: 'transform' } as const;

/** Over a gizmo handle: highlighted. */
const hover = (model: Model, key: string): Model =>
  Option.match(TransformGizmo.handleOf(model.gizmo, key), {
    onNone: () => model,
    onSome: (handle) => withGizmo(model, TransformGizmo.Message.Hovered({ handle })),
  });

/** Leaving a gizmo handle clears its highlight. */
const leave = (model: Model, key: string): Model =>
  Option.isSome(TransformGizmo.handleOf(model.gizmo, key))
    ? withGizmo(model, TransformGizmo.Message.Hovered({ handle: null }))
    : model;

const otherSpace = { world: 'local', local: 'world' } as const;

/** Snapping on or off: the gizmo's snaps with it. */
const toggleSnapping = (model: Model): Model => {
  const snapping = !model.snapping;
  const steps = snapping ? snaps : free;
  return {
    ...model,
    snapping,
    gizmo: { ...model.gizmo, gizmo: { ...model.gizmo.gizmo, ...steps } },
    tape: { ...model.tape, tape: { ...model.tape.tape, snap: steps.translationSnap } },
  };
};

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    PressedScene: ({ key, point, origin, direction }) => ({
      model: press({ ...model, orbited: false }, key, point, origin, direction),
    }),
    DraggedScene: ({ origin, direction }) => ({ model: drag(model, origin, direction) }),
    ReleasedScene: () => ({ model: release(model) }),
    ClickedThing: ({ key, point }) => ({ model: clickThing(model, key, point) }),
    MovedOver: ({ point }) => ({ model: moveOver(model, point) }),
    ClickedNothing: () => ({ model: clickNothing(model) }),
    HoveredScene: ({ key }) => ({
      model: withView(hoverPivot(hover(model, key), key), ViewportGizmo.Message.Entered({ key })),
    }),
    LeftScene: ({ key }) => ({
      model: withTape(
        withView(leavePivot(leave(model, key), key), ViewportGizmo.Message.Left({ key })),
        MeasuringTape.Message.Pointed({ point: null }),
      ),
    }),
    MadeGesture: ({ gesture: made }) => ({ model: gesture(model, made) }),
    EndedGesture: () => ({ model: endBox(model) }),
    ChoseMode: ({ mode }) => ({
      model: withGizmo(model, TransformGizmo.Message.ChoseMode({ mode })),
    }),
    ClickedSpace: () => ({
      model: withGizmo(
        model,
        TransformGizmo.Message.ChoseSpace({ space: otherSpace[model.gizmo.gizmo.space] }),
      ),
    }),
    ClickedSnapping: () => ({ model: toggleSnapping(model) }),
    ClickedPivot: () => ({ model: { ...model, style: otherStyle[model.style] } }),
    ClickedMeasure: () => ({ model: toggleMeasuring(model) }),
    EditedThing: ({ key, path, value }) => ({ model: editThing(model, key, path, value) }),
    ClickedUndo: () => ({ model: undo(model) }),
    ClickedRedo: () => ({ model: redo(model) }),
    ClickedBranch: () => ({ model: nextBranch(model) }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/**
 * Four things on a table, some selected, with the transform gizmo on the first (a
 * Foldkit submodel over the core's pure gizmo, after three's `TransformControls`). The
 * selected things glow and their sharp edges are drawn, and while a selection box is
 * drawn, what it holds is marked instead; the floor is Drei's shader grid.
 */
import {
  box,
  cone,
  directionalLight,
  edges,
  environment,
  fixed,
  type GeometryRef,
  hemisphereLight,
  mesh,
  type Placement,
  renderSettings,
  roundedBox,
  type SceneNode,
  scene,
  shaderGrid,
  sphere,
  standard,
  torus,
} from '@opsydyn/effect-three';
import {
  BoxSelect,
  MeasuringTape,
  PivotControls,
  TransformGizmo,
  ViewportGizmo,
} from '@opsydyn/effect-three-foldkit';
import { Array as Arr, Option } from 'effect';

import type { Kind, Model, Thing } from './model';

const geometries = {
  crate: roundedBox({ width: 0.7, height: 0.7, depth: 0.7, radius: 0.05 }),
  ball: sphere({ radius: 0.4, widthSegments: 32, heightSegments: 16 }),
  cone: cone({ radius: 0.4, height: 0.8, radialSegments: 24 }),
  ring: torus({ radius: 0.32, tube: 0.12, radialSegments: 12, tubularSegments: 40 }),
} satisfies Record<Kind, GeometryRef>;

/** The keys outlined: what the box holds while it is drawn, else what is selected. */
const outlined = (model: Model): ReadonlyArray<string> =>
  BoxSelect.drawing(model.box)
    ? BoxSelect.inside(
        model.box,
        model.camera,
        model.things.map(({ key, placement }) => ({ key, at: placement.position })),
      )
    : model.selected;

const outlineFor = (keys: ReadonlyArray<string>, thing: Thing) =>
  keys.includes(thing.key) ? edges({ color: 0xffffff, width: 2, threshold: 30 }) : null;

/** Marked things glow faintly too: a rounded crate or a ball has no sharp edges to draw. */
const glowFor = (keys: ReadonlyArray<string>, thing: Thing) =>
  keys.includes(thing.key) ? 0.35 : 0;

const thingNode = (keys: ReadonlyArray<string>, thing: Thing): SceneNode =>
  mesh({
    key: thing.key,
    position: thing.placement.position,
    rotation: fixed(thing.placement.rotation),
    scale: thing.placement.scale,
    geometry: geometries[thing.kind],
    material: standard({
      color: thing.colour,
      roughness: 0.45,
      metalness: 0.1,
      emissive: 0x3d79fa,
      emissiveIntensity: glowFor(keys, thing),
    }),
    castShadow: true,
    interactive: true,
    edges: outlineFor(keys, thing),
  });

const selectedPlacement = (model: Model): Option.Option<Placement> =>
  Option.flatMap(Arr.head(model.selected), (key) =>
    Option.fromNullishOr(model.things.find((thing) => thing.key === key)?.placement),
  );

/** The gizmo on the first selected thing, if something is selected and not measuring. */
const gizmoFor = (model: Model): ReadonlyArray<SceneNode> =>
  Option.match(selectedPlacement(model), {
    onNone: () => [],
    onSome: (placement) => [
      model.style === 'pivot'
        ? PivotControls.nodes(model.pivot, placement)
        : TransformGizmo.nodes(model.gizmo, placement, model.camera),
    ],
  });

export const sceneFor = (model: Model) => {
  const keys = outlined(model);
  return scene({
    camera: model.camera,
    gestures: 'scene',
    hud: ViewportGizmo.hud(model.view, model.camera),
    environment: environment({ environmentIntensity: 0.4 }),
    render: renderSettings({ shadows: 'soft', toneMapping: 'aces' }),
    children: [
      hemisphereLight({ key: 'sky', color: 0xffffff, groundColor: 0x6f6a62, intensity: 1 }),
      directionalLight({ key: 'sun', position: [4, 7, 3], intensity: 2.5, castShadow: true }),
      mesh({
        key: 'table',
        position: [0, -0.02, 0],
        rotation: fixed([-Math.PI / 2, 0, 0]),
        geometry: box({ width: 8, height: 8, depth: 0.02 }),
        material: standard({ color: 0xd9d6cf, roughness: 0.95 }),
        receiveShadow: true,
        // Picked only while measuring, so a tape end can be placed on it.
        interactive: model.measuring,
      }),
      shaderGrid({ key: 'grid', size: [8, 8], cellSize: 0.25, sectionSize: 1, fadeDistance: 14 }),
      ...model.things.map((thing) => thingNode(keys, thing)),
      ...(model.measuring ? [MeasuringTape.nodes(model.tape)] : gizmoFor(model)),
    ],
  });
};
view.ts
import { History, Inspector } 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 Model, Thing } from './model';
import { sceneFor } from './scene';

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, enabled: boolean, message: Message): Html =>
  h.button(
    [h.Class('e3-button'), h.Type('button'), h.Disabled(!enabled), h.OnClick(message)],
    [label],
  );

const modes = [
  ['translate', 'Move'],
  ['rotate', 'Turn'],
  ['scale', 'Scale'],
] as const;

const figure = (value: number): string => value.toFixed(2);

const undoable = (model: Model): string => {
  const ways = History.branches(model.history).length;
  const branched = ways > 1 ? ` ${ways} ways forward from here.` : '';
  return `${History.depth(model.history)} moves to undo.${branched}`;
};

/** Which branch redo takes, when there is more than one. */
const branchLabel = (model: Model): string => {
  const choices = History.branches(model.history);
  const taken = choices.findIndex((entry) => entry.id === History.presentEntry(model.history).next);
  return `Branch ${taken + 1} of ${choices.length}`;
};

/** One selected thing and where it is. */
const placed = (model: Model, { key, placement }: Thing): string =>
  `${key}: at ${placement.position.map(figure).join(', ')}, turned ${placement.rotation
    .map((angle) => `${Math.round((angle * 180) / Math.PI)}°`)
    .join(', ')}, scaled ${placement.scale.map(figure).join(', ')}. ${undoable(model)}`;

/** What is selected and where, read from the Model. */
/** What the tape says while measuring. */
const measurement = (model: Model): string =>
  Option.match(EffectThreeFoldkit.MeasuringTape.length(model.tape), {
    onNone: () => 'Measuring: click a point on the table or a thing, then another.',
    onSome: (length) =>
      model.tape.tape.to === null
        ? `${length.toFixed(2)} so far: click to place the other end.`
        : `${length.toFixed(2)} units between the ends. Click again to start a new tape.`,
  });

const readout = (model: Model): string => {
  if (model.measuring) return measurement(model);
  const selected = model.things.filter((thing) => model.selected.includes(thing.key));
  const [only] = selected;
  if (selected.length > 1) {
    return `${selected.map((thing) => thing.key).join(', ')} selected: the gizmo on ${model.selected[0] ?? ''} moves them alike. ${undoable(model)}`;
  }
  return only === undefined
    ? 'Click something on the table to move, turn or scale it, or shift-drag to box several.'
    : placed(model, only);
};

/**
 * The one selected thing's fields, read from the `Thing` schema (its key is how the
 * page finds it, so it is left out), and why the last edit was refused, if it was.
 */
const inspector = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> => {
  const selected = model.things.filter((thing) => model.selected.includes(thing.key));
  const [only] = selected;
  if (only === undefined || selected.length > 1 || model.measuring) return [];
  const fields = Inspector.fields(Thing, only).filter((field) => field.path[0] !== 'key');
  return [
    h.section(
      [h.Class('e3-inspector-panel'), h.AriaLabel(`${only.key}, inspected`)],
      [
        h.h3([], [`Inspector: ${only.key}`]),
        EffectThreeFoldkit.inspectorView(h, fields, (path, value) =>
          Message.EditedThing({ key: only.key, path, value }),
        ),
        ...Option.match(model.inspectorIssue, {
          onNone: () => [],
          onSome: (issue) => [h.p([h.Class('e3-inspector-issue'), h.Role('alert')], [issue])],
        }),
      ],
    ),
  ];
};

/** The selection box's outline over the canvas, while one is drawn. */
const boxOutline = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
  Option.match(EffectThreeFoldkit.BoxSelect.rectangle(model.box), {
    onNone: () => [],
    onSome: ({ left, top, width, height }) => [
      h.div(
        [
          h.Class('e3-select-box'),
          h.AriaHidden(true),
          h.Style({
            left: `${left}px`,
            top: `${top}px`,
            width: `${width}px`,
            height: `${height}px`,
          }),
        ],
        [],
      ),
    ],
  });

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Arrange — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      h.div(
        [h.Class('e3-select-area')],
        [
          EffectThreeFoldkit.view(
            {
              scene: sceneFor(model),
              mount: sceneMount,
              className: 'e3-stage e3-stage-tall',
              overlays: EffectThreeFoldkit.MeasuringTape.label(model.tape, h, {
                unit: 'units',
                className: 'e3-tape-label',
              }),
              onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
              onPointerDown: ({ key, point, ray }) =>
                Message.PressedScene({ key, point, origin: ray.origin, direction: ray.direction }),
              onDrag: ({ ray }) =>
                Message.DraggedScene({ origin: ray.origin, direction: ray.direction }),
              onDragEnd: () => Message.ReleasedScene(),
              onClick: ({ key, point }) => Message.ClickedThing({ key, point }),
              onPointerMove: ({ point }) => Message.MovedOver({ point }),
              onPointerMissed: () => Message.ClickedNothing(),
              onPointerEnter: ({ key }) => Message.HoveredScene({ key }),
              onPointerLeave: ({ key }) => Message.LeftScene({ key }),
              onGesture: (gesture) => Message.MadeGesture({ gesture }),
              onGestureEnd: () => Message.EndedGesture(),
            },
            h,
          ),
          ...boxOutline(model, h),
        ],
      ),
      h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
      h.div(
        [h.Class('e3-controls')],
        [
          ...modes.map(([mode, label]) =>
            toggle(h, label, model.gizmo.gizmo.mode === mode, Message.ChoseMode({ mode })),
          ),
          toggle(h, 'Local axes', model.gizmo.gizmo.space === 'local', Message.ClickedSpace()),
          toggle(h, 'Snap', model.snapping, Message.ClickedSnapping()),
          toggle(h, 'Pivot', model.style === 'pivot', Message.ClickedPivot()),
          toggle(h, 'Measure', model.measuring, Message.ClickedMeasure()),
          button(h, 'Undo', History.canUndo(model.history), Message.ClickedUndo()),
          button(h, 'Redo', History.canRedo(model.history), Message.ClickedRedo()),
          ...(History.branches(model.history).length > 1
            ? [button(h, branchLabel(model), true, Message.ClickedBranch())]
            : []),
        ],
      ),
      ...inspector(model, h),
      h.p(
        [h.Class('e3-note')],
        [
          'The gizmo is a Foldkit submodel: its handles are scene nodes, a press and the drag ',
          'rays after it are Messages, and each move comes back to the page as an OutMessage, ',
          'so every move can be undone. Drag empty space to orbit; shift-drag it to draw a ',
          'selection box (another submodel, after Drei’s Select), and the gizmo moves ',
          'everything selected alike. The axes in the corner turn the camera to look along ',
          'one: a third submodel, drawn in the scene’s HUD. Pivot swaps the gizmo for Drei’s ',
          'pivot controls, a fourth: arrows, squares, arcs and spheres all at once. Measure ',
          'turns clicks into a tape between two points, a fifth. The inspector below the ',
          'controls is generated from the Thing schema, which checks every edit.',
        ],
      ),
    ],
  ),
});