v2.6.0

<bmx-model-viewer>

Shows a 3D model in the page: STL, OBJ (with its MTL colours), glTF and GLB. Turn it, move it and zoom; pick a standard view or an orthographic projection; see it shaded, with its edges, or as an X-ray; cut a section through it; pull its parts apart in an exploded view; hide, isolate and select parts from the parts list; measure between two points; and pin notes to its surface, which the page can save and load as JSON.

17 properties · 5 events · 12 methods · 5 parts

Example

Drag to turn the model, right-drag to move it, scroll to zoom. Try Section, the exploded view and Measure in the toolbar; the parts list hides, isolates and selects parts.

Show markup
<bmx-model-viewer id="ex-mv" label="Camera mount" units="mm" style="--bmx-model-viewer-height: 26rem"></bmx-model-viewer>
<p class="note" id="ex-mv-out" role="status" style="margin: 12px 0 0">Drag to turn the model, right-drag to move it, scroll to zoom. Try Section, the exploded view and Measure in the toolbar; the parts list hides, isolates and selects parts.</p>

<script type="module">
  await customElements.whenDefined('bmx-model-viewer');
  const viewer = document.getElementById('ex-mv');
  const out = document.getElementById('ex-mv-out');

  // A small assembly written as OBJ here, so the page needs no model file:
  // boxes and cylinders, grouped into parts, coloured by an MTL file.
  const lines = ['mtllib mount.mtl'];
  let vertices = 0;
  let normals = 0;
  const r3 = n => Math.round(n * 1000) / 1000;
  const vertex = p => (lines.push(`v ${p.map(r3).join(' ')}`), ++vertices);
  const normal = n => (lines.push(`vn ${n.map(r3).join(' ')}`), ++normals);
  const face = (ids, n) => lines.push(`f ${ids.map((id, i) => `${id}//${Array.isArray(n) ? n[i] : n}`).join(' ')}`);
  const part = (object, group, material) => {
    if (object) lines.push(`o ${object}`);
    lines.push(`g ${group}`, `usemtl ${material}`);
  };
  const box = (c, s) => {
    const p = [];
    for (let i = 0; i < 8; i += 1) p.push(vertex([c[0] + ((i & 1 ? 1 : -1) * s[0]) / 2, c[1] + ((i & 2 ? 1 : -1) * s[1]) / 2, c[2] + ((i & 4 ? 1 : -1) * s[2]) / 2]));
    const sides = [[[0, 2, 3, 1], [0, 0, -1]], [[4, 5, 7, 6], [0, 0, 1]], [[0, 4, 6, 2], [-1, 0, 0]], [[1, 3, 7, 5], [1, 0, 0]], [[0, 1, 5, 4], [0, -1, 0]], [[2, 6, 7, 3], [0, 1, 0]]];
    for (const [ids, n] of sides) face(ids.map(i => p[i]), normal(n));
  };
  const cylinder = (axis, c, radius, length, segments) => {
    const at = (a, end) => {
      const u = Math.cos(a) * radius;
      const w = Math.sin(a) * radius;
      const t = (end * length) / 2;
      return axis === 'x' ? [c[0] + t, c[1] + u, c[2] + w] : [c[0] + u, c[1] + t, c[2] + w];
    };
    const out = a => (axis === 'x' ? [0, Math.cos(a), Math.sin(a)] : [Math.cos(a), 0, Math.sin(a)]);
    const low = [];
    const high = [];
    const ns = [];
    for (let i = 0; i < segments; i += 1) {
      const a = (i / segments) * Math.PI * 2;
      low.push(vertex(at(a, -1)));
      high.push(vertex(at(a, 1)));
      ns.push(normal(out(a)));
    }
    for (let i = 0; i < segments; i += 1) {
      const j = (i + 1) % segments;
      if (segments > 8) face([low[i], low[j], high[j], high[i]], [ns[i], ns[j], ns[j], ns[i]]);
      else face([low[i], low[j], high[j], high[i]], normal(out(((i + 0.5) / segments) * Math.PI * 2)));
    }
    const axisNormal = axis === 'x' ? [1, 0, 0] : [0, 1, 0];
    face(high, normal(axisNormal));
    face([...low].reverse(), normal(axisNormal.map(n => -n)));
  };

  part('Base', 'Base plate', 'anodised');
  box([0, 4, 0], [140, 8, 90]);
  [[-58, -33], [58, -33], [-58, 33], [58, 33]].forEach(([x, z], i) => {
    part('', `Bolt ${i + 1}`, 'steel');
    cylinder('y', [x, 11, z], 6, 6, 6);
  });
  part('Frame', 'Left upright', 'anodised');
  box([-45, 40, 0], [10, 64, 44]);
  part('', 'Right upright', 'anodised');
  box([45, 40, 0], [10, 64, 44]);
  part('Tilt head', 'Shaft', 'steel');
  cylinder('x', [0, 56, 0], 6, 116, 32);
  part('', 'Clamp', 'black');
  box([0, 56, 0], [36, 26, 34]);
  part('', 'Camera plate', 'black');
  box([0, 72, 0], [60, 6, 50]);
  part('', 'Knob', 'brass');
  cylinder('x', [64, 56, 0], 14, 10, 24);
  const mtl = 'newmtl anodised\nKd 0.2 0.42 0.74\nnewmtl steel\nKd 0.68 0.7 0.74\nnewmtl black\nKd 0.16 0.17 0.19\nnewmtl brass\nKd 0.82 0.64 0.3\n';

  viewer.addEventListener('bmxSelect', e => (out.textContent = e.detail.part ? `Selected: ${e.detail.name}.` : 'Nothing selected.'));
  viewer.addEventListener('bmxMeasure', e => (out.textContent = `Measured ${e.detail.distance.toFixed(1)} ${e.detail.units}.`));
  viewer.addEventListener('bmxChange', e => (out.textContent = `${e.detail.annotations.length} note(s) - save them as JSON with the model.`));

  await viewer.load(lines.join('\n'), { format: 'obj', name: 'Camera mount', resolve: async () => new TextEncoder().encode(mtl).buffer });
  viewer.annotations = [
    { id: 'n1', text: 'Hand-tighten the knob; 2 N·m at most.', position: [69, 56, 0], part: 'Knob' },
    { id: 'n2', text: 'M6 bolts into the bench, 4 off.', position: [58, 14, 33], part: 'Bolt 4' },
  ];
</script>
<bmx-model-viewer src="/models/bracket.glb" units="mm" label="Mounting bracket"></bmx-model-viewer>

Everything runs in the browser with WebGL 2; nothing is uploaded. Drawing happens only when something changes, so a still model costs nothing.

Keyboard: the view takes focus with Tab. Arrow keys turn the model, Shift with the arrows moves it, plus and minus zoom, Home fits it, 1 to 7 choose front, back, left, right, top, bottom and isometric, P switches projection. Measuring, adding notes and selecting work from the keyboard too: Enter picks the point under the cross in the middle of the view. The parts list is a tree: Space shows or hides a part, Enter selects it.

Properties

PropertyAttributeTypeDefaultDescription
allowOpen allow-open boolean false Offer an Open button, and open model files dropped on the view.
annotations annotations BmxModelAnnotation[] | string [] Notes pinned to the model, as an array or JSON. Two-way: bmxChange reports edits.
explode explode number 0 How far apart the parts are pulled, 0 to 1. Two-way.
format format BmxModelFormat — The format, when the address does not end in .stl, .obj, .gltf or .glb.
label label string — The model's name for assistive technology and the toolbar. Default: its file name.
panel panel boolean true Show the side panel (parts, notes, measurements). Two-way (the toolbar's panel button).
projection projection 'perspective' | 'orthographic' 'perspective' Two-way.
readonly readonly boolean false Notes can be read but not added, changed or removed.
renderMode render-mode BmxModelRenderMode 'shaded' How surfaces are drawn. Two-way.
section section 'none' | 'x' | 'y' | 'z' 'none' A section through the model across this axis, or none. Two-way.
sectionFlip section-flip boolean false Keep the other side of the cut. Two-way.
sectionPosition section-position number 0.5 Where the cut is, from 0 (one end of the model) to 1 (the other). Two-way.
src src string — The model's address. Files it refers to (a glTF's .bin and textures, an OBJ's .mtl) are fetched beside it.
strings strings Partial<BmxModelViewerStrings> | string — Words to show instead of the English ones, as an object or JSON.
toolbar toolbar boolean true Show the toolbar.
units units string '' The unit lengths are in, shown after measurements and sizes (mm, in).
upAxis up-axis 'auto' | 'y' | 'z' 'auto' Which way is up in the file. auto: Z for STL (as most CAD exports it), Y otherwise.

Events

EventDetailDescription
bmxChange { readonly annotations: BmxModelAnnotation[]; } The notes changed: added, edited or removed.
bmxError { readonly message: string; } A model could not be read.
bmxLoad BmxModelLoadDetail A model was read and shown.
bmxMeasure BmxModelMeasureDetail A measurement was taken.
bmxSelect { readonly part: string | null; readonly name: string; } The selected part changed (part is null when nothing is selected).

Methods

MethodSignatureDescription
clearMeasurements clearMeasurements() => Promise<void> Removes every measurement.
fit fit(part?: string) => Promise<void> Fits the camera to the whole model, or to one part.
getCamera getCamera() => Promise<BmxModelCamera>
getParts getParts() => Promise<BmxModelPartInfo[]> The model's parts, in tree order.
isolate isolate(part: string) => Promise<void> Shows one part alone.
load load(data: ArrayBuffer | Uint8Array | string | Blob | readonly File[], options?: BmxModelLoadOptions) => Promise<void> Shows a model from its data: bytes, text, a File or Blob, or several files at once (a glTF with its .bin and textures, an OBJ with its .mtl), the main one found by its extension.
screenshot screenshot(options?: { type?: "image/png" | "image/jpeg" | "image/webp"; quality?: number; background?: boolean; }) => Promise<Blob | null> A picture of the view, as drawn now (without the notes' pins).
select select(part: string | null) => Promise<void> Selects a part (highlighted, and reported by bmxSelect), or nothing.
setCamera setCamera(camera: Partial<BmxModelCamera>) => Promise<void> Moves the camera (a saved view, for example). Parts left out keep their values.
setPartVisible setPartVisible(part: string, visible: boolean) => Promise<void> Shows or hides a part and everything in it.
setView setView(view: BmxModelView) => Promise<void> Looks from a standard direction, keeping what is looked at.
showAll showAll() => Promise<void> Shows every part.

CSS shadow parts

PartDescription
panel The side panel (parts, notes, measurements).
pin A note's pin on the model.
stage The area the model is drawn in.
status The line under the view.
toolbar The bar of commands.

CSS custom properties

PropertyDescription
--bmx-model-viewer-background Behind the model.
--bmx-model-viewer-cap The inside of a section cut.
--bmx-model-viewer-edge Edge lines.
--bmx-model-viewer-height The height of the view.
--bmx-model-viewer-highlight The selected part's tint, and its edges.
--bmx-model-viewer-measure Measurement lines and points.
--bmx-model-viewer-panel-width The side panel's width.
--bmx-model-viewer-pin A note's pin.
--bmx-model-viewer-radius Corner radius of the frame.
--bmx-model-viewer-surface The colour a saved picture is laid on.