UNPKG

@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
/////////////////////////////////////////////////////////////////////////////// // 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 }; } }