@inweb/viewer-three
Version:
JavaScript library for rendering CAD and BIM files in a browser using Three.js
520 lines (415 loc) • 17.7 kB
text/typescript
///////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2002-2025, Open Design Alliance (the "Alliance").
// All rights reserved.
//
// This software and its documentation and related materials are owned by
// the Alliance. The software may only be incorporated into application
// programs owned by members of the Alliance, subject to a signed
// Membership Agreement and Supplemental Software License Agreement with the
// Alliance. The structure and organization of this software are the valuable
// trade secrets of the Alliance and its suppliers. The software is also
// protected by copyright law and international treaty provisions. Application
// programs incorporating this software must include the following statement
// with their copyright notices:
//
// This application incorporates Open Design Alliance software pursuant to a
// license agreement with Open Design Alliance.
// Open Design Alliance Copyright (C) 2002-2025 by Open Design Alliance.
// All rights reserved.
//
// By use of this software, its documentation or related materials, you
// acknowledge and accept the above terms.
///////////////////////////////////////////////////////////////////////////////
import { Camera, MathUtils, Matrix4, Object3D, Vector2, Vector3, Vector4 } from "three";
import type { Viewer } from "../Viewer";
import { OrbitDragger } from "./OrbitDragger";
import { convertUnits } from "../measurement/UnitConverter";
import { formatDistance } from "../measurement/UnitFormatter";
import { Snapper } from "../measurement/Snapper";
const _downPoint = new Vector2();
export class MeasureLineDragger extends OrbitDragger {
private overlay: MeasureOverlay;
private line: MeasureLine;
private snapper: Snapper;
private objects: Object3D[];
private scale = 1.0;
private units = "";
private precision: any = 2;
constructor(viewer: Viewer) {
super(viewer);
this.overlay = new MeasureOverlay(viewer.camera, viewer.canvas);
this.overlay.attach();
this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
this.overlay.addLine(this.line);
this.snapper = new Snapper(viewer.camera, viewer.renderer, viewer.canvas);
this.snapper.threshold = viewer.extents.getSize(new Vector3()).length() / 10000;
this.objects = [];
this.updateObjects();
this.updateUnits();
this.viewer.canvas.addEventListener("pointerdown", this.onPointerDown);
this.viewer.canvas.addEventListener("pointermove", this.onPointerMove);
this.viewer.canvas.addEventListener("pointerup", this.onPointerUp);
this.viewer.canvas.addEventListener("pointercancel", this.onPointerCancel);
this.viewer.canvas.addEventListener("pointerleave", this.onPointerLeave);
this.viewer.addEventListener("render", this.renderOverlay);
this.viewer.addEventListener("hide", this.updateObjects);
this.viewer.addEventListener("isolate", this.updateObjects);
this.viewer.addEventListener("show", this.updateObjects);
this.viewer.addEventListener("showall", this.updateObjects);
this.viewer.addEventListener("changecameramode", this.updateSnapperCamera);
this.viewer.addEventListener("optionschange", this.updateUnits);
}
override dispose() {
this.viewer.canvas.removeEventListener("pointerdown", this.onPointerDown);
this.viewer.canvas.removeEventListener("pointermove", this.onPointerMove);
this.viewer.canvas.removeEventListener("pointerup", this.onPointerUp);
this.viewer.canvas.removeEventListener("pointercancel", this.onPointerCancel);
this.viewer.canvas.removeEventListener("pointerleave", this.onPointerLeave);
this.viewer.removeEventListener("render", this.renderOverlay);
this.viewer.removeEventListener("hide", this.updateObjects);
this.viewer.removeEventListener("isolate", this.updateObjects);
this.viewer.removeEventListener("show", this.updateObjects);
this.viewer.removeEventListener("showall", this.updateObjects);
this.viewer.removeEventListener("changecameramode", this.updateSnapperCamera);
this.viewer.removeEventListener("optionschange", this.updateUnits);
this.objects.length = 0;
this.overlay.detach();
this.overlay.dispose();
super.dispose();
}
onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return;
const mouse = this.snapper.getMousePosition(event, _downPoint);
this.line.startPoint = this.snapper.getSnapPoint(mouse, this.objects);
this.line.render();
this.viewer.canvas.setPointerCapture(event.pointerId);
this.orbit.enabled = !this.line.startPoint;
};
onPointerMove = (event: PointerEvent) => {
if (this.orbit.enabled && this.orbit.state !== -1) return;
const mouse = this.snapper.getMousePosition(event, _downPoint);
const snapPoint = this.snapper.getSnapPoint(mouse, this.objects);
if (snapPoint && this.line.endPoint && snapPoint.equals(this.line.endPoint)) return;
this.line.endPoint = snapPoint;
this.line.render();
if (this.line.startPoint) this.changed = true; // <- to prevent context menu
};
onPointerUp = (event: PointerEvent) => {
if (this.line.startPoint && this.line.endPoint && this.line.getDistance() > 0) {
this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
this.overlay.addLine(this.line);
} else {
this.line.startPoint = undefined;
this.line.endPoint = undefined;
this.line.render();
}
this.viewer.canvas.releasePointerCapture(event.pointerId);
this.orbit.enabled = true;
};
onPointerCancel = (event: PointerEvent) => {
this.viewer.canvas.dispatchEvent(new PointerEvent("pointerup", event));
};
onPointerLeave = () => {
this.line.endPoint = undefined;
this.line.render();
};
clearOverlay = () => {
this.overlay.clear();
this.line = new MeasureLine(this.overlay, this.scale, this.units, this.precision);
this.overlay.addLine(this.line);
};
renderOverlay = () => {
this.overlay.render();
};
updateObjects = () => {
this.objects.length = 0;
this.viewer.models.forEach((model) => {
model.getVisibleObjects().forEach((object) => this.objects.push(object));
});
};
updateSnapperCamera = () => {
this.snapper.camera = this.viewer.camera;
this.overlay.camera = this.viewer.camera;
};
updateUnits = () => {
const model = this.viewer.models[0];
const units = this.viewer.options.rulerUnit ?? "Default";
const precision = this.viewer.options.rulerPrecision ?? "Default";
if (units === "Default") {
this.scale = model.getUnitScale();
this.units = model.getUnitString();
} else {
this.scale = convertUnits(model.getUnits(), units, 1);
this.units = units;
}
if (precision === "Default") {
this.precision = model.getPrecision();
} else {
this.precision = precision;
}
this.overlay.updateLineUnits(this.scale, this.units, this.precision);
};
}
class MeasureOverlay {
public camera: Camera;
public canvas: HTMLCanvasElement;
public container: HTMLElement;
public lines: MeasureLine[] = [];
public projector: MeasureProjector;
private resizeObserver: ResizeObserver;
constructor(camera: Camera, canvas: HTMLCanvasElement) {
this.camera = camera;
this.canvas = canvas;
this.projector = new MeasureProjector(camera, canvas);
this.resizeObserver = new ResizeObserver(this.resizeContainer);
}
dispose() {
this.clear();
}
attach() {
this.container = document.createElement("div");
this.container.id = "measure-container";
this.container.style.position = "absolute";
this.container.style.outline = "none";
this.container.style.pointerEvents = "none";
this.container.style.overflow = "hidden";
if (!this.canvas.parentElement) return;
this.canvas.parentElement.appendChild(this.container);
this.resizeObserver.observe(this.canvas);
}
detach() {
this.resizeObserver.disconnect();
this.container.remove();
this.container = undefined;
}
clear() {
this.lines.forEach((line) => line.dispose());
this.lines.length = 0;
}
render() {
this.projector.setFromCamera(this.camera);
this.lines.forEach((line) => line.render());
}
update() {
this.lines.forEach((line) => line.update());
}
addLine(line: MeasureLine) {
this.lines.push(line);
}
removeLine(line: MeasureLine) {
this.lines = this.lines.filter((x) => x !== line);
}
updateLineUnits(scale: number, units: string, precision: any) {
this.lines.forEach((line) => {
line.scale = scale;
line.units = units;
line.precision = precision;
});
}
resizeContainer = () => {
const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = this.canvas;
if (!offsetWidth || !offsetHeight) return; // <- invisible canvas, or canvas with parent removed
this.container.style.left = `${offsetLeft}px`;
this.container.style.top = `${offsetTop}px`;
this.container.style.width = `${offsetWidth}px`;
this.container.style.height = `${offsetHeight}px`;
};
}
const _middlePoint = new Vector3();
class MeasureLine {
private overlay: MeasureOverlay;
private elementStartPoint: HTMLElement;
private elementEndPoint: HTMLElement;
private elementLine: HTMLElement;
private elementLabel: HTMLElement;
public startPoint: Vector3;
public endPoint: Vector3;
public id = MathUtils.generateUUID();
public scale: number;
public units: string;
public precision: any;
public size = 10.0;
public lineWidth = 2;
public style = {
border: "2px solid #FFFFFF",
background: "#009bff",
boxShadow: "0 0 10px rgba(0,0,0,0.5)",
color: "white",
font: "1rem system-ui",
};
constructor(overlay: MeasureOverlay, scale: number, units: string, precision: any) {
this.overlay = overlay;
this.scale = scale;
this.units = units;
this.precision = precision;
this.elementStartPoint = overlay.container.appendChild(document.createElement("div"));
this.elementEndPoint = overlay.container.appendChild(document.createElement("div"));
this.elementLine = overlay.container.appendChild(document.createElement("div"));
this.elementLabel = overlay.container.appendChild(document.createElement("div"));
this.update();
}
dispose() {
this.elementStartPoint.remove();
this.elementEndPoint.remove();
this.elementLine.remove();
this.elementLabel.remove();
this.elementStartPoint = undefined;
this.elementEndPoint = undefined;
this.elementLine = undefined;
this.elementLabel = undefined;
}
render() {
const projector = this.overlay.projector;
if (this.startPoint) {
const { point, visible } = projector.projectPoint(this.startPoint);
this.elementStartPoint.style.display = visible ? "block" : "none";
this.elementStartPoint.style.left = `${point.x}px`;
this.elementStartPoint.style.top = `${point.y}px`;
} else {
this.elementStartPoint.style.display = "none";
}
if (this.endPoint) {
const { point, visible } = projector.projectPoint(this.endPoint);
this.elementEndPoint.style.display = visible ? "block" : "none";
this.elementEndPoint.style.left = `${point.x}px`;
this.elementEndPoint.style.top = `${point.y}px`;
} else {
this.elementEndPoint.style.display = "none";
}
if (this.startPoint && this.endPoint) {
const { point1, point2, visible } = projector.projectLine(this.startPoint, this.endPoint);
point2.sub(point1);
const angle = point2.angle();
const width = point2.length();
this.elementLine.style.display = visible ? "block" : "none";
this.elementLine.style.left = `${point1.x}px`;
this.elementLine.style.top = `${point1.y}px`;
this.elementLine.style.width = `${width}px`;
this.elementLine.style.transform = `translate(0px, ${-this.lineWidth / 2}px) rotate(${angle}rad)`;
} else {
this.elementLine.style.display = "none";
}
if (this.startPoint && this.endPoint) {
_middlePoint.lerpVectors(this.startPoint, this.endPoint, 0.5);
const { point, visible } = projector.projectPoint(_middlePoint);
const distance = this.getDistance();
this.elementLabel.style.display = visible && distance > 0 ? "block" : "none";
this.elementLabel.style.left = `${point.x}px`;
this.elementLabel.style.top = `${point.y}px`;
this.elementLabel.innerHTML = formatDistance(distance, this.units, this.precision);
} else {
this.elementLabel.style.display = "none";
}
}
update() {
this.elementStartPoint.id = `markup-dot-start-${this.id}`;
this.elementStartPoint.style.position = "absolute";
this.elementStartPoint.style.zIndex = "2";
this.elementStartPoint.style.width = `${this.size}px`;
this.elementStartPoint.style.height = `${this.size}px`;
this.elementStartPoint.style.border = this.style.border;
this.elementStartPoint.style.borderRadius = `${this.size}px`;
this.elementStartPoint.style.background = this.style.background;
this.elementStartPoint.style.boxShadow = this.style.boxShadow;
this.elementStartPoint.style.transform = "translate(-50%, -50%)";
this.elementEndPoint.id = `markup-dot-end-${this.id}`;
this.elementEndPoint.style.position = "absolute";
this.elementEndPoint.style.zIndex = "2";
this.elementEndPoint.style.width = `${this.size}px`;
this.elementEndPoint.style.height = `${this.size}px`;
this.elementEndPoint.style.border = this.style.border;
this.elementEndPoint.style.borderRadius = `${this.size}px`;
this.elementEndPoint.style.background = this.style.background;
this.elementEndPoint.style.boxShadow = this.style.boxShadow;
this.elementEndPoint.style.transform = "translate(-50%, -50%)";
this.elementLine.id = `markup-line-${this.id}`;
this.elementLine.style.position = "absolute";
this.elementLine.style.zIndex = "1";
this.elementLine.style.height = `${this.lineWidth}px`;
this.elementLine.style.background = this.style.background;
this.elementLine.style.boxShadow = this.style.boxShadow;
this.elementLine.style.transformOrigin = `0px ${this.lineWidth / 2}px`;
this.elementLabel.id = `markup-label-${this.id}`;
this.elementLabel.style.position = "absolute";
this.elementLabel.style.zIndex = "3";
this.elementLabel.style.padding = "2px";
this.elementLabel.style.paddingInline = "5px";
this.elementLabel.style.borderRadius = "5px";
this.elementLabel.style.background = this.style.background;
this.elementLabel.style.boxShadow = this.style.boxShadow;
this.elementLabel.style.color = this.style.color;
this.elementLabel.style.font = this.style.font;
this.elementLabel.style.transform = "translate(-50%, -50%)";
}
getDistance(): number {
return this.startPoint.distanceTo(this.endPoint) / this.scale;
}
}
let _widthHalf: number;
let _heightHalf: number;
const _viewMatrix = new Matrix4();
const _viewProjectionMatrix = new Matrix4();
const _vector = new Vector3();
const _vector1 = new Vector4();
const _vector2 = new Vector4();
const point = new Vector2();
const point1 = new Vector2();
const point2 = new Vector2();
class MeasureProjector {
public camera: Camera;
private canvas: HTMLElement;
constructor(camera: Camera, canvas: HTMLCanvasElement) {
this.camera = camera;
this.canvas = canvas;
}
setFromCamera(camera: Camera) {
this.camera = camera;
this.updateProjectionMatrix();
}
updateProjectionMatrix() {
const rect = this.canvas.getBoundingClientRect();
_widthHalf = rect.width / 2;
_heightHalf = rect.height / 2;
_viewMatrix.copy(this.camera.matrixWorldInverse);
_viewProjectionMatrix.multiplyMatrices(this.camera.projectionMatrix, _viewMatrix);
}
projectPoint(p: Vector3) {
_vector.copy(p).applyMatrix4(_viewProjectionMatrix);
const visible = _vector.z >= -1 && _vector.z <= 1;
point.x = (_vector.x + 1) * _widthHalf;
point.y = (-_vector.y + 1) * _heightHalf;
return { point, visible };
}
projectLine(p1: Vector3, p2: Vector3) {
let visible: boolean;
_vector1.copy(p1 as any).applyMatrix4(_viewProjectionMatrix);
_vector2.copy(p2 as any).applyMatrix4(_viewProjectionMatrix);
// see three/examples/jsm/renderers/Projector.js/clipLine for more details
const bc1near = _vector1.z + _vector1.w;
const bc2near = _vector2.z + _vector2.w;
const bc1far = -_vector1.z + _vector1.w;
const bc2far = -_vector2.z + _vector2.w;
if (bc1near >= 0 && bc2near >= 0 && bc1far >= 0 && bc2far >= 0) visible = true;
else if ((bc1near < 0 && bc2near < 0) || (bc1far < 0 && bc2far < 0)) visible = false;
else {
let alpha1 = 0;
let alpha2 = 1;
if (bc1near < 0) alpha1 = Math.max(alpha1, bc1near / (bc1near - bc2near));
else if (bc2near < 0) alpha2 = Math.min(alpha2, bc1near / (bc1near - bc2near));
if (bc1far < 0) alpha1 = Math.max(alpha1, bc1far / (bc1far - bc2far));
else if (bc2far < 0) alpha2 = Math.min(alpha2, bc1far / (bc1far - bc2far));
visible = alpha2 >= alpha1;
if (visible) {
_vector1.lerp(_vector2, alpha1);
_vector2.lerp(_vector1, 1 - alpha2);
}
}
_vector1.multiplyScalar(1 / _vector1.w);
_vector2.multiplyScalar(1 / _vector2.w);
point1.x = (_vector1.x + 1) * _widthHalf;
point1.y = (-_vector1.y + 1) * _heightHalf;
point2.x = (_vector2.x + 1) * _widthHalf;
point2.y = (-_vector2.y + 1) * _heightHalf;
return { point1, point2, visible };
}
}