DEV Community

Cover image for A strongly typed Google Model-Viewer implementation in React
Andrew Ross
Andrew Ross

Posted on • Updated on

A strongly typed Google Model-Viewer implementation in React

Overview

Over the past couple months I've been periodically working with custom hosting solutions for windows 3D vista pro tours. One of these virtual experiences happens to be a 3D tech refresh virtual marketplace where employees can trade in their existing devices for new ones. The idea is that the employees can interact with devices in this experience and project them into their space using Augmented Reality. While refactoring bits and pieces of the virtual-marketplace I once again came across the code used to render these objects -- and I was once again annoyed by the fact that I had been resorting to using dangerouslySetInnerHTML as a workaround for rendering 3D objects in an alwaysStrict Nextjs repo. How hard could it be after all to get the model-viewer element from @google/model-viewer to render as a native JSX.IntrinsicElement?

@google/model-viewer

If you're not yet familiar with the @google/model-viewer package you can find the docs here, the examples here, and the github repo here.

The Annoying Workaround

Below is the code used as a workaround for rendering each of the 3d objects used in the virtual marketplace experience. I've omitted the absolute URLs as I'm quite certain my employer would not be thrilled with me making those publicly accessible.


src/pages/assets/glb/[glb].tsx

import type {
  GetStaticPathsResult,
  GetStaticPropsContext,
  GetStaticPropsResult,
  InferGetStaticPropsType,
  PreviewData
} from "next";
import type { ParsedUrlQuery } from "querystring";
import { LoadingDots} from "@virtual-store/ui";
import { Suspense } from "react";
import { useRouter } from "next/router";

const DeviceObj = {
  UnrealMacBookPro14:
    "/v1668589751/VirtualTour/UnrealMacBookPro14_ynw7dg.glb",
  macbookair:
    "/v1668118327/VirtualTour/macbookair_ajugky.glb",
  macbook_pro_2021:
    "/v1668589750/VirtualTour/macbook_pro_2021_dby3mx.glb",
  delllatitude7330ruggedlaptop:
    "/v1668118348/VirtualTour/delllatitude7330ruggedlaptop_ziijbe.glb",
  "Lenovo M80q Tiny 2":
    "/v1668118312/VirtualTour/Lenovo_M80q_Tiny_2_jwtu9h.glb",
  lenovox1:
    "/v1668206027/VirtualTour/lenovox1_pldnaa.glb",
  lenovol13gen2:
    "/v1668206076/VirtualTour/lenovol13gen2_iewiqk.glb",
  genericlenovolaptop:
    "/v1668118314/VirtualTour/genericlenovolaptop_rjkvc8.glb",
  surfacehub:
    "/v1668118332/VirtualTour/surfacehub_epkes8.glb",
  surfacelaptop:
    "/v1668118326/VirtualTour/surfacelaptop_yajji1.glb",
  surfacelaptopstudio:
    "/v1668441368/VirtualTour/surfacelaptopstudio_mopmve.glb",
  SurfacePro7Plus:
    "/v1668118351/VirtualTour/SurfacePro7Plus_lxn8xe.glb",
  surfacestudio2:
    "/v1668118329/VirtualTour/surfacestudio2_tp8dp9.glb",
  thinkcentrem80qtiny:
    "/v1668206530/VirtualTour/thinkcentrem80qtiny_dal7ni.glb",
  thinkstationv2:
    "/v1668206602/VirtualTour/thinkstationv2_o8dxo8.glb"
} as const;

const paths = [
  "UnrealMacBookPro14",
  "macbookair",
  "macbook_pro_2021",
  "delllatitude7330ruggedlaptop",
  "Lenovo M80q Tiny 2",
  "lenovox1",
  "lenovol13gen2",
  "genericlenovolaptop",
  "surfacehub",
  "surfacelaptop",
  "surfacelaptopstudio",
  "SurfacePro7Plus",
  "thinkcentrem80qtiny",
  "thinkstationv2"
];

const posterPath = "/v1668208003/VirtualTour/initial-load_p0yyun-Circle_yzha4d.png";

async function htmlScaffolding(glbData: keyof typeof DeviceObj) {
  const glbPath = DeviceObj[glbData];
  const htmlScaffold = `<!DOCTYPE html>
    <head>
      <meta charset="utf-8" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <style>
        model-viewer {
          width: calc(100vw - 20px);
          height: calc(100vh - 20px);
        };
      </style>
    </head>
    <body>
      <div id="card" class="bg-blend-lighten">
        <model-viewer
          src="${glbPath}"
          id="viewer"
          shadow-intensity="1"
          camera-controls
          auto-rotate
          ar
          ar-modes="webxr scene-viewer quick-look"
          poster="${posterPath}"
          class="modelViewer">
          <button slot="ar-button" id="ar-button" class="font-normal tracking-wide text-white">View in your space</button>
        </model-viewer>
      </div>
      <script
        async
        type="module"
        src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
    </body> `;

  return htmlScaffold;
}

export const getStaticPaths = async (): Promise<
  GetStaticPathsResult<ParsedUrlQuery>
> => {
  return {
    paths: paths.map(path => `/assets/glb/${path}`),
    fallback: true
  };
};

const queryParamHandler = (props: string | string[] | undefined) => {
  return typeof props !== "undefined"
    ? Array.isArray(props)
      ? props[0]
      : props
    : "";
};

interface StaticPropsResultType {
  device: typeof DeviceObj[keyof typeof DeviceObj];
}

export const getStaticProps = async (
  ctx: GetStaticPropsContext<ParsedUrlQuery, PreviewData>
): Promise<
  GetStaticPropsResult<StaticPropsResultType>
> => {
  const glb = ctx.params ? queryParamHandler(ctx.params.glb) : "lenovol13gen2";
  return {
    props: {
      device: (await htmlScaffolding(
        glb as keyof typeof DeviceObj
      )) satisfies StaticPropsResultType['device']
    }
  };
};

export default function Glb<T extends typeof getStaticProps>({
  device
}: InferGetStaticPropsType<T>) {
  const router = useRouter();
  return (
    <>
      {router.isFallback ? (
        <LoadingDots />
      ) : (
        <Suspense fallback={<LoadingDots />}>
          <div className='min-h-screen min-w-[100vw]'>
            <i
              className='min-h-screen min-w-full'
              dangerouslySetInnerHTML={{ __html: device }}
            />
          </div>
        </Suspense>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

A Quick Type Reference

While we could delve into type definitions, decorators, the shadow dom, and globally accessible bits and pieces of the @google/model-viewer package, I'll spare those details in the spirit of keeping this entry on the shorter side. I will say that the package uses lit, three, and @types/three; the package, once installed and instantiated via a root script import in the src/pages/_app.tsx file (more on that shortly), has a typedef of utility exposed for consumption via an interface in the globalThis module. Specifically, it becomes accessible through the HTMLElementTagNameMap interface which, assuming @google/model-viewer and typescript are both installed if you've made it this far, has two separate definitions in node_modules. The first primary definition for HTMLElementTagNameMap is found in the typescript/lib/lib.dom.d.ts file; the second definition, an augmented global declaration, is housed in the @google/model-viewer/lib/model-viewer.d.ts file. The augmented file contains the following several-dozen lines of code:

import ModelViewerElementBase from './model-viewer-base.js';
export declare const ModelViewerElement: {
    new (...args: any[]): import("./features/annotation.js").AnnotationInterface;
    prototype: import("./features/annotation.js").AnnotationInterface;
} & object & {
    new (...args: any[]): import("./features/scene-graph.js").SceneGraphInterface;
    prototype: import("./features/scene-graph.js").SceneGraphInterface;
} & {
    new (...args: any[]): import("./features/staging.js").StagingInterface;
    prototype: import("./features/staging.js").StagingInterface;
} & {
    new (...args: any[]): import("./features/environment.js").EnvironmentInterface;
    prototype: import("./features/environment.js").EnvironmentInterface;
} & {
    new (...args: any[]): import("./features/controls.js").ControlsInterface;
    prototype: import("./features/controls.js").ControlsInterface;
} & {
    new (...args: any[]): import("./features/ar.js").ARInterface;
    prototype: import("./features/ar.js").ARInterface;
} & {
    new (...args: any[]): import("./features/loading.js").LoadingInterface;
    prototype: import("./features/loading.js").LoadingInterface;
} & import("./features/loading.js").LoadingStaticInterface & {
    new (...args: any[]): import("./features/animation.js").AnimationInterface;
    prototype: import("./features/animation.js").AnimationInterface;
} & typeof ModelViewerElementBase;
export declare type ModelViewerElement = InstanceType<typeof ModelViewerElement>;
export { RGB, RGBA } from './three-components/gltf-instance/gltf-2.0';
declare global {
    interface HTMLElementTagNameMap {
        'model-viewer': ModelViewerElement;
    }
}

Enter fullscreen mode Exit fullscreen mode

The Solution

If you haven't installed the @google/model-viewer package yet, do so now. Then, add the following to your src/pages/_app.tsx file. Note that while the npm package is installed in the project, attempting to reference the file where the module is located in the script components src field (even when using an absolute path) causes Nextjs to throw an error. The quick fix for this is to use the unpkg cdn for the @google/model-viewer package to pull the latest version in

src/pages/_app.tsx

import "../styles/index.css";
import Script from "next/script";
import type { AppProps } from "next/app";
import Head from "next/head";

export default function NextApp({
  Component,
  pageProps
}: AppProps) {
// inject the next app with the latest version of `@google/model-viewer`
  return (
    <>
      <Head>
        <title>{"Virtual Marketplace"}</title>
      </Head>
      <Script
        async
        strategy='afterInteractive'
        type='module'
        src='https://unpkg.com/@google/model-viewer@^2.1.1/dist/model-viewer.min.js'
      />
        <Component {...pageProps} />
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

src/types/react.d.ts

In this file we'll be augmenting the JSX namespace's IntrinsicElements interface via a global declaration. Note that all the defs we need are accessible through the globalThis module -- only a single triple-slash directive is required

Archer-face-of-god

/// <reference types="@google/model-viewer" />

export declare global {
  namespace JSX {
    interface IntrinsicElements {
      "model-viewer": React.DetailedHTMLProps<
        React.AllHTMLAttributes<
          Partial<globalThis.HTMLElementTagNameMap['model-viewer']>
        >,
        Partial<globalThis.HTMLElementTagNameMap['model-viewer']>
      >;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The underlying definition of the globalThis.HTMLElementTagNameMap["model-viewer"] type is as follows

type ModelViewer = AnnotationInterface & SceneGraphInterface & StagingInterface & EnvironmentInterface &  ControlsInterface & ARInterface & LoadingInterface & AnimationInterface & ModelViewerElementBase;
Enter fullscreen mode Exit fullscreen mode

important -- be sure to add src/types/react.d.ts to the include array field in your tsconfig.json for this JSX augmentation to be detected and incorporated

{
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    "src/types/react.d.ts"
  ],
}
Enter fullscreen mode Exit fullscreen mode

Confirming Reacts acceptance of model-viewer as one of its very own

If you're curious to determine whether or not this somewhat convoluted augmentation file actually did the trick, head to any .tsx file and add a <model-viewer></model-viewer> element in the returned tsx. You'll notice that React has indeed accepted the model-viewer element as one of its very own 🎉

Image description

macbook-pro

Image description

Below is the final code from the same file referenced at the very start of the article (src/pages/assets/glb/[glb].tsx):

import type {
  GetStaticPathsResult,
  GetStaticPropsContext,
  GetStaticPropsResult,
  InferGetStaticPropsType,
  PreviewData
} from "next";
import type { ParsedUrlQuery } from "querystring";
import { LoadingDots} from "@virtual-store/ui";
import { Suspense } from "react";
import { useRouter } from "next/router";

const DeviceObj = {
  UnrealMacBookPro14:
    "/v1668589751/VirtualTour/UnrealMacBookPro14_ynw7dg.glb",
  macbookair:
    "/v1668118327/VirtualTour/macbookair_ajugky.glb",
  macbook_pro_2021:
    "/v1668589750/VirtualTour/macbook_pro_2021_dby3mx.glb",
  delllatitude7330ruggedlaptop:
    "/v1668118348/VirtualTour/delllatitude7330ruggedlaptop_ziijbe.glb",
  "Lenovo M80q Tiny 2":
    "/v1668118312/VirtualTour/Lenovo_M80q_Tiny_2_jwtu9h.glb",
  lenovox1:
    "/v1668206027/VirtualTour/lenovox1_pldnaa.glb",
  lenovol13gen2:
    "/v1668206076/VirtualTour/lenovol13gen2_iewiqk.glb",
  genericlenovolaptop:
    "/v1668118314/VirtualTour/genericlenovolaptop_rjkvc8.glb",
  surfacehub:
    "/v1668118332/VirtualTour/surfacehub_epkes8.glb",
  surfacelaptop:
    "/v1668118326/VirtualTour/surfacelaptop_yajji1.glb",
  surfacelaptopstudio:
    "/v1668441368/VirtualTour/surfacelaptopstudio_mopmve.glb",
  SurfacePro7Plus:
    "/v1668118351/VirtualTour/SurfacePro7Plus_lxn8xe.glb",
  surfacestudio2:
    "/v1668118329/VirtualTour/surfacestudio2_tp8dp9.glb",
  thinkcentrem80qtiny:
    "/v1668206530/VirtualTour/thinkcentrem80qtiny_dal7ni.glb",
  thinkstationv2:
    "/v1668206602/VirtualTour/thinkstationv2_o8dxo8.glb"
} as const;

const paths = [
  "UnrealMacBookPro14",
  "macbookair",
  "macbook_pro_2021",
  "delllatitude7330ruggedlaptop",
  "Lenovo M80q Tiny 2",
  "lenovox1",
  "lenovol13gen2",
  "genericlenovolaptop",
  "surfacehub",
  "surfacelaptop",
  "surfacelaptopstudio",
  "SurfacePro7Plus",
  "thinkcentrem80qtiny",
  "thinkstationv2"
];

interface StaticPropsResultType {
  device: typeof DeviceObj[keyof typeof DeviceObj];
}

const posterPath =
  "/v1668208003/VirtualTour/initial-load_p0yyun-Circle_yzha4d.png";

async function htmlScaffolding(
  glbData: keyof typeof DeviceObj
): Promise<typeof DeviceObj[keyof typeof DeviceObj]> {
  const glbPath = DeviceObj[glbData];
  return glbPath;
}

export const getStaticPaths = async (): Promise<
  GetStaticPathsResult<ParsedUrlQuery>
> => {
  return {
    paths: paths.map(path => `/assets/glb/${path}`),
    fallback: true
  };
};

const queryParamHandler = (props: string | string[] | undefined) => {
  return typeof props !== "undefined"
    ? Array.isArray(props)
      ? props[0]
      : props
    : "";
};

export const getStaticProps = async (
  ctx: GetStaticPropsContext<ParsedUrlQuery, PreviewData>
): Promise<
  GetStaticPropsResult<StaticPropsResultType>
> => {
  const glb = ctx.params ? queryParamHandler(ctx.params.glb) : "lenovol13gen2";
  return {
    props: {
      device: (await htmlScaffolding(
        glb as keyof typeof DeviceObj
      )) satisfies StaticPropsResultType['device']
    }
  };
};

export default function Glb<T extends typeof getStaticProps>({
  device
}: InferGetStaticPropsType<T>) {
  const router = useRouter();
  return (
    <>
      {router.isFallback ? (
        <LoadingDots />
      ) : (
        <Suspense fallback={<LoadingDots />}>
          <div className='min-h-screen min-w-[100vw]' id='card'>
            <model-viewer
              src={device}
              id='viewer'
              shadow-intensity='1'
              camera-controls
              touch-action='pan-y'
              auto-rotate
              data-ar
              ar-status='not-presenting'
              ar-modes='webxr scene-viewer quick-look'
              poster={posterPath}
              className='modelViewer'>
              <button
                slot='ar-button'
                id='ar-button'
                className='bg-takeda-red font-normal tracking-wide text-white'>
                View in your space
              </button>
            </model-viewer>
          </div>
        </Suspense>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

For the sake of thoroughness, I've attached the contents of my global index.css file below. Towards the very bottom of the base layer you'll find model-viewer targeted. I would share the github repo link itself but unfortunately that's under lock and key.

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  @font-face {
    font-family: "Gotham";
    src: url("/fonts/Gotham-Book.woff2") format("woff2");
    font-weight: 400;
    font-display: swap;
    font-style: normal;
  }
  /*
    Chrome has a bug with transitions on load since 2012!

    To prevent a "pop" of content, you have to disable all transitions until
    the page is done loading.

    https://lab.laukstein.com/bug/input
    https://twitter.com/timer150/status/1345217126680899584
  */
  body.loading * {
    transition: none !important;
  }

  /*
    Create a root stacking context
  */
  #__next {
    isolation: isolate;
  }

  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  /*
    remove margin default
  */
  * {
    margin: 0;
  }

  html {
    height: 100%;
    box-sizing: border-box;
    touch-action: manipulation;
  }

  body {
    font-family: Gotham, ui-sans-serif, system-ui, -apple-system,
      BlinkMacSystemFont, "Segoe UI" Roboto, "Helvetica Neue", Arial,
      "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
      "Segoe UI Symbol", "Noto Color Emoji";
    position: relative;
    line-height: calc(1em + 0.5rem);
    min-height: 100%;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
    -moz-osx-font-smoothing: grayscale;
  }

  html,
  body {
    background-color: var(--accents-0);
    overscroll-behavior-x: none;
  }

  /*
    sensible media defaults
  */
  img,
  picture,
  video,
  canvas,
  svg,
  pre {
    display: block;
    max-width: 100%;
  }

  /*
    inherit fonts for form controls
  */
  input,
  button,
  textarea,
  select {
    font: inherit;
  }

  li {
    list-style: none;
  }

  a {
    color: inherit;
    text-decoration: none;
  }

  model-viewer {
    width: calc(100% - 1.25rem);
    height: calc(100vh - 1.25rem);
  }
}

@layer components {
  .modelViewer {
    @apply h-[calc(100vh-1.25rem)] w-[100%];
  }
  .tooltip {
    @apply invisible absolute transform-gpu transition-transform ease-in-out;
  }

  .has-tooltip {
    @apply visible z-50 my-auto -mt-1.5 border-collapse translate-x-3
    bg-opacity-75 px-1.5 py-2 text-xxs font-medium text-current;
  }
}

Enter fullscreen mode Exit fullscreen mode

Import a .glb file type from a remote source or as a static asset and give it a try -- it will render near instantly and should have 0 errors both locally and in prod. Feels good to get rid of that dirty dangerouslyEscapeInnerHTML workaround used previously.

Thanks for reading along and please feel free to drop any questions, comments, or concerns below! Cheers

Top comments (3)

Collapse
 
design_by_adrian profile image
Adrian vG

Your global type definition doesn't support the attributes of the model-viewer element. User @furkandindar has the same issue.

Property 'ar' does not exist on type 'DetailedHTMLProps<AllHTMLAttributes<Partial<AnnotationInterface & SceneGraphInterface & TransformerInterface & LDStageManagerInterface &...
Enter fullscreen mode Exit fullscreen mode

etc.

Collapse
 
design_by_adrian profile image
Adrian vG

According to this post on GitHub, a better solution is the following. I'm not getting any code suggests though, but it'll have to do:

import { ModelViewerElement } from "@google/model-viewer";

export declare global {
  namespace JSX {
    interface IntrinsicElements {
      "model-viewer": React.DetailedHTMLProps<Partial<ModelViewerElement>>;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
furkandindar profile image
Furkan Dindar • Edited

Hello, thanks for this awesome post! I followed this tutorial and model-viewer works with React TS on my project now. I am now able to play with src, camera-controls, shadow-softness, shadow-intensity properties but when I try to use exposure property, it's giving me an error. I checked your code you didn't use exposure property on your model-viewer. Do you have any idea about this? Interestingly, only exposure property doesn't work

Image description