<tosi-ensemble>

Load an ensemble into a scene in one line, declaratively, as a child of <tosi-b3d>:

<tosi-b3d>
  <tosi-ensemble src="/ensembles/standard-scene.json"></tosi-ensemble>
</tosi-b3d>
import { b3d, b3dBox } from 'tosijs-3d'
import { ensemble, registerSceneFeatures } from 'tosijs-3d-ensemble'

registerSceneFeatures()

preview.append(
  b3d(
    {},
    // The whole standard setup — sun, shadows, sky, ground, fog — as data.
    ensemble({ src: '/ensembles/standard-scene.json' }),
    // …and the one line that makes THIS scene different from the last one.
    b3dBox({ size: 1.5, y: 0.75, color: '#2f9e8f' }),
  ),
)
tosi-b3d { width: 100%; height: 320px; }
// The example above is the CLAIM — "the whole standard setup, as data" — and
// this is that claim checked, in the browser that just rendered it, against
// the renderer rather than against the attributes we wrote.
//
// ⚠️ LINE COMMENTS ONLY inside a fence. A block comment here would end the
// enclosing doc comment at its first close token, because block comments do
// not nest — which is how tosijs-3d shipped a doc page truncated at half its
// length, and how the first draft of THIS comment broke its own file by
// spelling the token out.
const el = await waitFor('tosi-b3d', 5000)
const built = await waitFor('tosi-ensemble', 5000)

test('the standard scene reaches the renderer', async () => {
  // Wait for the SCENE, not a duration: the engine mounts on a task and the
  // ensemble fetches its own file. A sleep here measures whatever happened to
  // be true when it expired, which is how a fog assertion once reported a
  // number belonging to a different document entirely.
  for (let i = 0; i < 200 && !el.scene?.lights?.length; i++) await waitMs(50)
  const scene = el.scene
  expect(Boolean(scene)).toBe(true)

  const kinds = scene.lights.map((l) => l.getClassName()).sort()
  // `sun` is a directional light and `light` is a hemispheric FILL — a
  // distinction the format holds deliberately, and one an attribute cannot
  // confirm.
  expect(kinds.includes('DirectionalLight')).toBe(true)
  expect(kinds.includes('HemisphericLight')).toBe(true)

  // `fog` — the one that was silently dead upstream (tosijs-3d#32) and offered
  // a "none" here that rendered LINEAR. Ask the SCENE what mode it is in.
  expect(scene.fogMode).toBe(2) // Scene.FOGMODE_EXP2

  // The `ground` piece, as a bounding box in world units: the assertion that
  // catches a size attribute the renderer never applied.
  const ground = scene.meshes.find((m) => m.name.startsWith('ground'))
  expect(Boolean(ground)).toBe(true)
  const halfWidth = ground.getBoundingInfo().boundingBox.extendSizeWorld.x
  expect(Math.round(halfWidth)).toBe(200) // width 400 in the file

  // And the piece the example adds on top of the ensemble is really on top of
  // it — the whole "standard setup PLUS my one thing" claim.
  expect(built.built.pieces.size).toBe(6)
  expect(scene.meshes.some((m) => m.name.includes('box'))).toBe(true)
})

That is the point of the whole format. A tosijs-3d scene is normally a stack of boilerplate — a sun, a shadow rig, a sky, a ground plane, fog, a camera setup — retyped per demo, in which the two lines that make THIS scene different are buried. Loading that stack as data means a scene reads as "the standard setup, plus the thing I am actually showing you".

Ensembles compose, so a scene can name several: a lighting rig, a terrain, and the arrangement standing on it.

It is not a combat format

Nothing here knows what an ensemble is FOR. registerSceneFeatures() gives you sun, sky, ground, terrain, water, clouds, ambient and fog; a game that wants hit points registers presets/combat as well. A scene that never does pays nothing and — more to the point — never sees shield in a property panel.

Attributes

src URL of the ensemble JSON
library library type to instantiate meshes from; omit for none
at where to put its local origin, "x y z", default "0 0 0"