diff --git a/examples/3DTiles-11-styling.html b/examples/3DTiles-11-styling.html new file mode 100644 index 0000000000..b50515b69c --- /dev/null +++ b/examples/3DTiles-11-styling.html @@ -0,0 +1,389 @@ + + + Itowns - 3D Tiles styling + + + + + + + + + + + + + +
+
+ Tileset +
+ + +
+
+

Feature Colors

+
+ Load a tileset to see feature types +
+
+ + + + + diff --git a/examples/jsm/OGC3DTilesHelper.js b/examples/jsm/OGC3DTilesHelper.js index 4ec54f1fc8..6815123a83 100644 --- a/examples/jsm/OGC3DTilesHelper.js +++ b/examples/jsm/OGC3DTilesHelper.js @@ -5,9 +5,28 @@ import { Coordinates, Extent, CameraUtils } from 'itowns'; // eslint-disable-next-line import/extensions import { createHTMLListFromObject } from './GUI/GuiTools.js'; +/** + * Extract the CityJSON Building id from metadata returned by getMetadataFromIntersections. + * Prefer BuildingId (Tyler), then id. Returns the first non-null value found in the metadata array. + * @param {Array|null} metadata + * @returns {string|null} + */ +export function extractBuildingId(metadata) { + if (!Array.isArray(metadata)) { return null; } + for (const m of metadata) { + if (m && typeof m === 'object') { + if (m.BuildingId != null && m.BuildingId !== '') { return String(m.BuildingId); } + if (m.id != null && m.id !== '') { return String(m.id); } + } + } + return null; +} + /** * Function allowing picking on a given 3D tiles layer and filling an html div - * with information on the picked feature. + * with information on the picked feature. Displays only the BuildingId (the id + * that links to the CityJSON building) when present, so users can report e.g. + * "Building 253 is missing a wall". * @param {MouseEvent} event * @param {Object} pickingArg * @param {HTMLDivElement} pickingArg.htmlDiv - div element which contains the @@ -26,20 +45,37 @@ export function fillHTMLWithPickingInfo(event, pickingArg) { // Get intersected objects const intersects = view.pickObjectsAt(event, 5, layer); - - // Get information from intersected objects (from the batch table and - // eventually the 3D Tiles extensions const closestC3DTileFeature = layer.getC3DTileFeatureFromIntersectsArray(intersects); - if (closestC3DTileFeature) { - // eslint-disable-next-line - htmlDiv.appendChild(createHTMLListFromObject(closestC3DTileFeature)); - } - + // Resolve metadata first; show only BuildingId when present (CityJSON building id) layer.getMetadataFromIntersections(intersects).then((metadata) => { - // eslint-disable-next-line - metadata?.forEach(m => htmlDiv.appendChild(createHTMLListFromObject(m))); + const buildingId = extractBuildingId(metadata); + if (buildingId != null) { + htmlDiv.appendChild(createHTMLListFromObject({ BuildingId: buildingId })); + } else { + // No BuildingId in tile (e.g. old tiles); show batchId as fallback with a note + const batchId = closestC3DTileFeature?.batchId ?? closestC3DTileFeature?.batchid ?? '—'; + htmlDiv.appendChild(createHTMLListFromObject({ + BuildingId: '(not in tile)', + batchId, + note: 'Tile has no BuildingId; batchId is the feature index in this tile.', + })); + } + }).catch((err) => { + console.warn('getMetadataFromIntersections failed (e.g. missing EXT_structural_metadata):', err?.message ?? err); + const i0 = intersects?.[0]; + const batchId = closestC3DTileFeature?.batchId ?? closestC3DTileFeature?.batchid ?? '—'; + const fallback = { + BuildingId: '(unavailable)', + note: 'Metadata unavailable (no property table or error)', + batchId, + }; + if (i0?.distance != null) { fallback.distance = Number(i0.distance).toFixed(2); } + if (i0?.point) { + fallback.point = { x: i0.point.x?.toFixed(2), y: i0.point.y?.toFixed(2), z: i0.point.z?.toFixed(2) }; + } + htmlDiv.appendChild(createHTMLListFromObject(fallback)); }); } diff --git a/packages/Main/src/Layer/OGC3DTilesLayer.js b/packages/Main/src/Layer/OGC3DTilesLayer.js index 5d680fc22b..1e87745173 100644 --- a/packages/Main/src/Layer/OGC3DTilesLayer.js +++ b/packages/Main/src/Layer/OGC3DTilesLayer.js @@ -23,9 +23,24 @@ import PointsMaterial, { ClassificationScheme, } from 'Renderer/PointsMaterial'; import { VIEW_EVENTS } from 'Core/View'; +import Style from 'Core/Style'; +import C3DTFeature from 'Core/3DTiles/C3DTFeature'; +import { optimizeGeometryGroups } from 'Utils/ThreeUtils'; const _raycaster = new THREE.Raycaster(); +/** + * Returns the feature ID buffer attribute from a geometry, checking 3D Tiles + * 1.1 attribute names first, then falling back to 1.0. + * @param {THREE.BufferGeometry} geometry + * @returns {THREE.BufferAttribute|undefined} + */ +function getFeatureIdAttribute(geometry) { + return geometry?.getAttribute('_FEATURE_ID_0') + ?? geometry?.getAttribute('_feature_id_0') + ?? geometry?.getAttribute('_batchid'); +} + // Stores lruCache, downloadQueue and parseQueue for each id of view {@link View} // every time a tileset has been added // https://github.com/iTowns/itowns/issues/2426 @@ -430,6 +445,16 @@ class OGC3DTilesLayer extends GeometryLayer { this.sseThreshold = config.sseThreshold; } + // Per-feature styling state + /** @type {Map>} */ + this._tileFeatures = new Map(); + /** @type {Map} */ + this._fillColorMaterials = new Map(); + /** @type {Style|null} */ + this._style = null; + /** @type {Map>} */ + this._originalMaterials = new Map(); + // Used for custom schedule callbacks (VR) this.tasks = []; this.tilesSchedulingCB = (func) => { @@ -524,9 +549,21 @@ class OGC3DTilesLayer extends GeometryLayer { this._assignFinalMaterial(obj); this._assignFinalAttributes(obj); }); + try { + this._initFeatures(scene); + if (this._style) { + this.updateStyle(); + } + } catch (err) { + console.warn('[OGC3DTilesLayer] Feature init error:', err); + } view.notifyChange(this); }); + this.tilesRenderer.addEventListener('dispose-model', (e) => { + this._cleanupTileFeatures(e.scene); + }); + this._setupCacheAndQueues(view); this._setupEvents(); @@ -586,6 +623,281 @@ class OGC3DTilesLayer extends GeometryLayer { } } + /** + * Initialize C3DTFeature objects from a loaded tile scene. Scans meshes + * for feature ID attributes (_FEATURE_ID_0 for 3D Tiles 1.1, _batchid + * for 1.0) and groups vertices by feature ID. + * @param {THREE.Object3D} scene - tile content root + * @private + */ + _initFeatures(scene) { + const featuresMap = new Map(); + const originalMats = new Map(); + + scene.traverse((child) => { + if (!child.isMesh) { return; } + + const featureIdAttr = getFeatureIdAttribute(child.geometry); + if (!featureIdAttr) { return; } + + // Save original material for later restore + originalMats.set(child, child.material); + + const indexAttr = child.geometry.getIndex(); + + const registerGroup = (currentFeatureId, start, count) => { + if (featuresMap.has(currentFeatureId)) { + featuresMap.get(currentFeatureId).groups.push({ start, count }); + } else { + const feature = new C3DTFeature( + scene.uuid, + currentFeatureId, + [{ start, count }], + {}, + child, + ); + featuresMap.set(currentFeatureId, feature); + } + }; + + if (indexAttr) { + // Indexed geometry: groups must be in index-buffer space + // for addGroup() to work correctly. + const indexCount = indexAttr.count; + if (indexCount === 0) { return; } + + let currentFeatureId = featureIdAttr.getX(indexAttr.getX(0)); + let start = 0; + let count = 0; + + for (let i = 0; i < indexCount; i++) { + const vertexIdx = indexAttr.getX(i); + const featureId = featureIdAttr.getX(vertexIdx); + if (featureId !== currentFeatureId) { + registerGroup(currentFeatureId, start, count); + currentFeatureId = featureId; + start = i; + count = 0; + } + count++; + if (i === indexCount - 1) { + registerGroup(currentFeatureId, start, count); + } + } + } else { + // Non-indexed geometry: groups in vertex space + const positionAttr = child.geometry.getAttribute('position'); + const vertexCount = positionAttr.count; + if (vertexCount === 0) { return; } + + let currentFeatureId = featureIdAttr.getX(0); + let start = 0; + let count = 0; + + for (let i = 0; i < vertexCount; i++) { + const featureId = featureIdAttr.getX(i); + if (featureId !== currentFeatureId) { + registerGroup(currentFeatureId, start, count); + currentFeatureId = featureId; + start = i; + count = 0; + } + count++; + if (i === vertexCount - 1) { + registerGroup(currentFeatureId, start, count); + } + } + } + }); + + if (featuresMap.size > 0) { + this._tileFeatures.set(scene, featuresMap); + this._originalMaterials.set(scene, originalMats); + this._populateFeatureMetadata(scene, featuresMap); + } + } + + /** + * Populate C3DTFeature userData from EXT_structural_metadata property + * tables. This makes per-feature properties (e.g. CityObjectType) + * available in style callbacks via feature.userData. + * @param {THREE.Object3D} scene - tile content root + * @param {Map} featuresMap - feature map + * @private + */ + _populateFeatureMetadata(scene, featuresMap) { + scene.traverse((child) => { + if (!child.isMesh) { return; } + + const { meshFeatures, structuralMetadata } = child.userData; + if (!meshFeatures || !structuralMetadata) { return; } + + const featureInfo = meshFeatures.getFeatureInfo(); + if (!featureInfo || featureInfo.length === 0) { return; } + + const tableIndex = featureInfo[0].propertyTable; + if (tableIndex === undefined) { return; } + + for (const [featureId, feature] of featuresMap) { + if (feature.object3d !== child) { continue; } + + const data = structuralMetadata.getPropertyTableData( + tableIndex, featureId, + ); + if (data) { + Object.assign(feature.userData, data); + } + } + }); + } + + /** + * Update style of the C3DTFeatures. Evaluates the style callbacks for each + * feature and assigns materials via geometry groups. + * @returns {boolean} true if style was applied, false otherwise + */ + updateStyle() { + if (!this._style) { + return false; + } + + const currentMaterials = []; + + for (const [, featuresMap] of this._tileFeatures) { + // Group features by their object3d + const meshFeatures = new Map(); + for (const [, feature] of featuresMap) { + if (!meshFeatures.has(feature.object3d)) { + meshFeatures.set(feature.object3d, []); + } + meshFeatures.get(feature.object3d).push(feature); + } + + for (const [object3d, features] of meshFeatures) { + object3d.geometry.clearGroups(); + object3d.material = []; + + for (const feature of features) { + this._style.context.setGeometry({ + properties: feature, + }); + + const color = new THREE.Color(this._style.fill.color); + const opacity = this._style.fill.opacity; + const materialId = `${color.getHexString()}_${opacity}`; + + let material; + if (this._fillColorMaterials.has(materialId)) { + material = this._fillColorMaterials.get(materialId); + } else { + material = new THREE.MeshStandardMaterial({ + color, + opacity, + transparent: opacity < 1, + alphaTest: 0.09, + }); + this._fillColorMaterials.set(materialId, material); + } + + // Find or add material in object3d.material array + let materialIndex = object3d.material.indexOf(material); + if (materialIndex < 0) { + object3d.material.push(material); + materialIndex = object3d.material.length - 1; + } + + feature.groups.forEach((group) => { + object3d.geometry.addGroup( + group.start, group.count, materialIndex, + ); + }); + } + + optimizeGeometryGroups(object3d); + + // Record materials in use + if (Array.isArray(object3d.material)) { + object3d.material.forEach((m) => { + if (!currentMaterials.includes(m)) { + currentMaterials.push(m); + } + }); + } else if (!currentMaterials.includes(object3d.material)) { + currentMaterials.push(object3d.material); + } + } + } + + // Remove unused cached materials + for (const [id, material] of this._fillColorMaterials) { + if (!currentMaterials.includes(material)) { + material.dispose(); + this._fillColorMaterials.delete(id); + } + } + + return true; + } + + /** + * Restore original materials on all styled meshes. Called when style is + * set to null. + * @private + */ + _restoreOriginalMaterials() { + for (const [, originalMats] of this._originalMaterials) { + for (const [mesh, material] of originalMats) { + mesh.geometry.clearGroups(); + mesh.material = material; + } + } + + // Dispose all cached style materials + this._fillColorMaterials.forEach(m => m.dispose()); + this._fillColorMaterials.clear(); + } + + /** + * Remove features and original materials for a disposed tile scene. + * @param {THREE.Object3D} scene - tile content root + * @private + */ + _cleanupTileFeatures(scene) { + this._tileFeatures.delete(scene); + this._originalMaterials.delete(scene); + } + + /** + * Sets the style for per-feature coloring. Accepts a Style instance, a + * plain object with fill.color / fill.opacity callbacks, or null to + * restore original materials. + * @param {Style|Object|null} value - the style to apply + */ + set style(value) { + if (value instanceof Style) { + this._style = value; + } else if (!value) { + this._style = null; + this._restoreOriginalMaterials(); + return; + } else { + this._style = new Style(value); + } + this.updateStyle(); + } + + get style() { + return this._style; + } + + /** + * Number of cached fill-color materials currently in use. + * @type {number} + */ + get materialCount() { + return this._fillColorMaterials.size; + } + handleTasks() { for (let t = 0, l = this.tasks.length; t < l; t++) { this.tasks[t](); @@ -620,6 +932,12 @@ class OGC3DTilesLayer extends GeometryLayer { this.tilesRenderer.dispose(); + // Clean up styling state + this._tileFeatures.clear(); + this._fillColorMaterials.forEach(m => m.dispose()); + this._fillColorMaterials.clear(); + this._originalMaterials.clear(); + // Clean up references this.tilesSchedulingCB = null; this._viewId = null; @@ -662,24 +980,35 @@ class OGC3DTilesLayer extends GeometryLayer { const { face, index, object, instanceId } = intersects[0]; + const featureIdAttr = getFeatureIdAttribute(object.geometry); + /** @type{number|null} */ let batchId; if (object.isPoints && index != null) { - batchId = object.geometry.getAttribute('_batchid')?.getX(index) ?? index; + batchId = featureIdAttr?.getX(index) ?? index; } else if (object.isMesh && face) { - batchId = object.geometry.getAttribute('_batchid')?.getX(face.a) ?? instanceId; + batchId = featureIdAttr?.getX(face.a) ?? instanceId; } if (batchId === undefined) { return null; } + // Look up in feature map first (works for both 1.0 and 1.1) + for (const [, featuresMap] of this._tileFeatures) { + const feature = featuresMap.get(batchId); + if (feature && feature.object3d === object) { + return feature; + } + } + + // Fall back to batch table lookup (original behavior) let tileObject = object; - while (!tileObject.batchTable) { + while (tileObject && !tileObject.batchTable) { tileObject = tileObject.parent; } - return tileObject.batchTable.getDataFromId(batchId); + return tileObject?.batchTable?.getDataFromId(batchId) ?? null; } /** diff --git a/packages/Main/src/Utils/ThreeUtils.js b/packages/Main/src/Utils/ThreeUtils.js index 373a7109a5..b075ced9a1 100644 --- a/packages/Main/src/Utils/ThreeUtils.js +++ b/packages/Main/src/Utils/ThreeUtils.js @@ -90,10 +90,15 @@ export function optimizeGeometryGroups(object3D) { usedIndexMaterials.push(currentMaterialIndex); continue; } else { - // indeed same group merge with previous group + // Same materialIndex — only merge when the two groups are + // contiguous in vertex space. Non-contiguous runs occur with + // per-feature styling when different feature types interleave. const previousGroup = object3D.geometry.groups[index + 1]; - previousGroup.count += group.count; // previous group wrap the current one - object3D.geometry.groups.splice(index, 1); // remove group + if (group.start + group.count === previousGroup.start) { + previousGroup.start = group.start; + previousGroup.count += group.count; + object3D.geometry.groups.splice(index, 1); + } } } diff --git a/packages/Main/test/unit/ogc3dtileslayer.js b/packages/Main/test/unit/ogc3dtileslayer.js index 23e3602164..48cb341c22 100644 --- a/packages/Main/test/unit/ogc3dtileslayer.js +++ b/packages/Main/test/unit/ogc3dtileslayer.js @@ -1,4 +1,5 @@ import assert from 'assert'; +import * as THREE from 'three'; import OGC3DTilesSource from 'Source/OGC3DTilesSource'; import OGC3DTilesLayer, { itownsGLTFLoader, @@ -250,4 +251,203 @@ describe('OGC3DTilesLayer', function () { assert.equal(material.opacity, layer.opacity); assert.equal(material.transparent, layer.opacity < 1.0); }); + + // Helper: create a mock tile scene with _FEATURE_ID_0 attribute (3D Tiles 1.1) + function createMockScene11(featureIds) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(featureIds.length * 3); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute( + '_FEATURE_ID_0', + new THREE.BufferAttribute(new Uint16Array(featureIds), 1), + ); + + const material = new THREE.MeshStandardMaterial({ color: 0xffffff }); + const mesh = new THREE.Mesh(geometry, material); + mesh.isMesh = true; + + const scene = new THREE.Group(); + scene.add(mesh); + return { scene, mesh, material }; + } + + it('should initialize features from _FEATURE_ID_0 (3D Tiles 1.1)', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + // 6 vertices: feature 0 (3 verts), feature 1 (2 verts), feature 2 (1 vert) + const { scene } = createMockScene11([0, 0, 0, 1, 1, 2]); + + layer._initFeatures(scene); + + assert.equal(layer._tileFeatures.size, 1); + const featuresMap = layer._tileFeatures.get(scene); + assert.ok(featuresMap); + assert.equal(featuresMap.size, 3); + + const f0 = featuresMap.get(0); + assert.equal(f0.batchId, 0); + assert.deepEqual(f0.groups, [{ start: 0, count: 3 }]); + + const f1 = featuresMap.get(1); + assert.equal(f1.batchId, 1); + assert.deepEqual(f1.groups, [{ start: 3, count: 2 }]); + + const f2 = featuresMap.get(2); + assert.equal(f2.batchId, 2); + assert.deepEqual(f2.groups, [{ start: 5, count: 1 }]); + }); + + it('should initialize features from _batchid (3D Tiles 1.0 fallback)', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(9); // 3 vertices + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute( + '_batchid', + new THREE.BufferAttribute(new Int32Array([5, 5, 7]), 1), + ); + + const material = new THREE.MeshStandardMaterial(); + const mesh = new THREE.Mesh(geometry, material); + mesh.isMesh = true; + + const scene = new THREE.Group(); + scene.add(mesh); + + layer._initFeatures(scene); + + const featuresMap = layer._tileFeatures.get(scene); + assert.equal(featuresMap.size, 2); + assert.ok(featuresMap.has(5)); + assert.ok(featuresMap.has(7)); + }); + + // Helper: create a mock indexed tile scene with _FEATURE_ID_0 + function createMockScene11Indexed(featureIds, indices) { + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(featureIds.length * 3); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute( + '_FEATURE_ID_0', + new THREE.BufferAttribute(new Uint16Array(featureIds), 1), + ); + geometry.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1)); + + const material = new THREE.MeshStandardMaterial({ color: 0xffffff }); + const mesh = new THREE.Mesh(geometry, material); + mesh.isMesh = true; + + const scene = new THREE.Group(); + scene.add(mesh); + return { scene, mesh, material }; + } + + it('should initialize features from indexed geometry', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + // 4 vertices with feature IDs: [0, 0, 1, 1] + // Index buffer references them: [0, 1, 2, 2, 3, 0] (6 indices, 2 triangles) + // Indices 0-2 map to vertices 0,1,2 => feature IDs 0,0,1 + // index 0 -> vertex 0 -> fid 0 + // index 1 -> vertex 1 -> fid 0 + // index 2 -> vertex 2 -> fid 1 (different => split) + // Indices 3-5 map to vertices 2,3,0 => feature IDs 1,1,0 + // index 3 -> vertex 2 -> fid 1 + // index 4 -> vertex 3 -> fid 1 + // index 5 -> vertex 0 -> fid 0 (different => split) + const { scene } = createMockScene11Indexed( + [0, 0, 1, 1], + [0, 1, 2, 2, 3, 0], + ); + + layer._initFeatures(scene); + + const featuresMap = layer._tileFeatures.get(scene); + assert.ok(featuresMap); + assert.equal(featuresMap.size, 2); + + const f0 = featuresMap.get(0); + assert.equal(f0.batchId, 0); + // Feature 0 appears in two non-contiguous runs in the index buffer: + // indices [0,1] (start=0, count=2) and index [5] (start=5, count=1) + assert.deepEqual(f0.groups, [ + { start: 0, count: 2 }, + { start: 5, count: 1 }, + ]); + + const f1 = featuresMap.get(1); + assert.equal(f1.batchId, 1); + // Feature 1: indices [2,3,4] (start=2, count=3) + assert.deepEqual(f1.groups, [{ start: 2, count: 3 }]); + }); + + it('should apply style to features and create materials', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + const { scene } = createMockScene11([0, 0, 1, 1]); + + layer._initFeatures(scene); + + layer.style = { + fill: { + color: (feature) => { + if (feature.batchId === 0) { return 'red'; } + return 'blue'; + }, + opacity: 1.0, + }, + }; + + // 2 distinct colors => 2 materials + assert.equal(layer.materialCount, 2); + }); + + it('should restore original materials when style is set to null', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + const { scene, mesh, material: originalMaterial } = createMockScene11([0, 0, 1, 1]); + + layer._initFeatures(scene); + + // Apply style + layer.style = { + fill: { + color: 'red', + opacity: 1.0, + }, + }; + + assert.notEqual(mesh.material, originalMaterial); + + // Remove style + layer.style = null; + + assert.equal(mesh.material, originalMaterial); + assert.equal(layer.materialCount, 0); + }); + + it('should clean up tile features on dispose', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + const { scene } = createMockScene11([0, 0, 1, 1]); + + layer._initFeatures(scene); + assert.equal(layer._tileFeatures.size, 1); + + layer._cleanupTileFeatures(scene); + assert.equal(layer._tileFeatures.size, 0); + }); + + it('should pick features using _FEATURE_ID_0', function () { + const layer = new OGC3DTilesLayer('3dtiles', { source }); + const { scene, mesh } = createMockScene11([0, 0, 0, 1, 1, 2]); + + layer._initFeatures(scene); + + // Simulate picking vertex at face.a = 4 (featureId = 1) + const intersects = [{ + face: { a: 4, b: 5, c: 3 }, + object: mesh, + }]; + + const result = layer.getC3DTileFeatureFromIntersectsArray(intersects); + assert.ok(result); + assert.equal(result.batchId, 1); + }); });