Last active
November 5, 2025 09:59
-
-
Save yiskang/f7c116e445e15a9c027b821064454d6b to your computer and use it in GitHub Desktop.
Human Eye Best View Extension for Autodesk Platform Services (APS) Viewer. Automatically finds the optimal viewing angle for selected 3D objects by analyzing visibility, occlusion, and geometric characteristics to provide human-like view selection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // After loading Viewer & model(s) | |
| const ext = await viewer.loadExtension('Autodesk.DeveloperAdvocacySupport.HumanEyeBestViewExtension', { | |
| // optional overrides: | |
| numCandidatesRing: 20, | |
| preferFrontHemisphere: true, | |
| enableNarrowSideSkip: true, | |
| weightVisible: 0.6, | |
| weightBlockers: 0.4, | |
| applyView: true | |
| }); | |
| // Run on selection via toolbar button, or programmatically: | |
| const sel = viewer.getSelection(); | |
| const result = await ext.computeForBestView(sel.length ? sel : [YOUR_DBID]); | |
| console.log('Best view:', result.best); | |
| ext.apply(result.best); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function () { | |
| ///////////////////////////////////////////////////////////////////// | |
| // Copyright (c) Autodesk, Inc. All rights reserved | |
| // Written by Developer Advocacy and Support | |
| // | |
| // Permission to use, copy, modify, and distribute this software in | |
| // object code form for any purpose and without fee is hereby granted, | |
| // provided that the above copyright notice appears in all copies and | |
| // that both that copyright notice and the limited warranty and | |
| // restricted rights notice below appear in all supporting | |
| // documentation. | |
| // | |
| // AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. | |
| // AUTODESK SPECIFICALLY DISCLAIMS ANY IMPLIED WARRANTY OF | |
| // MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. AUTODESK, INC. | |
| // DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE | |
| // UNINTERRUPTED OR ERROR FREE. | |
| ///////////////////////////////////////////////////////////////////// | |
| const EXT_ID = 'Autodesk.DeveloperAdvocacySupport.HumanEyeBestViewExtension'; | |
| /** | |
| * Human Eye Best View Extension for Autodesk Platform Services (APS) Viewer. | |
| * Automatically finds the optimal viewing angle for selected 3D objects by analyzing | |
| * visibility, occlusion, and geometric characteristics to provide human-like view selection. | |
| * | |
| * Features: | |
| * - Intelligent camera positioning using ray-casting and visibility analysis | |
| * - Blocker detection and visualization | |
| * - Preview mode for analyzing view obstructions | |
| * - Restore functionality to reset viewer state | |
| * | |
| * @extends Autodesk.Viewing.Extension | |
| */ | |
| class HumanEyeBestViewExtension extends Autodesk.Viewing.Extension { | |
| /** | |
| * Creates an instance of HumanEyeBestViewExtension. | |
| * | |
| * @param {Autodesk.Viewing.GuiViewer3D} viewer - The APS viewer instance | |
| * @param {Object} [options] - Configuration options to override defaults | |
| * @param {number} [options.numCandidatesRing=16] - Number of candidate viewing positions in ring | |
| * @param {boolean} [options.preferFrontHemisphere=true] - Prefer front-facing view candidates | |
| * @param {number} [options.bumpBackPct=0.10] - Percentage to pull back camera from computed position | |
| * @param {boolean} [options.enableNarrowSideSkip=true] - Skip edge-on views for elongated objects | |
| * @param {number} [options.weightVisible=0.6] - Weight factor for visibility scoring | |
| * @param {number} [options.weightBlockers=0.4] - Weight factor for blocker penalty | |
| * @param {boolean} [options.useOctree=true] - Use octree acceleration for performance | |
| * @param {string} [options.occluderMode='kNearestPerRay'] - Blocker detection mode | |
| * @param {boolean} [options.applyView=true] - Automatically apply best view when found | |
| * @param {boolean} [options.hideBlockers=true] - Hide blocking objects in best view mode | |
| * @param {THREE.Color} [options.colorTarget] - Color for target objects in preview mode | |
| * @param {THREE.Color} [options.colorBlocker] - Color for blocking objects in preview mode | |
| */ | |
| constructor(viewer, options) { | |
| super(viewer, options); | |
| // ===== Defaults you can override on load ===== | |
| this.opts = Object.assign({ | |
| // Candidate ring | |
| numCandidatesRing: 16, | |
| preferFrontHemisphere: true, | |
| bumpBackPct: 0.10, | |
| // Narrow-side heuristic (avoid edge-on views for elongated parts) | |
| enableNarrowSideSkip: true, | |
| coverageFactor: 4.0, | |
| ignoreNarrowSideDeg: 35, | |
| // Scoring | |
| weightVisible: 0.6, | |
| weightBlockers: 0.4, | |
| insidePenalty: 0.05, | |
| // Fallback accelerator | |
| useOctree: true, | |
| maxItemsPerNode: 20, | |
| maxDepth: 6, | |
| // Blocker gathering | |
| occluderMode: 'kNearestPerRay', // 'nearest' | 'all' | 'kNearestPerRay' | |
| occluderK: 3, | |
| minRayHits: 1, | |
| maxOccluders: 2000, | |
| // Visibility sampling | |
| samplePerPoint: 1, | |
| // Post-actions | |
| applyView: true, | |
| logDiagnostics: true, | |
| // Preview styling | |
| previewApplyBestView: true, | |
| colorTarget: new THREE.Color(0x2ecc71), // green | |
| colorBlocker: new THREE.Color(0xe74c3c), // red | |
| colorIntensity: 0.90, | |
| hideBlockers: true, | |
| // Whether Restore All should also fit the view back to whole model | |
| restoreFitView: false | |
| }, options || {}); | |
| // State | |
| this._group = null; | |
| this._btnRun = null; | |
| this._btnPreview = null; | |
| this._btnRestore = null; | |
| this._originalGhosting = true; | |
| this._lastResult = null; // { best, candidates } | |
| this._lastTargets = []; // dbIds array used in last compute | |
| this._themedIds = new Set(); // track what we colored | |
| this._onToolbarCreated = this._onToolbarCreated.bind(this); | |
| } | |
| /* ========================= | |
| * Extension lifecycle | |
| * =======================*/ | |
| /** | |
| * Loads the extension and initializes the user interface. | |
| * Called automatically by the viewer when the extension is loaded. | |
| * | |
| * @returns {boolean} True if successfully loaded | |
| */ | |
| load() { | |
| if (this.viewer.getToolbar()) this._createUI(); | |
| this.viewer.addEventListener(Autodesk.Viewing.TOOLBAR_CREATED_EVENT, this._onToolbarCreated); | |
| console.log(`${EXT_ID} loaded`); | |
| return true; | |
| } | |
| /** | |
| * Unloads the extension and cleans up resources. | |
| * Called automatically by the viewer when the extension is unloaded. | |
| * | |
| * @returns {boolean} True if successfully unloaded | |
| */ | |
| unload() { | |
| this.viewer.removeEventListener(Autodesk.Viewing.TOOLBAR_CREATED_EVENT, this._onToolbarCreated); | |
| this._removeUI(); | |
| console.log(`${EXT_ID} unloaded`); | |
| return true; | |
| } | |
| /** | |
| * Event handler for toolbar creation. Creates UI when toolbar becomes available. | |
| * @private | |
| */ | |
| _onToolbarCreated() { this._createUI(); } | |
| /** | |
| * Creates the toolbar UI with three main buttons: | |
| * - Best View: Computes and applies optimal viewing angle | |
| * - Preview: Shows blocking objects without hiding them | |
| * - Restore: Resets all visual modifications | |
| * @private | |
| */ | |
| _createUI() { | |
| const tb = this.viewer.getToolbar(); | |
| if (!tb) return; | |
| const groupId = `${EXT_ID}.controls`; | |
| let group = tb.getControl(groupId); | |
| if (!group) { | |
| group = new Autodesk.Viewing.UI.ControlGroup(groupId); | |
| tb.addControl(group); | |
| } | |
| this._group = group; | |
| // Run Best View (compute + apply + hide blockers) — existing behavior you liked | |
| this._btnRun = new Autodesk.Viewing.UI.Button(`${EXT_ID}.bestview`); | |
| this._btnRun.setToolTip('Best View (Human-eye)'); | |
| this._btnRun.setIcon('adsk-icon-fit-to-view'); | |
| this._btnRun.onClick = async () => { await this.runOnSelection(); }; | |
| group.addControl(this._btnRun); | |
| // NEW: Preview Blockers (no hiding; colorize only) | |
| this._btnPreview = new Autodesk.Viewing.UI.Button(`${EXT_ID}.preview`); | |
| this._btnPreview.setToolTip('Preview Blockers (no hiding)'); | |
| this._btnPreview.setIcon('adsk-icon-visible'); | |
| this._btnPreview.onClick = async () => { await this.previewBlockersOnSelection(); }; | |
| group.addControl(this._btnPreview); | |
| // NEW: Restore All (clear theming/ghosting/isolation/hidden) | |
| this._btnRestore = new Autodesk.Viewing.UI.Button(`${EXT_ID}.restore`); | |
| this._btnRestore.setToolTip('Restore All (clear preview/hides)'); | |
| this._btnRestore.setIcon('adsk-icon-refresh'); | |
| this._btnRestore.onClick = async () => { await this.restoreAll(); }; | |
| group.addControl(this._btnRestore); | |
| } | |
| /** | |
| * Removes all UI elements and cleans up button references. | |
| * @private | |
| */ | |
| _removeUI() { | |
| const tb = this.viewer.getToolbar?.(); | |
| if (tb && this._group) { | |
| if (this._btnRun) this._group.removeControl(this._btnRun.getId()); | |
| if (this._btnPreview) this._group.removeControl(this._btnPreview.getId()); | |
| if (this._btnRestore) this._group.removeControl(this._btnRestore.getId()); | |
| tb.removeControl(this._group); | |
| } | |
| this._group = null; | |
| this._btnRun = null; | |
| this._btnPreview = null; | |
| this._btnRestore = null; | |
| } | |
| /* ========================= | |
| * Public API | |
| * =======================*/ | |
| /** | |
| * Updates extension options with new values. | |
| * | |
| * @param {Object} partial - Partial options object to merge with current settings | |
| * @returns {Object} Complete updated options object | |
| * | |
| * @example | |
| * extension.setOptions({ | |
| * numCandidatesRing: 24, | |
| * hideBlockers: false, | |
| * colorTarget: new THREE.Color(0x00ff00) | |
| * }); | |
| */ | |
| setOptions(partial) { Object.assign(this.opts, partial || {}); return { ...this.opts }; } | |
| /** | |
| * Main function that computes and applies the best viewing angle for selected objects. | |
| * Analyzes visibility, detects blockers, applies camera position, and optionally hides blocking objects. | |
| * | |
| * @async | |
| * @returns {Promise<Object|null>} Result object with best candidate and all evaluated candidates, or null if failed | |
| * @returns {Object} result.best - Best viewing candidate with position, target, score, and blockers | |
| * @returns {Array} result.candidates - All evaluated viewing candidates with scores | |
| * | |
| * @example | |
| * // Select objects first, then call | |
| * const result = await extension.runOnSelection(); | |
| * if (result) { | |
| * console.log(`Best view score: ${result.best.score}`); | |
| * console.log(`Found ${result.best.blockers.dbIds.length} blocking objects`); | |
| * } | |
| */ | |
| async runOnSelection() { | |
| const sel = this.viewer.getSelection(); | |
| if (!sel || !sel.length) { | |
| Autodesk.Viewing.Private.logger.warn('[HumanEyeBestViewExtension] Select a part first.'); | |
| return null; | |
| } | |
| const result = await this.computeForBestView(sel, this.opts); | |
| if (!result) { | |
| Autodesk.Viewing.Private.logger.warn('[HumanEyeBestViewExtension] No result.'); | |
| return null; | |
| } | |
| this._lastResult = result; | |
| this._lastTargets = Array.isArray(sel) ? sel.slice() : [sel]; | |
| // Apply best view | |
| if (this.opts.applyView && result.best) this.apply(result.best); | |
| // Auto-hide blockers of the winning candidate | |
| if (this.opts.hideBlockers && result.best && result.best.blockers && result.best.blockers.dbIds.length) { | |
| this.viewer.hide(result.best.blockers.dbIds); | |
| } | |
| if (this.opts.logDiagnostics && result.candidates) { | |
| console.table(result.candidates.map(c => ({ | |
| idx: c.index, visPct: (c.visibleFrac * 100).toFixed(0), score: c.score.toFixed(3), | |
| inside: c.insideOther, blockers: c.blockers.dbIds.length | |
| }))); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Preview mode that visualizes blocking objects without hiding them. | |
| * Colors target objects green and blocking objects red for analysis. | |
| * Optionally applies the best camera angle for preview. | |
| * | |
| * @async | |
| * @returns {Promise<void>} | |
| * | |
| * @example | |
| * // Select objects and preview blockers | |
| * await extension.previewBlockersOnSelection(); | |
| * // Target will be green, blockers will be red | |
| */ | |
| async previewBlockersOnSelection() { | |
| const sel = this.viewer.getSelection(); | |
| const ids = (sel && sel.length) ? sel : this._lastTargets; | |
| if (!ids || !ids.length) { | |
| Autodesk.Viewing.Private.logger.warn('[HumanEyeBestViewExtension] Select a part first.'); | |
| return; | |
| } | |
| // Compute if needed | |
| if (!this._lastResult || !this._lastTargets || !arrayShallowEqual(ids, this._lastTargets)) { | |
| this._lastResult = await this.computeForBestView(ids, this.opts); | |
| this._lastTargets = ids.slice(); | |
| } | |
| const best = this._lastResult?.best; | |
| if (!best) { | |
| Autodesk.Viewing.Private.logger.warn('[HumanEyeBestViewExtension] No best candidate to preview.'); | |
| return; | |
| } | |
| // Optionally apply the best camera (but do NOT hide anything) | |
| if (this.opts.previewApplyBestView) this.apply(best); | |
| // Clear previous theming | |
| this._clearTheming(); | |
| this._originalGhosting = this.viewer.prefs.get('ghosting'); | |
| if (!this._originalGhosting) this.viewer.setGhosting(true); | |
| // Color target & blockers | |
| const targetV4 = colorToVec4(this.opts.colorTarget, this.opts.colorIntensity); | |
| const blockerV4 = colorToVec4(this.opts.colorBlocker, this.opts.colorIntensity); | |
| const model = this.viewer.model; | |
| const targetIds = Array.isArray(ids) ? ids : [ids]; | |
| for (const id of targetIds) { | |
| this.viewer.setThemingColor(id, targetV4, model, true); | |
| this._themedIds.add(id); | |
| } | |
| const blockers = best.blockers?.dbIds || []; | |
| for (const id of blockers) { | |
| this.viewer.setThemingColor(id, blockerV4, model, true); | |
| this._themedIds.add(id); | |
| } | |
| // Optional: slight emphasis on involved objects | |
| // (we’ll leave ghosting off to keep all geometry visible & solid) | |
| // If you want extra pop, uncomment the next two lines: | |
| // this.viewer.select([...targetIds, ...blockers], Autodesk.Viewing.SelectionMode.REGULAR); | |
| Autodesk.Viewing.Private.logger.info( | |
| `[HumanEyeBestViewExtension] Previewed blockers: ${blockers.length} (colored red), target colored green.` | |
| ); | |
| } | |
| /** | |
| * Restores the viewer to its original state by clearing all modifications: | |
| * - Removes color theming from objects | |
| * - Clears selection | |
| * - Removes isolation | |
| * - Restores original ghosting setting | |
| * - Shows all hidden objects | |
| * - Optionally fits view to entire model | |
| * | |
| * @async | |
| * @returns {Promise<void>} | |
| * | |
| * @example | |
| * // Restore everything to original state | |
| * await extension.restoreAll(); | |
| */ | |
| async restoreAll() { | |
| this._clearTheming(); | |
| this.viewer.clearSelection(); | |
| this.viewer.isolate([]); // clears isolate | |
| if( this.viewer.prefs.get('ghosting') != this._originalGhosting) this.viewer.setGhosting(this._originalGhosting); | |
| this.viewer.showAll(); | |
| if (this.opts.restoreFitView) { | |
| // // Quick whole-model fit (optional) | |
| // const box = safeModelBoundingBox(this.viewer.model); | |
| // const c = box.getCenter(new THREE.Vector3()); | |
| // const cam = this.viewer.navigation.getCamera(); | |
| // const dist = suggestedDistanceFor(box, cam, 1.2); | |
| // const eye = c.clone().add(new THREE.Vector3(0, -dist, dist * 0.4)); | |
| // this.viewer.navigation.setView(eye, c); | |
| // if (this.viewer.navigation.orientCameraUp) this.viewer.navigation.orientCameraUp(); | |
| this.viewer.fitToView(); | |
| } | |
| Autodesk.Viewing.Private.logger.info('[HumanEyeBestViewExtension] Restore All complete.'); | |
| } | |
| /** | |
| * Applies a computed viewpoint to the viewer camera and levels the horizon. | |
| * Sets the camera position, target, pivot point, and orients the camera up vector. | |
| * | |
| * @param {Object} bestView - Computed best view object | |
| * @param {THREE.Vector3} bestView.position - Camera eye position | |
| * @param {THREE.Vector3} bestView.target - Camera look-at target | |
| * | |
| * @example | |
| * const viewpoint = { | |
| * position: new THREE.Vector3(10, 10, 10), | |
| * target: new THREE.Vector3(0, 0, 0) | |
| * }; | |
| * extension.apply(viewpoint); | |
| */ | |
| apply(bestView) { | |
| if (!bestView) return; | |
| this.viewer.navigation.setView(bestView.position, bestView.target); | |
| if (this.viewer.navigation.orientCameraUp) this.viewer.navigation.orientCameraUp(); | |
| this.viewer.navigation.setPivotPoint(bestView.target); | |
| this.viewer.navigation.setPivotSetFlag(true); | |
| } | |
| /** | |
| * Core computation engine that finds the optimal viewing angle for given objects. | |
| * | |
| * Algorithm overview: | |
| * 1. Generates candidate camera positions in a ring around the target | |
| * 2. Filters candidates based on geometric heuristics (elongation analysis) | |
| * 3. Evaluates each candidate using visibility and blocker analysis | |
| * 4. Scores candidates based on weighted visibility and blocker penalties | |
| * 5. Returns the highest-scoring candidate as the best view | |
| * | |
| * @async | |
| * @param {number|number[]} dbIds - Viewer dbIds of target objects | |
| * @param {Object} [userOpts={}] - Options to override default settings | |
| * @returns {Promise<Object>} Result containing best candidate and all evaluated candidates | |
| * @returns {Object} result.best - Highest scoring candidate with position, target, score, and blocker info | |
| * @returns {Array<Object>} result.candidates - All evaluated candidates with detailed scoring data | |
| * | |
| * @example | |
| * const result = await extension.computeForBestView([123, 456], { | |
| * numCandidatesRing: 24, | |
| * weightVisible: 0.7, | |
| * weightBlockers: 0.3 | |
| * }); | |
| * | |
| * console.log(`Best view score: ${result.best.score.toFixed(3)}`); | |
| * console.log(`Visibility: ${(result.best.visibleFrac * 100).toFixed(1)}%`); | |
| */ | |
| async computeForBestView(dbIds, userOpts = {}) { | |
| const opts = Object.assign({}, this.opts, userOpts); | |
| const viewer = this.viewer; | |
| const model = viewer.model; | |
| const ids = Array.isArray(dbIds) ? dbIds : [dbIds]; | |
| await ensureInstanceTreeReady(model); | |
| // Target box & keypoints | |
| const targetBox = await getWorldBBoxForDbIds(model, ids); | |
| const targetCenter = targetBox.getCenter(new THREE.Vector3()); | |
| const keyPoints = bboxKeyPoints(targetBox); | |
| // AABB fragment cache (for fallback + “inside-other”) | |
| const { allFrags, targetFragSet } = buildFragmentBboxCache(model, ids); | |
| let accel = null; | |
| if (opts.useOctree) { | |
| const worldBox = safeModelBoundingBox(model); | |
| accel = buildAabbOctree(allFrags.nonTarget, worldBox, { maxItemsPerNode: opts.maxItemsPerNode, maxDepth: opts.maxDepth }); | |
| } else { | |
| accel = allFrags.nonTarget; // flat array | |
| } | |
| const aabbItems = opts.useOctree ? flattenOctreeLeaves(accel) : accel; | |
| // Make sure perspective is on for consistent computeFit | |
| const nav = viewer.navigation; | |
| if (!nav.getCamera().isPerspective) nav.toPerspective(); | |
| const camera = nav.getCamera(); | |
| const fov = nav.getVerticalFov(); | |
| const aspect = camera.aspect; | |
| // Ring directions (XZ plane) | |
| const camDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize(); | |
| // World axes for narrow-side heuristic | |
| const worldRight = nav.getWorldRightVector().clone().normalize(); | |
| const worldUp = nav.getWorldUpVector().clone().normalize(); | |
| const worldFront = new THREE.Vector3().crossVectors(worldUp, worldRight).normalize(); | |
| // Elongation analysis | |
| const size = targetBox.getSize(new THREE.Vector3()); | |
| const spanRight = Math.abs(size.clone().multiply(worldRight).length()); | |
| const spanFront = Math.abs(size.clone().multiply(worldFront).length()); | |
| const wideSpan = Math.max(spanRight, spanFront); | |
| const narrowSpan = Math.max(1e-6, Math.min(spanRight, spanFront)); | |
| const elongated = opts.enableNarrowSideSkip && (wideSpan / narrowSpan) > opts.coverageFactor; | |
| const wideAxis = elongated ? (wideSpan === spanRight ? worldRight : worldFront) : null; | |
| const ringDirs = []; | |
| for (let i = 0; i < opts.numCandidatesRing; i++) { | |
| const theta = (i / opts.numCandidatesRing) * Math.PI * 2; | |
| ringDirs.push(new THREE.Vector3(Math.cos(theta), -Math.sin(theta), 0).normalize()); | |
| } | |
| const filteredDirs = opts.preferFrontHemisphere ? ringDirs.filter(d => d.dot(camDir) < 0) : ringDirs; | |
| // Build candidates using computeFit + pull-back + height clamp | |
| const candidates = []; | |
| for (const d of filteredDirs) { | |
| const seedEye = targetCenter.clone().add(d); | |
| let eye; | |
| if (nav.computeFit) { | |
| const fit = nav.computeFit(seedEye, targetCenter, fov, targetBox, aspect); | |
| eye = fit && fit.position ? fit.position.clone() | |
| : targetCenter.clone().add(d.clone().multiplyScalar(suggestedDistanceFor(targetBox, camera, 1.6))); | |
| } else { | |
| eye = targetCenter.clone().add(d.clone().multiplyScalar(suggestedDistanceFor(targetBox, camera, 1.6))); | |
| } | |
| const delta = eye.clone().sub(targetCenter); | |
| eye.add(delta.clone().multiplyScalar(opts.bumpBackPct)); | |
| eye.z = Math.max(eye.z, targetCenter.z); | |
| if (wideAxis) { | |
| const bearing = delta.clone().normalize(); | |
| const angDeg = Math.acos(THREE.MathUtils.clamp(bearing.dot(wideAxis), -1, 1)) * 180 / Math.PI; | |
| if (Math.abs(angDeg) < opts.ignoreNarrowSideDeg) continue; | |
| } | |
| candidates.push({ eye }); | |
| } | |
| if (!candidates.length) { | |
| const dist = suggestedDistanceFor(targetBox, camera, 1.6); | |
| const eye = targetCenter.clone().add(camDir.clone().multiplyScalar(-dist)); | |
| eye.z = Math.max(eye.z, targetCenter.z); | |
| candidates.push({ eye }); | |
| } | |
| // Geometry or AABB fallback? | |
| const useGeometry = allGeometryReady(viewer) && !!viewer.impl?.rayIntersect; | |
| // Evaluate | |
| let best = null, bestScore = -Infinity; | |
| const results = []; | |
| for (let i = 0; i < candidates.length; i++) { | |
| const eye = candidates[i].eye; | |
| const visibleFrac = useGeometry | |
| ? visibilityFractionByRayOcclusionGeometry(viewer, eye, targetCenter, keyPoints, ids, opts.samplePerPoint) | |
| : (opts.useOctree | |
| ? visibilityFractionByRayOcclusion(eye, targetCenter, keyPoints, accel, targetFragSet, opts.samplePerPoint) | |
| : visibilityFractionByRayOcclusionFlat(eye, targetCenter, keyPoints, accel, targetFragSet, opts.samplePerPoint)); | |
| const blockers = useGeometry | |
| ? gatherOccludersForViewWithCounts_Geometry(viewer, eye, keyPoints, ids, { | |
| occluderMode: opts.occluderMode, occluderK: opts.occluderK, minRayHits: opts.minRayHits, maxOccluders: opts.maxOccluders | |
| }) | |
| : (opts.useOctree | |
| ? gatherOccludersForViewWithCounts_Octree(eye, keyPoints, accel, targetFragSet, { | |
| occluderMode: opts.occluderMode, occluderK: opts.occluderK, minRayHits: opts.minRayHits, maxOccluders: opts.maxOccluders | |
| }) | |
| : gatherOccludersForViewWithCounts_Flat(eye, keyPoints, accel, targetFragSet, { | |
| occluderMode: opts.occluderMode, occluderK: opts.occluderK, minRayHits: opts.minRayHits, maxOccluders: opts.maxOccluders | |
| })); | |
| const blockerCount = blockers.dbIds.length; | |
| const normBlockers = blockerCount / Math.max(1, keyPoints.length); | |
| const insideOther = isEyeInsideAnyAabb(eye, aabbItems, targetFragSet); | |
| const score = (opts.weightVisible * visibleFrac) | |
| - (opts.weightBlockers * normBlockers) | |
| - (insideOther ? opts.insidePenalty : 0); | |
| const cand = { | |
| index: i, | |
| position: eye.clone(), | |
| target: targetCenter.clone(), | |
| visibleFrac, | |
| score, | |
| insideOther, | |
| blockers | |
| }; | |
| results.push(cand); | |
| if (score > bestScore) { bestScore = score; best = cand; } | |
| } | |
| return { best, candidates: results }; | |
| } | |
| /* ========================= | |
| * Private helpers | |
| * =======================*/ | |
| /** | |
| * Clears all color theming applied to objects and resets the themed IDs set. | |
| * Uses the fast path of clearing all theming at once for better performance. | |
| * @private | |
| */ | |
| _clearTheming() { | |
| if (this._themedIds.size) { | |
| // Fast path: clear all theming at once | |
| this.viewer.clearThemingColors(this.viewer.model); | |
| this._themedIds.clear(); | |
| } | |
| } | |
| } | |
| Autodesk.Viewing.theExtensionManager.registerExtension(EXT_ID, HumanEyeBestViewExtension); | |
| /* ============================================================================ | |
| * Helpers & dependencies (scoped inside the IIFE) | |
| * ==========================================================================*/ | |
| /** | |
| * Gets the safe bounding box for a model, with fallback for older APS versions. | |
| * In APS v7+, uses the built-in getBoundingBox method. For older versions, | |
| * computes the bounding box by unioning all fragment bounds. | |
| * | |
| * @param {Object} model - APS model instance | |
| * @returns {THREE.Box3} World space bounding box of the entire model | |
| */ | |
| function safeModelBoundingBox(model) { | |
| const b = model.getBoundingBox(); // APS v7+ | |
| if (b && b.isBox3) return b.clone(); | |
| // Fallback: union all fragments (rarely needed) | |
| const fragList = model.getFragmentList(); | |
| const total = new THREE.Box3(); | |
| const temp = new THREE.Box3(); | |
| for (let i = 0; i < fragList.getCount(); i++) { | |
| fragList.getWorldBounds(i, temp); | |
| total.union(temp); | |
| } | |
| return total; | |
| } | |
| /** | |
| * Builds a cache of fragment bounding boxes categorized by target/non-target objects. | |
| * Creates efficient lookup structures for visibility and collision analysis. | |
| * | |
| * @param {Object} model - APS model instance | |
| * @param {number|number[]} targetDbIds - Viewer dbIds of target objects | |
| * @returns {Object} Fragment cache object | |
| * @returns {Object} result.allFrags - Contains target and nonTarget fragment arrays | |
| * @returns {Object} result.allFrags.target - Fragments belonging to target objects | |
| * @returns {Object} result.allFrags.nonTarget - Fragments not belonging to target objects | |
| * @returns {Set} result.targetFragSet - Set of target viewer object dbIds for fast lookup | |
| */ | |
| function buildFragmentBboxCache(model, targetDbIds) { | |
| const it = model.getInstanceTree(); | |
| const fragList = model.getFragmentList(); | |
| const targetSet = new Set(Array.isArray(targetDbIds) ? targetDbIds : [targetDbIds]); | |
| const fragCount = fragList.getCount(); | |
| const fragIdToDbId = new Int32Array(fragCount); | |
| fragIdToDbId.fill(-1); | |
| it.enumNodeChildren(it.getRootId(), function walk(nodeId) { | |
| it.enumNodeFragments(nodeId, fragId => { fragIdToDbId[fragId] = nodeId; }, true); | |
| it.enumNodeChildren(nodeId, walk); | |
| }); | |
| const temp = new THREE.Box3(); | |
| const all = { target: [], nonTarget: [] }; | |
| for (let fragId = 0; fragId < fragCount; fragId++) { | |
| fragList.getWorldBounds(fragId, temp); | |
| const dbId = fragIdToDbId[fragId]; | |
| const item = { fragId, dbId, box: temp.clone() }; | |
| if (targetSet.has(dbId)) all.target.push(item); | |
| else all.nonTarget.push(item); | |
| } | |
| return { allFrags: all, targetFragSet: targetSet }; | |
| } | |
| /** | |
| * Checks if all geometry in the viewer is fully loaded and ready for ray intersection. | |
| * This determines whether to use triangle-accurate geometry analysis or AABB fallback. | |
| * | |
| * @param {Object} viewer - APS viewer instance | |
| * @returns {boolean} True if all models are fully loaded and geometry is available | |
| */ | |
| function allGeometryReady(viewer) { | |
| const models = viewer.getVisibleModels?.() || (viewer.model ? [viewer.model] : []); | |
| for (const m of models) { if (!m.isLoadDone || !m.isLoadDone()) return false; } | |
| return true; | |
| } | |
| /** | |
| * Flattens an octree structure into a single array of leaf items. | |
| * Recursively traverses the octree and collects all items from leaf nodes. | |
| * | |
| * @param {Object} node - Root octree node to flatten | |
| * @returns {Array} Flat array containing all items from octree leaves | |
| */ | |
| function flattenOctreeLeaves(node) { | |
| if (!node) return []; | |
| const out = []; | |
| (function walk(n) { | |
| if (!n) return; | |
| if (n.children && n.children.length) n.children.forEach(walk); | |
| else if (n.items) out.push(...n.items); | |
| })(node); | |
| return out; | |
| } | |
| /** | |
| * Tests if a camera eye position is inside any non-target bounding box. | |
| * Used to penalize camera positions that would be inside other objects. | |
| * | |
| * @param {THREE.Vector3} eye - Camera eye position to test | |
| * @param {Array} itemsOrArray - Array of fragment items with bounding boxes | |
| * @param {Set} targetFragSet - Set of target fragment IDs to exclude from test | |
| * @returns {boolean} True if the eye position is inside any non-target AABB | |
| */ | |
| function isEyeInsideAnyAabb(eye, itemsOrArray, targetFragSet) { | |
| if (!itemsOrArray) return false; | |
| for (const it of itemsOrArray) { | |
| if (targetFragSet && targetFragSet.has && targetFragSet.has(it.dbId)) continue; | |
| if (it.box.containsPoint(eye)) return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Calculates visibility fraction using triangle-accurate geometry ray intersection. | |
| * Performs ray casting from camera to target key points and analyzes occlusion | |
| * by distinguishing between target and non-target object hits. | |
| * | |
| * @param {Object} viewer - APS viewer instance with ray intersection capability | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {THREE.Vector3} targetCenter - Center point of target objects | |
| * @param {Array<THREE.Vector3>} testPoints - Array of key points to test visibility for | |
| * @param {Array<number>} targetDbIds - Viewer dbIds of target objects | |
| * @param {number} [samplePerPoint=1] - Number of ray samples per test point (with jittering) | |
| * @returns {number} Fraction of visible points (0.0 to 1.0) | |
| */ | |
| function visibilityFractionByRayOcclusionGeometry(viewer, eye, targetCenter, testPoints, targetDbIds, samplePerPoint = 1) { | |
| let visible = 0, total = 0; | |
| const jitters = buildScreenJitters(samplePerPoint); | |
| const tmp = new THREE.Vector3(); | |
| const targetSet = new Set(targetDbIds); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist === 0) { visible++; total++; continue; } | |
| dir.normalize(); | |
| let pointVisible = false; | |
| for (const j of jitters) { | |
| const rayDir = jitterDirection(dir, j); | |
| const ray = new THREE.Ray(eye, rayDir); | |
| const hits = []; | |
| viewer.impl.rayIntersect(ray, false, false, false, hits); | |
| if (hits.length === 0) { pointVisible = true; break; } | |
| let nearestTarget = Infinity; | |
| let nearestNonTarget = Infinity; | |
| for (const h of hits) { | |
| if (targetSet.has(h.dbId)) nearestTarget = Math.min(nearestTarget, h.distance); | |
| else nearestNonTarget = Math.min(nearestNonTarget, h.distance); | |
| } | |
| if (nearestTarget < nearestNonTarget) { pointVisible = true; break; } | |
| } | |
| if (pointVisible) visible++; | |
| total++; | |
| } | |
| return total > 0 ? visible / total : 0; | |
| } | |
| /** | |
| * Gathers objects that occlude target objects from a specific viewpoint using triangle-accurate geometry. | |
| * Uses ray casting to identify blocking objects and counts how many rays each object blocks. | |
| * | |
| * @param {Object} viewer - APS viewer instance with ray intersection capability | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {Array<THREE.Vector3>} testPoints - Array of target key points to test occlusion for | |
| * @param {Array<number>} targetDbIds - Viewer dbIds of target objects | |
| * @param {Object} options - Configuration for occluder detection | |
| * @param {string} [options.occluderMode='kNearestPerRay'] - 'nearest', 'all', or 'kNearestPerRay' | |
| * @param {number} [options.occluderK=3] - Number of nearest occluders per ray (for kNearestPerRay mode) | |
| * @param {number} [options.minRayHits=1] - Minimum ray hits required to consider an object a blocker | |
| * @param {number} [options.maxOccluders=2000] - Maximum number of occluders to return | |
| * @returns {Object} Occluder information | |
| * @returns {Array<number>} result.dbIds - Viewer dbIds of blocking objects, sorted by hit count | |
| * @returns {Map} result.counts - Map of dbId to hit count for each blocking object | |
| */ | |
| function gatherOccludersForViewWithCounts_Geometry(viewer, eye, testPoints, targetDbIds, { | |
| occluderMode = 'kNearestPerRay', | |
| occluderK = 3, | |
| minRayHits = 1, | |
| maxOccluders = 2000 | |
| } = {}) { | |
| const hitCounts = new Map(); | |
| const targetSet = new Set(targetDbIds); | |
| const tmp = new THREE.Vector3(); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist <= 1e-9) continue; | |
| dir.normalize(); | |
| const ray = new THREE.Ray(eye, dir); | |
| const hits = []; | |
| viewer.impl.rayIntersect(ray, false, false, false, hits); | |
| if (!hits.length) continue; | |
| let nearestTarget = Infinity; | |
| for (const h of hits) if (targetSet.has(h.dbId)) nearestTarget = Math.min(nearestTarget, h.distance); | |
| const blockingHits = []; | |
| for (const h of hits) { | |
| if (!targetSet.has(h.dbId) && h.distance < nearestTarget - 1e-6) { | |
| blockingHits.push({ dbId: h.dbId, t: h.distance }); | |
| } | |
| } | |
| if (!blockingHits.length) continue; | |
| blockingHits.sort((a, b) => a.t - b.t); | |
| let kept; | |
| switch (occluderMode) { | |
| case 'nearest': kept = [blockingHits[0]]; break; | |
| case 'all': kept = blockingHits; break; | |
| case 'kNearestPerRay': | |
| default: kept = blockingHits.slice(0, Math.max(1, occluderK|0)); break; | |
| } | |
| for (const h of kept) hitCounts.set(h.dbId, (hitCounts.get(h.dbId) || 0) + 1); | |
| } | |
| let pairs = Array.from(hitCounts.entries()) | |
| .filter(([_, cnt]) => cnt >= (minRayHits|0)) | |
| .sort((a, b) => b[1] - a[1]); | |
| if (pairs.length > maxOccluders) pairs = pairs.slice(0, maxOccluders); | |
| return { dbIds: pairs.map(p => p[0]), counts: new Map(pairs) }; | |
| } | |
| /** | |
| * Fallback visibility calculation using AABB (bounding box) intersection with octree acceleration. | |
| * Used when triangle-accurate geometry is not available or for performance optimization. | |
| * | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {THREE.Vector3} targetCenter - Center point of target objects | |
| * @param {Array<THREE.Vector3>} testPoints - Array of key points to test visibility for | |
| * @param {Object} octree - Octree structure containing non-target fragment AABBs | |
| * @param {Set} targetFragSet - Set of target fragment IDs to exclude from occlusion test | |
| * @param {number} [samplePerPoint=1] - Number of ray samples per test point (with jittering) | |
| * @returns {number} Fraction of visible points (0.0 to 1.0) | |
| */ | |
| function visibilityFractionByRayOcclusion(eye, targetCenter, testPoints, octree, targetFragSet, samplePerPoint = 1) { | |
| let visible = 0, total = 0; | |
| const jitters = buildScreenJitters(samplePerPoint); | |
| const tmp = new THREE.Vector3(); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist === 0) { visible++; total++; continue; } | |
| dir.normalize(); | |
| let pointVisible = false; | |
| for (const j of jitters) { | |
| const jitteredDir = jitterDirection(dir, j); | |
| const ray = new THREE.Ray(eye, jitteredDir); | |
| const candidates = []; | |
| queryOctreeForRay(ray, maxDist, octree, candidates); | |
| let blocked = false; | |
| for (const c of candidates) { | |
| if (targetFragSet.has(c.dbId)) continue; | |
| const tHit = rayAabbFirstHitDistance(ray, c.box, maxDist); | |
| if (tHit !== null && tHit > 1e-6 && tHit < maxDist - 1e-6) { blocked = true; break; } | |
| } | |
| if (!blocked) { pointVisible = true; break; } | |
| } | |
| if (pointVisible) visible++; | |
| total++; | |
| } | |
| return total > 0 ? visible / total : 0; | |
| } | |
| /** | |
| * Simplified fallback visibility calculation using flat array iteration instead of octree. | |
| * Used when octree acceleration is disabled for memory-constrained environments. | |
| * | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {THREE.Vector3} targetCenter - Center point of target objects | |
| * @param {Array<THREE.Vector3>} testPoints - Array of key points to test visibility for | |
| * @param {Array} nonTargetItems - Flat array of non-target fragment items with bounding boxes | |
| * @param {Set} targetFragSet - Set of target fragment IDs to exclude from occlusion test | |
| * @param {number} [samplePerPoint=1] - Number of ray samples per test point (with jittering) | |
| * @returns {number} Fraction of visible points (0.0 to 1.0) | |
| */ | |
| function visibilityFractionByRayOcclusionFlat(eye, targetCenter, testPoints, nonTargetItems, targetFragSet, samplePerPoint = 1) { | |
| let visible = 0, total = 0; | |
| const jitters = buildScreenJitters(samplePerPoint); | |
| const tmp = new THREE.Vector3(); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist === 0) { visible++; total++; continue; } | |
| dir.normalize(); | |
| let pointVisible = false; | |
| for (const j of jitters) { | |
| const jittered = jitterDirection(dir, j); | |
| const ray = new THREE.Ray(eye, jittered); | |
| let blocked = false; | |
| for (const c of nonTargetItems) { | |
| if (targetFragSet.has(c.dbId)) continue; | |
| const tHit = rayAabbFirstHitDistance(ray, c.box, maxDist); | |
| if (tHit !== null && tHit > 1e-6 && tHit < maxDist - 1e-6) { blocked = true; break; } | |
| } | |
| if (!blocked) { pointVisible = true; break; } | |
| } | |
| if (pointVisible) visible++; | |
| total++; | |
| } | |
| return total > 0 ? visible / total : 0; | |
| } | |
| /** | |
| * Gathers occluding objects using octree acceleration for AABB-based collision detection. | |
| * Fallback method when triangle-accurate geometry is not available. | |
| * | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {Array<THREE.Vector3>} testPoints - Array of target key points to test occlusion for | |
| * @param {Object} octree - Octree structure containing non-target fragment AABBs | |
| * @param {Set} targetFragSet - Set of target fragment IDs to exclude from occlusion test | |
| * @param {Object} options - Configuration for occluder detection | |
| * @param {string} [options.occluderMode='kNearestPerRay'] - 'nearest', 'all', or 'kNearestPerRay' | |
| * @param {number} [options.occluderK=3] - Number of nearest occluders per ray (for kNearestPerRay mode) | |
| * @param {number} [options.minRayHits=1] - Minimum ray hits required to consider an object a blocker | |
| * @param {number} [options.maxOccluders=2000] - Maximum number of occluders to return | |
| * @returns {Object} Occluder information | |
| * @returns {Array<number>} result.dbIds - Viewer dbIds of blocking objects, sorted by hit count | |
| * @returns {Map} result.counts - Map of dbId to hit count for each blocking object | |
| */ | |
| function gatherOccludersForViewWithCounts_Octree(eye, testPoints, octree, targetFragSet, { | |
| occluderMode = 'kNearestPerRay', | |
| occluderK = 3, | |
| minRayHits = 1, | |
| maxOccluders = 2000 | |
| } = {}) { | |
| const EPS = 1e-6; | |
| const tmp = new THREE.Vector3(); | |
| const hitCounts = new Map(); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist <= 1e-9) continue; | |
| dir.normalize(); | |
| const ray = new THREE.Ray(eye, dir); | |
| const cands = []; | |
| queryOctreeForRay(ray, maxDist, octree, cands); | |
| const hits = []; | |
| for (const c of cands) { | |
| if (targetFragSet.has(c.dbId)) continue; | |
| const tHit = rayAabbFirstHitDistance(ray, c.box, maxDist); | |
| if (tHit !== null && tHit > EPS && tHit < maxDist - EPS) hits.push({ dbId: c.dbId, t: tHit }); | |
| } | |
| if (!hits.length) continue; | |
| hits.sort((a, b) => a.t - b.t); | |
| let kept; | |
| switch (occluderMode) { | |
| case 'nearest': kept = [hits[0]]; break; | |
| case 'all': kept = hits; break; | |
| case 'kNearestPerRay': | |
| default: kept = hits.slice(0, Math.max(1, occluderK|0)); break; | |
| } | |
| for (const h of kept) hitCounts.set(h.dbId, (hitCounts.get(h.dbId) || 0) + 1); | |
| } | |
| let pairs = Array.from(hitCounts.entries()) | |
| .filter(([_, cnt]) => cnt >= (minRayHits|0)) | |
| .sort((a, b) => b[1] - a[1]); | |
| if (pairs.length > maxOccluders) pairs = pairs.slice(0, maxOccluders); | |
| return { dbIds: pairs.map(p => p[0]), counts: new Map(pairs) }; | |
| } | |
| /** | |
| * Gathers occluding objects using flat array iteration for AABB-based collision detection. | |
| * Alternative to octree-based approach when memory usage needs to be minimized. | |
| * | |
| * @param {THREE.Vector3} eye - Camera eye position | |
| * @param {Array<THREE.Vector3>} testPoints - Array of target key points to test occlusion for | |
| * @param {Array} nonTargetItems - Flat array of non-target fragment items with bounding boxes | |
| * @param {Set} targetFragSet - Set of target fragment IDs to exclude from occlusion test | |
| * @param {Object} options - Configuration for occluder detection | |
| * @param {string} [options.occluderMode='kNearestPerRay'] - 'nearest', 'all', or 'kNearestPerRay' | |
| * @param {number} [options.occluderK=3] - Number of nearest occluders per ray (for kNearestPerRay mode) | |
| * @param {number} [options.minRayHits=1] - Minimum ray hits required to consider an object a blocker | |
| * @param {number} [options.maxOccluders=2000] - Maximum number of occluders to return | |
| * @returns {Object} Occluder information | |
| * @returns {Array<number>} result.dbIds - Viewer dbIds of blocking objects, sorted by hit count | |
| * @returns {Map} result.counts - Map of dbId to hit count for each blocking object | |
| */ | |
| function gatherOccludersForViewWithCounts_Flat(eye, testPoints, nonTargetItems, targetFragSet, { | |
| occluderMode = 'kNearestPerRay', | |
| occluderK = 3, | |
| minRayHits = 1, | |
| maxOccluders = 2000 | |
| } = {}) { | |
| const EPS = 1e-6; | |
| const tmp = new THREE.Vector3(); | |
| const hitCounts = new Map(); | |
| for (const tp of testPoints) { | |
| const dir = tmp.copy(tp).sub(eye); | |
| const maxDist = dir.length(); | |
| if (maxDist <= 1e-9) continue; | |
| dir.normalize(); | |
| const ray = new THREE.Ray(eye, dir); | |
| const hits = []; | |
| for (const c of nonTargetItems) { | |
| if (targetFragSet.has(c.dbId)) continue; | |
| const tHit = rayAabbFirstHitDistance(ray, c.box, maxDist); | |
| if (tHit !== null && tHit > EPS && tHit < maxDist - EPS) hits.push({ dbId: c.dbId, t: tHit }); | |
| } | |
| if (!hits.length) continue; | |
| hits.sort((a, b) => a.t - b.t); | |
| let kept; | |
| switch (occluderMode) { | |
| case 'nearest': kept = [hits[0]]; break; | |
| case 'all': kept = hits; break; | |
| case 'kNearestPerRay': | |
| default: kept = hits.slice(0, Math.max(1, occluderK|0)); break; | |
| } | |
| for (const h of kept) hitCounts.set(h.dbId, (hitCounts.get(h.dbId) || 0) + 1); | |
| } | |
| let pairs = Array.from(hitCounts.entries()) | |
| .filter(([_, cnt]) => cnt >= (minRayHits|0)) | |
| .sort((a, b) => b[1] - a[1]); | |
| if (pairs.length > maxOccluders) pairs = pairs.slice(0, maxOccluders); | |
| return { dbIds: pairs.map(p => p[0]), counts: new Map(pairs) }; | |
| } | |
| /** | |
| * Builds an octree spatial index for efficient AABB (axis-aligned bounding box) queries. | |
| * Recursively subdivides space into 8 children until termination conditions are met. | |
| * Used to accelerate ray-AABB intersection tests for large numbers of objects. | |
| * | |
| * @param {Array} items - Array of objects with .box property (THREE.Box3) | |
| * @param {THREE.Box3} bounds - World space bounds for this octree node | |
| * @param {Object} options - Octree construction parameters | |
| * @param {number} [options.maxItemsPerNode=20] - Maximum items before subdivision | |
| * @param {number} [options.maxDepth=6] - Maximum recursion depth | |
| * @param {number} [depth=0] - Current recursion depth (internal parameter) | |
| * @returns {Object} Octree node with box, items, and children properties | |
| * @returns {THREE.Box3} result.box - Bounding box of this node | |
| * @returns {Array} result.items - Items contained in this node (leaf nodes only) | |
| * @returns {Array|null} result.children - Child nodes (internal nodes only) | |
| */ | |
| function buildAabbOctree(items, bounds, { maxItemsPerNode = 20, maxDepth = 6 }, depth = 0) { | |
| if (!items || items.length === 0) return { box: bounds.clone(), items: [], children: null }; | |
| if (items.length <= maxItemsPerNode || depth >= maxDepth) { | |
| return { box: bounds.clone(), items, children: null }; | |
| } | |
| const center = bounds.getCenter(new THREE.Vector3()); | |
| const childBoxes = []; | |
| for (let xi = 0; xi < 2; xi++) for (let yi = 0; yi < 2; yi++) for (let zi = 0; zi < 2; zi++) { | |
| const min = new THREE.Vector3( | |
| xi === 0 ? bounds.min.x : center.x, | |
| yi === 0 ? bounds.min.y : center.y, | |
| zi === 0 ? bounds.min.z : center.z | |
| ); | |
| const max = new THREE.Vector3( | |
| xi === 0 ? center.x : bounds.max.x, | |
| yi === 0 ? center.y : bounds.max.y, | |
| zi === 0 ? center.z : bounds.max.z | |
| ); | |
| if (max.x <= min.x || max.y <= min.y || max.z <= min.z) continue; | |
| childBoxes.push(new THREE.Box3(min, max)); | |
| } | |
| const children = []; | |
| for (const cb of childBoxes) { | |
| const childItems = items.filter(it => cb.intersectsBox(it.box)); | |
| if (childItems.length > 0) { | |
| children.push(buildAabbOctree(childItems, cb, { maxItemsPerNode, maxDepth }, depth + 1)); | |
| } | |
| } | |
| if (children.length === 0) return { box: bounds.clone(), items, children: null }; | |
| return { box: bounds.clone(), items: [], children }; | |
| } | |
| /** | |
| * Recursively queries an octree to find all items that could potentially be hit by a ray. | |
| * Performs ray-AABB intersection test against octree nodes and collects items from intersected leaves. | |
| * | |
| * @param {THREE.Ray} ray - Ray to test against octree | |
| * @param {number} maxDist - Maximum ray distance to consider | |
| * @param {Object} node - Current octree node being tested | |
| * @param {Array} outHits - Output array to collect potentially intersected items | |
| */ | |
| function queryOctreeForRay(ray, maxDist, node, outHits) { | |
| const t = rayAabbFirstHitDistance(ray, node.box, maxDist === undefined ? Infinity : maxDist); | |
| if (t === null) return; | |
| if (node.children) node.children.forEach(c => queryOctreeForRay(ray, maxDist, c, outHits)); | |
| else outHits.push(...node.items); | |
| } | |
| /** | |
| * Computes the first intersection distance between a ray and an axis-aligned bounding box. | |
| * Uses the standard slab method for ray-AABB intersection testing. | |
| * Returns null if no intersection, or the distance to the first intersection point. | |
| * | |
| * @param {THREE.Ray} ray - Ray with origin and direction | |
| * @param {THREE.Box3} box - Axis-aligned bounding box to test | |
| * @param {number} [maxDist=Infinity] - Maximum distance to consider for intersection | |
| * @returns {number|null} Distance to first intersection, or null if no intersection | |
| */ | |
| function rayAabbFirstHitDistance(ray, box, maxDist = Infinity) { | |
| const o = ray.origin, d = ray.direction; | |
| const min = box.min, max = box.max; | |
| const EPS = 1e-12; | |
| let tmin = 0.0, tmax = maxDist; | |
| // X | |
| if (Math.abs(d.x) < EPS) { if (o.x < min.x || o.x > max.x) return null; } | |
| else { | |
| const inv = 1.0 / d.x; | |
| let t1 = (min.x - o.x) * inv, t2 = (max.x - o.x) * inv; | |
| if (t1 > t2) [t1, t2] = [t2, t1]; | |
| tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2); | |
| if (tmax < tmin) return null; | |
| } | |
| // Y | |
| if (Math.abs(d.y) < EPS) { if (o.y < min.y || o.y > max.y) return null; } | |
| else { | |
| const inv = 1.0 / d.y; | |
| let t1 = (min.y - o.y) * inv, t2 = (max.y - o.y) * inv; | |
| if (t1 > t2) [t1, t2] = [t2, t1]; | |
| tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2); | |
| if (tmax < tmin) return null; | |
| } | |
| // Z | |
| if (Math.abs(d.z) < EPS) { if (o.z < min.z || o.z > max.z) return null; } | |
| else { | |
| const inv = 1.0 / d.z; | |
| let t1 = (min.z - o.z) * inv, t2 = (max.z - o.z) * inv; | |
| if (t1 > t2) [t1, t2] = [t2, t1]; | |
| tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2); | |
| if (tmax < tmin) return null; | |
| } | |
| if (tmax < 0) return null; | |
| return tmin >= 0 ? tmin : 0.0; | |
| } | |
| /** | |
| * Generates an array of small angular offsets for ray jittering to improve sampling quality. | |
| * Creates a circular pattern of jitter vectors to reduce aliasing in visibility calculations. | |
| * When n=1, returns a single zero jitter. For n>1, distributes jitters in a circle. | |
| * | |
| * @param {number} n - Number of jitter samples to generate | |
| * @returns {Array<Object>} Array of jitter objects with ax, ay properties (angular offsets in radians) | |
| */ | |
| function buildScreenJitters(n) { | |
| if (n <= 1) return [{ ax: 0, ay: 0 }]; | |
| const arr = []; | |
| const r = 0.015; // radians | |
| for (let i = 0; i < n; i++) { | |
| const t = (i / n) * Math.PI * 2; | |
| arr.push({ ax: Math.cos(t) * r, ay: Math.sin(t) * r }); | |
| } | |
| return arr; | |
| } | |
| /** | |
| * Applies a small angular jitter to a direction vector to reduce sampling artifacts. | |
| * Constructs a local coordinate system around the direction and applies angular offsets. | |
| * Used for anti-aliasing in visibility calculations by slightly varying ray directions. | |
| * | |
| * @param {THREE.Vector3} dir - Base direction vector (should be normalized) | |
| * @param {Object} jitter - Jitter offsets with ax, ay properties (angular offsets in radians) | |
| * @param {number} jitter.ax - Angular offset around the 'right' axis | |
| * @param {number} jitter.ay - Angular offset around the 'up' axis | |
| * @returns {THREE.Vector3} New normalized direction vector with applied jitter | |
| */ | |
| function jitterDirection(dir, jitter) { | |
| const up = Math.abs(dir.y) < 0.999 ? new THREE.Vector3(0,1,0) : new THREE.Vector3(1,0,0); | |
| const right = new THREE.Vector3().crossVectors(dir, up).normalize(); | |
| const trueUp = new THREE.Vector3().crossVectors(right, dir).normalize(); | |
| return new THREE.Vector3( | |
| dir.x + right.x * jitter.ax + trueUp.x * jitter.ay, | |
| dir.y + right.y * jitter.ax + trueUp.y * jitter.ay, | |
| dir.z + right.z * jitter.ax + trueUp.z * jitter.ay | |
| ).normalize(); | |
| } | |
| /** | |
| * Generates key representative points on a bounding box for visibility analysis. | |
| * Returns the 8 corner points, 6 face centers, and the overall center (15 points total). | |
| * These points provide good coverage for testing visibility from different viewpoints. | |
| * | |
| * @param {THREE.Box3} box - Bounding box to generate key points for | |
| * @returns {Array<THREE.Vector3>} Array of 15 key points representing the bounding box | |
| */ | |
| function bboxKeyPoints(box) { | |
| const c = box.getCenter(new THREE.Vector3()); | |
| const min = box.min, max = box.max; | |
| return [ | |
| new THREE.Vector3(min.x, min.y, min.z), | |
| new THREE.Vector3(max.x, min.y, min.z), | |
| new THREE.Vector3(min.x, max.y, min.z), | |
| new THREE.Vector3(max.x, max.y, min.z), | |
| new THREE.Vector3(min.x, min.y, max.z), | |
| new THREE.Vector3(max.x, min.y, max.z), | |
| new THREE.Vector3(min.x, max.y, max.z), | |
| new THREE.Vector3(max.x, max.y, max.z), | |
| new THREE.Vector3(c.x, c.y, min.z), | |
| new THREE.Vector3(c.x, c.y, max.z), | |
| new THREE.Vector3(c.x, min.y, c.z), | |
| new THREE.Vector3(c.x, max.y, c.z), | |
| new THREE.Vector3(min.x, c.y, c.z), | |
| new THREE.Vector3(max.x, c.y, c.z), | |
| c.clone() | |
| ]; | |
| } | |
| /** | |
| * Calculates an appropriate camera distance for viewing a bounding box. | |
| * Uses the camera's field of view to determine distance that will frame the object properly. | |
| * Based on trigonometry: distance = (object_size / 2) / tan(fov / 2) * scale_factor. | |
| * | |
| * @param {THREE.Box3} box - Bounding box of the object to view | |
| * @param {Object} camera - Camera object with fov property (field of view in degrees) | |
| * @param {number} [distanceScale=1.6] - Scale factor to adjust the computed distance | |
| * @returns {number} Suggested camera distance from the object center | |
| */ | |
| function suggestedDistanceFor(box, camera, distanceScale = 1.6) { | |
| const size = box.getSize(new THREE.Vector3()); | |
| const diag = size.length(); | |
| if (diag === 0) return 10; | |
| const fovRad = (camera.fov || 45) * Math.PI / 180; | |
| const base = (diag / 2) / Math.tan(fovRad / 2); | |
| return base * distanceScale; | |
| } | |
| /** | |
| * Ensures the model's instance tree is fully loaded before proceeding. | |
| * The instance tree contains the hierarchical structure of objects in the model. | |
| * Returns a promise that resolves when the tree is available. | |
| * | |
| * @async | |
| * @param {Object} model - APS model instance | |
| * @returns {Promise<void>} Promise that resolves when instance tree is ready | |
| */ | |
| async function ensureInstanceTreeReady(model) { | |
| return new Promise(resolve => { | |
| if (model.getInstanceTree()) return resolve(); | |
| model.getObjectTree(() => resolve()); | |
| }); | |
| } | |
| /** | |
| * Computes the world space bounding box for a set of viewer object dbIds. | |
| * Iterates through all fragments belonging to the specified objects and unions their bounds. | |
| * Falls back to the entire model bounds if no valid fragments are found. | |
| * | |
| * @async | |
| * @param {Object} model - APS model instance | |
| * @param {number|number[]} dbIds - Database ID(s) of objects to compute bounds for | |
| * @returns {Promise<THREE.Box3>} World space bounding box containing all specified objects | |
| */ | |
| async function getWorldBBoxForDbIds(model, dbIds) { | |
| const ids = Array.isArray(dbIds) ? dbIds : [dbIds]; | |
| await ensureInstanceTreeReady(model); | |
| const it = model.getInstanceTree(); | |
| const fragList = model.getFragmentList(); | |
| const total = new THREE.Box3(); | |
| const temp = new THREE.Box3(); | |
| for (const id of ids) { | |
| it.enumNodeFragments(id, (fragId) => { | |
| fragList.getWorldBounds(fragId, temp); | |
| total.union(temp); | |
| }, true); | |
| } | |
| if (total.min.x === Infinity) { | |
| return safeModelBoundingBox(model); | |
| } | |
| return total; | |
| } | |
| /** | |
| * Performs shallow equality comparison between two arrays. | |
| * Checks if arrays have the same length and identical elements at each position. | |
| * Used to determine if target selection has changed between computations. | |
| * | |
| * @param {Array} a - First array to compare | |
| * @param {Array} b - Second array to compare | |
| * @returns {boolean} True if arrays are shallowly equal, false otherwise | |
| */ | |
| function arrayShallowEqual(a, b) { | |
| if (!a || !b || a.length !== b.length) return false; | |
| for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; | |
| return true; | |
| } | |
| /** | |
| * Clamps a numeric value to the range [0, 1]. | |
| * Utility function for ensuring color and intensity values stay within valid bounds. | |
| * | |
| * @param {number} x - Value to clamp | |
| * @returns {number} Value clamped to range [0, 1] | |
| */ | |
| function clamp01(x) { return x < 0 ? 0 : (x > 1 ? 1 : x); } | |
| /** | |
| * Converts various color input formats to a normalized THREE.Vector4 for APS viewer theming. | |
| * Handles THREE.Color objects, hex numbers, or existing Vector4s and ensures all components | |
| * are clamped to [0,1] range. The Vector4 format (r,g,b,intensity) is required by the | |
| * viewer's setThemingColor API for object coloring. | |
| * | |
| * @param {THREE.Color|number|THREE.Vector4} colorOrHex - Input color in various formats: | |
| * - THREE.Color object with r,g,b properties | |
| * - Hex number (e.g., 0xff0000 for red) | |
| * - Existing THREE.Vector4 (will be normalized and cloned) | |
| * @param {number} [intensity=1.0] - Color intensity/alpha value (0.0 to 1.0) | |
| * @returns {THREE.Vector4} Normalized Vector4 with (r,g,b,intensity) all clamped to [0,1] | |
| * | |
| * @example | |
| * // From hex number | |
| * const redColor = colorToVec4(0xff0000, 0.8); | |
| * | |
| * // From THREE.Color | |
| * const greenColor = colorToVec4(new THREE.Color('green'), 0.9); | |
| * | |
| * // From existing Vector4 (normalizes and clones) | |
| * const existingVec4 = new THREE.Vector4(1.2, -0.1, 0.5, 0.7); | |
| * const normalizedColor = colorToVec4(existingVec4, 0.6); | |
| */ | |
| function colorToVec4(colorOrHex, intensity = 1.0) { | |
| // If it's already a Vector4, just normalize/clamp and return a clone | |
| if (colorOrHex && colorOrHex.isVector4) { | |
| return new THREE.Vector4( | |
| clamp01(colorOrHex.x), | |
| clamp01(colorOrHex.y), | |
| clamp01(colorOrHex.z), | |
| clamp01(intensity) // use provided intensity, not the incoming w | |
| ); | |
| } | |
| // Build a THREE.Color from input (hex or Color) | |
| const c = (colorOrHex && colorOrHex.isColor) | |
| ? colorOrHex | |
| : new THREE.Color(colorOrHex != null ? colorOrHex : 0xffffff); | |
| const i = clamp01(intensity); | |
| // THREE.Color stores r,g,b in 0..1 already, but clamp defensively | |
| return new THREE.Vector4( | |
| clamp01(c.r), | |
| clamp01(c.g), | |
| clamp01(c.b), | |
| i | |
| ); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment