Debugging the Scene

Debugging BookNest's Scene with the Inspector

BookNest's books carry their catalog entry in metadata (Shelves and Walls). Fields listed in a mesh's inspectableCustomProperties (types include String, Slider, Checkbox, Color3 and Button) become editable in a Custom section. This page opens Inspector V2 inline, shrinking the canvas, and a service definition, V2's extension mechanism, selects Salt and Saffron once the panes exist:

Inspector V2 on BookNest's bookcase, with a book's catalog data as custom propertiesHTML
<style>body { margin: 0; height: 360px } #view { width: 100%; height: 100% }</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
(async () => {
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  BookNestStore.createRoom(scene);
  const books = BookNestStore.placeBooks(scene,
    BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8));
  new BABYLON.ArcRotateCamera('browse', -1.75, 1.45, 1.5,
    new BABYLON.Vector3(-1.85, 1.35, 2.6), scene).minZ = 0.05;
  const slider = BABYLON.InspectableType.Slider;
  for (const book of books) {                       // each book's catalog entry, editable
    for (const key of ['price', 'rating']) {
      Object.defineProperty(book, key, { configurable: true,   // the Inspector wraps it
        get: () => book.metadata[key], set: (value) => { book.metadata[key] = value; } });
    }
    book.inspectableCustomProperties = [
      { label: 'Price', propertyName: 'price', type: slider, min: 0, max: 60, step: 0.01 },
      { label: 'Rating', propertyName: 'rating', type: slider, min: 0, max: 5, step: 0.1 }];
  }
  for (const file of ['babylonjs-addons@9.28.0/babylonjs.addons.min.js',   // in this order
    'babylonjs-loaders@9.28.0/babylonjs.loaders.min.js',
    'babylonjs-materials@9.28.0/babylonjs.materials.min.js',
    'babylonjs-inspector@9.28.0/babylon.inspector-v2.bundle.js']) {
    await BABYLON.Tools.LoadScriptAsync(`https://cdn.jsdelivr.net/npm/${file}`);
  }
  INSPECTOR.ShowInspector(scene, { layoutMode: 'inline', themeMode: 'light',
    disableTeachingMoments: true, leftPaneDefaultCollapsed: true,
    serviceDefinitions: [{ friendlyName: 'Select a book',    // runs once the panes exist
      consumes: [INSPECTOR.SelectionServiceIdentity, INSPECTOR.PropertiesServiceIdentity],
      factory: (selection, properties) => {
        selection.selectedEntity = books[2];                 // Salt and Saffron
        properties.highlightSections(['Custom']);            // collapse the others
      } }] });
  setTimeout(() => {                                  // the figure: scroll to Custom
    [...document.querySelectorAll('span, div')].find((node) => node.textContent === 'Custom')
      ?.scrollIntoView();
    BookNestStore.renderWhenReady(engine, scene, 10);
  }, 1000);
})();
</script>
Browser output of Listing 7.109
Browser output of 109

The sliders edit the catalog entry itself through the setters, a quick test of how the details panel (Details Panel) copes with odd values. V2 redefines each shown property to watch it, so without configurable: true the section showed "Property "price" of object ... is not configurable".

For a missing book, read its parent and bounding-box size; for flat covers, check the material's albedo thumbnail; for slow frames, read Statistics (How a Frame Renders). Load the Inspector only in development.

Each book's price and rating exposed as inspectableCustomProperties, edited here in an Inspector-style Custom sectionHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 330px; display: block; touch-action: none; outline: none; }
</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
  { id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
  { id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
  { id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
  { id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
  { id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
  { id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
  const scene = new BABYLON.Scene(engine);
  scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
  const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
  sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
  return scene;
}
function paint(scene, name, hex) {                  // a matte color
  const material = new BABYLON.StandardMaterial(name, scene);
  material.diffuseColor = BABYLON.Color3.FromHexString(hex);
  material.specularColor = BABYLON.Color3.Black();
  return material;
}
function createRoom(scene) {                        // floor and three walls, front open
  const room = new BABYLON.TransformNode('room', scene);
  const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
  floor.material = paint(scene, 'oak', '#b08d6a');
  floor.parent = room;
  const plaster = paint(scene, 'plaster', '#fff3e0');
  for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
    const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
    wall.position.set(x, ROOM.height / 2, z);
    wall.rotation.y = x ? Math.PI / 2 : 0;
    wall.material = plaster;
    wall.parent = room;
  }
  return room;
}
function createBookcase(scene, name, x) {           // 8 boxes merged into 1 mesh
  const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
  const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
  for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
  const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
    part.position.set(px, py, pz);
    return part;
  }), true);
  bookcase.name = name;
  bookcase.material = paint(scene, 'walnut', '#6d4c33');
  bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2);   // against the back wall
  return bookcase;
}
function drawCover(scene, book) {                   // cover art painted on a canvas
  const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
  const c = tex.getContext();
  c.fillStyle = book.color; c.fillRect(0, 0, 256, 384);
  c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384);         // the dark left edge
  c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
  c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
  const lines = [''];
  for (const word of book.title.split(' ')) {
    const test = (lines[lines.length - 1] + ' ' + word).trim();
    if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
  }
  lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
  c.font = '20px Georgia'; c.fillText(book.author, 135, 330);
  tex.update();
  return tex;
}
function placeBooks(scene, bookcase, shelf = 2) {   // six books, spines out, covers on +x
  const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
  const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
  let x = -0.35;                                    // left end of the row, local to the bookcase
  return BOOKS.map((record) => {
    const width = record.pages / 4000 + 0.02;       // spine thickness from page count
    const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
    book.parent = bookcase;
    book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
    x += width + 0.012;
    book.material = new BABYLON.StandardMaterial(record.title, scene);
    book.material.diffuseTexture = drawCover(scene, record);
    book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
    book.metadata = record;                         // the record, for picking
    return book;
  });
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
  const box = (width, height, depth, x, y) => {
    const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
    mesh.position.set(x, y, -ROOM.depth / 2);
    return mesh;
  };
  const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
  const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
  const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
    .subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
  [solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
  return wall;
}
// ---- end of the bookstore helpers ----

/*
  The book opens Inspector V2 inline (babylonjs-inspector@9.28.0 plus the addons, loaders and
  materials scripts) and selects Salt and Saffron. Those bundles are large and need browser
  storage this sandbox lacks, so the page draws the same Custom section itself from the very
  inspectableCustomProperties the Inspector reads.
*/
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const books = placeBooks(scene, createBookcase(scene, 'bookcaseLeft', -1.8));
const camera = new BABYLON.ArcRotateCamera('browse', -1.72, 1.45, 1.7, new BABYLON.Vector3(-1.55, 1.35, 2.6), scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);

const slider = BABYLON.InspectableType.Slider;
for (const book of books) {                         // each book's catalog entry, editable
  for (const key of ['price', 'rating']) {
    Object.defineProperty(book, key, { configurable: true,   // the Inspector wraps it
      get: () => book.metadata[key], set: (value) => { book.metadata[key] = value; } });
  }
  book.inspectableCustomProperties = [
    { label: 'Price', propertyName: 'price', type: slider, min: 0, max: 60, step: 0.01 },
    { label: 'Rating', propertyName: 'rating', type: slider, min: 0, max: 5, step: 0.1 }];
}

const GUI = BABYLON.GUI;
const ui = GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui', true, scene);
ui.idealWidth = 600;
const pane = Object.assign(new GUI.Rectangle('pane'), { width: '220px', height: '170px', left: '-10px', top: '10px',
  background: 'rgba(255,255,255,0.95)', cornerRadius: 6, color: '#c9bfae', horizontalAlignment: 1, verticalAlignment: 0 });
ui.addControl(pane);
const stack = Object.assign(new GUI.StackPanel(), { verticalAlignment: 0, paddingTop: '6px' });
pane.addControl(stack);
const tag = Object.assign(new GUI.TextBlock('tag', ''), { color: '#222', fontSize: 13, resizeToFit: true,
  outlineWidth: 3, outlineColor: 'white' });
ui.addControl(tag);
const highlight = new BABYLON.HighlightLayer('selected', scene);

function inspect(book) {                             // build the Custom section from the list
  highlight.removeAllMeshes();
  highlight.addMesh(book, BABYLON.Color3.FromHexString('#ffe082'));
  stack.clearControls();
  stack.addControl(Object.assign(new GUI.TextBlock('', `${book.name}: Custom`), { height: '22px', fontSize: 13, color: '#1f5f8b' }));
  const refresh = () => { tag.text = `$${book.price.toFixed(2)}  ★ ${book.rating.toFixed(1)}`; };
  for (const { label, propertyName, min, max, step } of book.inspectableCustomProperties) {
    const text = Object.assign(new GUI.TextBlock('', ''), { height: '20px', fontSize: 12, color: '#333', textHorizontalAlignment: 0, paddingLeft: '10px' });
    const control = Object.assign(new GUI.Slider(), { minimum: min, maximum: max, step, value: book[propertyName],
      height: '20px', width: '190px', color: '#1f5f8b', background: '#e6ddd0', thumbColor: '#b5452f' });
    const show = () => { text.text = `${label}: ${book[propertyName]}`; refresh(); };
    control.onValueChangedObservable.add((value) => { book[propertyName] = +value.toFixed(2); show(); });   // through the setter
    stack.addControl(text);
    stack.addControl(control);
    show();
  }
  tag.linkWithMesh(book);
  tag.linkOffsetY = -70;
}
scene.onPointerObservable.add(({ type, pickInfo }) => {
  if (type === BABYLON.PointerEventTypes.POINTERTAP && books.includes(pickInfo?.pickedMesh)) inspect(pickInfo.pickedMesh);
});
inspect(books[2]);                                   // Salt and Saffron
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>