三维空间中的点
上一章回顾: 状态录制
- 你了解了如何通过 State 完成操作的录制和还原。
- 熟悉地使用 State 相关的方法和事件。
- 了解 Five SDK 的事件系统。
- 获取到点的三维位置。
准备工作
我们新建一个目录(src/4.points-in-3d
)以及对应的 html 文件 以及 jsx 或 tsx 文件。
携带着上一章的 State 代码,太过于繁琐,那我们从 展示三维空间 章的内容的基础开发。
<!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>三维中的点 | Points in 3d</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>
- JavaScript
- TypeScript
import React, { Component } from "react";
import { parseWork } from "@realsee/five";
/**
* React HOC 获取 work
* @param url 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: 获取当前窗口的尺寸
*/
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: 模态控制
*/
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="全景漫游" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
<BottomNavigationAction label="空间总览" 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";
/** work.json 的数据 URL */
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 获取 work
* @param url 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: 获取当前窗口的尺寸
*/
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: 模态控制
*/
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="全景漫游" icon={<DirectionsWalkIcon/>} value={Five.Mode.Panorama}/>
<BottomNavigationAction label="空间总览" 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";
/** work.json 的数据 URL */
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 {};
启动服务 npm run dev
。 并跳转到当前页面 "http://localhost:3000/src/4.points-in-3d/index.html"。
请查看你的控制台,端口号会因为你的配置以及当前端口占用情况变更,请已控制台输出的为准。 如果你使用其他开发构建工具,请按照自己的开发构建工具的要求启动服务。
事件系统
在屏幕上点击时,five SDK 的默认行为是选择点击位置附近最合适的 观察点(observer) 移动过去。大部分用户的动作都是如此,就好比浏览器对于 A 标签 的处理逻辑大多都是链接跳转。上述就是 five SDK 的内建 tapGesture事件。
内建事件
five SDK 内建的事件有如下:
- tapGesture: 鼠标左键点击或者手指点击。默认行为是点位移动。
- panGesture: 鼠标按住一栋或者手指在屏幕上拖拽移动。相机旋转(Topview下是相机平移)。
- pinchGesture: 手指双指做捏的手势。默认行为是修改相机可视角度。
- mouseWheel: 鼠标滚轮。默认行为是修改相机可视角度。
- gesture: 上述的任意事件。
阻止默认行为
所有事件和 浏览器对于 A 标签 的处理逻辑 一样,都可以阻止默认事件,你只需要监听 wants
开头的事件,在回调函数中 return false
即可。比如想阻止 tapGesture 的默认点位移动行为。可以做如下操作。
this.props.$five.on("wantsTapGesture", () => {
// highlight-start
// 阻止 tapGesture 触发
return false;
// highlight-end
});
具体每个事件的 API 可以查看详细文档
从 tapGesture 获取坐标
我们制作一个简单功能,用于标记在画布上点击的三维位置。 但是为了和 点位移动 功能不冲突,我们用一个
Switch
按钮来控制标记状态是否开启。
头部添加依赖
本章需要介绍一下 three.js 了。three.js 是一个三维图形库,Five SDK 使用 three.js 的数学库和渲染器。本章涉及 three.js 有两个内容,在这里做一些说明,你不必完全了解 three.js, 我做一些说明你就可以理解。
THREE.Vector3
: 你可以就认为是一个{ x: number, y: number, z: number }
的结构体,并且多加了一些数学方法(本次不会用到数学方法,只是记录xyz)THREE.Raycaster
: 光线投射类。你可以简单的理解为屏幕上的一个点对应到三维空间是一条射线。
;
射线有很多作用,比如:通过射线和模型之前的相交性检测,就可以判断是否对象被选中。
编写 MarkController 组件
- 添加一个 MarkController 文件,用于编写组件。
- 我们通过 active React state 状态来控制当前应用是否在标记的模式下。
- 通过
tapGesture
第一个参数是raycaster
,通过modelIntersectRaycaster
可以获取到焦点信息intersect
,intersect.point
就是交点的坐标。 - 通过
marks
React state 来存储所有交点,并且实现收集和删除。
- JavaScript
- TypeScript
import React, { Component } from "react";
import { compose } from "@wordpress/compose";
import { withFive, createFiveFeature } from "@realsee/five/react";
import Button from "@mui/material/Button";
import Switch from "@mui/material/Switch";
import Chip from "@mui/material/Chip";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
const FEATURES = createFiveFeature("intersectRaycaster", "on", "off");
const MarkController = compose(
withFive(FEATURES)
)(class extends Component {
state = { active: false, marks: [] };
onTapGesture = (raycaster) => {
if (this.state.active) {
const [intersect] = this.props.$five.intersectRaycaster(raycaster);
if (intersect) this.setState({ marks: this.state.marks.concat(intersect.point) });
return false;
}
};
componentDidMount() {
this.props.$five.on("wantsTapGesture", this.onTapGesture);
}
componentWillUnmount() {
this.props.$five.off("wantsTapGesture", this.onTapGesture);
}
render() {
return <Paper sx={{ position: "fixed", top: 10, left: 10, padding: 1 }}>
<Stack>
<Stack direction="row">
<Switch
checked={this.state.active}
onChange={(event, checked) => this.setState({ active: checked })}
/> <Button disabled>开启点击记录坐标</Button>
</Stack>
<Stack spacing={1}>
{this.state.marks.map((point, index) => {
const { x, y, z } = point;
return <Chip
key={index}
label={`x=${x.toFixed(2)} y=${y.toFixed(2)} z=${z.toFixed(2)}`}
onDelete={() => this.setState({marks: this.state.marks.filter((_, index_) => index_ !== index)})}
/>
})}
</Stack>
</Stack>
</Paper>
}
});
export { MarkController };
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 Switch from "@mui/material/Switch";
import Chip from "@mui/material/Chip";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
const FEATURES = createFiveFeature("intersectRaycaster", "on", "off");
type Props = PropTypeOfFiveFeatures<typeof FEATURES>;
type State = {
active: boolean,
marks: THREE.Vector3[],
};
const MarkController = compose(
withFive(FEATURES)
)(class extends Component<Props, State> {
state: State = { active: false, marks: [] };
onTapGesture: EventCallback["wantsTapGesture"] = (raycaster) => {
if (this.state.active) {
const [intersect] = this.props.$five.intersectRaycaster(raycaster);
if (intersect) this.setState({ marks: this.state.marks.concat(intersect.point) });
return false;
}
};
componentDidMount() {
this.props.$five.on("wantsTapGesture", this.onTapGesture);
}
componentWillUnmount() {
this.props.$five.off("wantsTapGesture", this.onTapGesture);
}
render() {
return <Paper sx={{ position: "fixed", top: 10, left: 10, padding: 1 }}>
<Stack>
<Stack direction="row">
<Switch
checked={this.state.active}
onChange={(event, checked) => this.setState({ active: checked })}
/> <Button disabled>开启点击记录坐标</Button>
</Stack>
<Stack spacing={1}>
{this.state.marks.map((point, index) => {
const { x, y, z } = point;
return <Chip
key={index}
label={`x=${x.toFixed(2)} y=${y.toFixed(2)} z=${z.toFixed(2)}`}
onDelete={() => this.setState({marks: this.state.marks.filter((_, index_) => index_ !== index)})}
/>
})}
</Stack>
</Stack>
</Paper>
}
});
export { MarkController };
使用标记组件
插入到 App 文件的 FiveProvider 中
- JavaScript
- TypeScript
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 { MarkController } from "./MarkController";
// highlight-end
/** work.json 的数据 URL */
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
<MarkController/>;
// 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 { MarkController } from "./MarkController";
// highlight-end
/** work.json 的数据 URL */
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
<MarkController/>
// highlight-end
</FiveProvider>;
}
});
export { App };
回到你的浏览器查看,会发现你的页面左上角出现一个选择开关。打开开关,点击画布内容,会输出点击位置的坐标。
真棒,一下子就理解和获取到了三维坐标🥳 。