├── ext ├── icons.png ├── icon3d.data.json ├── icon3d.infoCard.js └── icon3d.js ├── index.html ├── .gitignore ├── README.md └── app.js /ext/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallabyway/markupExt/HEAD/ext/icons.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 3D-markup demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | del/ 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 3D Icons Extension for APS Viewer 2 | 3 | Enhance the APS Viewer SDK scene with GPU-accelerated “pushpin” icons that occlude behind models and support millions of instances at 60 FPS, with optional interactive card popups. 4 | 5 | ## Demo 6 | 7 | **[Try Live Demo →](https://wallabyway.github.io/markupExt/)** 8 | 9 | ![Image](https://github.com/user-attachments/assets/5051e835-65bb-4e58-8e63-1e20b8964da4) 10 | 11 | ## Features 12 | 13 | - **High Performance**: Render 1,000,000+ icons at 60 FPS 14 | - **Multi-Icon Support**: 4 (or more) different icon types via a single 'sprite sheet' 15 | - **GPU Accelerated**: It uses PointCloud shader under the hood 16 | - **Two Usage Modes**: "Icons only", or "Icons with info card" 17 | 18 | ## Installation 19 | 20 | ### Option 1: Icons Only 21 | 22 | Use this for simple markup visualization without interactive info cards. 23 | > **NOTE:** You can remove the `icon3d.infoCard.js` file 24 | 25 | ```javascript 26 | import { Icon3dToolExtension } from './ext/icon3d.js'; 27 | 28 | // Register extension 29 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d', Icon3dToolExtension); 30 | 31 | // Load in viewer config 32 | const config = { 33 | extensions: ['icon3d'], 34 | extensionOptions: { 35 | 'icon3d': { dataSource: 'ext/icon3d.data.json' } 36 | } 37 | }; 38 | 39 | const viewer = new Autodesk.Viewing.GuiViewer3D(div, config); 40 | ``` 41 | 42 | 43 | ### Option 2: Icons with Info Cards 44 | 45 | Use this for interactive icons with clickable info cards and 3D line connectors. 46 | 47 | ```javascript 48 | import { Icon3dExtension } from './ext/icon3d.infoCard.js'; 49 | 50 | // Register extension 51 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d.infoCard', Icon3dExtension); 52 | 53 | // Load in viewer config 54 | const config = { 55 | extensions: ['icon3d.infoCard'], 56 | extensionOptions: { 57 | 'icon3d.infoCard': { dataSource: 'ext/icon3d.data.json' } 58 | } 59 | }; 60 | 61 | const viewer = new Autodesk.Viewing.GuiViewer3D(div, config); 62 | ``` 63 | 64 | ## Data Format 65 | 66 | Create a JSON file with your markup data: 67 | 68 | ```json 69 | [ 70 | { 71 | "id": 1, 72 | "x": 10.5, 73 | "y": 20.3, 74 | "z": 5.8, 75 | "icon": 0, 76 | "title": "Issue #1", 77 | "description": "Foundation crack detected", 78 | "priority": "Critical", 79 | "assignee": "John Doe", 80 | "date": "2024-01-15" 81 | } 82 | ] 83 | ``` 84 | 85 | **Icon Types:** 86 | - `0` = Issue 87 | - `1` = Warning 88 | - `2` = RFI 89 | - `3` = Hazard 90 | 91 | ## Customization 92 | 93 | ### Change Icon Sprite Sheet 94 | 95 | Replace `ext/icons.png` with your own 4-icon sprite sheet (each icon 256x256px). 96 | 97 | ### Adjust Icon Size 98 | 99 | Edit `ext/icon3d.js`: 100 | 101 | ```javascript 102 | this.config = { 103 | size: 150.0, // Icon size 104 | threshold: 5 // Click hit radius 105 | }; 106 | ``` 107 | 108 | ### Customize Info Card 109 | 110 | Edit `ext/icon3d.infoCard.js`: 111 | 112 | ```javascript 113 | const LABEL_Z_OFFSET = 75; // 3D line height (Z-axis) 114 | const LABEL_X_OFFSET = -90; // 2D label X offset in pixels 115 | const LABEL_Y_OFFSET = -200; // 2D label Y offset in pixels 116 | const LINE_COLOR = 0xffffff; // White line color 117 | ``` 118 | 119 | The info card UI uses Tailwind CSS and can be customized in the `createCardHTML()` method. 120 | 121 | ## Technical Details 122 | 123 | ### GPU Shader Optimization 124 | 125 | **Multi-Icon Rendering** (Fragment Shader): 126 | ```glsl 127 | gl_FragColor = gl_FragColor * texture2D(tex, 128 | vec2((gl_PointCoord.x + vColor.y * 1.0) / 4.0, 1.0 - gl_PointCoord.y) 129 | ); 130 | ``` 131 | 132 | **Distance-Based Scaling** (Vertex Shader): 133 | ```glsl 134 | gl_PointSize = size * (size / (length(mvPosition.xyz) + 1.0)); 135 | ``` 136 | 137 | 138 | ## License 139 | 140 | MIT License 141 | Copyright (c) 2025 142 | 143 | ## References 144 | - [APS Viewer Documentation](https://aps.autodesk.com/en/docs/viewer/v7/developers_guide/overview/) 145 | - [Original Blog Post by Philippe Leefsma](https://forge.autodesk.com/blog/high-performance-3d-markups-pointcloud-forge-viewer) 146 | - [THREE.js PointCloud Raycasting](https://stackoverflow.com/questions/28209645/raycasting-involving-individual-points-in-a-three-js-pointcloud) 147 | -------------------------------------------------------------------------------- /ext/icon3d.data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "icon": 0, 5 | "x": -2.55, 6 | "y": 87.93, 7 | "z": -16.69, 8 | "title": "HVAC Duct Gap", 9 | "description": "Air gap detected at duct connection joint", 10 | "priority": "High", 11 | "assignee": "John Smith", 12 | "date": "2024-11-06" 13 | }, 14 | { 15 | "id": 2, 16 | "icon": 1, 17 | "x": -61.60, 18 | "y": 87.93, 19 | "z": -4.55, 20 | "title": "HVAC Insulation Missing", 21 | "description": "Thermal insulation gap on supply duct", 22 | "priority": "Critical", 23 | "assignee": "Sarah Johnson", 24 | "date": "2024-11-05" 25 | }, 26 | { 27 | "id": 3, 28 | "icon": 2, 29 | "x": 23.38, 30 | "y": -52.91, 31 | "z": 7.09, 32 | "title": "Structural Crack", 33 | "description": "Hairline crack in concrete beam", 34 | "priority": "Medium", 35 | "assignee": "Mike Davis", 36 | "date": "2024-11-04" 37 | }, 38 | { 39 | "id": 4, 40 | "icon": 3, 41 | "x": -41.88, 42 | "y": -50.58, 43 | "z": -16.28, 44 | "title": "HVAC Clearance Issue", 45 | "description": "Insufficient clearance between duct and structural member", 46 | "priority": "High", 47 | "assignee": "Lisa Chen", 48 | "date": "2024-11-03" 49 | }, 50 | { 51 | "id": 5, 52 | "icon": 0, 53 | "x": -25.61, 54 | "y": -14.81, 55 | "z": -19.89, 56 | "title": "Wall Crack", 57 | "description": "Vertical crack in partition wall", 58 | "priority": "Medium", 59 | "assignee": "Robert Wilson", 60 | "date": "2024-11-02" 61 | }, 62 | { 63 | "id": 6, 64 | "icon": 1, 65 | "x": -42.14, 66 | "y": -24.37, 67 | "z": 6.87, 68 | "title": "HVAC Hanger Missing", 69 | "description": "Support hanger required for duct section", 70 | "priority": "High", 71 | "assignee": "Emma Brown", 72 | "date": "2024-11-01" 73 | }, 74 | { 75 | "id": 7, 76 | "icon": 2, 77 | "x": -49.75, 78 | "y": -24.37, 79 | "z": 6.87, 80 | "title": "Duct Penetration Gap", 81 | "description": "Fire-rated seal missing at wall penetration", 82 | "priority": "Critical", 83 | "assignee": "David Lee", 84 | "date": "2024-10-31" 85 | }, 86 | { 87 | "id": 8, 88 | "icon": 0, 89 | "x": 107.72, 90 | "y": -63.66, 91 | "z": 12.29, 92 | "title": "HVAC Damper Access", 93 | "description": "Access panel required for fire damper", 94 | "priority": "High", 95 | "assignee": "Jennifer Taylor", 96 | "date": "2024-10-30" 97 | }, 98 | { 99 | "id": 9, 100 | "icon": 3, 101 | "x": -81.37, 102 | "y": 63.46, 103 | "z": 11.46, 104 | "title": "Duct Collision", 105 | "description": "HVAC duct conflicts with electrical conduit", 106 | "priority": "Critical", 107 | "assignee": "Kevin Martinez", 108 | "date": "2024-10-29" 109 | }, 110 | { 111 | "id": 10, 112 | "icon": 1, 113 | "x": -44.30, 114 | "y": 73.45, 115 | "z": 11.46, 116 | "title": "HVAC Vibration Issue", 117 | "description": "Excessive vibration at fan connection", 118 | "priority": "Medium", 119 | "assignee": "Amanda White", 120 | "date": "2024-10-28" 121 | }, 122 | { 123 | "id": 11, 124 | "icon": 2, 125 | "x": -81.37, 126 | "y": 72.16, 127 | "z": 11.46, 128 | "title": "Duct Seam Gap", 129 | "description": "Unsealed longitudinal seam on rectangular duct", 130 | "priority": "High", 131 | "assignee": "Chris Anderson", 132 | "date": "2024-10-27" 133 | }, 134 | { 135 | "id": 12, 136 | "icon": 0, 137 | "x": -83.25, 138 | "y": 8.24, 139 | "z": 11.46, 140 | "title": "HVAC Grille Misalignment", 141 | "description": "Supply grille not aligned with ceiling grid", 142 | "priority": "Low", 143 | "assignee": "Nicole Garcia", 144 | "date": "2024-10-26" 145 | }, 146 | { 147 | "id": 13, 148 | "icon": 3, 149 | "x": -83.25, 150 | "y": 35.97, 151 | "z": 11.46, 152 | "title": "Return Air Gap", 153 | "description": "Gap at return air plenum connection", 154 | "priority": "Medium", 155 | "assignee": "Ryan Thompson", 156 | "date": "2024-10-25" 157 | }, 158 | { 159 | "id": 14, 160 | "icon": 1, 161 | "x": -83.24, 162 | "y": -16.53, 163 | "z": 11.67, 164 | "title": "HVAC Control Issue", 165 | "description": "Thermostat sensor location incorrect", 166 | "priority": "High", 167 | "assignee": "Stephanie Moore", 168 | "date": "2024-10-24" 169 | }, 170 | { 171 | "id": 15, 172 | "icon": 2, 173 | "x": 48.23, 174 | "y": -77.91, 175 | "z": 10.77, 176 | "title": "Diffuser Obstruction", 177 | "description": "Ceiling diffuser partially blocked by structure", 178 | "priority": "Medium", 179 | "assignee": "Brian Clark", 180 | "date": "2024-10-23" 181 | }, 182 | { 183 | "id": 16, 184 | "icon": 0, 185 | "x": -15.30, 186 | "y": -60.77, 187 | "z": 7.69, 188 | "title": "Ductwork Dent", 189 | "description": "Dent in flexible duct reducing airflow", 190 | "priority": "Medium", 191 | "assignee": "Patricia Harris", 192 | "date": "2024-10-22" 193 | } 194 | ] 195 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | // ES6 Module imports for icon3d extensions 2 | import { Icon3dTool, Icon3dToolExtension } from './ext/icon3d.js'; 3 | import { InfoCard, Icon3dExtension } from './ext/icon3d.infoCard.js'; 4 | 5 | // Register extensions with Autodesk Viewer 6 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d', Icon3dToolExtension); 7 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d.infoCard', Icon3dExtension); 8 | 9 | // Configuration Constants 10 | const DEFAULT_URN = "dXJuOmFkc2sub2JqZWN0czpvcy5vYmplY3Q6Y29uc29saWRhdGVkL3JtZV9hZHZhbmNlZF9zYW1wbGVfcHJvamVjdC5ydnQ"; 11 | const TOKEN_ENDPOINT = "https://hd24ouudmhx7ixzla4i6so2atm0fgsex.lambda-url.us-west-2.on.aws"; 12 | 13 | 14 | // View Panel Module 15 | class ViewPanel { 16 | constructor(viewer) { 17 | this.viewer = viewer; 18 | this.views = [ 19 | {"objectSet":[{"id":[],"idType":"lmv","isolated":[],"hidden":[5137],"explodeScale":0,"explodeOptions":{"magnitude":4,"depthDampening":0}}],"viewport":{"aspectRatio":2.799582463465553,"isOrthographic":false,"name":"","eye":[273.86784275360964,146.7518894182768,175.36229264090395],"target":[0.000002014034180319868,-0.0000037221806792331336,3.7483673054339306e-7],"up":[-0.43323576346953174,-0.2321490843671658,0.8708694367575764],"distanceToOrbit":356.7795480131608,"projection":"perspective","worldUpVector":[0,0,1],"pivotPoint":[0.0000020140341945307227,-0.000003722180665022279,3.7483673409610674e-7],"fieldOfView":53.13010235415598},"autocam":{"sceneUpDirection":{"x":0,"y":0,"z":1},"sceneFrontDirection":{"x":0,"y":1,"z":0},"cubeFront":{"x":1,"y":0,"z":0}},"renderOptions":{"environment":"Boardwalk","ambientOcclusion":{"enabled":true,"radius":13.123359580052492,"intensity":1},"toneMap":{"method":1,"exposure":-7,"lightMultiplier":-1e-20},"appearance":{"ghostHidden":false,"ambientShadow":true,"antiAliasing":true,"progressiveDisplay":true,"swapBlackAndWhite":false,"displayLines":true,"displayPoints":true}},"cutplanes":[]}, 20 | {"objectSet":[{"id":[],"idType":"lmv","isolated":[137,136],"hidden":[],"explodeScale":0,"explodeOptions":{"magnitude":4,"depthDampening":0}}],"viewport":{"aspectRatio":2.799582463465553,"isOrthographic":false,"name":"","eye":[4.770224907588691,10.501315445426778,194.6501071382107],"target":[4.770224907588691,10.501315445426778,-31.494710722429176],"up":[0,-1,0],"distanceToOrbit":233.17996753712927,"worldUpVector":[0,0,1],"pivotPoint":[6.657524634996134,10.817089271586445,-38.52986039891858],"fieldOfView":53.13010235415598},"autocam":{"sceneUpDirection":{"x":0,"y":0,"z":1},"sceneFrontDirection":{"x":0,"y":1,"z":0},"cubeFront":{"x":1,"y":0,"z":0}},"renderOptions":{"environment":"Boardwalk","ambientOcclusion":{"enabled":true,"radius":13.123359580052492,"intensity":1},"toneMap":{"method":1,"exposure":-7,"lightMultiplier":-1e-20},"appearance":{"ghostHidden":false,"ambientShadow":true,"antiAliasing":true,"progressiveDisplay":true,"swapBlackAndWhite":false,"displayLines":true,"displayPoints":true}}}, 21 | {"viewport":{"aspectRatio":2.799582463465553,"isOrthographic":false,"name":"","eye":[1.309364501288762,11.964856856538447,7.228263191923022],"target":[-3.263407495149824,8.019303072525965,5.116411430030672],"up":[-0.2499013854358662,-0.21562399299688886,0.9439574096331425],"distanceToOrbit":40.907152302782116,"projection":"perspective","worldUpVector":[0,0,1],"pivotPoint":[-25.605135917663574,-14.811074256896973,-8.404598951339722],"fieldOfView":53.13010235415598}}, 22 | {"objectSet":[{"id":[19709],"idType":"lmv","isolated":[],"hidden":[],"explodeScale":0,"explodeOptions":{"magnitude":4,"depthDampening":0}}],"viewport":{"aspectRatio":2.799582463465553,"isOrthographic":false,"name":"","eye":[-43.600577512374095,14.938957729942036,3.163517757201216],"target":[-53.888010451515754,0.9431408298252375,-10.102010635378146],"up":[-0.35946893634112687,-0.48904925495727425,0.7947407816587205],"distanceToOrbit":31.347135926111157,"projection":"perspective","worldUpVector":[0,0,1],"pivotPoint":[-58.355350494384766,-5.134572982788086,-15.862595081329346],"fieldOfView":53.13010235415598},"autocam":{"sceneUpDirection":{"x":0,"y":0,"z":1},"sceneFrontDirection":{"x":0,"y":1,"z":0},"cubeFront":{"x":1,"y":0,"z":0}},"renderOptions":{"environment":"Boardwalk","ambientOcclusion":{"enabled":true,"radius":13.123359580052492,"intensity":1},"toneMap":{"method":1,"exposure":-7,"lightMultiplier":-1e-20},"appearance":{"ghostHidden":true,"ambientShadow":true,"antiAliasing":true,"progressiveDisplay":true,"swapBlackAndWhite":false,"displayLines":true,"displayPoints":true}},"cutplanes":[[0,0,1,12.72574520111084]]} 23 | ]; 24 | this.createUI(); 25 | } 26 | 27 | createUI() { 28 | const panel = document.createElement('div'); 29 | panel.className = 'absolute top-8 left-8 z-[100] bg-white rounded-lg shadow-lg border border-gray-200 px-4 py-3 flex items-center gap-3'; 30 | 31 | const label = document.createElement('span'); 32 | label.className = 'text-sm font-semibold text-gray-700'; 33 | label.textContent = 'View'; 34 | panel.appendChild(label); 35 | 36 | const buttons = ['A', 'B', 'C', 'D']; 37 | buttons.forEach((letter, index) => { 38 | const btn = document.createElement('button'); 39 | btn.className = 'w-8 h-8 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium text-sm transition-colors'; 40 | btn.textContent = letter; 41 | btn.onclick = () => this.switchView(index); 42 | panel.appendChild(btn); 43 | }); 44 | 45 | document.body.appendChild(panel); 46 | } 47 | 48 | switchView(index) { if (this.viewer && this.views[index]) this.viewer.restoreState(this.views[index]); } 49 | } 50 | 51 | 52 | // Main Application 53 | class ViewerApp { 54 | constructor() { 55 | this.viewer = null; 56 | this.viewPanel = null; 57 | this.icon3dDataSource = 'ext/icon3d.data.json'; 58 | } 59 | 60 | async init() { 61 | const token = await (await fetch(TOKEN_ENDPOINT)).text(); 62 | 63 | Autodesk.Viewing.Initializer({ 64 | env: "AutodeskProduction2", 65 | api: 'streamingV2', 66 | accessToken: token 67 | }, () => { 68 | const config = { 69 | extensions: ['icon3d.infoCard'], 70 | extensionOptions: { 71 | 'icon3d.infoCard': { dataSource: this.icon3dDataSource } 72 | } 73 | }; 74 | 75 | this.viewer = new Autodesk.Viewing.Private.GuiViewer3D(document.getElementById('apsViewer'), config); 76 | 77 | this.viewer.start(); 78 | this.viewer.setTheme("light-theme"); 79 | this.viewPanel = new ViewPanel(this.viewer); 80 | 81 | Autodesk.Viewing.Document.load(`urn:${DEFAULT_URN}`, async (doc) => { 82 | const viewables = doc.getRoot().getDefaultGeometry(); 83 | 84 | this.viewer.loadDocumentNode(doc, viewables); 85 | // Slow down camera movement 86 | this.viewer.autocam.shotParams.destinationPercent = 2.5; 87 | this.viewer.autocam.shotParams.duration = 4; 88 | await this.viewer.waitForLoadDone(); 89 | this.viewer.hide(5137); 90 | }); 91 | }); 92 | } 93 | 94 | } 95 | 96 | const app = new ViewerApp(); 97 | document.addEventListener('DOMContentLoaded', () => app.init()); 98 | -------------------------------------------------------------------------------- /ext/icon3d.infoCard.js: -------------------------------------------------------------------------------- 1 | import { Icon3dTool } from './icon3d.js'; 2 | 3 | // Configuration 4 | const LINE_COLOR = 0xffffff; // White line color (change to 0x000000 for black, 0xff0000 for red, etc.) 5 | const LINE_THICKNESS = 0.9; // Line thickness (radius) 6 | const LABEL_X_OFFSET = -90; // 2D label X offset in pixels 7 | const LABEL_Y_OFFSET = -200; // 2D label Y offset in pixels 8 | const LABEL_Z_OFFSET = 75; // 3D line height (Z-axis) 9 | const OVERLAY_NAME = 'infocard-overlay'; 10 | 11 | export class InfoCard { 12 | constructor(icon3dTool, viewer) { 13 | this.icon3dTool = icon3dTool; 14 | this.viewer = viewer; 15 | this.overlayName = OVERLAY_NAME; 16 | this.labelOffset = new THREE.Vector3(0, 0, LABEL_Z_OFFSET); 17 | this.labelElement = document.getElementById('label'); 18 | 19 | this.state = { 20 | selectedId: null, 21 | label: null 22 | }; 23 | 24 | this.meshes = {}; 25 | 26 | // Setup camera change listener for repositioning 27 | this.viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, () => this.updatePosition()); 28 | 29 | // Create overlay scene for 3D lines 30 | this.viewer.overlays.addScene(this.overlayName); 31 | 32 | // Create 3D line once and reuse 33 | const material = new THREE.MeshBasicMaterial({ 34 | color: LINE_COLOR, 35 | transparent: true, 36 | opacity: 0.8 37 | }); 38 | this.meshes.line = new THREE.Mesh(new THREE.CylinderGeometry(LINE_THICKNESS, LINE_THICKNESS, 100, 8), material); 39 | this.meshes.line.visible = false; 40 | this.viewer.overlays.addMesh(this.meshes.line, this.overlayName); 41 | } 42 | 43 | show(itemId) { 44 | this.state.selectedId = itemId; 45 | const item = this.icon3dTool.getMarkupItem(itemId); 46 | 47 | // Position and show the 3D line 48 | const startPos = new THREE.Vector3(item.x, item.y, item.z + 3); 49 | const endPos = startPos.clone().add(this.labelOffset); 50 | const midpoint = startPos.clone().add(endPos).multiplyScalar(0.5); 51 | this.meshes.line.position.copy(midpoint); 52 | this.meshes.line.scale.y = this.labelOffset.length() / 100; 53 | this.meshes.line.rotation.set(Math.PI / 2, 0, 0); 54 | this.meshes.line.visible = true; 55 | 56 | // Create and position the 2D info card 57 | this.labelElement.innerHTML = this.createCardHTML(item); 58 | this.labelElement.style.display = 'block'; 59 | this.updatePosition(); 60 | this.viewer.impl.invalidate(true); 61 | } 62 | 63 | hide() { 64 | this.state.selectedId = null; 65 | 66 | // Hide 3D line 67 | if (this.meshes.line) { 68 | this.meshes.line.visible = false; 69 | } 70 | 71 | // Hide 2D card 72 | if (this.labelElement) { 73 | this.labelElement.style.display = 'none'; 74 | } 75 | 76 | this.viewer.impl.invalidate(true); 77 | } 78 | 79 | updatePosition() { 80 | if (this.state.selectedId === null) return; 81 | const item = this.icon3dTool.getMarkupItem(this.state.selectedId); 82 | 83 | // Calculate 3D position of line endpoint 84 | const startPos = new THREE.Vector3(item.x, item.y, item.z + 3); 85 | const endPos = startPos.clone().add(this.labelOffset); 86 | 87 | // Project to 2D screen coordinates 88 | this.state.label = endPos.project(this.viewer.impl.camera); 89 | 90 | // Convert normalized device coordinates to screen coordinates 91 | const x = (this.state.label.x + 1) * 0.5 * innerWidth + LABEL_X_OFFSET; 92 | const y = (-this.state.label.y + 1) * 0.5 * innerHeight + LABEL_Y_OFFSET; 93 | 94 | this.labelElement.style.left = x + 'px'; 95 | this.labelElement.style.top = y + 'px'; 96 | } 97 | 98 | createCardHTML(item) { 99 | const priorities = { 100 | 'Critical': 'bg-red-500', 101 | 'High': 'bg-orange-500', 102 | 'Medium': 'bg-yellow-500', 103 | 'Low': 'bg-green-500' 104 | }; 105 | 106 | const types = ['Issue', 'Warning', 'RFI', 'Quality']; 107 | 108 | return ` 109 |
110 |
111 |
112 |
113 | ${types[item.icon]} 114 |
115 | #${item.id} 116 |
117 | 118 |

${item.title}

119 |

${item.description}

120 | 121 |
122 |
123 | Priority: 124 | ${item.priority} 125 |
126 |
127 | Assignee: 128 | ${item.assignee} 129 |
130 |
131 | Date: 132 | ${item.date} 133 |
134 |
135 |
136 | `; 137 | } 138 | 139 | 140 | cleanup() { 141 | // Hide any visible cards 142 | this.hide(); 143 | 144 | // Remove 3D objects 145 | Object.values(this.meshes).forEach(mesh => mesh && this.viewer.overlays.removeMesh(mesh, this.overlayName)); 146 | this.meshes = {}; 147 | 148 | // Remove camera listener 149 | this.viewer.removeEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, () => this.updatePosition()); 150 | } 151 | } 152 | 153 | // Enhanced extension that combines Icon3dTool with InfoCard 154 | export class Icon3dExtension extends Autodesk.Viewing.Extension { 155 | constructor(viewer, options) { super(viewer, options); this.icon3dTool = null; this.infoCard = null; } 156 | 157 | async load() { 158 | // Create the core 3D tool 159 | this.icon3dTool = new Icon3dTool(this.viewer, this.options); 160 | this.viewer.toolController.registerTool(this.icon3dTool); 161 | 162 | // Create InfoCard and connect it to the tool 163 | this.infoCard = new InfoCard(this.icon3dTool, this.viewer); 164 | this.icon3dTool.setInfoCard(this.infoCard); 165 | 166 | // Activate the tool 167 | this.viewer.toolController.activateTool(this.icon3dTool.getName()); 168 | 169 | return true; 170 | } 171 | 172 | unload() { 173 | if (this.icon3dTool) { 174 | this.viewer.toolController.deactivateTool(this.icon3dTool.getName()); 175 | this.viewer.toolController.deregisterTool(this.icon3dTool); 176 | this.icon3dTool = null; 177 | } 178 | 179 | this.infoCard = null; 180 | return true; 181 | } 182 | } 183 | 184 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d.infoCard', Icon3dExtension); 185 | -------------------------------------------------------------------------------- /ext/icon3d.js: -------------------------------------------------------------------------------- 1 | export class Icon3dTool extends Autodesk.Viewing.ToolInterface { 2 | constructor(viewer, options = {}) { 3 | super(); 4 | this.viewer = viewer; 5 | this.options = options; 6 | this.names = ['icon3d-tool']; 7 | this.active = false; 8 | this.overlayName = 'icon3d-overlay'; 9 | this.infoCard = null; // Optional InfoCard reference 10 | 11 | this.config = { 12 | size: 120.0, 13 | threshold: 5, 14 | dataSource: options?.dataSource || 'ext/icon3d.data.json' 15 | }; 16 | 17 | this.state = { 18 | markupItems: [], 19 | hovered: null 20 | }; 21 | 22 | this.meshes = {}; 23 | this.raycaster = new THREE.Raycaster(); 24 | this.raycaster.params.PointCloud.threshold = this.config.threshold; 25 | 26 | // Remove inherited methods to use our own 27 | delete this.register; 28 | delete this.deregister; 29 | delete this.activate; 30 | delete this.deactivate; 31 | delete this.getPriority; 32 | delete this.handleSingleClick; 33 | delete this.handleMouseMove; 34 | } 35 | 36 | getName() { return this.names[0]; } 37 | getPriority() { return 50; } 38 | setInfoCard(infoCard) { this.infoCard = infoCard; } 39 | getMarkupItem(id) { return this.state.markupItems.find(item => item.id === id); } 40 | 41 | activate(name, viewer) { 42 | if (!this.active) { 43 | this.active = true; 44 | 45 | if (!this.viewer.overlays.hasScene(this.overlayName)) { 46 | this.viewer.overlays.addScene(this.overlayName); 47 | } 48 | 49 | // Wait for model to load before loading markup data 50 | if (this.viewer.model) { 51 | this.loadMarkupData(); 52 | } else { 53 | this.viewer.addEventListener(Autodesk.Viewing.GEOMETRY_LOADED_EVENT, () => { 54 | this.loadMarkupData(); 55 | }); 56 | } 57 | } 58 | 59 | return true; 60 | } 61 | 62 | deactivate(name) { 63 | if (this.active) { 64 | this.cleanup(); 65 | this.active = false; 66 | } 67 | return true; 68 | } 69 | 70 | handleSingleClick(event, button) { 71 | const hitIndex = this.performHitTest(event); 72 | if (hitIndex !== null && this.infoCard) { 73 | const item = this.state.markupItems[hitIndex]; 74 | this.infoCard.show(item.id); 75 | } 76 | return hitIndex !== null; 77 | } 78 | 79 | handleMouseMove(event) { 80 | const hitIndex = this.performHitTest(event); 81 | 82 | if (hitIndex !== null) { 83 | if (this.state.hovered !== null) { 84 | this.geometry.colors[this.state.hovered].r = 1.0; 85 | } 86 | this.state.hovered = hitIndex; 87 | this.geometry.colors[hitIndex].r = 2.0; 88 | this.geometry.colorsNeedUpdate = true; 89 | this.viewer.impl.invalidate(true); 90 | } else { 91 | if (this.state.hovered !== null) { 92 | this.geometry.colors[this.state.hovered].r = 1.0; 93 | this.state.hovered = null; 94 | this.geometry.colorsNeedUpdate = true; 95 | this.viewer.impl.invalidate(true); 96 | } 97 | } 98 | return false; 99 | } 100 | 101 | async loadMarkupData() { 102 | const response = await fetch(this.config.dataSource); 103 | this.state.markupItems = await response.json(); 104 | 105 | this.geometry = new THREE.Geometry(); 106 | this.state.markupItems.forEach(item => { 107 | this.geometry.vertices.push(new THREE.Vector3(item.x, item.y, item.z + 3)); 108 | this.geometry.colors.push(new THREE.Color(1.0, item.icon, 0)); 109 | }); 110 | 111 | // Load texture and wait for it to be ready 112 | const texture = THREE.ImageUtils.loadTexture('ext/icons.png', undefined, () => { 113 | this.viewer.impl.invalidate(true); 114 | }); 115 | 116 | const material = new THREE.ShaderMaterial({ 117 | vertexColors: THREE.VertexColors, 118 | fragmentShader: this.fragmentShader, 119 | vertexShader: this.vertexShader, 120 | depthWrite: true, 121 | depthTest: true, 122 | uniforms: { 123 | size: { type: 'f', value: this.config.size }, 124 | tex: { type: 't', value: texture } 125 | } 126 | }); 127 | 128 | this.meshes.pointCloud = new THREE.PointCloud(this.geometry, material); 129 | this.viewer.overlays.addMesh(this.meshes.pointCloud, this.overlayName); 130 | } 131 | 132 | performHitTest(event) { 133 | if (!this.meshes.pointCloud) return null; 134 | 135 | const canvas = this.viewer.canvas; 136 | const rect = canvas.getBoundingClientRect(); 137 | const x = ((event.offsetX || event.clientX - rect.left) / canvas.clientWidth) * 2 - 1; 138 | const y = -((event.offsetY || event.clientY - rect.top) / canvas.clientHeight) * 2 + 1; 139 | 140 | const camera = this.viewer.impl.camera; 141 | 142 | if (camera.isPerspective) { 143 | // Perspective camera: rays emanate from camera position 144 | const vector = new THREE.Vector3(x, y, 0.5).unproject(camera); 145 | this.raycaster.set( 146 | camera.position, 147 | vector.sub(camera.position).normalize() 148 | ); 149 | } else { 150 | // Orthographic camera: parallel rays from screen point 151 | const origin = new THREE.Vector3(x, y, -1).unproject(camera); 152 | const direction = new THREE.Vector3(0, 0, -1).transformDirection(camera.matrixWorld); 153 | this.raycaster.set(origin, direction); 154 | } 155 | 156 | const intersects = this.raycaster.intersectObject(this.meshes.pointCloud); 157 | return intersects.length > 0 ? intersects[0].index : null; 158 | } 159 | 160 | cleanup() { 161 | this.infoCard?.cleanup(); 162 | Object.values(this.meshes).forEach(mesh => this.viewer.overlays.removeMesh(mesh, this.overlayName)); 163 | this.meshes = {}; 164 | this.state.hovered = null; 165 | } 166 | 167 | get vertexShader() { 168 | return ` 169 | uniform float size; 170 | varying vec3 vColor; 171 | void main() { 172 | vColor = color; 173 | vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); 174 | gl_PointSize = size * (size / (length(mvPosition.xyz) + 1.0)); 175 | gl_Position = projectionMatrix * mvPosition; 176 | } 177 | `; 178 | } 179 | 180 | get fragmentShader() { 181 | return ` 182 | uniform sampler2D tex; 183 | varying vec3 vColor; 184 | void main() { 185 | gl_FragColor = vec4(vColor.x, vColor.x, vColor.x, 1.0); 186 | gl_FragColor = gl_FragColor * texture2D(tex, vec2((gl_PointCoord.x + vColor.y * 1.0) / 4.0, 1.0 - gl_PointCoord.y)); 187 | if (gl_FragColor.w < 0.5) discard; 188 | } 189 | `; 190 | } 191 | } 192 | 193 | // Simple extension wrapper for standalone use 194 | export class Icon3dToolExtension extends Autodesk.Viewing.Extension { 195 | constructor(viewer, options) { super(viewer, options); this.tool = null; } 196 | 197 | async load() { 198 | this.tool = new Icon3dTool(this.viewer, this.options); 199 | this.viewer.toolController.registerTool(this.tool); 200 | this.viewer.toolController.activateTool(this.tool.getName()); 201 | 202 | return true; 203 | } 204 | 205 | unload() { 206 | this.viewer.toolController.deactivateTool(this.tool.getName()); 207 | this.viewer.toolController.deregisterTool(this.tool); 208 | this.tool = null; 209 | return true; 210 | } 211 | } 212 | 213 | Autodesk.Viewing.theExtensionManager.registerExtension('icon3d', Icon3dToolExtension); 214 | --------------------------------------------------------------------------------