diff --git a/.gitignore b/.gitignore index 83b410cdc0..97cfb4e16d 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,4 @@ php-conf/appconfig.ini params-local.php # release related artefacts -VERSION +VERSION \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a8802e816f..82d57297f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +- Feat #2054: Visualize 3D models locally without relying on sketchfab iframe - Fix #2094: Make Semgrep-based SAST analyzer available in tagged release - Feat #701: Code refactoring to separate upload status transitions and notifications to prepare for upload status overhaul - Security #1867: Update the gitlab static application security testing (SAST) job using the Semgrep-based analyzer diff --git a/data/dev/external_link.csv b/data/dev/external_link.csv index 0f8b8dabb3..5684b0d141 100644 --- a/data/dev/external_link.csv +++ b/data/dev/external_link.csv @@ -9,3 +9,6 @@ id,dataset_id,url,external_link_type_id 36,144,https://github.com/ShashaankV/GD,1 1525,2342,https://scicrunch.org/resolver/RRID:SCR_021313,1 1526,2342,https://github.com/cihga39871/Atria,7 +1527,8,https://s3.ap-northeast-1.wasabisys.com/gigadb-datasets/dev/tests/3d-models/100006/leaf_01.las,5 +1528,8,https://s3.ap-northeast-1.wasabisys.com/gigadb-datasets/dev/tests/3d-models/100006/beet_03.stl,5 +1529,8,https://s3.ap-northeast-1.wasabisys.com/gigadb-datasets/dev/tests/3d-models/100006/GeoB8502_865cm_Shell-4.obj,5 \ No newline at end of file diff --git a/features/bootstrap/DatasetViewContext.php b/features/bootstrap/DatasetViewContext.php index fcf605322e..4bdfa823fe 100644 --- a/features/bootstrap/DatasetViewContext.php +++ b/features/bootstrap/DatasetViewContext.php @@ -413,10 +413,7 @@ public function iShouldNotSeeTabWithTable($arg1, TableNode $table) */ public function iHaveAddedLinkToDataset($arg1, $arg2, $arg3) { - if ("3D Viewer" == $arg1 ) { - $this->gigadbWebsiteContext->loadUserData("3D_Viewer_${arg3}_test_data"); - } - elseif ("Code Ocean" == $arg1 ) { + if ("Code Ocean" == $arg1 ) { $this->gigadbWebsiteContext->loadUserData("Code_Ocean_${arg3}_test_data"); } else { diff --git a/features/dataset-view.feature b/features/dataset-view.feature index 2ea133e6bd..7381c0204e 100644 --- a/features/dataset-view.feature +++ b/features/dataset-view.feature @@ -152,13 +152,6 @@ Feature: a user visit the dataset page | Funding body | Awardee | Award ID | Comments | | National Science Foundation | Matthew W. Hahn | DEB-1249633 | Matthew W Hahn | - @ok - Scenario: 3D Viewer - Given I am not logged in to Gigadb web site - And I have added "3D Viewer" link "https://sketchfab.com/models/ea49d0dd500647cbb4b61ad5ca9e659a" to dataset "101001" - When I go to "/dataset/101001" - Then I should see "3D Models" tab with text "3D Models:" - @ok Scenario: Protocols.io Given I am not logged in to Gigadb web site diff --git a/less/index.less b/less/index.less index 0e17270e2d..e2ba8cd445 100644 --- a/less/index.less +++ b/less/index.less @@ -64,4 +64,5 @@ @import "modules/team-grid.less"; @import "modules/map.less"; @import "modules/lists.less"; -@import "modules/tooltip.less"; \ No newline at end of file +@import "modules/tooltip.less"; +@import "modules/model-viewer.less"; diff --git a/less/modules/datatables.less b/less/modules/datatables.less index fc6feb06a4..be0ebe156c 100644 --- a/less/modules/datatables.less +++ b/less/modules/datatables.less @@ -28,3 +28,13 @@ table.dataTable thead { .tab-content { overflow: hidden; } + +.dataset-tab-content { + overflow: visible; + [role="tabpanel"] { + overflow: hidden; + &.visible { + overflow: visible; + } + } +} diff --git a/less/modules/model-viewer.less b/less/modules/model-viewer.less new file mode 100644 index 0000000000..361d5bf7df --- /dev/null +++ b/less/modules/model-viewer.less @@ -0,0 +1,304 @@ +// stack order +@stack-order-controls-info: 1; +@stack-order-play-button: 2; +@stack-order-loading-overlay: 3; +@stack-order-error-display: 4; +@stack-order-help-modal: 5; +@stack-order-fullscreen: 9999; +@stack-order-fullscreen-controls: 10000; +@stack-order-fullscreen-help-modal: 10001; + + +.model-selector { + width: 450px; + margin-bottom: 20px; +} + +.model-viewer-container { + position: relative; + width: 912px; + height: 512px; + background: transparent; +} + +@media (max-width: 912px) { + .model-viewer-container { + width: 100%; + height: auto; + aspect-ratio: 1.78; + } +} + +.canvas-container { + width: 100%; + height: 100%; + background-color: @color-light-gray; + display: block; +} + +// controls info + +.controls { + position: absolute; + background: transparent; + color: @color-true-white; + bottom: 0; + right: 0; + padding: 12px; + z-index: @stack-order-controls-info; + display: flex; + gap: 6px; +} + +.controls .controls-btn { + text-decoration: none; + color: @color-warm-black; + background: transparent; + font-size: 18px; + opacity: 0.8; + transition: opacity 0.2s ease, background 0.2s ease; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + opacity: 1; + color: @color-true-white; + text-decoration: none; + background: rgba(0, 0, 0, 0.5); + } +} + +// help modal + +.help-modal { + position: absolute; + inset: 0; + z-index: @stack-order-help-modal; + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + color: @color-true-white; + + .help-modal-content { + background: rgba(0, 0, 0, 0.9); + border-radius: 8px; + padding: 24px; + min-width: 300px; + max-width: 90%; + } + + .help-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + .help-modal-title { + margin: 0; + font-size: 18px; + font-weight: bold; + } + + .help-modal-close { + background: transparent; + border: none; + color: @color-true-white; + font-size: 20px; + padding: 4px; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } + } + } + + .help-modal-body { + h3 { + font-size: 14px; + margin: 0 0 16px 0; + opacity: 0.8; + } + + .help-control { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; + + i { + font-size: 24px; + width: 24px; + text-align: center; + } + + .help-control-text { + div:first-child { + font-weight: bold; + margin-bottom: 4px; + } + + .help-control-detail { + font-size: 14px; + opacity: 0.8; + } + } + } + } +} + +.model-viewer-container.fullscreen { + .canvas-container { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + z-index: @stack-order-fullscreen; + } + + .controls { + position: fixed; + bottom: 0; + right: 0; + z-index: @stack-order-fullscreen-controls; + } + + .help-modal { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + z-index: @stack-order-fullscreen-help-modal; + } +} + +// play button + +.play-button-overlay { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: @stack-order-play-button; + background: rgba(0, 0, 0, 0.3); +} + +.play-button { + width: 150px; + height: 150px; + background: rgba(0, 0, 0, 0.5); + color: @color-true-white; + border: none; + border-radius: 50%; + padding: 20px; + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.2s ease-in-out, background 0.3s ease-in-out; +} + +.play-button:hover { + transform: scale(1.1); + background: @color-gigadb-green; + box-shadow: 0 0 40px rgba(255, 255, 255, 0.5), + 0 0 20px fade(@color-gigadb-green, 60%); +} + +.play-button:focus { + outline: none; + box-shadow: 0 0 0 4px @color-true-white, + 0 0 0 8px @color-gigadb-green, + 0 0 0 12px fade(@color-gigadb-green, 30%); +} + +.play-button-icon { + font-size: 70px; + position: relative; + /* make button look visually centered */ + left: 6px; +} + +@media (max-width: 768px) { + .play-button { + width: 120px; + height: 120px; + padding: 16px; + + .play-button-icon { + font-size: 50px; + } + } +} + +// loading overlay + +.loading-overlay { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + z-index: @stack-order-loading-overlay; + background: rgba(0, 0, 0, 0.1); + backdrop-filter: blur(5px); +} + +.loading-overlay.active { + display: flex; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid transparent; + border-top: 4px solid @color-gigadb-green; + border-radius: 50%; + animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + margin-bottom: 8px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 18px; + color: @color-warm-black; + opacity: 1; + text-align: center; + margin-top: 5px; + font-weight: 500; +} + +// error display + +.error-display { + max-width: 100%; + position: absolute; + inset-inline: 12px; + bottom: 12px; + margin: 0; + z-index: @stack-order-error-display; +} + +// model description +.model-description { + // avoid layout shift + height: 22px; +} diff --git a/ops/configuration/nginx-conf/sites/nginx.target_deployment.https.conf.dist b/ops/configuration/nginx-conf/sites/nginx.target_deployment.https.conf.dist index 992dfc3107..2d2e144ce5 100644 --- a/ops/configuration/nginx-conf/sites/nginx.target_deployment.https.conf.dist +++ b/ops/configuration/nginx-conf/sites/nginx.target_deployment.https.conf.dist @@ -8,8 +8,8 @@ server { add_header X-Content-Type-Options nosniff; add_header X-Frame-Options DENY; add_header X-XSS-Protection "1; mode=block"; - add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' http://gigadb.org http://penguin.genomics.cn https://www.rosaceae.org *.protocols.io https://sketchfab.com https://codeocean.com *.hypothes.is *.datatables.net *.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.matomo.cloud/gigadb.matomo.cloud/matomo.js *.hypothes.is *.datatables.net *.cloudflare.com *.google-analytics.com https://www.rosaceae.org https://www.protocols.io https://hypothes.is https://codeocean.com https://tumormap.ucsc.edu https://openlayers.org/en/v4.6.5/build/ol.js; frame-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu; child-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu; img-src blob: data: 'self' https://assets.gigadb-cdn.net *.tile.openstreetmap.org; report-uri https://gigadb.report-uri.com/r/d/csp/enforce;"; - add_header Content-Security-Policy-Report-Only "default-src 'self' https://gigadb.org; script-src 'self' https://cdn.matomo.cloud/gigadb.matomo.cloud/matomo.js https://*.hypothes.is https://*.datatables.net https://*.cloudflare.com https://*.google-analytics.com https://www.rosaceae.org https://www.protocols.io https://hypothes.is https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn https://openlayers.org/en/v4.6.5/build/ol.js; frame-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn; child-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn; report-uri https://gigadb.report-uri.com/r/d/csp/reportOnly;"; + add_header Content-Security-Policy "default-src https://s3.ap-northeast-1.wasabisys.com 'self' 'unsafe-inline' 'unsafe-eval' http://gigadb.org http://penguin.genomics.cn https://www.rosaceae.org *.protocols.io https://sketchfab.com https://codeocean.com *.hypothes.is *.datatables.net *.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.matomo.cloud/gigadb.matomo.cloud/matomo.js *.hypothes.is *.datatables.net *.cloudflare.com *.google-analytics.com https://www.rosaceae.org https://www.protocols.io https://hypothes.is https://codeocean.com https://tumormap.ucsc.edu https://openlayers.org/en/v4.6.5/build/ol.js https://cdn.jsdelivr.net; frame-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu; child-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu; img-src blob: data: 'self' https://assets.gigadb-cdn.net *.tile.openstreetmap.org; report-uri https://gigadb.report-uri.com/r/d/csp/enforce;"; + add_header Content-Security-Policy-Report-Only "default-src https://s3.ap-northeast-1.wasabisys.com 'self' https://gigadb.org; script-src 'self' https://cdn.matomo.cloud/gigadb.matomo.cloud/matomo.js https://*.hypothes.is https://*.datatables.net https://*.cloudflare.com https://*.google-analytics.com https://www.rosaceae.org https://www.protocols.io https://hypothes.is https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn https://openlayers.org/en/v4.6.5/build/ol.js https://cdn.jsdelivr.net; frame-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn; child-src https://www.protocols.io https://hypothes.is https://www.rosaceae.org https://sketchfab.com https://codeocean.com https://tumormap.ucsc.edu http://penguin.genomics.cn; report-uri https://gigadb.report-uri.com/r/d/csp/reportOnly;"; # Cors headers add_header Access-Control-Allow-Origin '*'; @@ -106,7 +106,7 @@ server { add_header X-Content-Type-Options nosniff; add_header X-Frame-Options DENY; add_header X-XSS-Protection "1; mode=block"; - add_header Content-Security-Policy "default-src 'self'; script-src https://*.matomo.cloud https://*.sentry.io https://sentry.io https://nominatim.openstreetmap.org 'self' 'unsafe-inline' 'unsafe-eval'; style-src https://fonts.googleapis.com 'self' 'unsafe-inline'; font-src https://fonts.gstatic.com https://fonts.googleapis.com 'self' data:; connect-src https://*.matomo.cloud 'self' ws://* wss://* https://*.sentry.io https://sentry.io https://*.googleapis.com; object-src 'self'; img-src https://*.google.com https://api.mapbox.com 'self' blob: data:; frame-src 'self' http://penguin.genomics.cn;"; + add_header Content-Security-Policy "default-src https://s3.ap-northeast-1.wasabisys.com 'self'; script-src https://*.matomo.cloud https://*.sentry.io https://sentry.io https://nominatim.openstreetmap.org https://cdn.jsdelivr.net 'self' 'unsafe-inline' 'unsafe-eval'; style-src https://fonts.googleapis.com 'self' 'unsafe-inline'; font-src https://fonts.gstatic.com https://fonts.googleapis.com 'self' data:; connect-src https://*.matomo.cloud 'self' ws://* wss://* https://*.sentry.io https://sentry.io https://*.googleapis.com; object-src 'self'; img-src https://*.google.com https://api.mapbox.com 'self' blob: data:; frame-src 'self' http://penguin.genomics.cn;"; # Cors headers add_header Access-Control-Allow-Origin '*'; diff --git a/protected/js/model-viewer/helpers/coerceSelected.js b/protected/js/model-viewer/helpers/coerceSelected.js new file mode 100644 index 0000000000..f93cc23bd9 --- /dev/null +++ b/protected/js/model-viewer/helpers/coerceSelected.js @@ -0,0 +1,7 @@ +export function coerceSelected(value) { + if (typeof value === 'string') { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? value : parsed; + } + return value; +} diff --git a/protected/js/model-viewer/helpers/debounce.js b/protected/js/model-viewer/helpers/debounce.js new file mode 100644 index 0000000000..a77301bc14 --- /dev/null +++ b/protected/js/model-viewer/helpers/debounce.js @@ -0,0 +1,39 @@ +/** + * Creates a debounced version of a function that delays invoking the function until after + * a specified wait time has elapsed since the last time it was invoked. + * Useful for rate-limiting function calls that would otherwise be called too frequently. + * + * @param {Function} func The function to debounce + * @param {number} wait The number of milliseconds to delay + * @returns {Function} Returns the debounced function with a cancel() method + * @example + * + * // Create debounced version of expensive calculation + * const debouncedCalc = debounce(expensiveCalculation, 250); + * + * // Call it multiple times rapidly + * debouncedCalc(); // Only the last call after 250ms of inactivity executes + * debouncedCalc(); + * debouncedCalc(); + * + * // Cancel pending execution if needed + * debouncedCalc.cancel(); + */ +export function debounce(func, wait) { + let timeout; + const debouncedFn = function (...args) { + const context = this; + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + func.apply(context, args); + }, wait); + }; + debouncedFn.cancel = function () { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + }; + return debouncedFn; +} diff --git a/protected/js/model-viewer/helpers/getContainerDimensions.js b/protected/js/model-viewer/helpers/getContainerDimensions.js new file mode 100644 index 0000000000..7be5bdb4e5 --- /dev/null +++ b/protected/js/model-viewer/helpers/getContainerDimensions.js @@ -0,0 +1,3 @@ +export function getContainerDimensions(container) { + return [container.innerWidth(), container.innerHeight()]; +} diff --git a/protected/js/model-viewer/helpers/invariant.js b/protected/js/model-viewer/helpers/invariant.js new file mode 100644 index 0000000000..553b442070 --- /dev/null +++ b/protected/js/model-viewer/helpers/invariant.js @@ -0,0 +1,5 @@ +export function invariant(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/protected/js/model-viewer/helpers/logger.js b/protected/js/model-viewer/helpers/logger.js new file mode 100644 index 0000000000..f1162073b6 --- /dev/null +++ b/protected/js/model-viewer/helpers/logger.js @@ -0,0 +1,57 @@ +function parseDataToLog(data) { + return data +} + +const isProduction = false // process.env.NODE_ENV === 'production' + +export function logger(level = 'debug', message, data) { + if (isProduction) { + return // Omit logs in production + } + + const timestamp = new Date().toISOString().slice(0, 19).replace('T', ' ') + const caller = + new Error().stack?.split('\n')[2]?.trim().split(' ')[1] || 'Unknown' + const logEntry = { + level, + message, + timestamp, + caller, + data, + } + + const coloredLevel = level.toUpperCase() + + const formattedMessage = + `[${logEntry.timestamp}] ` + + `${coloredLevel} ` + + `(${logEntry.caller}): ` + + `${logEntry.message}` + + switch (level) { + case 'info': + data + ? console.info(formattedMessage, parseDataToLog(data)) + : console.info(formattedMessage) + break + case 'warn': + data + ? console.warn(formattedMessage, parseDataToLog(data)) + : console.warn(formattedMessage) + break + case 'error': + data + ? console.error(formattedMessage, parseDataToLog(data)) + : console.error(formattedMessage) + break + case 'debug': + data + ? console.debug(formattedMessage, parseDataToLog(data)) + : console.debug(formattedMessage) + break + default: + data + ? console.log(formattedMessage, parseDataToLog(data)) + : console.log(formattedMessage) + } +} diff --git a/protected/js/model-viewer/index.js b/protected/js/model-viewer/index.js new file mode 100644 index 0000000000..49c4ff42be --- /dev/null +++ b/protected/js/model-viewer/index.js @@ -0,0 +1,78 @@ +import { createUi } from "./ui/index.js"; +import { createModelViewer } from "./viewer/index.js"; +import { logger } from "./helpers/logger.js"; +import { invariant } from "./helpers/invariant.js"; +import { selector } from "./ui/selectors.js"; + +const defaultOptions = { + loadModelOnInit: true, +}; + +/** + * @param {Array} files - Array of file objects representing 3D model files + * @param {number} files[].id - Unique identifier for the file + * @param {string} files[].location - URL pointing to the 3D model file + * @param {string} files[].name - Display name of the file + * @param {string} files[].extension - File extension, must be one of: 'stl', 'obj', 'ply', 'las' + * @param {Object} [options] - Configuration options + * @param {boolean} [options.loadModelOnInit=true] - Whether to load the first model immediately on initialization + */ +export function modelViewer(files, options = {}) { + const mergedOptions = { ...defaultOptions, ...options }; + + const root = $(selector.root); + const container = root.find(selector.canvasContainer); + + invariant(container.length !== 0, "Expected element not found"); + + function getFileByProperty(property, value) { + return files.find((file) => file[property] === value); + } + + /** + * @param {{ searchBy: string, value: string, key: string }} args + * @returns {string | null} + * + * example: getFileProperty({ searchBy: "id", value: "123", key: "description" }) + * returns the description of the file with id 123 or null if no such file exists + */ + function getFileProperty({ searchBy, value, key }) { + const file = getFileByProperty(searchBy, value); + if (!file) { + return null; + } + return file[key]; + } + + const { loadModel } = createModelViewer(container); + + const uiState = createUi({ + root, + onSelect: handleLoadModel, + onPlay: handleLoadModel, + getDataProperty: getFileProperty, + }); + + async function handleLoadModel(fileId) { + if (!fileId) { + uiState.status = "idle"; + return; + } + + try { + uiState.error = null; + uiState.status = "pending"; + const file = getFileByProperty("id", fileId); + await loadModel(file); + uiState.status = "success"; + } catch (err) { + logger("error", "Error loading model", err); + uiState.error = err; + uiState.status = "error"; + } + } + + if (mergedOptions.loadModelOnInit) { + handleLoadModel(uiState.selected); + } +} diff --git a/protected/js/model-viewer/ui/index.js b/protected/js/model-viewer/ui/index.js new file mode 100644 index 0000000000..31002b9270 --- /dev/null +++ b/protected/js/model-viewer/ui/index.js @@ -0,0 +1,167 @@ +import { createUiState } from "./uiState.js"; +import { createUiView } from "./uiView.js"; +import { invariant } from "../helpers/invariant.js"; +import { selector } from "./selectors.js"; +import { coerceSelected } from "../helpers/coerceSelected.js"; + +/** + * Creates and initializes the UI component for the model viewer + * @param {Object} param0 Configuration object + * @param {JQuery} param0.root Root DOM element containing the model viewer UI + * @param {function(string|null): void} param0.onSelect Callback when model is selected from dropdown + * @param {function(string|null): void} param0.onPlay Callback when play button is clicked + * @param {function({searchBy: string, value: string, key: string}): string|null} param0.getDataProperty Function to get file properties + * @returns {Object} UI state object with status, error and selected model properties + */ +export function createUi({ root, onSelect, onPlay, getDataProperty }) { + // mandatory elements + const domElements = { + viewerContainer: root.find(selector.viewerContainer), + canvasContainer: root.find(selector.canvasContainer), + loadingOverlay: root.find(selector.loadingOverlay), + playButtonOverlay: root.find(selector.playButtonOverlay), + errorDisplay: root.find(selector.errorDisplay), + modelSelector: root.find(selector.modelSelector), + modelDescription: root.find(selector.modelDescription), + }; + + Object.values(domElements).forEach((el) => { + invariant(el.length !== 0, "Expected element not found"); + }); + + // optional elements + domElements.controls = root.find(selector.controls); + + const playButton = domElements.playButtonOverlay.find(selector.playButton); + const helpButton = domElements.controls.find(selector.helpButton); + const fullscreenButton = domElements.controls.find(selector.fullscreenButton); + const helpModal = root.find(selector.helpModal); + const helpModalClose = helpModal.find(selector.helpModalClose); + const loadingDisplay = domElements.loadingOverlay.find(selector.loadingDisplay); + + const uiView = createUiView(domElements, getDataProperty); + + const modelState = createUiState( + { status: "idle", error: null, selected: null }, + () => uiView.updateUI(modelState) + ); + + /** + * Handles model selection from dropdown, updates state and triggers callback + */ + function handleSelect() { + modelState.selected = coerceSelected(domElements.modelSelector.val()) || null; + onSelect(modelState.selected); + } + + /** + * Handles play button click, triggers callback with selected model + */ + function handlePlay() { + onPlay(modelState.selected); + } + + /** + * Shows help modal when help button is clicked + * @param {Event} e Click event + */ + function handleHelp(e) { + e.preventDefault(); + helpModal.fadeIn(); + } + + /** + * Hides help modal when close button is clicked + * @param {Event} e Click event + */ + function handleHelpClose(e) { + e.preventDefault(); + helpModal.fadeOut(); + } + + /** + * Toggles browser fullscreen mode + */ + function toggleFullscreenMode() { + document.fullscreenElement != null + ? document.exitFullscreen() + : document.documentElement.requestFullscreen(); + } + + /** + * Handles fullscreen button click + * @param {Event} e Click event + */ + function handleFullscreen(e) { + e.preventDefault(); + toggleFullscreenMode(); + } + + /** + * Updates UI when fullscreen state changes + */ + function handleFullscreenChange() { + const isFullscreen = document.fullscreenElement != null; + domElements.viewerContainer.toggleClass("fullscreen", isFullscreen); + } + + /** + * Handles keyboard shortcuts + * @param {KeyboardEvent} e Keyboard event + */ + function handleKeyDown(e) { + if (e.key === "Escape" && helpModal.is(":visible")) { + helpModal.fadeOut(); + return; + } + + if (e.key.toLowerCase() === "h") { + e.preventDefault(); + helpModal.is(":visible") ? helpModal.fadeOut() : helpModal.fadeIn(); + } + + if (e.key.toLowerCase() === "f") { + e.preventDefault(); + toggleFullscreenMode(); + } + } + + /** + * Initializes UI state and event listeners + */ + function init() { + loadingDisplay.hide(); + domElements.controls.hide(); + domElements.playButtonOverlay.show(); + modelState.selected = coerceSelected(domElements.modelSelector.val()) || null; + uiView.updateUI(modelState); + domElements.modelSelector.on("change", handleSelect); + playButton.on("click", handlePlay); + helpButton.on("click", handleHelp); + $(document).on("keydown", handleKeyDown); + fullscreenButton.on("click", handleFullscreen); + helpModalClose.on("click", handleHelpClose); + $(document).on("fullscreenchange", handleFullscreenChange); + } + + /** + * Removes all event listeners + */ + function unmount() { + domElements.modelSelector.off("change", handleSelect); + playButton.off("click", handlePlay); + helpButton.off("click", handleHelp); + fullscreenButton.off("click", handleFullscreen); + helpModalClose.off("click", handleHelpClose); + $(document).off("keydown", handleKeyDown); + $(document).off("fullscreenchange", handleFullscreenChange); + } + + init(); + + $(window).on("beforeunload", () => { + unmount(); + }); + + return modelState; +} diff --git a/protected/js/model-viewer/ui/selectors.js b/protected/js/model-viewer/ui/selectors.js new file mode 100644 index 0000000000..0907e6c8d7 --- /dev/null +++ b/protected/js/model-viewer/ui/selectors.js @@ -0,0 +1,18 @@ +export const selector = { + root: "#modelViewerRoot", + viewerContainer: ".js-model-viewer-container", + canvasContainer: ".js-canvas-container", + loadingOverlay: ".js-loading-overlay", + loadingDisplay: ".js-loading-display", + loadingText: ".js-loading-text", + playButtonOverlay: ".js-play-button-overlay", + errorDisplay: ".js-error-display", + modelSelector: ".js-model-selector", + modelDescription: ".js-model-description", + controls: ".js-controls", + playButton: ".js-play-button", + helpButton: ".js-controls-info-btn", + fullscreenButton: ".js-fullscreen-btn", + helpModal: ".js-help-modal", + helpModalClose: ".js-help-modal-close", +}; diff --git a/protected/js/model-viewer/ui/uiState.js b/protected/js/model-viewer/ui/uiState.js new file mode 100644 index 0000000000..73dc4fb1c8 --- /dev/null +++ b/protected/js/model-viewer/ui/uiState.js @@ -0,0 +1,10 @@ +export function createUiState(initialState, onStateChange) { + const state = { ...initialState }; + return new Proxy(state, { + set(target, property, value) { + target[property] = value; + onStateChange(property, value); + return true; + }, + }); +} diff --git a/protected/js/model-viewer/ui/uiView.js b/protected/js/model-viewer/ui/uiView.js new file mode 100644 index 0000000000..31891c7fe5 --- /dev/null +++ b/protected/js/model-viewer/ui/uiView.js @@ -0,0 +1,74 @@ +import { selector } from "./selectors.js"; + +// this module determines how the UI changes when the state changes +export function createUiView(domElements, getDataProperty) { + const { + loadingOverlay, + playButtonOverlay, + errorDisplay, + modelDescription, + controls, + } = domElements; + + const loadingText = loadingOverlay.find(selector.loadingText); + const loadingDisplay = loadingOverlay.find(selector.loadingDisplay); + + function updateUI(state) { + const { status, error, selected } = state; + + switch (status) { + case "idle": + loadingDisplay.hide(); + loadingText.text(""); + playButtonOverlay.show(); + controls.hide(); + break; + case "pending": + loadingDisplay.show(); + loadingText.text("Loading model"); + playButtonOverlay.hide(); + controls.hide(); + break; + case "success": + loadingDisplay.hide(); + loadingText.text("Model loaded"); + playButtonOverlay.hide(); + controls.show(); + break; + case "error": + loadingDisplay.hide(); + loadingText.text(""); + playButtonOverlay.show(); + controls.hide(); + break; + } + + const errorContent = errorDisplay.find(".js-error-content"); + if (error) { + errorDisplay.addClass(["alert", "alert-danger"]); + errorContent.text(error); + errorContent.show(); + errorDisplay.show(); + } else { + errorDisplay.removeClass(["alert", "alert-danger"]); + errorContent.text(""); + errorContent.hide(); + errorDisplay.hide(); + } + + if (selected !== null) { + const description = getDataProperty({ + searchBy: "id", + value: selected, + key: "description", + }); + modelDescription.find(".js-description-content").text(description); + } else { + modelDescription.find(".js-description-content").text(""); + } + } + + return { + updateUI, + }; +} diff --git a/protected/js/model-viewer/viewer/components/camera.js b/protected/js/model-viewer/viewer/components/camera.js new file mode 100644 index 0000000000..5579d61e94 --- /dev/null +++ b/protected/js/model-viewer/viewer/components/camera.js @@ -0,0 +1,10 @@ +import { PerspectiveCamera } from "three"; + +export function createCamera({ + aspectRatio = 1 +}) { + const camera = new PerspectiveCamera(45, aspectRatio, 0.1, 100); + camera.position.set(5, 5, 5); + + return camera; +} diff --git a/protected/js/model-viewer/viewer/components/lights.js b/protected/js/model-viewer/viewer/components/lights.js new file mode 100644 index 0000000000..92bc8aeb00 --- /dev/null +++ b/protected/js/model-viewer/viewer/components/lights.js @@ -0,0 +1,21 @@ +import { DirectionalLight, HemisphereLight } from "three"; + +const intensityFactor = 2; + +function createLights() { + // NOTE in case of low performance, remove one directional light + const hemiLight = new HemisphereLight(0xcce0ff, 0x777777, 2 * intensityFactor); + const sunlight = new DirectionalLight(0xffd7b3, 2.5 * intensityFactor); + const backLight = new DirectionalLight(0xb3d7ff, 0.2 * intensityFactor); + + sunlight.position.set(-10, 10, 10); + backLight.position.set(10, -10, -10); + + return [ + sunlight, + backLight, + hemiLight, + ]; +} + +export { createLights }; diff --git a/protected/js/model-viewer/viewer/components/models/extensions.js b/protected/js/model-viewer/viewer/components/models/extensions.js new file mode 100644 index 0000000000..9fe713e757 --- /dev/null +++ b/protected/js/model-viewer/viewer/components/models/extensions.js @@ -0,0 +1,6 @@ +const STL = "stl"; +const OBJ = "obj"; +const PLY = "ply"; +const LAS = "las"; + +export { STL, OBJ, PLY, LAS }; diff --git a/protected/js/model-viewer/viewer/components/models/index.js b/protected/js/model-viewer/viewer/components/models/index.js new file mode 100644 index 0000000000..5cd045da1a --- /dev/null +++ b/protected/js/model-viewer/viewer/components/models/index.js @@ -0,0 +1,19 @@ +import { Mesh } from "three"; +import { createLoader } from "./loader.js"; +import { setupModel } from "./setupModel.js"; + +/** + * Loads a 3D model from a given location (URL) and extension + * @param {Object} params - Parameters object + * @param {string} params.extension - The extension of the model file, expected to be one of: 'stl', 'obj', 'ply', 'las' + * @param {string} params.location - The URL location of the model file + * @returns {Promise} A promise that resolves to an array of Three.js Mesh objects + */ +export async function load({ extension, location }) { + const lcExt = extension.toLowerCase(); + const loader = createLoader(lcExt); + + let loadedObject = await loader.loadAsync(location); + + return setupModel(loadedObject, lcExt); +} diff --git a/protected/js/model-viewer/viewer/components/models/loader.js b/protected/js/model-viewer/viewer/components/models/loader.js new file mode 100644 index 0000000000..59bfbb349c --- /dev/null +++ b/protected/js/model-viewer/viewer/components/models/loader.js @@ -0,0 +1,34 @@ +import { STLLoader } from "three/addons/loaders/STLLoader.js"; +import { OBJLoader } from "three/addons/loaders/OBJLoader.js"; +import { PLYLoader } from "three/addons/loaders/PLYLoader.js"; +import { LASLoader } from "https://cdn.jsdelivr.net/npm/@loaders.gl/las@4.3.2/+esm"; +import { load } from "https://cdn.jsdelivr.net/npm/@loaders.gl/core@4.3.2/+esm"; +import { STL, OBJ, LAS, PLY } from "./extensions.js"; + +const loaders = { + [STL]: STLLoader, + [OBJ]: OBJLoader, + [PLY]: PLYLoader, +}; + +export function createLoader(ext) { + switch (ext) { + case STL: + case OBJ: + case PLY: + return new loaders[ext](); + case LAS: + return { + loadAsync: async (location) => { + const response = await fetch(location); + const arrayBuffer = await response.arrayBuffer(); + return load(arrayBuffer, LASLoader, { + shape: "mesh", + colorDepth: 16, + }); + }, + }; + } + + throw new Error(`Unsupported extension: ${ext}`); +} diff --git a/protected/js/model-viewer/viewer/components/models/material.js b/protected/js/model-viewer/viewer/components/models/material.js new file mode 100644 index 0000000000..f980d3c4c1 --- /dev/null +++ b/protected/js/model-viewer/viewer/components/models/material.js @@ -0,0 +1,30 @@ +import { MeshPhysicalMaterial, PointsMaterial } from "three"; + +export function createPointsMaterial(options) { + return new PointsMaterial({ + size: 0.02, + sizeAttenuation: true, + color: 0x888888, + alphaTest: 0.5, + transparent: true, + opacity: 1, + map: null, + alphaMap: null, + vertexColors: false, + ...options + }); +} + +export function createMaterial() { + const material = new MeshPhysicalMaterial({ + color: 0x9ba3b0, + metalness: 0.7, + roughness: 0.3, + clearcoat: 0.5, + clearcoatRoughness: 0.2, + reflectivity: 1, + envMapIntensity: 1, + }); + + return material; +} \ No newline at end of file diff --git a/protected/js/model-viewer/viewer/components/models/setupModel.js b/protected/js/model-viewer/viewer/components/models/setupModel.js new file mode 100644 index 0000000000..3cde6023b0 --- /dev/null +++ b/protected/js/model-viewer/viewer/components/models/setupModel.js @@ -0,0 +1,134 @@ +import { + Mesh, + Box3, + Vector3, + BufferGeometry, + BufferAttribute, + Points, +} from "three"; +import { OBJ, STL, PLY, LAS } from "./extensions.js"; +import { createMaterial, createPointsMaterial } from "./material.js"; + +function hasNormals(geometry) { + return geometry.attributes && geometry.attributes.normal !== undefined; +} + +function setupObj(loadedModel) { + const meshes = loadedModel.children?.filter((child) => child instanceof Mesh); + if (!meshes || meshes.length === 0) { + throw new Error("No meshes found in OBJ file"); + } + return meshes.flatMap((mesh) => setupGeometry(mesh.geometry)); +} + +function validateLasModel(loadedModel) { + if (!loadedModel?.attributes?.POSITION?.value) { + throw new Error("Invalid LAS file: No position data found"); + } +} + +function createPositionAttribute(positionData, vertexCount) { + const positions = new Float32Array(vertexCount * 3); + for (let i = 0; i < vertexCount; i++) { + positions[i * 3] = positionData[i * 3]; + positions[i * 3 + 1] = positionData[i * 3 + 1]; + positions[i * 3 + 2] = positionData[i * 3 + 2]; + } + return new BufferAttribute(positions, 3); +} + +function createColorAttribute(colorData, vertexCount) { + const colors = new Float32Array(vertexCount * 3); + for (let i = 0; i < vertexCount; i++) { + // Convert 8-bit color to float + colors[i * 3] = colorData[i * 4] / 255; + colors[i * 3 + 1] = colorData[i * 4 + 1] / 255; + colors[i * 3 + 2] = colorData[i * 4 + 2] / 255; + } + return new BufferAttribute(colors, 3); +} + +function centerGeometry(geometry) { + geometry.computeBoundingBox(); + const center = new Vector3(); + geometry.boundingBox.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); +} + +function scalePoints(points) { + const box = new Box3().setFromObject(points); + const size = box.getSize(new Vector3()).length(); + const scale = 5 / size; + points.scale.set(scale, scale, scale); +} + +function setupLas(loadedModel) { + validateLasModel(loadedModel); + + const geometry = new BufferGeometry(); + const vertexCount = loadedModel.header.vertexCount; + const positionData = loadedModel.attributes.POSITION.value; + + geometry.setAttribute( + "position", + createPositionAttribute(positionData, vertexCount) + ); + + if (loadedModel.attributes.COLOR_0?.value) { + const colorData = loadedModel.attributes.COLOR_0.value; + geometry.setAttribute( + "color", + createColorAttribute(colorData, vertexCount) + ); + } + + const material = createPointsMaterial(); + const points = new Points(geometry, material); + + centerGeometry(geometry); + scalePoints(points); + + return [points]; +} + +function setupGeometry(geometry) { + if (!hasNormals(geometry)) { + geometry.computeVertexNormals(); + } + + if (geometry.center) { + geometry.center(); + } else if (geometry.isBufferGeometry) { + geometry.computeBoundingBox(); + const center = new Vector3(); + geometry.boundingBox.getCenter(center); + geometry.translate(-center.x, -center.y, -center.z); + } + + const material = createMaterial(); + const mesh = new Mesh(geometry, material); + + // Scale model to fit view + const box = new Box3().setFromObject(mesh); + const size = box.getSize(new Vector3()).length(); + const scale = 5 / size; + mesh.scale.set(scale, scale, scale); + + return [mesh]; +} + +// all setup functions return an array of meshes for consistency, even if there is only one mesh +export function setupModel(loadedModel, ext) { + switch (ext) { + case OBJ: + return setupObj(loadedModel); + case STL: + return setupGeometry(loadedModel); + case PLY: + return setupGeometry(loadedModel); + case LAS: + return setupLas(loadedModel); + default: + throw new Error(`Unsupported extension: ${ext}`); + } +} diff --git a/protected/js/model-viewer/viewer/components/scene.js b/protected/js/model-viewer/viewer/components/scene.js new file mode 100644 index 0000000000..2e9c607d5f --- /dev/null +++ b/protected/js/model-viewer/viewer/components/scene.js @@ -0,0 +1,8 @@ +import { Scene, Color } from "three"; + +export function createScene() { + const scene = new Scene(); + scene.background = new Color(0xe5e5e5); + + return scene; +} diff --git a/protected/js/model-viewer/viewer/helpers.js b/protected/js/model-viewer/viewer/helpers.js new file mode 100644 index 0000000000..927d2c8af8 --- /dev/null +++ b/protected/js/model-viewer/viewer/helpers.js @@ -0,0 +1,14 @@ +import { AxesHelper, GridHelper } from 'three'; + +function createAxesHelper() { + const helper = new AxesHelper(3); + helper.position.set(-3.5, 0, -3.5); + return helper; +} + +function createGridHelper() { + const helper = new GridHelper(6); + return helper; +} + +export { createAxesHelper, createGridHelper }; diff --git a/protected/js/model-viewer/viewer/index.js b/protected/js/model-viewer/viewer/index.js new file mode 100644 index 0000000000..822ee946e4 --- /dev/null +++ b/protected/js/model-viewer/viewer/index.js @@ -0,0 +1,87 @@ +import { createScene } from "./components/scene.js"; +import { createCamera } from "./components/camera.js"; +import { createRenderer } from "./systems/renderer.js"; +import { createControls } from "./systems/controls.js"; +import { createLights } from "./components/lights.js"; +import { createResizer } from "./systems/resizer.js"; +import { load } from "./components/models/index.js"; +import { getContainerDimensions } from "../helpers/getContainerDimensions.js"; + +/** + * Creates and manages a 3D model viewer with scene, camera, renderer, and controls + * @param {HTMLElement} container - DOM element to contain the 3D viewer + * @returns {Object} Object containing viewer control methods + * @returns {Function} returns.loadModel - Async function to load and display a 3D model + * @returns {Function} returns.render - Function to render the current scene + */ +export function createModelViewer(container) { + let scene; + let camera; + let renderer; + let controls; + let models = []; + let onDestroyCallbacks = []; + + function create() { + scene = createScene(); + camera = createCamera({ + aspectRatio: + getContainerDimensions(container)[0] / + getContainerDimensions(container)[1], + }); + renderer = createRenderer(); + container.append(renderer.domElement); + controls = createControls(camera, renderer.domElement); + + const lights = createLights(); + + scene.add(...lights); + + const { destroy: destroyResizer } = createResizer({ + camera, + renderer, + onResize: render, + container, + }); + + onDestroyCallbacks.push(destroyResizer); + + // re-render when user interacts with the controls + controls.addEventListener("change", render); + } + + function render() { + renderer.render(scene, camera); + } + + async function loadModel(data) { + const { location, extension } = data; + // unload previously loaded model + if (models.length > 0) { + scene.remove(...models); + } + // reset controls to undo any orbiting done in previous model + controls.reset(); + models = await load({ location, extension }); + // set orbiting center around model center position + controls.target.copy(models[0].position); + scene.add(...models); + render(); + } + + function unmount() { + controls.removeEventListener("change", render); + onDestroyCallbacks.forEach((callback) => callback()); + } + + create(); + + $(window).on("beforeunload", () => { + unmount(); + }); + + return { + render, + loadModel, + }; +} diff --git a/protected/js/model-viewer/viewer/systems/controls.js b/protected/js/model-viewer/viewer/systems/controls.js new file mode 100644 index 0000000000..04f7c5bff1 --- /dev/null +++ b/protected/js/model-viewer/viewer/systems/controls.js @@ -0,0 +1,18 @@ +import { OrbitControls } from "three/addons/controls/OrbitControls.js"; + +export function createControls(camera, canvas) { + const controls = new OrbitControls(camera, canvas); + + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.screenSpacePanning = true; + + controls.zoomSpeed = 1.2; + + controls.target.set(0, 0, 0); + controls.update(); + + controls.tick = () => controls.update(); + + return controls; +} diff --git a/protected/js/model-viewer/viewer/systems/renderer.js b/protected/js/model-viewer/viewer/systems/renderer.js new file mode 100644 index 0000000000..ccdfa94112 --- /dev/null +++ b/protected/js/model-viewer/viewer/systems/renderer.js @@ -0,0 +1,11 @@ +import { WebGLRenderer } from 'three'; + +function createRenderer() { + const renderer = new WebGLRenderer({ antialias: true }); + + renderer.physicallyCorrectLights = true; + + return renderer; +} + +export { createRenderer }; \ No newline at end of file diff --git a/protected/js/model-viewer/viewer/systems/resizer.js b/protected/js/model-viewer/viewer/systems/resizer.js new file mode 100644 index 0000000000..79c086d1c1 --- /dev/null +++ b/protected/js/model-viewer/viewer/systems/resizer.js @@ -0,0 +1,55 @@ +import { debounce } from "../../helpers/debounce.js"; +import { getContainerDimensions } from "../../helpers/getContainerDimensions.js"; + +const setSize = ([width, height], camera, renderer) => { + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height); + renderer.setPixelRatio(window.devicePixelRatio); +}; + +function createResizeObserver(onResize) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentRect.width > 0 && entry.contentRect.height > 0) { + onResize(); + } + } + }); + + return resizeObserver; +} + +function createResizer({ + camera, + renderer, + onResize, + container, +}) { + setSize(getContainerDimensions(container), camera, renderer); + + const debouncedResize = debounce(() => { + setSize(getContainerDimensions(container), camera, renderer); + onResize(); + }, 100); + + $(window).on("resize", debouncedResize); + $(window).on("fullscreenchange", debouncedResize); + + const observer = createResizeObserver(debouncedResize); + observer.observe(container[0]); + + function destroy() { + $(window).off("resize", debouncedResize); + $(window).off("fullscreenchange", debouncedResize); + debouncedResize.cancel(); + observer.unobserve(container[0]); + observer.disconnect(); + } + + return { + destroy, + }; +} + +export { createResizer }; diff --git a/protected/views/dataset/view.php b/protected/views/dataset/view.php index 84892383cd..fb35d11718 100755 --- a/protected/views/dataset/view.php +++ b/protected/views/dataset/view.php @@ -307,7 +307,7 @@ function showText() { -
+
getTotalItemCount() > 0) { $samplesPerPage = $sampleDataProvider->getItemCount(); @@ -500,9 +500,19 @@ function showText() { ?> getDatasetExternalLinksTypesNames(["Protocols.io", "JBrowse", "3D Models", "Code Ocean"]) as $linkType => $linkCode) { + $modelLinks = $links->getDatasetExternalLinks(['3D Models']); + if (count($modelLinks) > 0) { ?> -
+
+

3D Models:

+ renderPartial('//shared/_model_viewer', ['data' => $modelLinks]); ?> +
+ getDatasetExternalLinksTypesNames(["Protocols.io", "JBrowse", "Code Ocean"]) as $linkType => $linkCode) { + ?> +

:

getDatasetExternalLinks([$linkType]) as $link) { @@ -517,9 +527,6 @@ function showText() { echo ""; echo "
"; break; - case "3D Models": - echo ""; - break; case "Code Ocean": echo "

$p

"; break; diff --git a/protected/views/shared/_model_viewer.php b/protected/views/shared/_model_viewer.php new file mode 100644 index 0000000000..bf21b5396a --- /dev/null +++ b/protected/views/shared/_model_viewer.php @@ -0,0 +1,143 @@ + $item['id'], + 'location' => $item['url'], + 'name' => pathinfo($item['url'], PATHINFO_BASENAME), + 'extension' => pathinfo($item['url'], PATHINFO_EXTENSION), + ]; +}, $data); + + +?> + +
+
+
+ + +
+
+
+

+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ + +
+
+ +clientScript->registerScript( + 'import-map', <<assetManager->forceCopy = YII_DEBUG; +$jsDir = Yii::getPathOfAlias('application.js.model-viewer'); +$jsUrl = Yii::app()->assetManager->publish($jsDir); + +Yii::app()->clientScript->registerScriptFile($jsUrl . '/index.js', CClientScript::POS_END, ['type' => 'module']); +?> + + \ No newline at end of file diff --git a/sql/3D_Viewer_101001_test_data.sql b/sql/3D_Viewer_101001_test_data.sql deleted file mode 100644 index c182f9f5bd..0000000000 --- a/sql/3D_Viewer_101001_test_data.sql +++ /dev/null @@ -1,3 +0,0 @@ --- to be loaded in production_like.pgdmp -insert into external_link_type(id, name) values(5, '3D Models'); -insert into external_link(dataset_id, url, external_link_type_id) values(80,'https://sketchfab.com/models/ea49d0dd500647cbb4b61ad5ca9e659a',5); diff --git a/tests/acceptance/DatasetView.feature b/tests/acceptance/DatasetView.feature index a77c6cab49..841ce53941 100644 --- a/tests/acceptance/DatasetView.feature +++ b/tests/acceptance/DatasetView.feature @@ -216,4 +216,19 @@ Feature: a user visit the dataset page And I press the button "+" And I should see "Alternative names:PYGAD" When I press the button "-" - Then I should not see "Alternative names:PYGAD" \ No newline at end of file + Then I should not see "Alternative names:PYGAD" + + @ok @issue-2054 + Scenario: 3D Models tab + Given I have not signed in + When I am on "/dataset/100006" + Then I should see "3D Models" + + @ok @issue-2054 + Scenario: 3D model drop down list + Given I have not signed in + When I am on "/dataset/100006" + And I follow "3D Models" + Then I should see "3D Models:" + And I should see "Select a model" + And I should see "GeoB8502_865cm_Shell-4.obj" \ No newline at end of file