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
+
+
+ Load
+
+
+
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);
+ });
});