diff --git a/package.json b/package.json index c39f161d..7058f3ed 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/three": "^0.143.0", "deepmerge": "^4.2.2", "is-plain-object": "^5.0.0", + "pdfjs-dist": "^4.6.82", "ste-events": "^3.0.7", "ste-signals": "^3.0.9", "ste-simple-events": "^3.0.7", diff --git a/src/main.ts b/src/main.ts index a2184b89..d89ce907 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ -import { Viewer, open, THREE, getViewerSettingsFromUrl } from '.' +import { Viewer, open, getViewerSettingsFromUrl } from '.' +import * as THREE from 'three' // Parse URL for source file const params = new URLSearchParams(window.location.search) @@ -36,6 +37,10 @@ async function load (url: string | ArrayBuffer) { viewer.camera.save() viewer.gizmos.loading.visible = false + const plan = await viewer.gizmos.plans.addPlan('https://vimdevelopment01storage.blob.core.windows.net/samples/floor_plan.png') + const plan2 = await viewer.gizmos.plans.addPlan('https://vimdevelopment01storage.blob.core.windows.net/samples/floor_plan.pdf') + plan.color = new THREE.Color(0x00ff00) + // Useful for debuging in console. globalThis.THREE = THREE globalThis.vim = vim diff --git a/src/vim-loader/colorAttributes.ts b/src/vim-loader/colorAttributes.ts index 2a2dd894..dc48fb9f 100644 --- a/src/vim-loader/colorAttributes.ts +++ b/src/vim-loader/colorAttributes.ts @@ -3,7 +3,7 @@ */ import * as THREE from 'three' -import { MergedSubmesh } from './mesh' +import { MergedSubmesh, SimpleMesh } from './mesh' import { Vim } from './vim' import { InsertableSubmesh } from './progressive/insertableSubmesh' import { AttributeTarget } from './objectAttributes' @@ -101,6 +101,9 @@ export class ColorAttribute { this.resetMergedInsertableColor(sub) return } + if (sub instanceof SimpleMesh) { + return + } const colors = sub.three.geometry.getAttribute( 'color' diff --git a/src/vim-loader/mesh.ts b/src/vim-loader/mesh.ts index aa07e295..e21a80f6 100644 --- a/src/vim-loader/mesh.ts +++ b/src/vim-loader/mesh.ts @@ -115,7 +115,7 @@ export class Mesh { * Returns submesh for given index. */ getSubMesh (index: number) { - return new StandardSubmesh(this, index) + return new SealedSubmesh(this, index) } /** @@ -126,7 +126,7 @@ export class Mesh { throw new Error('Can only be called when mesh.merged = true') } const index = this.binarySearch(this.submeshes, faceIndex * 3) - return new StandardSubmesh(this, index) + return new SealedSubmesh(this, index) } /** @@ -134,7 +134,7 @@ export class Mesh { * @returns Returns all submeshes */ getSubmeshes () { - return this.instances.map((s, i) => new StandardSubmesh(this, i)) + return this.instances.map((s, i) => new SealedSubmesh(this, i)) } private binarySearch (array: number[], element: number) { @@ -164,7 +164,8 @@ export class Mesh { } // eslint-disable-next-line no-use-before-define -export type MergedSubmesh = StandardSubmesh | InsertableSubmesh +export type MergedSubmesh = SealedSubmesh | InsertableSubmesh | SimpleMesh +// eslint-disable-next-line no-use-before-define export type Submesh = MergedSubmesh | InstancedSubmesh export class SimpleInstanceSubmesh { @@ -179,7 +180,21 @@ export class SimpleInstanceSubmesh { } } -export class StandardSubmesh { +export class SimpleMesh { + mesh: THREE.Mesh + get three () { return this.mesh } + readonly index : number = 0 + readonly merged = true + readonly meshStart = 0 + readonly meshEnd : number + + constructor (mesh: THREE.Mesh) { + this.mesh = mesh + this.meshEnd = mesh.geometry.index!.count + } +} + +export class SealedSubmesh { mesh: Mesh index: number diff --git a/src/vim-loader/objectAttributes.ts b/src/vim-loader/objectAttributes.ts index 32cc8d63..bc6d24bc 100644 --- a/src/vim-loader/objectAttributes.ts +++ b/src/vim-loader/objectAttributes.ts @@ -3,9 +3,9 @@ */ import * as THREE from 'three' -import { MergedSubmesh, SimpleInstanceSubmesh, Submesh } from './mesh' +import { MergedSubmesh, SimpleInstanceSubmesh, SimpleMesh, Submesh } from './mesh' -export type AttributeTarget = Submesh | SimpleInstanceSubmesh +export type AttributeTarget = Submesh | SimpleInstanceSubmesh | SimpleMesh export class ObjectAttribute { readonly vertexAttribute: string @@ -51,7 +51,7 @@ export class ObjectAttribute { for (let m = 0; m < this._meshes.length; m++) { const sub = this._meshes[m] if (sub.merged) { - this.applyMerged(sub as MergedSubmesh, number) + this.applyMerged(sub, number) } else { this.applyInstanced(sub, number) } diff --git a/src/vim-loader/progressive/insertableSubmesh.ts b/src/vim-loader/progressive/insertableSubmesh.ts index af90c4de..3745c4a7 100644 --- a/src/vim-loader/progressive/insertableSubmesh.ts +++ b/src/vim-loader/progressive/insertableSubmesh.ts @@ -9,6 +9,7 @@ export class InsertableSubmesh { mesh: InsertableMesh index: number private _colors: Float32Array + readonly merged = true constructor (mesh: InsertableMesh, index: number) { this.mesh = mesh @@ -26,13 +27,6 @@ export class InsertableSubmesh { return this.mesh.mesh } - /** - * True if parent mesh is merged. - */ - get merged () { - return true - } - private get submesh () { return this.mesh.geometry.submeshes[this.index] } diff --git a/src/vim-loader/progressive/instancedSubmesh.ts b/src/vim-loader/progressive/instancedSubmesh.ts index b8342244..6aa30177 100644 --- a/src/vim-loader/progressive/instancedSubmesh.ts +++ b/src/vim-loader/progressive/instancedSubmesh.ts @@ -8,6 +8,7 @@ import { InstancedMesh } from './instancedMesh' export class InstancedSubmesh { mesh: InstancedMesh index: number + readonly merged = false constructor (mesh: InstancedMesh, index: number) { this.mesh = mesh @@ -25,13 +26,6 @@ export class InstancedSubmesh { return this.mesh.mesh } - /** - * True if parent mesh is merged. - */ - get merged () { - return false - } - /** * Returns vim instance associated with this submesh. */ diff --git a/src/vim-webgl-viewer/camera/camera.ts b/src/vim-webgl-viewer/camera/camera.ts index 2e91b377..4de8c174 100644 --- a/src/vim-webgl-viewer/camera/camera.ts +++ b/src/vim-webgl-viewer/camera/camera.ts @@ -115,6 +115,11 @@ export interface ICamera { */ stop () : void + /** + * Immediately stops the camera movement. And prevents any further movement until set to false. + */ + freeze: boolean + /** * The target at which the camera is looking at and around which it rotates. */ @@ -152,6 +157,7 @@ export class Camera implements ICamera { private _inputVelocity = new THREE.Vector3() private _velocity = new THREE.Vector3() private _speed: number = 0 + private _freeze: boolean = false // orbit private _orthographic: boolean = false @@ -204,13 +210,25 @@ export class Camera implements ICamera { /** Ignore movement permissions when true */ private _force: boolean = false + get freeze () { + return this._freeze + } + + set freeze (value: boolean) { + this._freeze = value + } + /** * Represents allowed movement along each axis using a Vector3 object. * Each component of the Vector3 should be either 0 or 1 to enable/disable movement along the corresponding axis. */ private _allowedMovement = new THREE.Vector3(1, 1, 1) get allowedMovement () { - return this._force ? new THREE.Vector3(1, 1, 1) : this._allowedMovement + return this._force + ? new THREE.Vector3(1, 1, 1) + : this._freeze + ? new THREE.Vector3(0, 0, 0) + : this._allowedMovement } set allowedMovement (axes: THREE.Vector3) { @@ -225,7 +243,11 @@ export class Camera implements ICamera { * Each component of the Vector2 should be either 0 or 1 to enable/disable rotation around the corresponding axis. */ get allowedRotation () { - return this._force ? new THREE.Vector2(1, 1) : this._allowedRotation + return this._force + ? new THREE.Vector2(1, 1) + : this._freeze + ? new THREE.Vector2(0, 0) + : this._allowedRotation } set allowedRotation (axes: THREE.Vector2) { diff --git a/src/vim-webgl-viewer/gizmos/gizmos.ts b/src/vim-webgl-viewer/gizmos/gizmos.ts index 38b8e1e8..dc701020 100644 --- a/src/vim-webgl-viewer/gizmos/gizmos.ts +++ b/src/vim-webgl-viewer/gizmos/gizmos.ts @@ -7,6 +7,7 @@ import { IMeasure, Measure } from './measure/measure' import { SectionBox } from './sectionBox/sectionBox' import { GizmoMarkers } from './markers/gizmoMarkers' import { Camera } from '../camera/camera' +import { Plans2D } from './plans2D' /** * Represents a collection of gizmos used for various visualization and interaction purposes within the viewer. @@ -53,6 +54,8 @@ export class Gizmos { */ readonly markers: GizmoMarkers + readonly plans : Plans2D + constructor (viewer: Viewer, camera : Camera) { this.viewer = viewer this._measure = new Measure(viewer) @@ -67,6 +70,7 @@ export class Gizmos { this.rectangle = new GizmoRectangle(viewer) this.axes = new GizmoAxes(camera, viewer.viewport, viewer.settings.axes) this.markers = new GizmoMarkers(viewer) + this.plans = new Plans2D(viewer) viewer.viewport.canvas.parentElement?.prepend(this.axes.canvas) } diff --git a/src/vim-webgl-viewer/gizmos/plan2D.ts b/src/vim-webgl-viewer/gizmos/plan2D.ts new file mode 100644 index 00000000..2b06ab00 --- /dev/null +++ b/src/vim-webgl-viewer/gizmos/plan2D.ts @@ -0,0 +1,250 @@ +import { Vim } from '../../vim-loader/vim' +import { Viewer } from '../viewer' +import * as THREE from 'three' +import { ObjectAttribute } from '../../vim-loader/objectAttributes' +import { SimpleMesh } from '../../vim-loader/mesh' +import { TransformControls } from 'three/examples/jsm/controls/TransformControls' +import { createTransparent } from '../../vim-loader/materials/standardMaterial' + +/** + * Class representing a 2D plan in a 3D viewer. + */ +export class Plan2D { + /** The type of the object, always 'Plan2D'. */ + public readonly type = 'Plan2D' + + private readonly _viewer: Viewer + private readonly _mesh: SimpleMesh + private readonly _frameMaterial: THREE.MeshPhongMaterial + private readonly _texMaterial: THREE.MeshBasicMaterial + private readonly _texture: THREE.Texture + private readonly _geometry: THREE.PlaneGeometry + private readonly _gizmo: TransformControls + + private _outlineAttribute: ObjectAttribute + private _subs: (() => void)[] = [] + private _focused: boolean = false + + /** The vim object from which this object came from. */ + vim: Vim | undefined + + /** + * Creates an instance of Plan2D. + * @param viewer - The viewer in which the Plan2D will be displayed. + * @param canvas - The canvas containing the 2D plan image. + */ + constructor (viewer: Viewer, canvas: HTMLCanvasElement) { + this._viewer = viewer + + this._texture = new THREE.CanvasTexture(canvas) + + // Create a plane geometry (width and height based on canvas dimensions) + this._geometry = new THREE.PlaneGeometry(100, 100) + + this._texMaterial = new THREE.MeshBasicMaterial({ map: this._texture }) + this._texMaterial.side = THREE.DoubleSide + this._frameMaterial = createTransparent().material + this._frameMaterial.opacity = 0 + + const frameMesh = new THREE.Mesh(this._geometry, this._frameMaterial) + frameMesh.scale.set(canvas.width, canvas.height, 1).multiplyScalar(0.001) + const texMesh = new THREE.Mesh(this._geometry, this._texMaterial) + + frameMesh.add(texMesh) + frameMesh.userData.vim = this + + // Set variables + this._mesh = new SimpleMesh(frameMesh) + this._gizmo = this.addGizmo(this.mesh) + + const array = [this._mesh] + this._outlineAttribute = new ObjectAttribute( + false, + 'selected', + 'selected', + array, + (v) => (v ? 1 : 0) + ) + } + + /** + * Initializes the transform controls (gizmo) for the Plan2D. + * @param object - The object to which the gizmo will be attached. + */ + private addGizmo (object: THREE.Object3D): TransformControls { + // Create and add the control to your scene + const gizmo = new TransformControls(this._viewer.camera.three, this._viewer.viewport.canvas) + gizmo.attach(object) // Attach to the plane mesh + + // Hide the gizmo when the object is not selected + gizmo.visible = false + gizmo.enabled = false + this._subs.push( + this._viewer.selection.onValueChanged.sub(() => { + gizmo.visible = this.selected + gizmo.enabled = this.selected + }) + ) + this._viewer.renderer.add(gizmo) + + // Freeze the camera when dragging the gizmo + const onChange = (): void => { + this._viewer.camera.freeze = gizmo.dragging + this._viewer.renderer.needsUpdate = true + } + gizmo.addEventListener('change', onChange) + this._subs.push(() => gizmo.removeEventListener('change', onChange)) + + // Enable interaction modes + gizmo.setMode('translate') // Can be 'translate', 'rotate', or 'scale' + + // Optional: Add key binding to toggle between translation and rotation + const onKey = (event: KeyboardEvent): void => { + if (!this.selected) return + switch (event.key) { + case 't': // 't' for translate + gizmo.setMode('translate') + break + case 'r': // 'r' for rotate + gizmo.setMode('rotate') + break + case 's': // 's' for scale + gizmo.setMode('scale') + break + } + } + window.addEventListener('keydown', onKey) + this._subs.push(() => window.removeEventListener('keydown', onKey)) + + return gizmo + } + + /** + * Gets the Three.js mesh associated with this Plan2D. + */ + get mesh (): THREE.Mesh { + return this._mesh.mesh + } + + /** + * Checks if the Plan2D is currently selected in the viewer. + */ + get selected (): boolean { + return this._viewer.selection.has(this) + } + + /** + * Gets the position of the Plan2D in the 3D scene. + */ + get position (): THREE.Vector3 { + return this.mesh.position + } + + /** + * Sets the position of the Plan2D in the 3D scene. + */ + set position (value: THREE.Vector3) { + this.mesh.position.copy(value) + } + + /** + * Gets whether the Plan2D has an outline applied. + */ + get outline (): boolean { + return this._outlineAttribute.value + } + + /** + * Sets whether the Plan2D should have an outline applied. + */ + set outline (value: boolean) { + this._outlineAttribute.apply(value) + this._viewer.renderer.needsUpdate = true + } + + /** + * Gets whether the Plan2D is focused. + */ + get focused (): boolean { + return this._focused + } + + /** + * Sets the focused state of the Plan2D. + */ + set focused (value: boolean) { + this._focused = value + this._frameMaterial.opacity = value ? 0.1 : 0 + } + + /** + * Gets the visibility of the Plan2D. + */ + get visible (): boolean { + return this.mesh.visible + } + + /** + * Sets the visibility of the Plan2D. + */ + set visible (value: boolean) { + this.mesh.visible = value + this._viewer.renderer.needsUpdate = true + } + + /** + * Gets the color of the Plan2D frame. + */ + get color (): THREE.Color { + return this._frameMaterial.color + } + + /** + * Sets the color of the Plan2D frame. + */ + set color (color: THREE.Color) { + this._frameMaterial.color.copy(color) + this._viewer.renderer.needsUpdate = true + } + + /** + * Gets the size of the Plan2D. + */ + get size (): THREE.Vector2 { + return new THREE.Vector2(this.mesh.scale.x, this.mesh.scale.y) + } + + /** + * Sets the size of the Plan2D. + */ + set size (value: THREE.Vector2) { + this.mesh.scale.set(value.x, value.y, 1) + this.mesh.geometry.computeBoundingBox() + this._viewer.renderer.needsUpdate = true + } + + /** + * Retrieves the bounding box of the Plan2D, computing it if necessary. + */ + getBoundingBox (): THREE.Box3 { + if (!this.mesh.geometry.boundingBox) { + this.mesh.geometry.computeBoundingBox() + } + return this.mesh.geometry.boundingBox! + } + + /** + * Disposes of the Plan2D, cleaning up resources. + */ + dispose (): void { + this._viewer.renderer.remove(this.mesh) + this._viewer.renderer.remove(this._gizmo) + this._frameMaterial.dispose() + this._texMaterial.dispose() + this._texture.dispose() + this._geometry.dispose() + this._subs.forEach((s) => s()) + this._subs = [] + this._viewer.renderer.needsUpdate = true + } +} diff --git a/src/vim-webgl-viewer/gizmos/plans2D.ts b/src/vim-webgl-viewer/gizmos/plans2D.ts new file mode 100644 index 00000000..b5eb500c --- /dev/null +++ b/src/vim-webgl-viewer/gizmos/plans2D.ts @@ -0,0 +1,97 @@ +import { Viewer } from '../viewer' +import * as PDF from 'pdfjs-dist' +import { Plan2D } from './plan2D' + +PDF.GlobalWorkerOptions.workerSrc = 'node_modules/pdfjs-dist/build/pdf.worker.mjs' + +export class Plans2D { + private _viewer: Viewer + + constructor (viewer: Viewer) { + this._viewer = viewer + } + + /** + * Adds a plan to the viewer from a given URL, which can be a PDF or an image file. + * @param url - The URL of the PDF or image file. + * @returns A promise that resolves to the created Plan2D instance. + */ + async addPlan (url: string): Promise { + let canvas: HTMLCanvasElement + + // Determine the file type based on the file extension + const extension = url.split('.').pop()?.toLowerCase() + if (extension === 'pdf') { + canvas = await loadPdfAsImage(url) + } else if (['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(extension!)) { + canvas = await loadImageAsCanvas(url) + } else { + throw new Error('Unsupported file type') + } + + const plan = new Plan2D(this._viewer, canvas) + this._viewer.renderer.add(plan.mesh) + return plan + } +} + +/** + * Loads a PDF file from a URL and renders it onto a canvas. + * @param pdfUrl - The URL of the PDF file. + * @returns A promise that resolves to the canvas containing the rendered PDF page. + */ +async function loadPdfAsImage (pdfUrl: string): Promise { + console.log('Loading PDF...') + let page: PDF.PDFPageProxy + try { + const loadingTask = PDF.getDocument(pdfUrl) + loadingTask.onProgress = (progress) => console.log('Progress:', progress) + const doc = await loadingTask.promise + console.log('Done loading PDF...') + page = await doc.getPage(1) // Load the first page + } catch (e) { + console.log('Error loading PDF:', e) + return Promise.reject(e) + } + + const viewport = page.getViewport({ scale: 1.5 }) + + // Create a canvas to render the PDF page + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d')! + canvas.width = viewport.width + canvas.height = viewport.height + + const renderContext = { + canvasContext: context, + viewport + } + + await page.render(renderContext).promise + return canvas // Return the rendered canvas +} + +/** + * Loads an image from a URL and draws it onto a canvas. + * @param imageUrl - The URL of the image file. + * @returns A promise that resolves to the canvas containing the drawn image. + */ +async function loadImageAsCanvas (imageUrl: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.crossOrigin = 'anonymous' // Use this if loading images from a different origin + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + const context = canvas.getContext('2d')! + context.drawImage(image, 0, 0) + resolve(canvas) + } + image.onerror = (e) => { + console.log('Error loading image:', e) + reject(e) + } + image.src = imageUrl + }) +} diff --git a/src/vim-webgl-viewer/raycaster.ts b/src/vim-webgl-viewer/raycaster.ts index 93d632fb..6b4d2cd9 100644 --- a/src/vim-webgl-viewer/raycaster.ts +++ b/src/vim-webgl-viewer/raycaster.ts @@ -11,6 +11,7 @@ import { Camera } from './camera/camera' import { Renderer } from './rendering/renderer' import { GizmoMarker } from './gizmos/markers/gizmoMarker' import { GizmoMarkers } from './gizmos/markers/gizmoMarkers' +import { Plan2D } from './gizmos/plan2D' /** * Type alias for THREE intersection array @@ -26,12 +27,14 @@ export type ActionModifier = 'none' | 'shift' | 'ctrl' * Highlevel aggregate of information about a raycast result */ export class RaycastResult { - object: Object3D | GizmoMarker | undefined + object: Object3D | GizmoMarker | Plan2D | undefined intersections: ThreeIntersectionList firstHit: THREE.Intersection | undefined constructor (intersections: ThreeIntersectionList) { this.intersections = intersections + + // Markers have priority over other objects const [markerHit, marker] = this.GetFirstMarkerHit(intersections) if (marker) { this.object = marker @@ -39,15 +42,22 @@ export class RaycastResult { return } - const [objectHit, obj] = this.GetFirstVimHit(intersections) + const [objectHit, obj] = this.GetFirstHit(intersections) this.firstHit = objectHit this.object = obj } - private GetFirstVimHit ( + private GetFirstHit ( intersections: ThreeIntersectionList - ): [THREE.Intersection, Object3D] | [] { + ): [THREE.Intersection, Object3D | Plan2D] | [] { for (let i = 0; i < intersections.length; i++) { + // Check for Plan2D + const data = intersections[i].object.userData.vim + if (data instanceof Plan2D) { + return [intersections[i], data] + } + + // Check for visible vim object const obj = this.getVimObjectFromHit(intersections[i]) if (obj?.visible) return [intersections[i], obj] } diff --git a/src/vim-webgl-viewer/selection.ts b/src/vim-webgl-viewer/selection.ts index 43094895..7442a182 100644 --- a/src/vim-webgl-viewer/selection.ts +++ b/src/vim-webgl-viewer/selection.ts @@ -7,8 +7,9 @@ import { Vim, ViewerMaterials } from '..' import { SignalDispatcher } from 'ste-signals' import { GizmoMarker } from './gizmos/markers/gizmoMarker' import { Object3D } from '../vim-loader/object3D' +import { Plan2D } from './gizmos/plan2D' -export type SelectableObject = Object3D | GizmoMarker +export type SelectableObject = Object3D | GizmoMarker | Plan2D /** * Provides selection behaviour for the viewer