Skip to content
This repository was archived by the owner on Apr 19, 2026. It is now read-only.

Commit 890d0d9

Browse files
Update 3D Tiles Viewer
1 parent ec06aac commit 890d0d9

File tree

1 file changed

+179
-62
lines changed

1 file changed

+179
-62
lines changed

viewers/templates/3D Tiles Viewer.html

Lines changed: 179 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,8 @@
714714
else if (child.isMesh || child.isPoints || child.isLine) {
715715
if (child.isMesh && child.userData.gltfExtensions) {
716716
// Currently only EXT_mesh_features with attributes is partially functional
717-
const ext = child.userData.gltfExtensions.EXT_mesh_features;
717+
const ext = child.userData.gltfExtensions.EXT_mesh_features ||
718+
child.userData.gltfExtensions.EXT_instance_features;
718719

719720
if (ext && ext.featureIds && !ext.featureIds.find( f => f.texture !== undefined )) {
720721
tileset_obj.hasGLTFExtensions = true;
@@ -835,6 +836,68 @@
835836
document.getElementById('btn_load').disabled = false;
836837
}
837838

839+
function highlightFeatureInstanced( instanceId ) {
840+
// 1. REUSE EVERYTHING
841+
// Instead of a new mesh every time, we just reposition the one we have
842+
if (!featureHighlightMesh || featureHighlightMesh?.geometry !== pickedObject.geometry) {
843+
if (featureHighlightMesh) {
844+
// Detach it from the old parent
845+
if (featureHighlightMesh.parent) {
846+
featureHighlightMesh.parent.remove( featureHighlightMesh );
847+
}
848+
849+
featureHighlightMesh.traverse((child) => {
850+
if (child.geometry) child.geometry.dispose();
851+
if (child.material) child.material.dispose();
852+
});
853+
854+
featureHighlightMesh = null;
855+
}
856+
857+
const highlightGeo = pickedObject.geometry;
858+
859+
featureHighlightMesh = new THREE.Mesh(
860+
highlightGeo,
861+
new THREE.MeshBasicMaterial({
862+
color: 0xf08080,
863+
transparent: true,
864+
opacity: 0.5,
865+
depthTest: true,
866+
depthWrite: false,
867+
polygonOffset: true,
868+
polygonOffsetFactor: -1,
869+
polygonOffsetUnits: -1
870+
})
871+
);
872+
873+
// Add the wireframe once
874+
const wireframe = new THREE.LineSegments(
875+
new THREE.EdgesGeometry( highlightGeo, 30 ),
876+
new THREE.LineBasicMaterial( { color: 0xffffff } )
877+
);
878+
879+
featureHighlightMesh.add( wireframe );
880+
}
881+
882+
// THE TELEPORTATION
883+
// Copy the specific instance's transform from the InstancedMesh
884+
const matrix = new THREE.Matrix4();
885+
pickedObject.getMatrixAt( instanceId, matrix );
886+
887+
// SYNC WITH PARENT
888+
// If the InstancedMesh itself has a position/rotation,
889+
// we must account for it so the highlight doesn't fly off to space
890+
featureHighlightMesh.matrix.copy( matrix );
891+
featureHighlightMesh.matrixAutoUpdate = false; // We handle the matrix manually now
892+
893+
// ATTACH
894+
pickedObject.add( featureHighlightMesh );
895+
896+
featureHighlightMesh.visible = true;
897+
featureHighlightMesh.frustumCulled = false;
898+
featureHighlightMesh.children.forEach( child => child.frustumCulled = false );
899+
}
900+
838901
function highlightFeature( featureId, attrName ) {
839902
const geometry = pickedObject.geometry;
840903
const featureIdAttr = geometry.getAttribute( attrName );
@@ -852,8 +915,10 @@
852915
const c = indexAttr.getX( i + 2 );
853916

854917
// If the first vertex of the triangle matches the ID, we take the whole triangle
855-
if (featureIdAttr.getX( a ) === featureId) {
856-
activeIndices.push( a, b, c );
918+
if ( a < featureIdAttr.count ) {
919+
if (featureIdAttr.getX( a ) === featureId) {
920+
activeIndices.push( a, b, c );
921+
}
857922
}
858923
}
859924

@@ -881,7 +946,7 @@
881946
);
882947

883948
// Create the Wireframe Outline (The "Border")
884-
const wireframeGeo = new THREE.EdgesGeometry( highlightGeo );
949+
const wireframeGeo = new THREE.EdgesGeometry( highlightGeo, 30 );
885950
const wireframe = new THREE.LineSegments(
886951
wireframeGeo,
887952
new THREE.LineBasicMaterial( { color: 0xffffff, linewidth: 3 } ) // White border
@@ -890,6 +955,10 @@
890955
// Add the wireframe to our highlight mesh so they move together
891956
featureHighlightMesh.add( wireframe );
892957

958+
featureHighlightMesh.position.set( 0, 0, 0 );
959+
featureHighlightMesh.rotation.set( 0, 0, 0 );
960+
featureHighlightMesh.scale.set( 1, 1, 1 );
961+
893962
// Sync transformation with the parent object
894963
pickedObject.add( featureHighlightMesh );
895964
}
@@ -898,16 +967,23 @@
898967
document.getElementById('metadata-card').style.display = 'none';
899968

900969
// Clean up previous highlight
901-
if (featureHighlightMesh && pickedObject) {
902-
pickedObject.remove( featureHighlightMesh );
970+
if (featureHighlightMesh) {
971+
// Detach it from the old parent
972+
if (featureHighlightMesh.parent) {
973+
featureHighlightMesh.parent.remove( featureHighlightMesh );
974+
}
903975

904-
// Clean up the children (the wireframe)
905-
featureHighlightMesh.traverse((child) => {
906-
if (child.geometry) child.geometry.dispose();
907-
if (child.material) child.material.dispose();
908-
});
976+
if (pickedObject?.isInstancedMesh) {
977+
featureHighlightMesh.visible = false;
978+
} else {
979+
// Clean up the children (the wireframe)
980+
featureHighlightMesh.traverse( ( child ) => {
981+
if (child.geometry) child.geometry.dispose();
982+
if (child.material) child.material.dispose();
983+
});
909984

910-
featureHighlightMesh = null;
985+
featureHighlightMesh = null;
986+
}
911987
}
912988

913989
// --- RESTORE PREVIOUS SELECTION ---
@@ -969,60 +1045,85 @@
9691045

9701046
const extensions = pickedObject.userData.gltfExtensions;
9711047

972-
if (extensions?.EXT_mesh_features) {
973-
const meshFeatures = extensions.EXT_mesh_features;
1048+
if (extensions) {
1049+
const isInstanced = pickedObject.isInstancedMesh;
1050+
const meshFeatures = extensions.EXT_mesh_features || extensions?.EXT_instance_features;
9741051

9751052
// Get ALL attribute-based feature sets
9761053
const attributeSets = meshFeatures.featureIds?.filter( f => f.attribute !== undefined ) || [];
9771054

978-
if (attributeSets.length > 0 && intersect.face) {
979-
// For now, we take the first set.
980-
// More advanced viewer could let the user toggle which 'class' they are inspecting.
981-
const attributeInfo = attributeSets[ 0 ];
982-
const featureIdIndex = attributeInfo.attribute;
983-
const attrName = `_feature_id_${ featureIdIndex }`;
984-
const featureIdAttr = pickedObject.geometry.getAttribute( attrName );
985-
986-
if (featureIdAttr) {
987-
const featureId = featureIdAttr.getX(intersect.face.a);
988-
989-
// 2. Check for nullFeatureId (The "empty" space check)
990-
if (featureId === attributeInfo.nullFeatureId) {
991-
console.log( 'Hit a null feature area.' );
992-
return; // Don't highlight or show UI for 'nothing'
993-
}
1055+
if (attributeSets.length > 0) {
1056+
let schema = tileset_obj.userData.schema;
1057+
let fid_class = [];
1058+
1059+
for ( let i = 0; i < attributeSets.length; i++ ) {
1060+
const attributeInfo = attributeSets[ i ];
1061+
const featureIdIndex = attributeInfo.attribute;
1062+
1063+
const attrName1 = `_feature_id_${ featureIdIndex }`;
1064+
const attrName2 = `_FEATURE_ID_${ featureIdIndex }`;
1065+
1066+
const featureIdAttr = pickedObject.geometry.getAttribute( attrName1 ) ||
1067+
pickedObject.geometry.getAttribute( attrName2 );
9941068

995-
// --- Identify the Metadata Class ---
996-
let className = 'FeatureId'; // Fallback
997-
let schema = tileset_obj.userData.schema;
998-
999-
// Check if this feature set points to a property table
1000-
if (attributeInfo.propertyTable !== undefined) {
1001-
const tableIndex = attributeInfo.propertyTable;
1002-
1003-
// Metadata usually lives in the gltfExtensions of the model or tileset
1004-
const structuralMetadata = extensions.EXT_structural_metadata;
1005-
1006-
if (structuralMetadata?.propertyTables?.[ tableIndex ]) {
1007-
className = structuralMetadata.propertyTables[ tableIndex ].class;
1069+
if (featureIdAttr && !isInstanced && intersect.face) {
1070+
const featureId = featureIdAttr.getX( intersect.face.a );
1071+
1072+
// Check for nullFeatureId (The "empty" space check)
1073+
// Important: attributeInfo.nullFeatureId is often undefined
1074+
const isNull = attributeInfo.nullFeatureId !== undefined && featureId === attributeInfo.nullFeatureId;
1075+
1076+
if (!isNull) {
1077+
// --- Identify the Metadata Class ---
1078+
let className = 'FeatureId ' + featureIdIndex; // Fallback
1079+
1080+
// Check if this feature set points to a property table
1081+
if (attributeInfo.propertyTable !== undefined) {
1082+
const tableIndex = attributeInfo.propertyTable;
1083+
1084+
// Metadata usually lives in the gltfExtensions of the model or tileset
1085+
const structuralMetadata = extensions.EXT_structural_metadata;
1086+
1087+
if (structuralMetadata?.propertyTables?.[ tableIndex ]) {
1088+
className = structuralMetadata.propertyTables[ tableIndex ].class;
1089+
}
1090+
}
1091+
1092+
fid_class.push( { class: className, id: featureId, layerIndex: i } );
1093+
} else {
1094+
if (attributeSets.length === 1) {
1095+
console.log( 'Hit a null feature area.' );
1096+
return; // Don't highlight or show UI for 'nothing'
1097+
}
1098+
}
1099+
} else if (featureIdAttr && isInstanced) {
1100+
const instanceId = intersect.instanceId; // Only exists if it's an InstancedMesh
1101+
1102+
if (instanceId !== undefined) {
1103+
const featureId = featureIdAttr.getX( instanceId );
1104+
fid_class.push( { class: 'Instance Feature', id: featureId, isInstance: true, instanceId: instanceId } );
10081105
}
10091106
}
1107+
}
10101108

1109+
if (fid_class.length > 0) {
10111110
// Restore color
1012-
if (selectedObject.material?.emissive) {
1013-
selectedObject.material.emissive.copy(selectedObject.userData.originalEmissive);
1111+
if (selectedObject?.material?.emissive) {
1112+
selectedObject.material.emissive.copy( selectedObject.userData.originalEmissive );
10141113
}
10151114

1016-
highlightFeature( featureId, attrName );
1115+
// Trigger the geometry-based highlight for the primary ID
1116+
isInstanced
1117+
? highlightFeatureInstanced( fid_class[ 0 ].instanceId )
1118+
: highlightFeature( fid_class[ 0 ].id, `_feature_id_${ fid_class[ 0 ].layerIndex }` );
10171119

1018-
// 3. UI logic
10191120
showUiPanel(
1020-
{ featureId: { class: className, id: featureId }, ...pickedObject.userData.metadata },
1121+
{ featureId: fid_class, ...pickedObject.userData.metadata },
10211122
event, schema, pickedObject.name, false
10221123
);
1023-
1024-
return;
10251124
}
1125+
1126+
return;
10261127
}
10271128
}
10281129

@@ -1071,7 +1172,7 @@
10711172
// Identify if this is a Multi-Content wrapper or a legitimate data array
10721173
// If the schema says it's NOT an array, but we received an array, it's a wrapper.
10731174
const isArrayType = propDef?.type === 'ARRAY' || propDef?.type === 'VEC2' || propDef?.type === 'VEC3' || propDef?.type === 'VEC4';
1074-
const actualVal = (!isArrayType && Array.isArray(val)) ? val[0] : val;
1175+
const actualVal = (!isArrayType && Array.isArray( val )) ? val[ 0 ] : val;
10751176

10761177
// 1. Handle Enums
10771178
if (propDef?.type === 'ENUM' && schema.enums?.[ propDef.enumType ]) {
@@ -1184,17 +1285,22 @@
11841285
}
11851286
}
11861287

1187-
// 6. FeatureId - Maybe entries EXT_mesh_features, EXT_structural_metadata...
1188-
1189-
if (data.featureId?.class) {
1190-
html += `
1191-
<div style="border-bottom: 1px solid #555; margin-bottom: 8px; padding-bottom: 6px;">
1192-
<div style="font-size: 10px; color: #00d4ff; letter-spacing: 0.5px;">${data.featureId.class}</div>
1193-
</div>`;
1194-
1195-
if (data.featureId.id !== undefined) {
1196-
html += `<div class="prop-row"><span class="prop-label">${'ID: '}</span><span class="prop-value">${data.featureId.id}</span></div>`;
1197-
}
1288+
// 6. FeatureId - Maybe entries from EXT_mesh_features, EXT_structural_metadata...
1289+
1290+
if (data.featureId && Array.isArray( data.featureId )) {
1291+
data.featureId.forEach( ( fid_class, index ) => {
1292+
// Use the class name if found, otherwise use a readable layer index
1293+
const label = fid_class.class || `Feature Layer ${index}`;
1294+
const idValue = fid_class.id !== undefined ? fid_class.id : 'N/A';
1295+
1296+
html += `
1297+
<div class="feature-group" style="margin-top: 10px; border-left: 2px solid #00d4ff; padding-left: 8px;">
1298+
<div style="font-size: 10px; color: #aaa; font-weight: bold;">${ label }</div>
1299+
<div class="prop-row" style="display: flex; justify-content: space-between; align-items: center;">
1300+
<span class="prop-label" style="color: #00d4ff;">ID:</span><span class="prop-value">${ idValue }</span>
1301+
</div>
1302+
</div>`;
1303+
});
11981304
}
11991305
}
12001306

@@ -1379,6 +1485,17 @@
13791485
stats = null;
13801486
}
13811487

1488+
if (featureHighlightMesh) {
1489+
if (pickedObject) pickedObject.remove( featureHighlightMesh );
1490+
1491+
featureHighlightMesh.traverse( ( child ) => {
1492+
if (child.geometry) child.geometry.dispose();
1493+
if (child.material) child.material.dispose();
1494+
});
1495+
1496+
featureHighlightMesh = null;
1497+
}
1498+
13821499
scene.remove( tileset_obj );
13831500

13841501
renderer.clear();

0 commit comments

Comments
 (0)