Creating reusable objects
A reusable object owns a model and its supported behavior. The consuming application owns scene composition, input, placement, simulation, and shared services. The engine provides generic services; it does not import the object library. See the object package reference for existing classes and their specific contracts.
Keep the package independent
Create a folder such as objects/marker/ containing index.js, object.json, and any local helper or asset files. Every import inside the package must resolve inside that same folder. Pass the engine namespace into the constructor as new Marker(api, options); use api.THREE instead of loading another Three.js copy.
Use new URL('./assets/example.png', import.meta.url) for a package-local asset URL. Do not embed an application directory, hostname, original checkout path, or an import of a sibling object package. A renamed or relocated package then needs only an updated import in the consuming application.
Implement a small object
This example uses the public ObjectState helper for transforms, snapshots, bounds, and cleanup. The marker's origin is at its base; dimensions are metres and rotations are radians.
// objects/marker/index.js
export class Marker {
constructor(api, {name = 'Marker', height = 1, color = '#cc8855'} = {}) {
if (api.OBJECT_PROTOCOL !== 1) throw new Error('Object protocol 1 required');
if (!Number.isFinite(height) || height <= 0) throw new RangeError('Invalid height');
this.api = api;
this.state = new api.ObjectState({type: 'Marker', name, height, color});
this.root = this.state.root;
this.properties = this.state.properties;
this.mesh = new api.THREE.Mesh(
new api.THREE.BoxGeometry(.6, height, .6),
new api.THREE.MeshStandardMaterial({color, roughness: .7})
);
this.mesh.position.y = height / 2;
this.mesh.castShadow = this.mesh.receiveShadow = true;
this.root.add(this.mesh);
}
get position() { return this.root.position; }
get rotation() { return this.root.rotation; }
get quaternion() { return this.root.quaternion; }
get scale() { return this.root.scale; }
setPosition(x, y, z) { this.position.set(x, y, z); return this; }
setColor(color) {
this.mesh.material.color.set(color);
this.properties.color = color;
return this;
}
update(dt, time, context) {} // No animation is needed for this object.
getBounds() { return this.state.getBounds(); }
serialize() { return this.state.serialize(); }
restore(snapshot) { this.state.restore(snapshot); return this; }
dispose() { this.state.dispose(); }
}
root is the Object3D added to a scene. properties records public state; use methods to make changes that also affect geometry or materials. update receives elapsed seconds and application time, with optional context supplied by the consumer. getBounds() must return finite world-space visual bounds. It is not automatically a collision shape.
dispose() must be safe to call twice. ObjectState disposes resources attached to this example's root. Register detached resources and listeners with this.state.scope; release replaced geometry, materials, and textures when rebuilding. Borrowed terrain, wind, engine services, or shared asset data remain caller-owned unless the object's contract explicitly says otherwise.
Declare the package and Studio entry
{
"package": "marker",
"version": "1.0.0",
"entry": "index.js",
"classes": ["Marker"],
"engineObjectProtocol": 1,
"externalFileDependencies": [],
"studio": {"entry": "studio.js", "protocol": 1}
}
The Studio entry is optional for ordinary applications. To expose the object in Model Studio, add:
// objects/marker/studio.js
import {Marker} from './index.js';
export const models = [{
id: 'marker',
label: 'Marker',
create: api => new Marker(api),
properties: [{
id: 'color', label: 'Color', type: 'color',
get: object => object.properties.color,
set: (object, value) => object.setColor(value)
}],
actions: [{
id: 'turn', label: 'Quarter turn',
run: object => { object.rotation.y += Math.PI / 2; }
}]
}];
In Studio, expand Load another package, enter marker, and press Load package. Choose the model and add it to the scene. There is no need to edit Studio's built-in catalogue. Packages are trusted application code; the loader is not a sandbox or a general-purpose mesh-file importer.
Studio factories may be asynchronous. Their context includes an abort signal, camera, terrain, quality, and shared wind. Check cancellation during loading and release allocations if construction fails. A successfully returned object becomes viewer-owned. Property types, actions, cancellation, and loading boundaries are specified in the Model Studio guide. The existing tests/fixtures/studio-object/ is a working package example.
Save state and reinject dependencies
The example's generic restore() applies transforms. To restore constructor-dependent height and color, reconstruct first:
const saved = marker.serialize();
marker.dispose();
marker = new Marker(api, saved.properties).restore(saved);
scene.add(marker.root);
A snapshot is not an executable scene. Reinject borrowed context when rebuilding. For example, await loadAuthoredAssets() once and pass the returned assets into every reconstructed AuthoredPerson; provide terrain and wind again when required. Do not serialize live renderers, functions, or service instances as though they were portable JSON.
Verify and document
From the complete development project, run node tools/verify.mjs --root=objects/marker to check package closure. Then copy the package into an independent consumer with its own engine copy. Check actual rendering, property/action behavior, bounds, reconstruction, and repeated disposal. Static import checks alone cannot establish those behaviors.
Document constructor options, required API/services, units and axes, resource ownership, and snapshot limits under docs/. Keep licenses and asset provenance with the distribution. The API reference, getting started guide, and distribution guide cover the surrounding integration.