$S Specification (v0.1)Status: Draft, stable surface. The v0.x line may add ops and selectors but will not remove or repurpose the ones below without a major-version bump.
📖 Repo: https://github.com/tejaswigowda/3dom 🕹️ Live demo: examples/bare.html
3DOM (“three-D Object Model”) is a runtime-free model for addressing and
editing a three.js scene graph. $S is its jQuery-style binding. The model
knows nothing about any editor: every mutation is routed through a Host.
createSconst $S = createS( sceneOrHost, opts );
sceneOrHost: a THREE.Object3D root, or a Host (§6).opts: when a bare scene is given, { onChange?, historyLimit? } for the
built-in DefaultHost.$S(selector) → ChainableSet, with attached helpers:
.host .scene .autoLabel(opts?) .op(json) .ops(list) .undo()
.redo() .query(selector) .listSelectors().$S.listSelectors()Enumerates every addressable class and id selector in the current scene, with match counts. Returns an array sorted by count descending:
$S.listSelectors();
// → [{ selector: '.wheel', count: 4 }, { selector: '#body', count: 1 }, …]
selector is either a selector string (§3) or an array of nodes.
A CSS subset, matched against the scene graph:
| Form | Meaning |
|---|---|
type |
object type via is* flags: mesh group light camera sprite line points bone object3d. An unknown bare token matches nothing (not everything). |
.class |
node has this class (author-set on userData.classes, or auto-labelled). |
#id |
userData.label === id, falling back to node.name === id. |
.a.b |
compound: has class a AND class b. |
A B |
descendant combinator. |
A > B |
child combinator. |
* |
every node in scope. |
:selected / :lasso |
resolves to the host app’s live selection, not a graph query. On a bare DefaultHost this is empty. |
No union (,), attribute or state pseudos in v0.1.
Class names are normalised: lower-cased, spaces → -, non-[a-z0-9-] stripped.
autoLabel(root, opts?)Two phases, both idempotent:
indexSubtree(root, force): compute per-node descriptors into
userData.descriptors (plus a geometry hash for symmetry pairing). Skips nodes
already indexed for the same geometry unless opts.force === true.deriveAllClasses(root): write userData.classes from type, descriptors, and
name stem.Class sources (all deterministic):
mesh light camera group, plus concrete light/camera/mesh
subtypes (point-light, perspective-camera, skinned-mesh, …)."Chair 1","Chair 2" → .chair (trailing index dropped).
Auto-generated names (Object_12, mesh_0, …) are ignored..red .blue ….left/.right/.center,
.top/.bottom, .front/.back..elongated .flat .blocky .thin..paired, .pair-left/.pair-right..vertical .horizontal..largest .medium .smallest.role (leaf/group) is computed but deliberately not emitted as a class.
Ops are the closed set of undoable mutations. Chain methods and op-JSON both
route here. Every op resolves its selector, builds Host commands, and executes
them as one undoable step. When more than one command results, it becomes a multi.
| Op | Chain method | Guard / clamp |
|---|---|---|
recolor |
.recolor(color) |
clone-on-write for shared materials; textured materials warned, not silently overwritten. |
scale |
.scale(factor, axis?) |
factor clamped to [0.1, 10]. |
move |
.move(x, y, z) |
per-axis delta clamped ±100; y grounded ≥ 0. |
rotate |
.rotate(axis, degrees) |
degrees clamped ±360. |
delete |
.delete() |
skips merged-mesh sub-nodes and detached nodes. |
duplicate |
.duplicate(x?, y?, z?) |
bakes world transform, applies offset. |
setMaterial |
.setMaterial(props) |
builds a MeshStandardMaterial from props. |
setOpacity |
.setOpacity(v) |
sets transparent as needed. |
setVisible |
.setVisible(v) |
none |
wireframe |
.wireframe(on?) |
none |
castShadow |
.castShadow(v?) |
object prop. |
receiveShadow |
.receiveShadow(v?) |
object prop. |
metalness |
.metalness(v) |
material prop. |
roughness |
.roughness(v) |
material prop. |
OP_SET exports the canonical op-name list.
{ "op": "recolor", "selector": ".wheel", "args": { "color": "#111" } }
op: required, one of OP_SET.selector: required. A selector string or an array of nodes.args: op-specific. Common aliases are accepted (factor/value,
x/dx, degrees/value, on/value). See OP_SCHEMA.dispatchOp(host, json) runs one. dispatchOps(host, list) runs many in order
and returns per-op results { success, message? }.
The Host is the only injection seam between the model and a runtime. The ops layer never imports a concrete command class. It calls Host factories.
A Host is any object providing:
interface Host {
scene: THREE.Object3D;
execute( command ): void; // apply + record for undo
undo(): void;
redo(): void;
multi( commands[] ): command; // group into one undoable step
notify( kind: string, payload? ): void;
// command factories, each returns { name, execute(), undo() }
setPosition, setRotation, setScale, setValue,
setColor, setMaterialColor, setMaterialValue, setMaterial,
addObject, removeObject
}
DefaultHost (built-in) implements all of this over plain Object3Ds. It
keeps its own bounded undo/redo stack and an onChange callback. This is what
you get when you pass a bare scene.execute/undo/redo/notify onto its command bus
and signals. So 3DOM edits participate in the app’s history and UI updates.resolveHost(x, opts) returns x unchanged if it already looks like a Host
(has .execute and .scene), else wraps it in a DefaultHost.
The library imports three as a bare specifier. Consumers provide it via
bundler resolution, an import map, or a global. setThree(instance) /
getThree() allow overriding the resolved instance (ES-module live binding, so
importers see the update).
MIT © Tejaswi Gowda.