Recap of the previous chapter: Points in 3D space
You learned about the Five SDK event system and built a small app that retrieves the 3D position of a point from a click event.
In this chapter you will learn
How to place tags in a 3D space.
Getting ready
Let's create a new directory (src/5.tagging) along with its html file and a jsx or tsx file. Carrying over the State code from the previous chapter would be too cumbersome, so we'll build on top of the content from the Displaying a 3D space chapter instead.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Adding Tags</title>
<style>
* { margin: 0; padding: 0; }
html, body #app { width: 100%; height: 100%; overflow: hidden; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index"></script>
</body>
</html>import React, { Component } from "react";
import { parseWork } from "@realsee/five";
/**
* React HOC that fetches the work
* @param url the URL of work.json
*/
function withFetchWork(url) {
return function(Compnent) {
return class extends Component {
state = { work: null };
componentDidMount() {
fetch(url).then(res => res.json()).then(json => {
this.setState({ work: parseWork(json) });
});
}
render() {
if (this.state.work === null) return null;
return <Compnent work={this.state.work} {...this.props}/>;
}
}
}
}
export { withFetchWork };import React, { Component } from "react";
/**
* React HOC: get the current window dimensions
*/
function withWindowDimensions() {
return function(Compnent) {
return class extends Component {
state = this.getWindowDimensions();
resizeListener = () => {
this.setState(this.getWindowDimensions());
};
getWindowDimensions() {
return { width: window.innerWidth, height: window.innerHeight };
}
componentDidMount() {
window.addEventListener("resize", this.resizeListener, false);
}
componentWillUnmount() {
window.removeEventListener("resize", this.resizeListener, false);
}
render() {
const dimensions = { width: this.state.width, height: this.state.height };
return <Compnent windowDimensions={dimensions} {...this.props}/>;
}
}
}
}
export { withWindowDimensions };import React, { Component } from "react";
import { Five } from "@realsee/five";
import { withFive, createFiveFeature } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
const FEATURES = createFiveFeature("currentState", "setState");
/**
* React Component: mode control
*/
const ModeController = compose(
withFive(FEATURES)
)(class extends Component {
render() {
return <Paper sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}>
<BottomNavigation
showLabels
value={this.props.$five.currentState.mode}
onChange={(_, newValue) => {
this.props.$five.setState({ mode: newValue });
}}
>
<BottomNavigationAction label="Panorama" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
<BottomNavigationAction label="Floorplan" icon={<ViewInArIcon/>} value={Five.Mode.Floorplan}/>
</BottomNavigation>
</Paper>;
}
})
export { ModeController };import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
/** Data URL of work.json */
const workURL = "https://vr-public.realsee-cdn.cn/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
withFetchWork(workURL),
withWindowDimensions()
)(class extends Component {
render() {
const { work, windowDimensions } = this.props;
return <FiveProvider initialWork={work}>
<FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
<ModeController/>;
</FiveProvider>;
}
});
export { App };import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM.render(<App/>, document.querySelector("#app"));
export {};import React, { Component, ComponentClass } from "react";
import { Work, parseWork } from "@realsee/five";
/**
* React HOC that fetches the work
* @param url the URL of work.json
*/
function withFetchWork<P extends Record<string, any>>(url: string) {
return function(Compnent: ComponentClass<P & { work: Work }>): ComponentClass<P> {
return class extends Component<P, {work: Work | null}> {
state = { work: null };
componentDidMount() {
fetch(url).then(res => res.json()).then(json => {
this.setState({ work: parseWork(json) });
});
}
render() {
if (this.state.work === null) return null;
return <Compnent work={this.state.work} {...this.props}/>;
}
}
}
}
export { withFetchWork };import React, { Component, ComponentClass } from "react";
/**
* React HOC: get the current window dimensions
*/
function withWindowDimensions<P extends Record<string, any>>() {
return function(Compnent: ComponentClass<P & { windowDimensions: { width: number, height: number} }>): ComponentClass<P> {
return class extends Component<P, {width: number, height: number}> {
state = this.getWindowDimensions();
resizeListener = () => {
this.setState(this.getWindowDimensions());
};
getWindowDimensions() {
return { width: window.innerWidth, height: window.innerHeight };
}
componentDidMount() {
window.addEventListener("resize", this.resizeListener, false);
}
componentWillUnmount() {
window.removeEventListener("resize", this.resizeListener, false);
}
render() {
const dimensions = { width: this.state.width, height: this.state.height };
return <Compnent windowDimensions={dimensions} {...this.props}/>;
}
}
}
}
export { withWindowDimensions };import React, { Component } from "react";
import { Five, Mode } from "@realsee/five";
import { withFive, createFiveFeature, PropTypeOfFiveFeatures } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk";
import ViewInArIcon from "@mui/icons-material/ViewInAr";
const FEATURES = createFiveFeature("currentState", "setState");
type Props = PropTypeOfFiveFeatures<typeof FEATURES>;
/**
* React Component: mode control
*/
const ModeController = compose(
withFive(FEATURES)
)(class extends Component<Props> {
render() {
return <Paper sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}>
<BottomNavigation
showLabels
value={this.props.$five.currentState.mode}
onChange={(_, newValue: Mode) => {
this.props.$five.setState({ mode: newValue });
}}
>
<BottomNavigationAction label="Panorama" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
<BottomNavigationAction label="Floorplan" icon={<ViewInArIcon/>} value={Five.Mode.Floorplan}/>
</BottomNavigation>
</Paper>;
}
})
export { ModeController };import React, { Component } from "react";
import { Work } from "@realsee/five";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
/** Data URL of work.json */
const workURL = "https://vr-public.realsee-cdn.cn/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
withFetchWork(workURL),
withWindowDimensions()
)(class extends Component<{work: Work, windowDimensions: { width: number, height: number }}> {
render() {
const { work, windowDimensions } = this.props;
return <FiveProvider initialWork={work}>
<FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
<ModeController/>
</FiveProvider>;
}
});
export { App };import React from "react";
import ReactDOM from "react-dom";
import { App } from "./App";
ReactDOM.render(<App/>, document.querySelector("#app"));
export {};Start the dev server with npm run dev, then navigate to the current page at "http://localhost:3000/src/5.tagging/index.html".
Info
Please check your console: the port number may change depending on your configuration and on which ports are currently in use, so always rely on the console output. If you use a different build tool, start the server according to that tool's requirements.
Building the tagging feature
Adding tag styles
Add the tag styles to the html file.
The styles are not required; they just make the tags look a little nicer.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Adding Tags</title>
<style>
* { margin: 0; padding: 0; }
html, body #app { width: 100%; height: 100%; overflow: hidden; }
/* highlight-start */
.tag { position: absolute; width: 0; height: 0; transform: translateZ(0); }
.tag-pannel { position: absolute; width: 100px; min-height: 20px; transform: translate(-50%, 0); left: 50%; bottom: 10px; background: #333; color: #fff; border-radius: 2px; text-align: center; line-height: 20px; padding: 8px; font-size: 14px;}
.tag-pannel:after { content: ""; display: block; position: absolute; width: 10px; height: 10px; left: 50%; bottom: -5px; transform: translate(-50%, 0) rotate(45deg); background: #333; pointer-events: none; }
/* highlight-end */
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index"></script>
</body>
</html>About useFiveProject2d
This chapter uses the `useFiveProject2d` method. It maps a 3D coordinate to a position on the 2D screen.
$five.project2d(vector: THREE.Vector3, testModel: boolean): THREE.Vector2 | null
- Pass in a 3D coordinate to get a 2D screen coordinate. The origin is at the top-left, and the unit is pixels. You can use it directly as, for example,
{ left: returnValue.x + "px", top: returnValue.y + "px" }. - If the 3D coordinate cannot be projected onto the screen (for example, it is behind the camera or occluded), it returns
null. - The second parameter, testModel, controls whether model collision is taken into account, i.e. whether a coordinate occluded by the model returns
null.
Writing the TaggingController
- Add a TaggingController file to hold the component.
- Store tag positions and text in the
tagsReact state. - Store the tag currently being created in the
newTagReact state. - Listen to Five SDK's
intersectionOnModelUpdateevent to keep the in-progress tag positioned under the mouse. - For each tag, call the
$five.project2dmethod (inside thetagElementmethod) to get the screen canvas coordinate, then render it by updating the styles.
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { withFive, createFiveFeature } from "@realsee/five/react";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
const FEATURES = createFiveFeature("project2d", "currentState", "on", "off");
/**
* React Component: tagging
*/
const TaggingController = compose(
withFive(FEATURES)
)(class extends Component {
state = { tags: [], newTag: null };
addTag = () => {
this.setState({ newTag: { label: window.prompt("Add tag", "") || "Untitled" } });
}
tagElement(tag, key) {
const position = tag.position && this.props.$five.project2d(tag.position, true);
const style = position ? { left: position.x, top: position.y } : { display: "none" };
return <div className="tag" style={style} key={key}>
<div className="tag-pannel"><span className="tag-content">{tag.label}</span></div>
</div>
}
onIntersectionUpdate = intersect => {
if (this.state.newTag) this.setState({ newTag: { position: intersect.point, label: this.state.newTag.label } });
};
onTapGesture = () => {
if (this.state.newTag && this.state.newTag.position) {
this.setState({
tags: this.state.tags.concat(this.state.newTag),
newTag: null
});
return false;
}
};
componentDidMount() {
this.props.$five.on("intersectionOnModelUpdate", this.onIntersectionUpdate);
this.props.$five.on("wantsTapGesture", this.onTapGesture);
}
componentWillUnmount() {
this.props.$five.off("intersectionOnModelUpdate", this.onIntersectionUpdate);
this.props.$five.off("wantsTapGesture", this.onTapGesture);
}
render() {
return <React.Fragment>
<Paper sx={{ position: "fixed", top: 10, left: 10 }}>
<Button onClick={this.addTag}>Add tag</Button>
</Paper>
{this.state.newTag && this.tagElement(this.state.newTag)}
{this.state.tags.map((tag, index) => this.tagElement(tag, index))}
</React.Fragment>;
}
});
export { TaggingController };import * as THREE from "three";
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { EventCallback } from "@realsee/five";
import { withFive, createFiveFeature, PropTypeOfFiveFeatures } from "@realsee/five/react";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
type Tag = { position?: THREE.Vector3, label: string };
const FEATURES = createFiveFeature("project2d", "currentState", "on", "off");
type Props = PropTypeOfFiveFeatures<typeof FEATURES>;
type State = {
tags: Tag[],
newTag: Tag | null
};
/**
* React Component: tagging
*/
const TaggingController = compose(
withFive(FEATURES)
)(class extends Component<Props, State> {
state: State = { tags: [], newTag: null };
addTag = () => {
this.setState({ newTag: { label: window.prompt("Add tag", "") || "Untitled" } });
}
tagElement(tag: Tag, key?: number | string) {
const position = tag.position && this.props.$five.project2d(tag.position, true);
const style = position ? { left: position.x, top: position.y } : { display: "none" };
return <div className="tag" style={style} key={key}>
<div className="tag-pannel"><span className="tag-content">{tag.label}</span></div>
</div>
}
onIntersectionUpdate: EventCallback["intersectionOnModelUpdate"] = intersect => {
if (this.state.newTag) this.setState({ newTag: { position: intersect.point, label: this.state.newTag.label } });
};
onTapGesture: EventCallback["wantsTapGesture"] = () => {
if (this.state.newTag && this.state.newTag.position) {
this.setState({
tags: this.state.tags.concat(this.state.newTag),
newTag: null
});
return false;
}
};
componentDidMount() {
this.props.$five.on("intersectionOnModelUpdate", this.onIntersectionUpdate);
this.props.$five.on("wantsTapGesture", this.onTapGesture);
}
componentWillUnmount() {
this.props.$five.off("intersectionOnModelUpdate", this.onIntersectionUpdate);
this.props.$five.off("wantsTapGesture", this.onTapGesture);
}
render() {
return <React.Fragment>
<Paper sx={{ position: "fixed", top: 10, left: 10 }}>
<Button onClick={this.addTag}>Add tag</Button>
</Paper>
{this.state.newTag && this.tagElement(this.state.newTag)}
{this.state.tags.map((tag, index) => this.tagElement(tag, index))}
</React.Fragment>;
}
});
export { TaggingController };Using the tagging component
Insert it into the FiveProvider in the App file.
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
// highlight-start
import { TaggingController } from "./TaggingController";
// highlight-end
/** Data URL of work.json */
const workURL = "https://vr-public.realsee-cdn.cn/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
withFetchWork(workURL),
withWindowDimensions()
)(class extends Component {
render() {
const { work, windowDimensions } = this.props;
return <FiveProvider initialWork={work}>
<FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
<ModeController/>;
// highlight-start
<TaggingController/>;
// highlight-end
</FiveProvider>;
}
});
export { App };import React, { Component } from "react";
import { Work } from "@realsee/five";
import { createFiveProvider, FiveCanvas } from "@realsee/five/react";
import { compose } from "@wordpress/compose";
import { withFetchWork } from "./withFetchWork";
import { withWindowDimensions } from "./withWindowDimensions";
import { ModeController } from "./ModeController";
// highlight-start
import { TaggingController } from "./TaggingController";
// highlight-end
/** Data URL of work.json */
const workURL = "https://vr-public.realsee-cdn.cn/release/static/image/release/five/work-sample/07bdc58f413bc5494f05c7cbb5cbdce4/work.json";
const FiveProvider = createFiveProvider();
const App = compose(
withFetchWork(workURL),
withWindowDimensions()
)(class extends Component<{work: Work, windowDimensions: { width: number, height: number }}> {
render() {
const { work, windowDimensions } = this.props;
return <FiveProvider initialWork={work}>
<FiveCanvas width={windowDimensions.width} height={windowDimensions.height}/>
<ModeController/>
// highlight-start
<TaggingController/>
// highlight-end
</FiveProvider>;
}
});
export { App };Go back to your browser and take a look. You'll see an "Add tag" button appear in the top-left corner of the page. Click it, enter a tag name, then move the mouse and click at the spot you want. The tag is placed there.
Yes, this really is a handy feature 🥳.
