EDITING
A scene editor.
Add things and lights, then select them by clicking, in the scene list, or by shift-dragging a box. Move, turn and scale them with the gizmo, and change anything else in the inspector, which is generated from the scene's own node schema and refuses what that schema refuses. Undo, redo, or jump to any step in the history; a change after an undo starts a branch, and the old one stays. Save the scene as a file, open it again, and see what changed since you saved. Frame a shot or light the scene by mood; an assistant can do the same through Foldkit DevTools. Open this page in a second tab and edit the same scene in both: every change goes through a lockstep session, so both tabs keep one document and one history. To edit from another machine, run bun run relay from the repository and open this page with ?relay=ws://host:4325/any-room on each.
The document is the Model.
The scene being edited is a list of scene nodes in the Model. The gizmo, the selection box and the viewport gizmo are Foldkit submodels; the inspector is read from SceneSchema.SceneNode; the history is a tree of documents; a file is the schema's encoding; and the changes since saving are the kernel's own diff. Nothing here edits a three.js object directly.
See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
document.ts
/**
* The document the editor edits: a scene's top-level nodes, as plain `SceneNode`s. What
* can be added, how each node is placed for the gizmo, and how a hit on the scene finds
* the node it belongs to. The editor's own staging (floor, grid, light markers, gizmos)
* is drawn around the document and never saved in it.
*/
import {
box,
cone,
fixed,
type Placement,
pointLight,
type Rotation,
setAt,
roundedBox,
type SceneNode,
mesh,
sphere,
standard,
torus,
type Vec3,
} from '@opsydyn/effect-three';
import { Schema } from 'effect';
import { paintColour } from '../palette';
/** What the editor can add. */
export const Addable = Schema.Literals(['crate', 'box', 'ball', 'cone', 'ring', 'light']);
export type Addable = typeof Addable.Type;
export const addables: ReadonlyArray<Addable> = Addable.literals;
const addedColour = {
crate: paintColour.coral,
box: paintColour.periwinkle,
ball: paintColour.blue,
cone: paintColour.gold,
ring: paintColour.mint,
} as const;
const addedGeometry = {
crate: roundedBox({ width: 0.8, height: 0.8, depth: 0.8, radius: 0.06 }),
box: box({ width: 1, height: 0.5, depth: 0.6 }),
ball: sphere({ radius: 0.45, widthSegments: 32, heightSegments: 16 }),
cone: cone({ radius: 0.4, height: 0.9, radialSegments: 24 }),
ring: torus({ radius: 0.35, tube: 0.12, radialSegments: 12, tubularSegments: 40 }),
} as const;
/** A new node of `kind` under `key`, standing on the floor at the middle. */
export const nodeOf = (kind: Addable, key: string): SceneNode =>
kind === 'light'
? pointLight({ key, position: [0, 2.5, 0], intensity: 8, color: 0xfff1d6 })
: mesh({
key,
position: [0, 0.5, 0],
rotation: fixed([0, 0, 0]),
geometry: addedGeometry[kind],
material: standard({ color: addedColour[kind], roughness: 0.45, metalness: 0.05 }),
castShadow: true,
});
/** The first `${kind}-n` not yet a key in `nodes`. */
export const freshKey = (nodes: ReadonlyArray<SceneNode>, kind: string): string => {
const taken = new Set(nodes.map((node) => node.key));
const numbered = Array.from({ length: nodes.length + 1 }, (_, index) => `${kind}-${index + 1}`);
return numbered.find((key) => !taken.has(key)) ?? `${kind}-${nodes.length + 1}`;
};
type Turn = Vec3 | Rotation;
/** A node's turn as Euler angles: a fixed rotation's, a bare one's, or none. */
const eulerOf = (turn: Turn): Vec3 => {
if (!('_tag' in turn)) return turn;
return turn._tag === 'Fixed' ? turn.euler : [0, 0, 0];
};
/** Where a node is, for the gizmo: what it has of position, turn and scale. */
export const placementOf = (node: SceneNode): Placement => ({
position: 'position' in node ? node.position : [0, 0, 0],
rotation: 'rotation' in node ? eulerOf(node.rotation) : [0, 0, 0],
scale: 'scale' in node ? node.scale : [1, 1, 1],
});
/** The turn written back as the node keeps it: bare angles, or a fixed rotation. */
const turnLike = (turn: Turn, euler: Vec3): Turn => ('_tag' in turn ? fixed(euler) : euler);
/** The node at `placement`: each of position, turn and scale it has, set. */
export const placed = (node: SceneNode, placement: Placement): SceneNode => {
const turn =
'rotation' in node ? turnLike(node.rotation, placement.rotation) : placement.rotation;
const fields = [
['position', placement.position],
['rotation', turn],
['scale', placement.scale],
] as const;
const moved = fields
.filter(([name]) => name in node)
.reduce<unknown>((at, [name, value]) => setAt(at, [name], value), node);
// SAFETY: only fields `node` has are replaced, each with a value of its own type.
return moved as SceneNode;
};
/** A copy of `node` under a fresh key, a step to the side. */
export const duplicated = (nodes: ReadonlyArray<SceneNode>, node: SceneNode): SceneNode => {
const { position, rotation, scale } = placementOf(node);
const key = freshKey(nodes, node.key.replace(/-\d+$/, ''));
return placed(
{ ...node, key },
{ position: [position[0] + 1, position[1], position[2]], rotation, scale },
);
};
/** Light markers are the editor's: drawn under `marker:${key}` so the light can be picked. */
export const markerKey = (key: string): string => `marker:${key}`;
/**
* The document node a hit is on: its top-level ancestor (a group's, for a mesh inside
* one), a light for its marker, or nothing for the editor's own staging.
*/
export const nodeKeyOf = (
nodes: ReadonlyArray<SceneNode>,
hit: { readonly key: string; readonly path: ReadonlyArray<string> },
): string | undefined => {
const top = hit.path.at(-1) ?? hit.key;
const key = top.startsWith('marker:') ? top.slice('marker:'.length) : top;
return nodes.some((node) => node.key === key) ? key : undefined;
};
/** Each node's position, for the selection box. */
export const selectableOf = (nodes: ReadonlyArray<SceneNode>) =>
nodes.map((node) => ({ key: node.key, at: placementOf(node).position }));
model.ts
import {
History,
perspectiveCamera,
type SceneNode,
SceneSchema,
type Vec3,
} from '@opsydyn/effect-three';
import { BoxSelect, TransformGizmo, ViewportGizmo } from '@opsydyn/effect-three-foldkit';
import { Option, Schema } from 'effect';
import { nodeOf, placed } from './document';
import { NameTab } from './session';
import { Relay, Session } from './wire';
const Nodes = Schema.Array(SceneSchema.SceneNode);
export const Model = Schema.Struct({
/** The document: the scene's top-level nodes, as they are now (moving, mid-drag). */
nodes: Nodes,
/** Every finished change to the document, as a branching history. */
history: SceneSchema.History(Nodes),
/** Geometries and materials the document's nodes share by id, from a file opened. */
resources: SceneSchema.ResourceDefinitions,
/** The document as last saved or loaded: the review panel lists what changed since. */
saved: Nodes,
/** The nodes when the current drag began. */
before: Schema.Option(Nodes),
selected: Schema.Array(Schema.String),
gizmo: TransformGizmo.Model,
box: BoxSelect.Model,
view: ViewportGizmo.Model,
/** Whether the last press was on the viewport gizmo (see the arrange sample). */
pressedView: Schema.Boolean,
camera: SceneSchema.Camera,
snapping: Schema.Boolean,
/** How the scene is lit: the editor's staging, not part of the document. */
mood: Schema.Literals(['studio', 'daylight', 'sunset', 'night']),
/** Editing together with other tabs: where this tab is in the session. */
session: Session,
/** The relay the page named, if any: the session then runs through it, not the channel. */
relay: Schema.Option(Relay),
/** Why the inspector's last edit, or the last file loaded, was refused. */
issue: Schema.Option(Schema.String),
});
export type Model = typeof Model.Type;
const standing = (position: Vec3) => ({
position,
rotation: [0, 0, 0] as const,
scale: [1, 1, 1] as const,
});
export const startNodes: ReadonlyArray<SceneNode> = [
placed(nodeOf('crate', 'crate-1'), standing([-1.2, 0.4, 0])),
placed(nodeOf('ball', 'ball-1'), standing([0.4, 0.45, -0.6])),
nodeOf('light', 'light-1'),
];
export const init = () => ({
model: {
nodes: startNodes,
history: History.make<ReadonlyArray<SceneNode>>(startNodes, 'Opened'),
resources: { geometries: [], materials: [] },
saved: startNodes,
before: Option.none(),
selected: ['crate-1'],
gizmo: TransformGizmo.init('gizmo', { size: 1.2 }),
box: BoxSelect.init(),
view: ViewportGizmo.init('view'),
pressedView: false,
camera: perspectiveCamera({
fov: 45,
position: [4.5, 4, 6.5],
lookAt: [0, 0.4, 0],
damping: 0.15,
}),
snapping: false,
mood: 'studio' as const,
session: { _tag: 'Offline' as const },
relay: Option.none(),
issue: Option.none(),
} satisfies Model,
// A name for this tab, then a session with any other editor tabs open (or through a relay).
commands: [NameTab()],
});
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 { Addable } from './document';
import { Wire } from './wire';
const Path = Schema.Array(Schema.Union([Schema.String, Schema.Number]));
export const Message = defineMessageUnion({
// The scene.
PressedScene: {
key: Schema.String,
path: Schema.Array(Schema.String),
origin: SceneSchema.Vec3,
direction: SceneSchema.Vec3,
},
DraggedScene: { origin: SceneSchema.Vec3, direction: SceneSchema.Vec3 },
ReleasedScene: {},
ClickedScene: { key: Schema.String, path: Schema.Array(Schema.String) },
ClickedNothing: {},
HoveredScene: { key: Schema.String },
LeftScene: { key: Schema.String },
MadeGesture: { gesture: SceneSchema.Gesture },
EndedGesture: {},
// Intents, as an assistant (or a person) asks for them: put a thing somewhere, frame a
// shot of some things (none: everything), light the scene with a mood.
PlacedThing: { kind: Addable, at: SceneSchema.Vec3 },
FramedShot: {
subject: Schema.Array(Schema.String),
angle: Schema.Literals(['front', 'back', 'left', 'right', 'top', 'three-quarter', 'low']),
},
ChoseMood: { mood: Schema.Literals(['studio', 'daylight', 'sunset', 'night']) },
// The outliner and toolbar.
ClickedAdd: { kind: Addable },
ClickedOutlined: { key: Schema.String },
ClickedDuplicate: {},
ClickedDelete: {},
ChoseMode: { mode: Schema.Literals(['translate', 'rotate', 'scale']) },
ClickedSnapping: {},
// History.
ClickedUndo: {},
ClickedRedo: {},
ClickedStep: { id: Schema.Number },
// The inspector.
EditedNode: {
key: Schema.String,
path: Path,
value: Schema.Union([Schema.Number, Schema.String, Schema.Boolean]),
},
// Files.
ClickedSave: {},
ChoseFiles: { files: Schema.Array(Schema.instanceOf(File)) },
ReadFile: { text: Schema.String },
CompletedDownload: {},
ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
// Editing together (see session.ts).
NamedTab: { peer: Schema.String, at: Schema.Number, relay: Schema.Option(Schema.String) },
WaitedForWelcome: {},
HeardWire: { wire: Wire, at: Schema.Number },
TickedTurn: { at: Schema.Number },
LeftPage: {},
CompletedSay: {},
// Editing together between machines, through a relay (see session.ts).
ConnectedRelay: {},
FailedRelay: { reason: Schema.String },
LostRelay: {},
ReleasedRelay: {},
ClickedReconnect: {},
});
export type Message = typeof Message.Type;
update.ts
import {
describeScene,
type FieldPath,
type FieldValue,
type Gesture,
Inspector,
Lockstep,
movedAlike,
orbit,
type Placement,
scene,
type SceneNode,
SceneFile,
SceneSchema,
shotCamera,
type ShotAngle,
type Vec3,
} from '@opsydyn/effect-three';
import { BoxSelect, TransformGizmo, ViewportGizmo } from '@opsydyn/effect-three-foldkit';
import { Array as Arr, Match, Option, Result } from 'effect';
import type { Return } from 'foldkit/update';
import { DownloadFile, ReadText } from './command';
import { nodeKeyOf, placed, placementOf, selectableOf } from './document';
import { applyEdit, type Edit } from './edit';
import { Message } from './message';
import type { Model } from './model';
import { NameTab, type RelayService, Say, selfOf, WaitForWelcome } from './session';
import { type Packet, quietMilliseconds, type Relay, type Wire } from './wire';
type AppReturn = Return<Model, Message, RelayService>;
type Nodes = ReadonlyArray<SceneNode>;
type Live = Extract<Model['session'], { readonly _tag: 'Live' }>;
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 nodeIn = (nodes: Nodes, key: string): Option.Option<SceneNode> =>
Arr.findFirst(nodes, (node) => node.key === key);
// PROPOSING
/**
* An Edit this tab asks for: into its next turn packet when in a session, applied here
* at once when offline (no channel, as in tests), and dropped while still joining.
*/
const propose = (model: Model, edit: Edit): Model =>
Match.valueTags(model.session, {
Offline: () => applyEdit(model, edit, true),
Joining: () => model,
Live: (session) => ({
...model,
session: { ...session, lockstep: Lockstep.submit(session.lockstep, edit) },
}),
});
// THE GIZMO
/** Each node after the gizmo put the first selected at `placement`; the rest move alike. */
const placeSelected = (model: Model, placement: Placement): Model => {
const before = Option.getOrElse(model.before, () => model.nodes);
const first = Option.flatMap(Arr.head(model.selected), (key) => nodeIn(before, key));
return Option.match(first, {
onNone: () => model,
onSome: (moved) => ({
...model,
nodes: model.nodes.map((node) => {
const was = Option.getOrElse(nodeIn(before, node.key), () => node);
if (node.key === moved.key) return placed(node, placement);
if (!model.selected.includes(node.key)) return node;
return placed(node, movedAlike(placementOf(moved), placement, placementOf(was)));
}),
}),
});
};
const fromGizmo = (model: Model, out: TransformGizmo.OutMessage): Model =>
TransformGizmo.OutMessage.match(out, {
Moved: ({ placement }) => placeSelected(model, placement),
Finished: ({ placement }) => {
const next = placeSelected(model, placement);
const placements = next.nodes
.filter((node) => model.selected.includes(node.key))
.map((node) => ({ key: node.key, placement: placementOf(node) }));
return propose({ ...next, before: Option.none() }, { _tag: 'Place', placements });
},
});
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);
};
const pressGizmo = (model: Model, key: string, origin: Vec3, direction: Vec3): Model => {
const first = Option.flatMap(Arr.head(model.selected), (selected) =>
nodeIn(model.nodes, selected),
);
return Option.match(Option.all([TransformGizmo.handleOf(model.gizmo, key), first]), {
onNone: () => model,
onSome: ([handle, node]) =>
withGizmo(
{ ...model, before: Option.some(model.nodes) },
TransformGizmo.Message.Pressed({
handle,
origin,
direction,
placement: placementOf(node),
camera: model.camera,
}),
),
});
};
const whileHeld = (model: Model, message: TransformGizmo.Message): Model =>
TransformGizmo.held(model.gizmo) ? withGizmo(model, message) : model;
// THE VIEWPORT GIZMO
const withView = (model: Model, message: ViewportGizmo.Message): Model => {
const result = ViewportGizmo.update(model.view, message);
return { ...model, view: result.model, camera: result.outMessage?.camera ?? model.camera };
};
const press = (model: Model, key: string, origin: Vec3, direction: Vec3): Model =>
Option.isSome(ViewportGizmo.axisOf(model.view, key))
? withView(
{ ...model, pressedView: true },
ViewportGizmo.Message.Pressed({ key, camera: model.camera }),
)
: pressGizmo({ ...model, pressedView: false }, key, origin, direction);
const hover = (model: Model, key: string): Model => {
const gizmo = Option.match(TransformGizmo.handleOf(model.gizmo, key), {
onNone: () => model,
onSome: (handle) => withGizmo(model, TransformGizmo.Message.Hovered({ handle })),
});
return withView(gizmo, ViewportGizmo.Message.Entered({ key }));
};
const leave = (model: Model, key: string): Model => {
const gizmo = Option.isSome(TransformGizmo.handleOf(model.gizmo, key))
? withGizmo(model, TransformGizmo.Message.Hovered({ handle: null }))
: model;
return withView(gizmo, ViewportGizmo.Message.Left({ key }));
};
// SELECTING
const select = (
model: Model,
path: { readonly key: string; readonly path: ReadonlyArray<string> },
) =>
Option.match(Option.fromNullishOr(nodeKeyOf(model.nodes, path)), {
onNone: () => ({ ...model, pressedView: false }),
onSome: (key) => ({ ...model, selected: [key], pressedView: false }),
});
const clickNothing = (model: Model): Model =>
model.pressedView ? { ...model, pressedView: false } : { ...model, selected: [] };
const offNodes = (gesture: Gesture): boolean => !('key' in gesture) || gesture.key === undefined;
const gesture = (model: Model, made: Gesture): Model => {
if (made._tag === 'Drag' && BoxSelect.claims(model.box, made)) {
return { ...model, box: BoxSelect.update(model.box, BoxSelect.dragOf(made)).model };
}
return !TransformGizmo.held(model.gizmo) && offNodes(made)
? { ...model, camera: orbit(model.camera, made) }
: model;
};
const endBox = (model: Model): Model => {
const result = BoxSelect.update(
model.box,
BoxSelect.Message.Ended({ camera: model.camera, selectable: selectableOf(model.nodes) }),
);
return { ...model, box: result.model, selected: result.outMessage?.keys ?? model.selected };
};
// SESSION
const live = (model: Model, session: Live): Model => ({ ...model, session });
/** Says `wire` to the others: through the relay when the page names one, else the channel. */
const say = (model: Model, wire: Wire) =>
Say({ wire, via: Option.isSome(model.relay) ? 'relay' : 'tabs' });
/** The Welcome for `peer`: the session and the shared document as they are now. */
const welcome = (model: Model, lockstep: Lockstep.Lockstep<Edit>, peer: string): Wire => ({
_tag: 'Welcome',
to: peer,
turn: lockstep.turn,
lead: lockstep.lead,
members: lockstep.members,
nodes: model.nodes,
resources: model.resources,
history: model.history,
});
/**
* Every turn whose packets are all in, applied: each Edit to the document (mine when
* this tab made it), then a Welcome to any newcomer whose first turn has come.
*/
const settle = (model: Model, session: Live): AppReturn => {
const before = session.lockstep.turn;
const result = Lockstep.advance(session.lockstep);
const applied = result.applied.reduce(
(at, { peer, input }) => applyEdit(at, input, peer === result.state.self),
live(model, { ...session, lockstep: result.state }),
);
const newcomers = Lockstep.welcomeDue(before, result.state);
return {
model: applied,
commands: newcomers.map((peer) => say(applied, welcome(applied, result.state, peer))),
};
};
/** The turn the session waits on, and since when; none once it waits on no one. */
const stallOf = (session: Live, at: number): Live['stalled'] => {
if (Lockstep.waitingOn(session.lockstep).length === 0) return Option.none();
const turn = session.lockstep.turn;
const same = Option.filter(session.stalled, (stalled) => stalled.turn === turn);
return Option.orElse(same, () => Option.some({ turn, since: at }));
};
/** Whether this tab, leading, has waited too long on others at the turn all wait on. */
const tooQuiet = (session: Live, at: number): boolean =>
Lockstep.leaderAt(session.lockstep, session.lockstep.turn) === session.lockstep.self &&
Option.exists(session.stalled, (stalled) => at - stalled.since >= quietMilliseconds);
/** As leader, tabs held up on for too long are dropped from the turn all wait on. */
const dropQuiet = (model: Model, session: Live, at: number): AppReturn => {
if (!tooQuiet(session, at)) return { model: live(model, session) };
const { lockstep } = session;
const quiet = Lockstep.waitingOn(lockstep).filter((peer) => peer !== lockstep.self);
const dropped = quiet.reduce(
(state, peer) => Lockstep.remove(state, peer, lockstep.turn),
lockstep,
);
const next = settle(model, { _tag: 'Live', lockstep: dropped, stalled: Option.none() });
return {
model: next.model,
commands: [
...quiet.map((peer) => say(model, { _tag: 'Drop', peer, until: lockstep.turn })),
...(next.commands ?? []),
],
};
};
/** A turn's tick: this tab's packet out, what is complete applied, the quiet dropped. */
const tick = (model: Model, at: number): AppReturn => {
if (model.session._tag !== 'Live') return { model };
const sent = Lockstep.send(model.session.lockstep);
const settled = settle(model, { ...model.session, lockstep: sent.state });
const session = settled.model.session;
const dropped =
session._tag === 'Live'
? dropQuiet(settled.model, { ...session, stalled: stallOf(session, at) }, at)
: settled;
return {
model: dropped.model,
commands: [
...(sent.packet === undefined ? [] : [say(model, { _tag: 'Turn', packet: sent.packet })]),
...(settled.commands ?? []),
...(dropped.commands ?? []),
],
};
};
/** Says hello, and waits to be welcomed. */
const hello = (model: Model, peer: string): AppReturn => ({
model,
commands: [say(model, { _tag: 'Hello', peer }), WaitForWelcome()],
});
/**
* This tab named: it joins, saying hello at once over the channel, or once the relay
* the page names is open (`connected`).
*/
const named = (model: Model, peer: string, relay: Option.Option<string>): AppReturn => {
const joining: Model = {
...model,
session: { _tag: 'Joining', self: peer, heard: [] },
relay: Option.map(relay, (url) => ({ url, link: 'Connecting' as const })),
};
return Option.isSome(relay) ? { model: joining } : hello(joining, peer);
};
const linked = (model: Model, link: Relay['link']): Model => ({
...model,
relay: Option.map(model.relay, (relay) => ({ ...relay, link })),
});
/** The relay open: this tab says hello through it. */
const connected = (model: Model): AppReturn =>
model.session._tag === 'Joining'
? hello(linked(model, 'Open'), model.session.self)
: { model: linked(model, 'Open') };
/**
* The relay unreachable, or gone: this tab carries on in a session of its own, with its
* document as it is (the others drop it when it goes quiet).
*/
const lost = (model: Model): Model => {
if (!Option.exists(model.relay, (relay) => relay.link !== 'Lost')) return model;
const unlinked = linked(model, 'Lost');
return Option.match(selfOf(model.session), {
onNone: () => unlinked,
onSome: (self) =>
live(unlinked, { _tag: 'Live', lockstep: Lockstep.make(self), stalled: Option.none() }),
});
};
/** Reconnecting: a new name, so this tab joins through the relay again as a newcomer. */
const reconnect = (model: Model): AppReturn =>
Option.exists(model.relay, (relay) => relay.link === 'Lost')
? { model, commands: [NameTab()] }
: { model };
/** No welcome came: this tab starts a session of its own, with its document as it is. */
const waited = (model: Model): Model =>
model.session._tag === 'Joining'
? live(model, {
_tag: 'Live',
lockstep: Lockstep.make(model.session.self),
stalled: Option.none(),
})
: model;
/**
* Whether this tab can take a welcome: still joining, or (the welcome came just after it
* gave up waiting) in a session of its own that no one else is in.
*/
const welcomable = (session: Model['session'], to: string): boolean => {
if (session._tag === 'Joining') return session.self === to;
if (session._tag !== 'Live') return false;
const { lockstep } = session;
return lockstep.self === to && Lockstep.membersAt(lockstep, lockstep.turn).length === 1;
};
const heardWhileJoining = (session: Model['session']) =>
session._tag === 'Joining' ? session.heard : [];
/** Welcomed: this tab joins the session and takes its document and history. */
const welcomed = (model: Model, wire: Extract<Wire, { readonly _tag: 'Welcome' }>): AppReturn => {
if (!welcomable(model.session, wire.to)) return { model };
const lockstep = Lockstep.join(wire.to, wire, heardWhileJoining(model.session));
const document = {
...model,
nodes: wire.nodes,
resources: wire.resources,
history: wire.history,
saved: wire.nodes,
selected: [],
};
return settle(document, { _tag: 'Live', lockstep, stalled: Option.none() });
};
/** A Hello: the leader admits the newcomer in its next packet. */
const helloed = (model: Model, peer: string): AppReturn =>
model.session._tag === 'Live'
? {
model: live(model, {
...model.session,
lockstep: Lockstep.admit(model.session.lockstep, peer),
}),
}
: { model };
/** A turn packet: kept while joining, received (and what it completes applied) once in. */
const turned = (model: Model, packet: Packet): AppReturn =>
Match.valueTags(model.session, {
Offline: () => ({ model }),
Joining: (session) => ({
model: { ...model, session: { ...session, heard: [...session.heard, packet] } },
}),
Live: (session) =>
settle(model, { ...session, lockstep: Lockstep.receive(session.lockstep, packet) }),
});
/** A tab gone, by its own goodbye or the leader's drop: no one waits for it from `until`. */
const gone = (model: Model, peer: string, until: number): AppReturn =>
model.session._tag === 'Live'
? settle(model, {
...model.session,
lockstep: Lockstep.remove(model.session.lockstep, peer, until),
})
: { model };
/** What another tab said, as this tab's session stands. */
const hear = (model: Model, wire: Wire): AppReturn =>
Match.valueTags(wire, {
Hello: ({ peer }) => helloed(model, peer),
Turn: ({ packet }) => turned(model, packet),
Welcome: (welcome) => welcomed(model, welcome),
Bye: ({ peer, until }) => gone(model, peer, until),
Drop: ({ peer, until }) => gone(model, peer, until),
});
/** Leaving: the others stop waiting after the last turn this tab sent. */
const bye = (model: Model): AppReturn =>
model.session._tag === 'Live'
? {
model,
commands: [
say(model, {
_tag: 'Bye',
peer: model.session.lockstep.self,
until: Lockstep.leave(model.session.lockstep) + 1,
}),
],
}
: { model };
// LOCAL
/** The things to frame: those named that are in the document, or all of them. */
const subjectOf = (model: Model, keys: ReadonlyArray<string>): Nodes => {
const named = model.nodes.filter((node) => keys.includes(node.key));
return named.length === 0 ? model.nodes : named;
};
/** The camera framing the subject from `angle`; with nothing of known size, as it was. */
const frame = (model: Model, keys: ReadonlyArray<string>, angle: ShotAngle): Model => {
const bounds = describeScene(
scene({ resources: model.resources, children: subjectOf(model, keys) }),
).bounds;
return bounds === null ? model : { ...model, camera: shotCamera(model.camera, bounds, angle) };
};
const toggleSnapping = (model: Model): Model => {
const snapping = !model.snapping;
return {
...model,
snapping,
gizmo: { ...model.gizmo, gizmo: { ...model.gizmo.gizmo, ...(snapping ? snaps : free) } },
};
};
const refused = (model: Model, issue: string): Model => ({ ...model, issue: Option.some(issue) });
const proposeField = (model: Model, key: string, path: FieldPath, value: FieldValue): Model =>
propose({ ...model, issue: Option.none() }, { _tag: 'Field', key, path, value });
/** An inspector edit to `node`, checked here first so a refusal is shown at once. */
const checkField = (model: Model, node: SceneNode, path: FieldPath, value: FieldValue): Model =>
Result.match(Inspector.edit(SceneSchema.SceneNode, node, path, value), {
onFailure: (issue) => refused(model, issue),
onSuccess: () => proposeField(model, node.key, path, value),
});
const editNode = (model: Model, key: string, path: FieldPath, value: FieldValue): Model =>
Option.match(nodeIn(model.nodes, key), {
onNone: () => model,
onSome: (node) => checkField(model, node, path, value),
});
// FILES
/** The document as a scene file: its nodes and what they share, in a scene of their own. */
const fileText = (model: Model): string =>
SceneFile.save(scene({ resources: model.resources, children: model.nodes }));
const save = (model: Model): AppReturn => ({
model: { ...model, saved: model.nodes },
commands: [DownloadFile({ name: 'scene.json', text: fileText(model) })],
});
/** A file read: its scene proposed as the document, or why it cannot be. */
const load = (model: Model, text: string): Model =>
Result.match(SceneFile.read(text), {
onFailure: (error) => refused(model, `That file is not a scene: ${error.issue}`),
onSuccess: (loaded) =>
propose(
{ ...model, saved: loaded.children },
{ _tag: 'Open', nodes: loaded.children, resources: loaded.resources },
),
});
/** The first file chosen is read; choosing none does nothing. */
const chooseFiles = (model: Model, files: ReadonlyArray<File>): AppReturn =>
Option.match(Arr.head(files), {
onNone: () => ({ model }),
onSome: (file) => ({ model, commands: [ReadText({ file })] }),
});
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
PressedScene: ({ key, origin, direction }) => ({ model: press(model, key, origin, direction) }),
DraggedScene: ({ origin, direction }) => ({
model: whileHeld(model, TransformGizmo.Message.Dragged({ origin, direction })),
}),
ReleasedScene: () => ({ model: whileHeld(model, TransformGizmo.Message.Released()) }),
ClickedScene: (hit) => ({ model: select(model, hit) }),
ClickedNothing: () => ({ model: clickNothing(model) }),
HoveredScene: ({ key }) => ({ model: hover(model, key) }),
LeftScene: ({ key }) => ({ model: leave(model, key) }),
MadeGesture: ({ gesture: made }) => ({ model: gesture(model, made) }),
EndedGesture: () => ({ model: endBox(model) }),
ClickedAdd: ({ kind }) => ({ model: propose(model, { _tag: 'Add', kind, at: null }) }),
PlacedThing: ({ kind, at }) => ({ model: propose(model, { _tag: 'Add', kind, at }) }),
FramedShot: ({ subject, angle }) => ({ model: frame(model, subject, angle) }),
ChoseMood: ({ mood }) => ({ model: { ...model, mood } }),
ClickedOutlined: ({ key }) => ({ model: { ...model, selected: [key] } }),
ClickedDuplicate: () => ({
model: propose(model, { _tag: 'Duplicate', keys: model.selected }),
}),
ClickedDelete: () => ({ model: propose(model, { _tag: 'Delete', keys: model.selected }) }),
ChoseMode: ({ mode }) => ({
model: withGizmo(model, TransformGizmo.Message.ChoseMode({ mode })),
}),
ClickedSnapping: () => ({ model: toggleSnapping(model) }),
ClickedUndo: () => ({ model: propose(model, { _tag: 'Undo' }) }),
ClickedRedo: () => ({ model: propose(model, { _tag: 'Redo' }) }),
ClickedStep: ({ id }) => ({ model: propose(model, { _tag: 'Jump', id }) }),
EditedNode: ({ key, path, value }) => ({ model: editNode(model, key, path, value) }),
ClickedSave: () => save(model),
ChoseFiles: ({ files }) => chooseFiles(model, files),
ReadFile: ({ text }) => ({ model: load(model, text) }),
CompletedDownload: () => ({ model }),
ReceivedSceneLifecycle: () => ({ model }),
NamedTab: ({ peer, relay }) => named(model, peer, relay),
WaitedForWelcome: () => ({ model: waited(model) }),
HeardWire: ({ wire }) => hear(model, wire),
TickedTurn: ({ at }) => tick(model, at),
LeftPage: () => bye(model),
CompletedSay: () => ({ model }),
ConnectedRelay: () => connected(model),
FailedRelay: () => ({ model: lost(model) }),
LostRelay: () => ({ model: lost(model) }),
ReleasedRelay: () => ({ model }),
ClickedReconnect: () => reconnect(model),
});
scene.ts
/**
* The editor's scene: the document's nodes, picked and the selected ones lit, a marker
* for each light so it can be picked and moved, the editor's own floor and lights, the
* transform gizmo on the selection and the viewport gizmo in the HUD. Everything the
* editor adds is keyed `editor:…` or `marker:…`, apart from what a file can hold.
*/
import {
box,
basic,
moodStaging,
fixed,
type MaterialRef,
mesh,
renderSettings,
scene,
type SceneNode,
shaderGrid,
sphere,
standard,
} from '@opsydyn/effect-three';
import { BoxSelect, TransformGizmo, ViewportGizmo } from '@opsydyn/effect-three-foldkit';
import { Array as Arr, Option } from 'effect';
import { markerKey, placementOf, selectableOf } from './document';
import type { Model } from './model';
const glow = 0x3d79fa;
/** A selected mesh's material, lit faintly where its kind can glow. */
const lit = (material: MaterialRef): MaterialRef => {
if ('emissiveIntensity' in material)
return { ...material, emissive: glow, emissiveIntensity: 0.4 };
// Lambert and Phong glow without an intensity: a darker blue, as faint.
if (!('emissive' in material)) return material;
// SAFETY: `material` has `emissive`, so this is the same kind with only its colour changed.
return { ...material, emissive: 0x18306a } as MaterialRef;
};
/** What is marked: what the selection box holds while drawn, else what is selected. */
const markedKeys = (model: Model): ReadonlyArray<string> =>
BoxSelect.drawing(model.box)
? BoxSelect.inside(model.box, model.camera, selectableOf(model.nodes))
: model.selected;
/** A document node as the editor shows it: picked, and lit when marked. */
const shown = (node: SceneNode, marked: boolean): SceneNode => {
if (node._tag === 'Mesh') {
return { ...node, interactive: true, material: marked ? lit(node.material) : node.material };
}
if (!('interactive' in node)) return node;
// SAFETY: `node` has `interactive`, so this is the same kind with only that changed.
return { ...node, interactive: true } as SceneNode;
};
/** A light's marker: a small ball where it is, in its colour, for picking it. */
const markerFor = (node: SceneNode, marked: boolean): ReadonlyArray<SceneNode> =>
node._tag === 'PointLight' || node._tag === 'SpotLight' || node._tag === 'DirectionalLight'
? [
mesh({
key: markerKey(node.key),
position: placementOf(node).position,
geometry: sphere({ radius: 0.12, widthSegments: 16, heightSegments: 8 }),
material: basic({ color: marked ? glow : node.color }),
interactive: true,
}),
]
: [];
const gizmoFor = (model: Model): ReadonlyArray<SceneNode> =>
Option.match(
Option.flatMap(Arr.head(model.selected), (key) =>
Arr.findFirst(model.nodes, (node) => node.key === key),
),
{
onNone: () => [],
onSome: (node) => [TransformGizmo.nodes(model.gizmo, placementOf(node), model.camera)],
},
);
export const sceneFor = (model: Model) => {
const marked = markedKeys(model);
const staging = moodStaging(model.mood, 'editor');
return scene({
camera: model.camera,
gestures: 'scene',
hud: ViewportGizmo.hud(model.view, model.camera),
environment: staging.environment,
sky: staging.sky,
render: renderSettings({ shadows: 'soft', toneMapping: 'aces' }),
resources: model.resources,
children: [
...staging.lights,
mesh({
key: 'editor:floor',
position: [0, -0.02, 0],
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: box({ width: 12, height: 12, depth: 0.02 }),
material: standard({ color: 0xd9d6cf, roughness: 0.95 }),
receiveShadow: true,
}),
shaderGrid({
key: 'editor:grid',
size: [12, 12],
cellSize: 0.25,
sectionSize: 1,
fadeDistance: 18,
}),
...model.nodes.map((node) => shown(node, marked.includes(node.key))),
...model.nodes.flatMap((node) => markerFor(node, marked.includes(node.key))),
...gizmoFor(model),
],
});
};
view.ts
import {
describeScene,
Lockstep,
formatChanges,
formatSummary,
moodStaging,
moods,
History,
Inspector,
scene,
sceneChanges,
type SceneNode,
SceneSchema,
} from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Array as Arr, Match, Option } from 'effect';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../environment';
import { type Addable, addables } from './document';
import { Message } from './message';
import type { Model } from './model';
import { sceneFor } from './scene';
import type { Relay } from './wire';
type H = HtmlBuilder<Message>;
const button = (h: H, label: string, enabled: boolean, message: Message): Html =>
h.button(
[h.Class('e3-button'), h.Type('button'), h.Disabled(!enabled), h.OnClick(message)],
[label],
);
const toggle = (h: H, 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 addLabel = {
crate: '+ Crate',
box: '+ Box',
ball: '+ Ball',
cone: '+ Cone',
ring: '+ Ring',
light: '+ Light',
} as const satisfies Record<Addable, string>;
const modes = [
['translate', 'Move'],
['rotate', 'Turn'],
['scale', 'Scale'],
] as const;
const shots = [
['front', 'Front'],
['three-quarter', 'Three-quarter'],
['top', 'Top'],
['low', 'Low'],
] as const;
const moodLabel = {
studio: 'Studio',
daylight: 'Daylight',
sunset: 'Sunset',
night: 'Night',
} as const;
/** Shots of the selection (or everything) and lighting moods: the vocabulary agents use. */
const staging = (model: Model, h: H): Html =>
h.div(
[h.Class('e3-controls')],
[
h.span([h.Class('e3-controls-label')], ['Shot']),
...shots.map(([angle, label]) =>
button(h, label, true, Message.FramedShot({ subject: model.selected, angle })),
),
h.span([h.Class('e3-controls-label')], ['Light']),
...moods.map((mood) =>
toggle(h, moodLabel[mood], model.mood === mood, Message.ChoseMood({ mood })),
),
],
);
/** The scene as an assistant reads it: the document, the camera and the mood's staging. */
const description = (model: Model, h: H): Html => {
const mood = moodStaging(model.mood, 'editor');
const shown = scene({
camera: model.camera,
environment: mood.environment,
sky: mood.sky,
resources: model.resources,
children: model.nodes,
});
return panel(h, 'Describe', [
h.pre([h.Class('e3-changes')], [formatSummary(describeScene(shown))]),
]);
};
/** Edits wait for a session: a tab still looking for others cannot make them yet. */
const canEdit = (model: Model): boolean => model.session._tag !== 'Joining';
const toolbar = (model: Model, h: H): Html =>
h.div(
[h.Class('e3-controls')],
[
...addables.map((kind) =>
button(h, addLabel[kind], canEdit(model), Message.ClickedAdd({ kind })),
),
button(h, 'Duplicate', model.selected.length > 0, Message.ClickedDuplicate()),
button(h, 'Delete', model.selected.length > 0, Message.ClickedDelete()),
...modes.map(([mode, label]) =>
toggle(h, label, model.gizmo.gizmo.mode === mode, Message.ChoseMode({ mode })),
),
toggle(h, 'Snap', model.snapping, Message.ClickedSnapping()),
button(h, 'Undo', History.canUndo(model.history), Message.ClickedUndo()),
button(h, 'Redo', History.canRedo(model.history), Message.ClickedRedo()),
button(h, 'Save', true, Message.ClickedSave()),
h.label(
[h.Class('e3-button e3-file')],
[
'Open…',
h.input([
h.Type('file'),
h.Accept('.json,application/json'),
h.OnFileChange((files) => Message.ChoseFiles({ files })),
]),
],
),
],
);
const panel = (h: H, title: string, children: ReadonlyArray<Html>, className = ''): Html =>
h.section(
[h.Class(`e3-editor-panel ${className}`.trim()), h.AriaLabel(title)],
[h.h3([], [title]), ...children],
);
/** The document's nodes, one row each: what it is and its key; a click selects it. */
const outliner = (model: Model, h: H): Html =>
panel(h, 'Scene', [
h.ul(
[h.Class('e3-outline')],
model.nodes.map((node) =>
h.keyed('li')(
node.key,
[],
[
h.button(
[
h.Class('e3-outline-row'),
h.Type('button'),
h.AriaPressed(String(model.selected.includes(node.key))),
h.OnClick(Message.ClickedOutlined({ key: node.key })),
],
[h.span([h.Class('e3-outline-kind')], [node._tag]), node.key],
),
],
),
),
),
]);
const inspectable = (node: SceneNode) =>
Inspector.fields(SceneSchema.SceneNode, node).filter(
(field) => field.path[0] !== 'key' && field.path[0] !== 'children',
);
/** The one selected node's fields, from the scene's node schema. */
const inspector = (model: Model, h: H): Html => {
const node =
model.selected.length === 1
? Option.flatMap(Arr.head(model.selected), (key) =>
Arr.findFirst(model.nodes, (each) => each.key === key),
)
: Option.none();
return panel(
h,
'Inspector',
[
...Option.match(node, {
onNone: () => [h.p([h.Class('e3-note')], ['Select one thing to inspect it.'])],
onSome: (selected) => [
h.p([h.Class('e3-inspector-title')], [selected.key]),
EffectThreeFoldkit.inspectorView(h, inspectable(selected), (path, value) =>
Message.EditedNode({ key: selected.key, path, value }),
),
],
}),
...Option.match(model.issue, {
onNone: () => [],
onSome: (issue) => [h.p([h.Class('e3-inspector-issue'), h.Role('alert')], [issue])],
}),
],
'e3-editor-inspector',
);
};
/** Every step, oldest first; steps off the present's line are other branches. */
const history = (model: Model, h: H): Html => {
const line = new Set(
History.pathTo(model.history, model.history.current).map((entry) => entry.id),
);
return panel(h, 'History', [
h.ol(
[h.Class('e3-history')],
model.history.entries.map((entry) =>
h.keyed('li')(
String(entry.id),
[],
[
h.button(
[
h.Class(line.has(entry.id) ? 'e3-step' : 'e3-step e3-step-branch'),
h.Type('button'),
h.AriaCurrent(entry.id === model.history.current ? 'step' : 'false'),
h.OnClick(Message.ClickedStep({ id: entry.id })),
],
[entry.label],
),
],
),
),
),
]);
};
/** What changed since the document was last saved or opened, a line each. */
const changes = (model: Model, h: H): Html => {
const result = sceneChanges(
scene({ resources: model.resources, children: model.saved }),
scene({ resources: model.resources, children: model.nodes }),
);
const text =
result._tag === 'Changes'
? formatChanges(result.changes)
: `Not a valid scene: ${result.error._tag}`;
return panel(h, 'Since saved', [h.pre([h.Class('e3-changes')], [text])]);
};
/** Who this tab is editing with: the tabs in its session now. */
const others = (lockstep: Lockstep.Lockstep<unknown>): number =>
Lockstep.membersAt(lockstep, lockstep.turn).length - 1;
const withTabs = (count: number): string =>
count === 0
? 'Editing alone. Open this page in another tab to edit together.'
: `Editing with ${count} other ${count === 1 ? 'tab' : 'tabs'}.`;
const overRelay = (count: number, url: string): string =>
count === 0
? `Editing alone, through the relay at ${url}. Open this address elsewhere to edit together.`
: `Editing with ${count} other ${count === 1 ? 'editor' : 'editors'}, through the relay at ${url}.`;
const overTabs = (model: Model): string =>
Match.valueTags(model.session, {
Offline: () => 'Editing here only.',
Joining: () => 'Looking for other editor tabs…',
Live: ({ lockstep }) => withTabs(others(lockstep)),
});
const throughRelay = (model: Model, relay: Relay): string => {
if (relay.link === 'Lost') return `Not connected to the relay at ${relay.url}: editing alone.`;
if (relay.link === 'Connecting') return `Connecting to the relay at ${relay.url}…`;
return Match.valueTags(model.session, {
Offline: () => 'Editing here only.',
Joining: () => `Looking for other editors at ${relay.url}…`,
Live: ({ lockstep }) => overRelay(others(lockstep), relay.url),
});
};
const together = (model: Model): string =>
Option.match(model.relay, {
onNone: () => overTabs(model),
onSome: (relay) => throughRelay(model, relay),
});
/** Who this tab edits with, and a way back to the relay once it has lost it. */
const session = (model: Model, h: H): Html =>
h.p(
[h.Class('e3-note e3-together'), h.AriaLive('polite')],
[
together(model),
...(Option.exists(model.relay, (relay) => relay.link === 'Lost')
? [' ', button(h, 'Reconnect', true, Message.ClickedReconnect())]
: []),
],
);
const selection = (model: Model): string =>
model.selected.length === 0
? 'Click something, or shift-drag to box several. Drag empty space to orbit.'
: `${model.selected.join(', ')} selected. ${History.depth(model.history)} steps to undo.`;
const boxOutline = (model: Model, h: H): 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: H): Document => ({
title: 'Editor — 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',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onPointerDown: ({ key, path, ray }) =>
Message.PressedScene({ key, path, origin: ray.origin, direction: ray.direction }),
onDrag: ({ ray }) =>
Message.DraggedScene({ origin: ray.origin, direction: ray.direction }),
onDragEnd: () => Message.ReleasedScene(),
onClick: ({ key, path }) => Message.ClickedScene({ key, path }),
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')], [selection(model)]),
session(model, h),
toolbar(model, h),
staging(model, h),
h.div(
[h.Class('e3-editor-panels')],
[
outliner(model, h),
inspector(model, h),
history(model, h),
changes(model, h),
description(model, h),
],
),
],
),
});
command.ts
/** The editor's work outside the Model: writing a scene file, and reading one. */
import { Effect, Schema } from 'effect';
import { Command } from 'foldkit';
import { Message } from './message';
/** Offers `text` as a download named `name`, as a link the page clicks for the person. */
export const DownloadFile = Command.define('DownloadFile', {
args: { name: Schema.String, text: Schema.String },
messages: [Message.CompletedDownload],
execute: ({ name, text }) =>
Effect.sync(() => {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const link = Object.assign(document.createElement('a'), { href: url, download: name });
link.click();
URL.revokeObjectURL(url);
return Message.CompletedDownload();
}),
});
/** The text of a file the person chose. */
export const ReadText = Command.define('ReadText', {
args: { file: Schema.instanceOf(File) },
messages: [Message.ReadFile],
execute: ({ file }) =>
Effect.promise(() => file.text()).pipe(Effect.map((text) => Message.ReadFile({ text }))),
});