Search code examples
reactjsautodesk-forgeautodesk-viewer

How to Change Viewer Model Dynamically


I have a react project and am trying to use the Autodesk Forge viewer. I have it sort of working but am getting a lot of errors and weird behavior that I don't understand. I'm pretty new to both Forge viewer and React so I'm sure I'm missing something simple but I don't know what it is.

The general idea of this page is that the user gets a list of locations from the DB (outside Forge) that they can click on. If they click on one to select it the system checks if there is a dwg file associated with it. If not it displays a generic div that says there is no file associated but if so it displays the dwg in the forge viewer. So the viewer itself is sometimes hidden but should always be there but will need to change the file that it's displaying sometimes.

Right now I have it so that when I click the first one it comes up and displays it correctly. However, when I click another and then back to the first one it blanks out and gives me an error in the console. Here is my forge component:

import React, { useEffect, useRef } from "react";
import styled from "styled-components";
import { BorderedZoneOuter } from "./Generic/CommonStyledComponents";
import { ForgeBackgroundService } from "../services/ForgeBackgroundService";
import { valueIsNullOrUndefined } from "../Utility";

const viewerLibraryURL =
  "https://developer.api.autodesk.com/modelderivative/v2/viewers/viewer3D.min.js?v=v7.*";
const viewerStylesheetURL =
  "https://developer.api.autodesk.com/modelderivative/v2/viewers/style.min.css?v=v7.*";

let viewerLibraryLoaded = false;
let viewerStyleLoaded = false;
let viewerLoading = false;

const ForgeContainer = styled(BorderedZoneOuter)`
  position: relative;
  flex: 1;
`;

const _backgroundService = new ForgeBackgroundService();
let viewer: Autodesk.Viewing.GuiViewer3D | undefined;

const ForgeViewer = (props: {
  urn: string;
  viewerReady?: (viewer: Autodesk.Viewing.GuiViewer3D) => void;
}) => {
  const container: any = useRef();

  useEffect(() => {
    const handleStyleLoad = () => {
      console.log("style loaded");
      viewerStyleLoaded = true;
      viewerLibraryLoaded && loadViewer();
    };

    const handleScriptLoad = () => {
      console.log("script loaded");
      viewerLibraryLoaded = true;
      viewerStyleLoaded && loadViewer();
    };

    const loadViewer = () => {
      console.log("loading viewer");
      if (!viewerLoading) {
        viewerLoading = true;
        Autodesk.Viewing.Initializer(
          {
            env: "AutodeskProduction2",
            api: "streamingV2",
            getAccessToken: (onTokenReady) => {
              if (onTokenReady) {
                _backgroundService.GetToken().then((t) => {
                  if (valueIsNullOrUndefined(t)) {
                    return;
                  }
                  onTokenReady(t!.token, t!.expiresIn);
                });
              }
            },
          },
          () => {
            viewer = new Autodesk.Viewing.GuiViewer3D(container.current);
            viewer.start();
            loadModel(props.urn);
            viewerLoading = false;
          }
        );
      }
    };

    const loadStyleSheet = (href: string) => {
      const styles = document.createElement("link");
      styles.rel = "stylesheet";
      styles.type = "text/css";
      styles.href = href;
      styles.onload = handleStyleLoad;
      document.getElementsByTagName("head")[0].appendChild(styles);
    };

    const loadViewerScript = (href: string) => {
      const script = document.createElement("script");
      script.src = href;
      script.async = true;
      script.onload = handleScriptLoad;
      document.getElementsByTagName("head")[0].appendChild(script);
    };

    function loadModel(urn: string): void {
      console.log(urn);
      console.log(viewer);
      Autodesk.Viewing.Document.load(
        urn,
        (doc) => {
          console.log(doc);
          const defaultModel = doc.getRoot().getDefaultGeometry();
          console.log(defaultModel);
          viewer?.loadDocumentNode(doc, defaultModel)
            .then((m: Autodesk.Viewing.Model) => {
              console.log(m);
              if (props.viewerReady) {
                props.viewerReady(viewer!);
              }
            });
        },
        () => {
          console.error("failed to load document");
        }
      );
    }

    if (!valueIsNullOrUndefined(viewer)) {
      console.log("have viewer, loading model");
      loadModel(props.urn);
    } else {
      console.log("no viewer, loading scripts");
      loadStyleSheet(viewerStylesheetURL);
      loadViewerScript(viewerLibraryURL);
    }

    return () => {
      viewer?.finish();
    };
  }, [props]);

  return <ForgeContainer ref={container} />;
};

export default ForgeViewer;

In the parent component that uses the viewer, here is the relevant portion of the tsx:

  <MainCanvas>
    <PageTitle>Create Spaces</PageTitle>
    <button onClick={select}>select</button>
    {valueIsNullOrUndefined(state.selectedFloor) && (
      <NoBackgroundZone>
        <div>You have not selected a floor</div>
        <div>
          Please select a floor to view the background and create spaces
        </div>
      </NoBackgroundZone>
    )}

    {!valueIsNullOrUndefined(state.selectedFloor) &&
      valueIsNullOrUndefined(state.backgroundUrn) && (
        <NoBackgroundZone>
          <div>There is no background added for this floor</div>
          <LinkButton>Add a background</LinkButton>
        </NoBackgroundZone>
      )}

    {!valueIsNullOrUndefined(state.selectedFloor) &&
      !valueIsNullOrUndefined(state.backgroundUrn) && (
        <ForgeViewer urn={state.backgroundUrn!} viewerReady={viewerReady} />
      )}
  </MainCanvas>

The error I get is:

Uncaught TypeError: Cannot read properties of null (reading 'hasModels') Viewer3D.js:1799
  at C.he.loadDocumentNode (Viewer3D.js:1799:53)
  at ForgeViewer.tsx:98:1
  ...

The line number that it's referring to in the error is the one that starts with viewer?.loadDocumnentNode(... If it's using the conditional access how can it be null and still throwing the error? I also have logged all three variables on that line (doc, defaultModel, and viewer) right before that call and they never show up as null...

Can anyone tell me what I'm doing wrong here? I've looked at a lot of different samples but all of them seem to deal with just getting something displayed and I can't find anything about changing the display.


Solution

  • Ok so after doing a lot more logging and investigating I think I have this solved with a few changes described below for anyone that finds themselves in the same situation I was.

    First, it turns out the issue was mostly centered around the viewer being 'finished' in the destructor function of useEffect here:

    return () => {
      viewer?.finish();
    };
    

    After this happens the viewer in effect is dead, but I didn't set my value to undefined so the next time it found the viewer as non-null and tried to use it but it had already been finished. First simple change:

    return () => {
      viewer?.finish();
      viewer = undefined;
    };
    

    This makes sure that the viewer is recreated if it's been finished.

    Secondly was actually in my consumer that is using this module. The props for this component has a viewer ready callback that tells the consuming module when the viewer is ready for use. My calling component then does other things with the viewer (finds layers and blocks, etc.). However, I was storing this in a standard variable:

    const SpaceCreator = () => {
      let viewer: Autodesk.Viewing.GuiViewer3D | undefined;
    
      ...
    
      function viewerReady(v: Autodesk.Viewing.GuiViewer3D): void {
        viewer = v;
      }
    
      ...
    }
    

    Instead I needed to make this part of the state and use the reducer to set the current viewer like so:

    const SpaceCreator = () => {
      const [state, dispatch] = useReducer(reducer, new SpaceCreatorState());
    
      ...
    
      function viewerReady(v: Autodesk.Viewing.GuiViewer3D): void {
        dispatch({ type: SpaceCreatorActions.viewer, payload: v });
      }
    
      ...
    }
    

    This was almost it, except that it kept reloading on me constantly. I updated the dependencies in the useEffect to be only props.urn so that it would only re-run when the urn changed. This stopped the constant reloading, but React (linting anyway) complained about the dependency array not including the viewerReady event since it was used in the useEffect method. After some more research, basically the viewerReady event was being redone on every render causing the value to change which is causing the constant re-renders. Instead I needed to use useCallback to create the method which makes it not change unless one of it's dependencies changes (and since I have no dependencies it just creates on the first render). Here is the final method:

    const viewerReady = useCallback((v: Autodesk.Viewing.GuiViewer3D) => {
        dispatch({ type: SpaceCreatorActions.viewer, payload: v });
      }, []);
    

    After all of this it seems to be stable, working correctly, and React is happy. Hope this helps someone else having this issue.