|
714 | 714 | else if (child.isMesh || child.isPoints || child.isLine) { |
715 | 715 | if (child.isMesh && child.userData.gltfExtensions) { |
716 | 716 | // 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; |
718 | 719 |
|
719 | 720 | if (ext && ext.featureIds && !ext.featureIds.find( f => f.texture !== undefined )) { |
720 | 721 | tileset_obj.hasGLTFExtensions = true; |
|
835 | 836 | document.getElementById('btn_load').disabled = false; |
836 | 837 | } |
837 | 838 |
|
| 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 | + |
838 | 901 | function highlightFeature( featureId, attrName ) { |
839 | 902 | const geometry = pickedObject.geometry; |
840 | 903 | const featureIdAttr = geometry.getAttribute( attrName ); |
|
852 | 915 | const c = indexAttr.getX( i + 2 ); |
853 | 916 |
|
854 | 917 | // 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 | + } |
857 | 922 | } |
858 | 923 | } |
859 | 924 |
|
|
881 | 946 | ); |
882 | 947 |
|
883 | 948 | // Create the Wireframe Outline (The "Border") |
884 | | - const wireframeGeo = new THREE.EdgesGeometry( highlightGeo ); |
| 949 | + const wireframeGeo = new THREE.EdgesGeometry( highlightGeo, 30 ); |
885 | 950 | const wireframe = new THREE.LineSegments( |
886 | 951 | wireframeGeo, |
887 | 952 | new THREE.LineBasicMaterial( { color: 0xffffff, linewidth: 3 } ) // White border |
|
890 | 955 | // Add the wireframe to our highlight mesh so they move together |
891 | 956 | featureHighlightMesh.add( wireframe ); |
892 | 957 |
|
| 958 | + featureHighlightMesh.position.set( 0, 0, 0 ); |
| 959 | + featureHighlightMesh.rotation.set( 0, 0, 0 ); |
| 960 | + featureHighlightMesh.scale.set( 1, 1, 1 ); |
| 961 | + |
893 | 962 | // Sync transformation with the parent object |
894 | 963 | pickedObject.add( featureHighlightMesh ); |
895 | 964 | } |
|
898 | 967 | document.getElementById('metadata-card').style.display = 'none'; |
899 | 968 |
|
900 | 969 | // 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 | + } |
903 | 975 |
|
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 | + }); |
909 | 984 |
|
910 | | - featureHighlightMesh = null; |
| 985 | + featureHighlightMesh = null; |
| 986 | + } |
911 | 987 | } |
912 | 988 |
|
913 | 989 | // --- RESTORE PREVIOUS SELECTION --- |
|
969 | 1045 |
|
970 | 1046 | const extensions = pickedObject.userData.gltfExtensions; |
971 | 1047 |
|
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; |
974 | 1051 |
|
975 | 1052 | // Get ALL attribute-based feature sets |
976 | 1053 | const attributeSets = meshFeatures.featureIds?.filter( f => f.attribute !== undefined ) || []; |
977 | 1054 |
|
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 ); |
994 | 1068 |
|
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 } ); |
1008 | 1105 | } |
1009 | 1106 | } |
| 1107 | + } |
1010 | 1108 |
|
| 1109 | + if (fid_class.length > 0) { |
1011 | 1110 | // 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 ); |
1014 | 1113 | } |
1015 | 1114 |
|
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 }` ); |
1017 | 1119 |
|
1018 | | - // 3. UI logic |
1019 | 1120 | showUiPanel( |
1020 | | - { featureId: { class: className, id: featureId }, ...pickedObject.userData.metadata }, |
| 1121 | + { featureId: fid_class, ...pickedObject.userData.metadata }, |
1021 | 1122 | event, schema, pickedObject.name, false |
1022 | 1123 | ); |
1023 | | - |
1024 | | - return; |
1025 | 1124 | } |
| 1125 | + |
| 1126 | + return; |
1026 | 1127 | } |
1027 | 1128 | } |
1028 | 1129 |
|
|
1071 | 1172 | // Identify if this is a Multi-Content wrapper or a legitimate data array |
1072 | 1173 | // If the schema says it's NOT an array, but we received an array, it's a wrapper. |
1073 | 1174 | 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; |
1075 | 1176 |
|
1076 | 1177 | // 1. Handle Enums |
1077 | 1178 | if (propDef?.type === 'ENUM' && schema.enums?.[ propDef.enumType ]) { |
|
1184 | 1285 | } |
1185 | 1286 | } |
1186 | 1287 |
|
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 | + }); |
1198 | 1304 | } |
1199 | 1305 | } |
1200 | 1306 |
|
|
1379 | 1485 | stats = null; |
1380 | 1486 | } |
1381 | 1487 |
|
| 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 | + |
1382 | 1499 | scene.remove( tileset_obj ); |
1383 | 1500 |
|
1384 | 1501 | renderer.clear(); |
|
0 commit comments