How-to: Communicator ESM with Next.js and TypeScript

We recently released ESM for Communicator for an improved development experience.

Here is a guide for setting up Communicator ESM with TypeScript in Next.js, a full-stack React framework.

Note: This guide was created for HOOPS Communicator 2024.4.0 and instructions may vary in future releases.

Initial Setup

  1. Download HOOPS Communicator from https://manage.techsoft3d.com/ or our Developer zone if you are an existing partner.

  2. Create an empty directory for your project and open Visual Studio Code or your preferred IDE. (The rest of this tutorial will be based on Visual Studio Code.)

  3. Open a terminal and run npx create-next-app@latest.


    Apply your desired configuration as shown above. More info on configuration related to Next.js can be found in the Next.js documentation.
    cd to the new <Next_App> directory created (test_app_ts in this case) and run npm run dev to start the development server to view the app and verify successful installation.

  4. Create a directory under <Next_App>/app called hoops.

  5. Copy <Communicator_Install_Directory>/web_viewer/types to this directory.

  6. Copy <Communicator_Install_Directory>/web_viewer/hoops-web-viewer-monolith.mjs to <Next_App>/app/hoops/types/web-viewer.

Note: we are using the monolith version of the web viewer here for simplicity. The non-monolithic version will require additional configuration at this time, which we will not cover in this guide.

  1. Create a new file called package.json in <Next_App>/app/hoops/types/web-viewer and paste the following:
{
  "name": "@hoops/web-viewer",
  "version": "0.0.1",
  "main": "./hoops-web-viewer-monolith.mjs",
  "typings": "./index.d.ts"
}

Communicator Integration

Create a viewer component

  1. Copy microengine.scs from <Communicator>/quick_start/converted_models/standard/scs to a new directory called <Next_App>/public/models.

  2. Create a directory in <Next_App>/app called components.

  3. Create Viewer.tsx here and create an empty basic component:

export default function Viewer() {
   return (
     <>
     </>
    )
}
  1. At the top of the file, add the 'use client' directive to support the web viewer and React functionalities. Also, include the module imports for this basic demo:
'use client'
import * as Communicator from '../hoops/types/web-viewer';
import { useState, useEffect } from 'react';
  1. Within the Viewer function, create a variable hwv for the viewer and a container (div element) for the viewer:
let hwv : Communicator.WebViewer;

return (
    <>
	<div id="viewer"></div>              
    </>    
 )

(If you’d like to confirm your setup, scroll to the bottom for the completed files.)

  1. Above the return in that function, add a startViewer function:
    function startViewer(container: string, model: string) {
        hwv = new Communicator.WebViewer({
            containerId: container,
            endpointUri: model
        });

        window.onresize = () => {
            hwv.resizeCanvas();
        }

        hwv.start();

        return hwv;
    }
  1. We’ll want this function executed once the page has rendered, so we can use useEffect to call it. Add this before everything else you’ve defined in the Viewer function:
useEffect(() => {
    let hwv = startViewer("viewer", "./models/microengine.scs");
       
    return () => {
        hwv.shutdown();
    }
}, [])

Note the use of the cleanup function (see https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) for info).

Render the component

  1. Delete all contents in <Next_App>/app/page.js and replace with the following so just the Viewer component is rendered:
import Viewer from './components/Viewer';

export default function Home() {
  return (
     <Viewer />
  )
}
  1. Check your app by running npm run dev:
    nextjs_ts1

Note: You may see error messages in the terminal. This is a known issue and a resolution is being worked on.

Using State

Next, we will create a simple demo of using State to change what’s on the web page. Specifically, we will display the node ID of the most recently clicked node in the lower right corner of the screen:

  1. In Viewer.tsx in the Viewer function, add the following before the useEffect:
const [nodeId, setNodeId] = useState<number | null>(null);
  1. In the startViewer function, add a callback to set the nodeId state to the most recently clicked node. Add the following immediately before hwv.start():
        hwv.setCallbacks({
            selectionArray: (selEvents)=>{
                if (selEvents.length > 0){
                    const lastSelectedNode = selEvents[0].getSelection().getNodeId();
                    setNodeId(lastSelectedNode);
                }
                else {
                    setNodeId(null);
                }
            }
        });
  1. Finally, add the HTML elements to display the nodeId by adding the following in the return statement, just after the viewer div:
            <div id="nodeDisplay" style={{position: "absolute", bottom: "10px", right: "10px", fontSize: "18px", padding: "10px"}}>
                <p id="nodeDisplayText">{"Node ID: " + nodeId}</p>
            </div>
  1. Run npm run dev and notice the “Node ID” display changes based on the last node that’s clicked.
    nextjs_ts2

Summary

Directory structure:

Viewer.tsx

'use client'
import * as Communicator from '../hoops/types/web-viewer';
import { useState, useEffect } from 'react';

export default function Viewer() {

    const [nodeId, setNodeId] = useState<number | null>(null);

    useEffect(()=>{
        let hwv = startViewer("viewer", "./models/microengine.scs");

        // Cleanup
        return () => {
            hwv.shutdown();
        }
    }, []);

    let hwv : Communicator.WebViewer;

    function startViewer(container: string, model: string) {
        hwv = new Communicator.WebViewer({
            containerId: container,
            endpointUri: model
        });

        window.onresize = () => {
            hwv.resizeCanvas();
        }

        hwv.setCallbacks({
            selectionArray: (selEvents)=>{
                if (selEvents.length > 0){
                    const lastSelectedNode = selEvents[0].getSelection().getNodeId();
                    setNodeId(lastSelectedNode);
                }
                else {
                    setNodeId(null);
                }
            }
        });

        hwv.start();

        return hwv;
    }

    return (
        <>
            <div id="viewer"></div>
            <div id="nodeDisplay" style={{position: "absolute", bottom: "10px", right: "10px", fontSize: "18px", padding: "10px"}}>
                <p id="nodeDisplayText">{"Node ID: " + nodeId}</p>
            </div>
        </>
    )
}

Please let me know if you have questions or run into any issues with setting up HOOPS Communicator.

Feel free to share any of your own tips as well!

1 Like

I ran your sample as above and it works just fine - i’m very happy I can create Next.JS components. Just to help myself understand better, I tried to change the background color with hwv.view.setBackgroundColor(new Color(33,33,33),new Color(180,180,180)). I get a crash. I tried to import {Color} from ‘…/hoops/types/common’ where it apparently lives, but also got a crash. Then I thought maybe I’d just turn on the Axis Triad. No luck there, either. The NodeId thing works, and I added a getViewerVersionString(), which also works - well, sort of. It returns 24.4.0 Build <BUILD_ID>

Any ideas what I’m doing wrong?

thanks!

  • David

Hi @david1 ,

In order to change the background color and set the axis triad, the sceneReady callback must be executed first. Have you tried calling these methods in the sceneReady callback? For example, in the sample code, you can add the following after hwv.setCallbacks({ in Viewer.tsx:

hwv.setCallbacks({
  sceneReady: () => {
    hwv.view.setBackgroundColor(new Color(33,33,33), new Color(180,180,180));
    hwv.view.axisTriad.enable();
  },
  selectionArray: // ...

Also, if you haven’t done the following already, you’ll need to:

  1. Add a package.json with the following contents in hoops/types/common, :
{
    "name": "@hoops/common",
    "version": "0.0.1",
    "main": "../web-viewer/hoops-web-viewer-monolith.mjs",
    "typings": "./index.d.ts"
}
  1. Add import { Color } from '../hoops/types/common'; at the top of Viewer.tsx.

Let me know if this works for you!

1 Like

I thought I read every callback. but that works great. when it paints, though, it overwrites my

i thought at first it was because it was using the default text color (I’m using dark theme, so text is white), so I changed it to be red. when I refresh, I can see the red text, but then the window repaints.

{"Node ID: " + nodeId}

I tried pasting the tag itself and I accepted it as HTML. I took a pic:

image

also, while I’m on the Next.js topic, how would a switch models? the endpoint is set in the WebViewer constructor and I think useEffect is only called once, similar to window.onLoad().

Hello @david1,

I’m able to change the text font color to red:

The text remains visible as I orbit the camera. Is that what you mean by when the window repaints?

Here is the change in Viewer.tsx:

return (
        <>
            <div id="viewer"></div>
            <div id="nodeDisplay" style={{position: "absolute", bottom: "10px", right: "10px", fontSize: "18px", padding: "10px"}}>
                <p id="nodeDisplayText">
					<span style={{ color: "#FF0000" }}>{"Node ID: " + nodeId}</span>
				</p>
            </div>
        </>
    )

@david1 glad that worked for you! Could you clarify your question here? I think part of what you wrote was cut off so I’m not sure what you’re asking:
“when it paints, though, it overwrites my”

As for your question about switching models, there are few ways to change the model. You can first clear the model, then use one of the various “loadSubtreeFrom…” methods such as loadSubtreeFromScsFile. When using model streaming, you could also use the switchToModel method (which is not supported for SCS files).

Here’s a simple demo using the sample above:

  1. Copy bnc.scs from <Communicator>/quick_start/converted_models/standard/scs.
  2. Create a state variable for the web viewer so we can access the created viewer:
const [webViewer, setWebViewer] = useState<Communicator.WebViewer | undefined>(undefined);
  1. Update the useEffect to set the webViewer state:
//...
let hwv = startViewer("viewer", "./models/microengine.scs");
setWebViewer(hwv);
//...
  1. Create a new function in the Viewer component that will clear the model and load the new scs file:
    function loadBnc() {
        if (webViewer === undefined) return;
        webViewer.model.clear().then(()=>{
            const rootNode = webViewer.model.getAbsoluteRootNode();
            webViewer.model.loadSubtreeFromScsFile(rootNode, './models/bnc.scs').then((nodeIdArr) => {
                console.log("Newly loaded model's root node IDs: ");
                console.log(nodeIdArr);
            })
        });
    }
  1. Add a button to invoke this function in the JSX:
<button id="modelChangeBtn" style={{position: "absolute", top: "10px", right: "10px", fontSize: "18px", padding: "10px"}} onClick={loadBnc}>Change Model</button>

next_change_model

Yes, that works for me as well, but when I apply the background colors from the earlier discussion, painting overwrites. here it is without the background change:

and here it is with the sceneReady changes above:

try and add this code to your setcallbacks and see if yours works:

       sceneReady: () => {
            hwv.view.setBackgroundColor(
                new Color(180, 180, 180),
                new Color(33, 33, 33),
            );
            hwv.view.axisTriad.enable();
        },

@david1, I was unable to reproduce that effect. However, this makes me think perhaps your JSX is configured differently such that the <div> for the node ID is behind the viewer (since, by default, the viewer background is transparent). What styles are being applied to the text if you view the code in the brower’s HTML inspector? Can you share the full JSX returned from the Viewer component?

I don’t think it’s using a painter’s. The viewer is the last

in the viewer:

Actual contents of Viewer.tsx:

“use client”;
import { Camera, WebViewer } from “…/hoops/types/web-viewer”;
import { Color } from “…/hoops/types/common/”;
import { useState, useEffect } from “react”;

interface Props {
divName?: string | null;
modelName: string;
}
export default function Viewer({ divName, modelName }: Props) {
const [nodeId, setNodeId] = useState<number | null>(null);
const [versionString, setVersionString] = useState<string | null>(null);
const [webViewer, setWebViewer] = useState<WebViewer | undefined>(undefined);

useEffect(() => {
let hwv = startViewer(divName ? divName : “viewer”, modelName);

// Cleanup
return () => {
  hwv.shutdown();
};

}, );

let hwv: WebViewer;

function loadBnc() {
if (webViewer === undefined) return;
webViewer.model.clear().then(() => {
const rootNode = webViewer.model.getAbsoluteRootNode();
webViewer.model
.loadSubtreeFromScsFile(rootNode, “./models/bnc.scs”)
.then((nodeIdArr) => {
console.log("Newly loaded model’s root node IDs: ");
console.log(nodeIdArr);
});
});
}
function startViewer(container: string, model: string) {
hwv = new WebViewer({
containerId: container,
endpointUri: model,
});
setWebViewer(hwv);
window.onresize = () => {
hwv.resizeCanvas();
};

hwv.setCallbacks({
  selectionArray: (selEvents) => {
    if (selEvents.length > 0) {
      const lastSelectedNode = selEvents[0].getSelection().getNodeId();
      console.log("selEvent", lastSelectedNode);
      setNodeId(lastSelectedNode);
    } else {
      console.log("setNodeId is nullselection");
      setNodeId(null);
    }
  },
  sceneReady: () => {
    hwv.view.setBackgroundColor(
      new Color(180, 180, 180),
      new Color(33, 33, 33),
    );
    hwv.view.axisTriad.enable();
  },
  beginInteraction: () => {
    console.log("start interaction");
  },
  endInteraction: () => {
    console.log("interaction over");
  },
  camera: (camera: Camera) => {
    console.log("camera changed", camera);
  },
});
setVersionString(hwv.getViewerVersionString());

hwv.start();

return hwv;

}

return (
<>


<button
id=“modelChangeBtn”
style={{
position: “absolute”,
top: “10px”,
right: “10px”,
fontSize: “18px”,
padding: “10px”,
}}
onClick={loadBnc}
>
Push me

  <div
    id="nodeDisplay"
    style={{
      position: "absolute",
      bottom: "10px",
      right: "10px",
      fontSize: "18px",
      padding: "10px",
    }}
  >
    <p id="nodeDisplayText">
      <span style={{ color: "#FF0000" }}>{"Node ID: " + nodeId}</span>
    </p>
    <p id="versionText">
      <span style={{ color: "#FF00FF" }}>
        {"Version: " + versionString}
      </span>
    </p>
  </div>
  <div id="viewer" />
</>

);
}

@david1 thank you! The behavior you’re seeing is expected since the viewer’s <div> is last in your JSX. This is drawing the viewer on top of the previous elements.

To fix, you can either move <div id="viewer" /> to the beginning of the returned JSX or add a larger zIndex to your style for the previous elements so they stay on top (like zIndex: 2).

OMG! Works! It is a painters. but that’s great. I have a 100 ideas about integration now. this is so great. thank you!

1 Like

One final question. Performance is really bad. I suspect Chrome isn’t using the GPU. maybe that’s a question for another chat, so delete this if so.

@david1, with regards to performance, this sample uses file-based loading (via scs files) for simplicity. Communicator also supports streaming for improved performance and responsiveness. More details can be found here: https://docs.techsoft3d.com/communicator/latest/prog_guide/servers/servers.html
Also, this section of our documentation discusses other ways to improve performance.
Let me know if you have additional questions about this or feel free message me directly if you’d prefer to set up a call.

1 Like