BUILD, THEN REWIND
A scene is a function of state.
Click the plate or a piece to stack another. Pick a colour and a block, ball or drum. Then drag the history slider: the sculpture replays exactly, because nothing in the scene exists outside the Model.
Rewind for free.
The history slider only changes a number in the Model. The scene is derived from it on every render, so the 3D view follows with no extra code. With Foldkit DevTools in time-travel mode, picking any history row does the same.
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 { Schema } from 'effect';
import { Paint } from '../palette';
/** The pieces the sculpture is built from. */
export const Piece = Schema.Literals(['block', 'ball', 'drum']);
export type Piece = typeof Piece.Type;
export const pieces: ReadonlyArray<Piece> = Piece.literals;
export const Block = Schema.Struct({
id: Schema.Number,
x: Schema.Int,
y: Schema.Int,
z: Schema.Int,
paint: Paint,
piece: Piece,
});
export type Block = typeof Block.Type;
/**
* Every block ever placed, in order, and how many of them are shown. Scrubbing `shown`
* back replays the build; placing a block while scrubbed back discards the later ones.
*/
export const Model = Schema.Struct({
blocks: Schema.Array(Block),
shown: Schema.Int,
paint: Paint,
piece: Piece,
nextId: Schema.Number,
});
export type Model = typeof Model.Type;
const seed: ReadonlyArray<Block> = [
{ id: 0, x: 0, y: 0, z: 0, paint: 'blue', piece: 'block' },
{ id: 1, x: 1, y: 0, z: 0, paint: 'gold', piece: 'drum' },
{ id: 2, x: 0, y: 1, z: 0, paint: 'coral', piece: 'ball' },
];
export const init = () => ({
model: {
blocks: seed,
shown: seed.length,
paint: 'mint',
piece: 'block',
nextId: seed.length,
} satisfies Model,
});
/** The blocks currently shown. */
export const shownBlocks = (model: Model): ReadonlyArray<Block> =>
model.blocks.slice(0, model.shown);
message.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
import { Paint } from '../palette';
import { Piece } from './model';
export const Message = defineMessageUnion({
ClickedSurface: { key: Schema.String, x: Schema.Number, z: Schema.Number },
ClickedPaint: { paint: Paint },
ClickedPiece: { piece: Piece },
ChangedHistory: { shown: Schema.Int },
ClickedUndo: {},
ClickedRedo: {},
ClickedClear: {},
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { Array as Arr, Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { Message } from './message';
import { type Block, type Model, shownBlocks } from './model';
import { half } from './scene';
type AppReturn = Return<Model, Message>;
const clamp = (value: number): number => Math.max(-half, Math.min(half, Math.round(value)));
/** The grid column a click lands in: the clicked block's own column, or the ground cell. */
const columnFor = (model: Model, key: string, x: number, z: number) =>
Option.match(
Arr.findFirst(shownBlocks(model), (block) => `block-${block.id}` === key),
{
onNone: () => ({ x: clamp(x), z: clamp(z) }),
onSome: (block) => ({ x: block.x, z: block.z }),
},
);
/** Stacks a block on top of a column, discarding any history after the shown point. */
const place = (model: Model, key: string, x: number, z: number): Model => {
const column = columnFor(model, key, x, z);
const shown = shownBlocks(model);
const height = shown.filter((block) => block.x === column.x && block.z === column.z).length;
const block: Block = {
id: model.nextId,
...column,
y: height,
paint: model.paint,
piece: model.piece,
};
return { ...model, blocks: [...shown, block], shown: shown.length + 1, nextId: model.nextId + 1 };
};
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
ClickedSurface: ({ key, x, z }) => ({ model: place(model, key, x, z) }),
ClickedPaint: ({ paint }) => ({ model: modifyFields(model, { paint: () => paint }) }),
ClickedPiece: ({ piece }) => ({ model: modifyFields(model, { piece: () => piece }) }),
ChangedHistory: ({ shown }) => ({
model: modifyFields(model, {
shown: () => Math.max(0, Math.min(model.blocks.length, shown)),
}),
}),
ClickedUndo: () => ({
model: modifyFields(model, { shown: (shown) => Math.max(0, shown - 1) }),
}),
ClickedRedo: () => ({
model: modifyFields(model, { shown: (shown) => Math.min(model.blocks.length, shown + 1) }),
}),
ClickedClear: () => ({ model: { ...model, blocks: [], shown: 0 } }),
ReceivedSceneLifecycle: () => ({ model }),
});
scene.ts
/** The sculpture as a pure function of the model: a ground plate and the shown pieces. */
import {
ambientLight,
box,
cylinder,
directionalLight,
mesh,
perspectiveCamera,
scene,
type SceneNode,
sharedGeometry,
sharedMaterial,
sphere,
standard,
} from '@opsydyn/effect-three';
import { paintColour, paints } from '../palette';
import { type Block, type Model, shownBlocks } from './model';
/** Blocks sit on a grid from -half to +half on x and z. */
export const half = 4;
const blockNode = (block: Block): SceneNode =>
mesh({
key: `block-${block.id}`,
position: [block.x, block.y + 0.5, block.z],
geometry: sharedGeometry(block.piece),
material: sharedMaterial(block.paint),
interactive: true,
});
export const sceneFor = (model: Model) =>
scene({
camera: perspectiveCamera({ fov: 40, position: [9, 9, 12], lookAt: [0, 0.5, 0] }),
resources: {
geometries: [
{ id: 'block', geometry: box({ width: 0.96, height: 0.96, depth: 0.96 }) },
{ id: 'ball', geometry: sphere({ radius: 0.48 }) },
{ id: 'drum', geometry: cylinder({ radiusTop: 0.46, radiusBottom: 0.46, height: 0.96 }) },
],
materials: paints.map((paint) => ({
id: paint,
material: standard({ color: paintColour[paint], roughness: 0.55 }),
})),
},
children: [
ambientLight({ key: 'ambient', intensity: 0.6 }),
directionalLight({ key: 'sun', intensity: 2.2, position: [5, 10, 7] }),
mesh({
key: 'ground',
position: [0, -0.1, 0],
geometry: box({ width: half * 2 + 1, height: 0.2, depth: half * 2 + 1 }),
material: standard({ color: 0x9aa3b5, roughness: 0.9 }),
interactive: true,
}),
...shownBlocks(model).map(blockNode),
],
});
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../environment';
import { type Paint, paintCss, paints } from '../palette';
import { Message } from './message';
import { type Model, type Piece, pieces } 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 swatch = (model: Model, h: HtmlBuilder<Message>, paint: Paint): Html =>
h.button(
[
h.Class('e3-button'),
h.Type('button'),
h.AriaPressed(String(model.paint === paint)),
h.OnClick(Message.ClickedPaint({ paint })),
],
[h.span([h.Class('e3-swatch'), h.Style({ background: paintCss(paint) })], []), paint],
);
const piecePicker = (model: Model, h: HtmlBuilder<Message>, piece: Piece): Html =>
h.button(
[
h.Class('e3-button'),
h.Type('button'),
h.AriaPressed(String(model.piece === piece)),
h.OnClick(Message.ClickedPiece({ piece })),
],
[piece],
);
const history = (model: Model, h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('e3-controls')],
[
button(h, 'Undo', Message.ClickedUndo()),
h.input([
h.Class('e3-range'),
h.Type('range'),
h.Min('0'),
h.Max(String(model.blocks.length)),
h.Value(String(model.shown)),
h.AriaLabel('Build history'),
h.OnInput((value) => Message.ChangedHistory({ shown: Number.parseInt(value, 10) || 0 })),
]),
button(h, 'Redo', Message.ClickedRedo()),
button(h, 'Clear', Message.ClickedClear()),
],
);
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'Sculpture — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onClick: ({ key, point }) => Message.ClickedSurface({ key, x: point[0], z: point[2] }),
},
h,
),
h.div(
[h.Class('e3-controls')],
paints.map((paint) => swatch(model, h, paint)),
),
h.div(
[h.Class('e3-controls'), h.AriaLabel('Piece')],
pieces.map((piece) => piecePicker(model, h, piece)),
),
history(model, h),
h.p(
[h.Class('e3-note')],
[
`Showing ${model.shown} of ${model.blocks.length} pieces. Click the ground or a piece to stack a block, ball or drum. `,
'The scene is a pure function of the model, so the history slider replays the build exactly.',
],
),
],
),
});