From e2bde20d168fef727f6a1fbbe0ba946353b4fccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Wed, 2 Apr 2025 17:49:36 +0200 Subject: [PATCH 1/3] refactor: add the HasClippingPlanes interface --- src/core/HasClippingPlanes.ts | 9 +++++++++ src/entities/Entity3D.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/core/HasClippingPlanes.ts diff --git a/src/core/HasClippingPlanes.ts b/src/core/HasClippingPlanes.ts new file mode 100644 index 0000000000..f9d4b0e096 --- /dev/null +++ b/src/core/HasClippingPlanes.ts @@ -0,0 +1,9 @@ +import type { Plane } from 'three'; + +/** + * Interface for objects that support clipping planes. + */ +export default interface HasClippingPlanes { + get clippingPlanes(): Plane[] | null; + set clippingPlanes(planes: Plane[] | null); +} diff --git a/src/entities/Entity3D.ts b/src/entities/Entity3D.ts index 9cd3c8c89d..84b4fbd38e 100644 --- a/src/entities/Entity3D.ts +++ b/src/entities/Entity3D.ts @@ -1,6 +1,7 @@ import { Box3, type Material, type Mesh, type Object3D, type Plane, type Vector2 } from 'three'; import type Context from '../core/Context'; +import type HasClippingPlanes from '../core/HasClippingPlanes'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; import type MemoryUsage from '../core/MemoryUsage'; import { type GetMemoryUsageContext } from '../core/MemoryUsage'; @@ -43,7 +44,12 @@ export interface Entity3DEventMap extends EntityEventMap { */ class Entity3D extends Entity - implements Pickable, MemoryUsage, RenderingContextHandler, HasDefaultPointOfView + implements + Pickable, + MemoryUsage, + RenderingContextHandler, + HasClippingPlanes, + HasDefaultPointOfView { readonly isMemoryUsage = true as const; readonly hasDefaultPointOfView = true as const; -- GitLab From 81ed73ea92f767556796cedc23c896b3f47852a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Guimmara?= Date: Thu, 3 Apr 2025 16:41:31 +0200 Subject: [PATCH 2/3] refactor(examples): add bindButtonGroup() --- examples/widgets/bindButtonGroup.js | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 examples/widgets/bindButtonGroup.js diff --git a/examples/widgets/bindButtonGroup.js b/examples/widgets/bindButtonGroup.js new file mode 100644 index 0000000000..0acd9037fa --- /dev/null +++ b/examples/widgets/bindButtonGroup.js @@ -0,0 +1,49 @@ +/** + * Binds a bootstrap button group. + * @param {string} id The id of the + + +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + diff --git a/examples/cross_section_tool.js b/examples/cross_section_tool.js new file mode 100644 index 0000000000..18853bca62 --- /dev/null +++ b/examples/cross_section_tool.js @@ -0,0 +1,419 @@ +import { AmbientLight, DirectionalLight, MathUtils, Vector2, Vector3 } from 'three'; +import { MapControls } from 'three/examples/jsm/controls/MapControls.js'; + +import XYZ from 'ol/source/XYZ.js'; + +import Instance from '@giro3d/giro3d/core/Instance.js'; +import Coordinates from '@giro3d/giro3d/core/geographic/Coordinates.js'; +import Extent from '@giro3d/giro3d/core/geographic/Extent.js'; +import ColorLayer from '@giro3d/giro3d/core/layer/ColorLayer.js'; +import ElevationLayer from '@giro3d/giro3d/core/layer/ElevationLayer.js'; +import Map from '@giro3d/giro3d/entities/Map.js'; +import { MapLightingMode } from '@giro3d/giro3d/entities/MapLightingOptions.js'; +import MapboxTerrainFormat from '@giro3d/giro3d/formats/MapboxTerrainFormat.js'; +import Inspector from '@giro3d/giro3d/gui/Inspector.js'; +import CrossSectionTool from '@giro3d/giro3d/interactions/CrossSectionTool.js'; +import TiledImageSource from '@giro3d/giro3d/sources/TiledImageSource.js'; + +import StatusBar from './widgets/StatusBar.js'; +import WmtsSource from '@giro3d/giro3d/sources/WmtsSource.js'; +import BilFormat from '@giro3d/giro3d/formats/BilFormat.js'; +import { bindButtonGroup } from './widgets/bindButtonGroup.js'; +import { bindToggle } from './widgets/bindToggle.js'; +import { bindButton } from './widgets/bindButton.js'; +import { bindNumberInput } from './widgets/bindNumberInput.js'; + +Instance.registerCRS( + 'IGNF:WGS84G', + 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]', +); + +// Chamonix Mont-Blanc coordinates +const poi = new Coordinates('EPSG:4326', 6.8697, 45.9231).as('EPSG:3857').toVector3(); + +const extentSize = 30_000; +const extent = Extent.fromCenterAndSize( + 'EPSG:3857', + { x: poi.x, y: poi.y }, + extentSize, + extentSize, +); + +const instance = new Instance({ + target: 'view', + crs: extent.crs, + backgroundColor: null, + renderer: { + // We will need to enable the stencil buffer to visualize the cross-section + stencil: true, + }, +}); + +const center = extent.centerAsVector3(); + +const directionalLight = new DirectionalLight('white', 3); +const ambientLight = new AmbientLight('white', 1); + +directionalLight.position.set(center.x - 5000, center.y - 2000, 10000); +directionalLight.target.position.copy(center); + +instance.add(directionalLight); +instance.add(directionalLight.target); +instance.add(ambientLight); + +directionalLight.updateMatrixWorld(true); +directionalLight.target.updateMatrixWorld(true); + +const mapbox = new Map({ + extent, + lighting: { + enabled: true, + mode: MapLightingMode.LightBased, + elevationLayersOnly: true, + }, + subdivisionThreshold: 1, + terrain: { + segments: 64, + enabled: true, + skirts: { + enabled: true, + depth: 0, + }, + }, + backgroundColor: 'beige', +}); + +instance.add(mapbox); + +const key = + 'pk.eyJ1IjoidG11Z3VldCIsImEiOiJjbGJ4dTNkOW0wYWx4M25ybWZ5YnpicHV6In0.KhDJ7W5N3d1z3ArrsDjX_A'; + +// Adds a XYZ elevation layer with MapBox terrain RGB tileset +const elevationLayer = new ElevationLayer({ + extent, + preloadImages: true, + resolutionFactor: 0.5, + minmax: { min: 5000, max: 9000 }, + source: new TiledImageSource({ + format: new MapboxTerrainFormat(), + source: new XYZ({ + url: `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${key}`, + projection: 'EPSG:3857', + crossOrigin: 'anonymous', + }), + }), +}); +mapbox.addLayer(elevationLayer); + +// Adds a XYZ color layer with MapBox satellite tileset +const satelliteLayer = new ColorLayer({ + extent, + resolutionFactor: 1.5, + preloadImages: true, + source: new TiledImageSource({ + source: new XYZ({ + url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${key}`, + projection: 'EPSG:3857', + crossOrigin: 'anonymous', + }), + }), +}); +mapbox.addLayer(satelliteLayer); + +const url = 'https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetCapabilities'; + +const ign = new Map({ + extent, + lighting: { + enabled: true, + mode: MapLightingMode.LightBased, + elevationLayersOnly: true, + }, + subdivisionThreshold: 1, + terrain: { + segments: 64, + enabled: true, + skirts: { + enabled: true, + depth: 0, + }, + }, + backgroundColor: 'blue', +}); + +instance.add(ign); + +// Let's build the elevation layer from the WMTS capabilities +WmtsSource.fromCapabilities(url, { + layer: 'ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES', + format: new BilFormat(), + noDataValue: -1000, +}) + .then(elevationWmts => { + ign.addLayer( + new ElevationLayer({ + name: 'elevation', + extent: ign.extent, + // We don't need the full resolution of terrain + // because we are not using any shading. This will save a lot of memory + // and make the terrain faster to load. + resolutionFactor: 0.25, + minmax: { min: 0, max: 5000 }, + noDataOptions: { + replaceNoData: false, + }, + source: elevationWmts, + }), + ); + }) + .catch(console.error); + +// Let's build the color layer from the WMTS capabilities +WmtsSource.fromCapabilities(url, { + layer: 'HR.ORTHOIMAGERY.ORTHOPHOTOS', +}) + .then(orthophotoWmts => { + ign.addLayer( + new ColorLayer({ + name: 'color', + extent: ign.extent, + source: orthophotoWmts, + }), + ); + }) + .catch(console.error); + +instance.view.camera.position.set(poi.x - extentSize - 15000, poi.y - extentSize - 15000, 35_000); +instance.view.camera.lookAt(new Vector3(poi.x, poi.y, 2000)); + +Inspector.attach('inspector', instance); + +const crossSectionTool = new CrossSectionTool({ instance }); + +crossSectionTool.setPlaneHelperSize(new Vector2(45_000, 9000)); + +crossSectionTool.add(mapbox); +crossSectionTool.add(ign, { negated: true }); +crossSectionTool.setPlane(poi, new Vector3(0, 1, 0)); + +crossSectionTool.setControlMode('translate'); + +// TODO +// const controls = new MapControls(instance.view.camera, instance.domElement); +// controls.target.set(poi.x, poi.y, 2000); +// instance.view.setControls(controls); + +instance.notifyChange(); + +StatusBar.bind(instance); + +const [setMode] = bindButtonGroup('button-transform', index => { + switch (index) { + case 0: + crossSectionTool.setControlMode('translate'); + break; + case 1: + crossSectionTool.setControlMode('rotate'); + break; + } +}); + +const [showMapbox] = bindToggle('show-mapbox', show => { + mapbox.visible = show; + instance.notifyChange(); +}); + +const [showIgn] = bindToggle('show-ign', show => { + ign.visible = show; + instance.notifyChange(); +}); + +const [showPlane] = bindToggle('show-plane', show => { + crossSectionTool.showPlaneHelper = show; +}); + +const [setAngleSnap] = bindNumberInput('angle-snap', v => { + if (v !== 0) { + crossSectionTool.controls.setRotationSnap(MathUtils.degToRad(v)); + } else { + crossSectionTool.controls.setRotationSnap(null); + } +}); + +const reset = () => { + showPlane(true); + showMapbox(true); + showIgn(true); + setMode(0); + setAngleSnap(0); + + crossSectionTool.setPlane(poi, new Vector3(0, 1, 0)); +}; + +bindButton('reset', () => { + reset(); +}); + +reset(); + +// import Instance from '@giro3d/giro3d/core/Instance'; +// import { +// AlwaysStencilFunc, +// AmbientLight, +// BackSide, +// Clock, +// DecrementWrapStencilOp, +// DirectionalLight, +// FrontSide, +// IncrementWrapStencilOp, +// KeepStencilOp, +// Mesh, +// MeshBasicMaterial, +// MeshStandardMaterial, +// NotEqualStencilFunc, +// Plane, +// PlaneGeometry, +// PlaneHelper, +// ReplaceStencilOp, +// Scene, +// SphereGeometry, +// Vector3, +// } from 'three'; +// import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +// const instance = new Instance({ +// crs: 'EPSG:3857', +// target: 'view', +// backgroundColor: null, +// renderer: { stencil: true }, +// }); + +// const position = new Vector3(0, 0, 0); + +// const light = new DirectionalLight(); + +// light.position.set(-5, -2, 4); +// light.target.lookAt(position); +// instance.add(light); +// instance.add(light.target); + +// const ambient = new AmbientLight('white', 0.3); + +// instance.add(ambient); + +// const controls = new OrbitControls(instance.view.camera, instance.domElement); +// controls.enableDamping = true; +// controls.dampingFactor = 0.25; + +// instance.view.setControls(controls); + +// instance.view.camera.position.set(-10, 0, 0); +// instance.view.camera.lookAt(position); + +// function createStencilMeshes(baseMesh) { +// const material = baseMesh.material; +// const geometry = baseMesh.geometry; + +// const stencilBaseMat = new MeshBasicMaterial(); +// stencilBaseMat.depthWrite = false; +// stencilBaseMat.depthTest = false; +// stencilBaseMat.colorWrite = false; +// stencilBaseMat.stencilWrite = true; +// stencilBaseMat.stencilFunc = AlwaysStencilFunc; + +// const stencilBack = stencilBaseMat.clone(); +// stencilBack.side = BackSide; +// stencilBack.clippingPlanes = material.clippingPlanes; +// stencilBack.stencilFail = IncrementWrapStencilOp; +// stencilBack.stencilZFail = IncrementWrapStencilOp; +// stencilBack.stencilZPass = IncrementWrapStencilOp; + +// const stencilFront = stencilBaseMat.clone(); +// stencilFront.side = FrontSide; +// stencilFront.clippingPlanes = material.clippingPlanes; +// stencilFront.stencilFail = DecrementWrapStencilOp; +// stencilFront.stencilZFail = DecrementWrapStencilOp; +// stencilFront.stencilZPass = DecrementWrapStencilOp; + +// const meshBack = new Mesh(geometry, stencilBack); +// const meshFront = new Mesh(geometry, stencilFront); + +// baseMesh.add(meshBack); +// baseMesh.add(meshFront); +// } + +// function makeSphere(plane) { +// const geometry = new SphereGeometry(1, 64, 32); + +// const renderable = new Mesh( +// geometry, +// new MeshStandardMaterial({ +// color: 'gray', +// side: FrontSide, +// clippingPlanes: [plane], +// }), +// ); + +// instance.add(renderable); + +// createStencilMeshes(renderable); + +// return renderable; +// } + +// let distance = 0; + +// const plane = new Plane(new Vector3(1, 0, 0), distance); + +// const sphere = makeSphere(plane); + +// const planeHelper = new PlaneHelper(plane, 5); + +// instance.add(planeHelper); + +// planeHelper.updateMatrixWorld(true); + +// const planeMesh = new Mesh( +// new PlaneGeometry(4, 4), +// new MeshStandardMaterial({ +// color: 0xe91e63, +// metalness: 0.1, +// side: BackSide, +// roughness: 0.75, + +// stencilWrite: true, +// stencilRef: 0, +// stencilFunc: NotEqualStencilFunc, +// stencilFail: ReplaceStencilOp, +// stencilZFail: ReplaceStencilOp, +// stencilZPass: ReplaceStencilOp, +// }), +// ); + +// planeMesh.renderOrder = 5; +// planeMesh.onAfterRender = renderer => renderer.clearStencil(); + +// instance.add(planeMesh); + +// planeMesh.rotateY(Math.PI / 2); + +// instance.scene.updateMatrixWorld(true); + +// instance.notifyChange(); + +// const clock = new Clock(); +// let t = 0; +// function animate() { +// const speed = 0.5; +// t += clock.getDelta() * speed; +// distance = Math.sin(t); + +// planeMesh.position.setX(-distance); +// plane.constant = distance; + +// instance.scene.updateMatrixWorld(true); +// instance.render(); +// requestAnimationFrame(animate); +// } + +// animate(); diff --git a/examples/sandbox.css b/examples/sandbox.css new file mode 100644 index 0000000000..60f4b8404f --- /dev/null +++ b/examples/sandbox.css @@ -0,0 +1,4 @@ +#view canvas { + background: rgb(132, 170, 182); + background: radial-gradient(circle, rgba(132, 170, 182, 1) 0%, rgba(37, 44, 48, 1) 100%); +} diff --git a/src/interactions/CrossSectionTool.ts b/src/interactions/CrossSectionTool.ts new file mode 100644 index 0000000000..57af42f411 --- /dev/null +++ b/src/interactions/CrossSectionTool.ts @@ -0,0 +1,236 @@ +import type { Vector2 } from 'three'; +import { + ArrowHelper, + BackSide, + FrontSide, + GridHelper, + MathUtils, + Mesh, + MeshBasicMaterial, + Object3D, + Plane, + PlaneGeometry, + Vector3, +} from 'three'; +import { TransformControls } from 'three/examples/jsm/Addons.js'; +import type Disposable from '../core/Disposable'; +import type HasClippingPlanes from '../core/HasClippingPlanes'; +import type Instance from '../core/Instance'; +import Shape from '../entities/Shape'; + +const tmpDirection = new Vector3(); + +type Parameters = { + /** Is the clipping plane enabled on this object ? */ + enabled: boolean; + /** Is the clipping plane negated on this object ? */ + negated: boolean; +}; + +export default class CrossSectionTool implements Disposable { + private readonly _trackedObjects: Map = new Map(); + + private readonly _plane: Plane; + private readonly _negatedPlane: Plane; + private readonly _instance: Instance; + private readonly _onUpdate: () => void; + private readonly _onUpdateAndNotify: () => void; + private readonly _controls: TransformControls; + private readonly _dummy = new Object3D(); + private readonly _planeRepresentation: Mesh; + private readonly _originPoint = new Shape(); + private readonly _planeOrientationArrow = new ArrowHelper( + undefined, + undefined, + undefined, + 'cyan', + ); + + get showPlaneHelper() { + return this._planeRepresentation.visible; + } + + get controls() { + return this._controls; + } + + set showPlaneHelper(v: boolean) { + if (this._planeRepresentation.visible !== v) { + this._planeRepresentation.visible = v; + this._instance.notifyChange(); + } + } + + constructor(params: { instance: Instance }) { + this._plane = new Plane(); + this._negatedPlane = this._plane.clone().negate(); + + this._instance = params.instance; + + this._controls = new TransformControls( + this._instance.view.camera, + this._instance.domElement, + ); + + this._dummy.name = 'CrossSectionTool-dummy'; + this._instance.add(this._dummy); + this._controls.attach(this._dummy); + + this._planeRepresentation = new Mesh( + new PlaneGeometry(1, 1, 1, 1), + new MeshBasicMaterial({ + color: 'cyan', + opacity: 0.2, + transparent: true, + side: FrontSide, + }), + ); + const backside = new Mesh( + new PlaneGeometry(1, 1, 1, 1), + new MeshBasicMaterial({ + color: 'red', + opacity: 0.2, + transparent: true, + side: BackSide, + }), + ); + const grid = new GridHelper(1, 20, 'white', 'black'); + this._dummy.add(this._planeRepresentation); + grid.rotateX(MathUtils.degToRad(90)); + this._planeRepresentation.scale.set(1, 1, 1); + this._planeRepresentation.add(grid); + this._planeRepresentation.add(backside); + + this._planeRepresentation.updateMatrixWorld(true); + + const helper = this._controls.getHelper(); + + this._controls.connect(); + + this._controls.enabled = true; + this._controls.space = 'local'; + // TODO + // this._controls.setRotationSnap(MathUtils.degToRad(15)); + + this._onUpdate = this.update.bind(this); + this._onUpdateAndNotify = this.updateAndNotify.bind(this); + this._instance.addEventListener('update-end', this._onUpdate); + this._controls.addEventListener('change', this._onUpdateAndNotify); + + this._instance.add(helper); + + this._originPoint.color = 'cyan'; + this._originPoint.showVertices = true; + this._instance.add(this._originPoint); + this._instance.add(this._planeOrientationArrow); + } + + setPlaneHelperSize(size: Vector2) { + this._planeRepresentation.scale.set(size.width, size.height, 1); + this._planeRepresentation.updateMatrixWorld(true); + } + + setControlMode(mode: 'rotate' | 'translate') { + this._controls.setMode(mode); + + this._controls.showX = false; + this._controls.showY = false; + this._controls.showZ = false; + + switch (mode) { + case 'rotate': + // Rotating around the normal of the plane makes no sense + this._controls.showX = true; + this._controls.showY = true; + break; + case 'translate': + // Translating on the XY plane itself makes no sense as well + this._controls.showZ = true; + break; + } + } + + private updateAndNotify() { + this.update(); + + this._instance.notifyChange(); + } + + private update() { + const direction = this._dummy.getWorldDirection(tmpDirection); + const origin = this._dummy.position; + + this._planeOrientationArrow.setDirection(direction); + this._planeOrientationArrow.position.copy(origin); + this._planeOrientationArrow.setLength(10000); + this._planeOrientationArrow.updateMatrixWorld(true); + + this._plane.setFromNormalAndCoplanarPoint(direction, origin); + + this._negatedPlane.copy(this._plane).negate(); + this._controls.getHelper().updateMatrixWorld(true); + this._planeRepresentation.updateMatrixWorld(true); + + this._originPoint.setPoints([origin]); + + this._trackedObjects.forEach((params, obj) => { + this.updateObject(obj, params); + }); + } + + private updateObject(obj: HasClippingPlanes, params: Parameters) { + if (params.enabled) { + if (params.negated) { + obj.clippingPlanes = [this._plane]; + } else { + obj.clippingPlanes = [this._negatedPlane]; + } + } else { + obj.clippingPlanes = null; + } + } + + setPlane(origin: Vector3, direction: Vector3) { + this._dummy.position.copy(origin); + this._dummy.lookAt(origin.clone().add(direction)); + + this.update(); + } + + add(obj: HasClippingPlanes, options?: { enabled?: boolean; negated?: boolean }): boolean { + if (this._trackedObjects.has(obj)) { + return false; + } + + const params: Parameters = { + enabled: options?.enabled ?? true, + negated: options?.negated ?? false, + }; + + this._trackedObjects.set(obj, params); + + this.updateObject(obj, params); + + return true; + } + + get plane(): Plane { + return this._plane; + } + + /** + * Disposes the tool and removes the clipping planes on all tracked objects. + */ + dispose(): void { + this._trackedObjects.forEach((v, k) => { + k.clippingPlanes = null; + }); + + this._instance.scene.remove(this._controls.getHelper()); + + this._instance.removeEventListener('update-end', this._onUpdate); + this._controls.removeEventListener('change', this._onUpdateAndNotify); + + // TODO delete objects (helper, etc) + } +} diff --git a/src/interactions/api.ts b/src/interactions/api.ts index ebe4f70c5f..cd70ddca1d 100644 --- a/src/interactions/api.ts +++ b/src/interactions/api.ts @@ -1,3 +1,4 @@ +import type CrossSectionTool from './CrossSectionTool'; import type DrawTool from './DrawTool'; import type { afterRemovePointOfRing, @@ -22,6 +23,7 @@ export { CommonCreationOptions, CreateShapeOptions, CreationOptions, + CrossSectionTool, DrawTool, DrawToolEventMap, inhibitHook, -- GitLab