37 Commits
v0.1.0 ... main

Author SHA1 Message Date
8294edc1d1 Annotation sequence in editor skips unused in set
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-05 21:28:11 -07:00
846106a1a8 Fix: View menu width on mobile
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-04 09:47:59 -07:00
717ef152f1 Fix: Successfully delete final annotation
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 20:35:23 -07:00
859183fe2e Add disable hs buttons when 0 annotations
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 20:04:00 -07:00
9953dff4a0 Add better reset image
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 11:28:55 -07:00
8ed4e1f679 Fix: reset after annotation selection
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 11:26:02 -07:00
7e353bee24 Add reset view position button to model menu
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 10:02:43 -07:00
383818b6f8 Fix: annotation drag
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-03 07:59:39 -07:00
88cd5e4727 Fix: set initial view for current selected view
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-02 21:03:41 -07:00
a2246015c9 Merge pull request 'Convert configs from json to toml' (#58) from toml_annot into main
Reviewed-on: #58
2025-05-02 03:54:19 +00:00
840b5e34df Update readme
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-01 20:47:33 -07:00
9857e77a1a Restore preview edit functions
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-01 20:16:29 -07:00
ea51793e2f Un-fix: extension.json
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-01 19:49:26 -07:00
5561038c25 Add TOML handling to JS modules
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-05-01 19:18:04 -07:00
95647069eb Read toml formatted configs for display
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-04-30 09:00:44 -07:00
4c11d44918 Bump version to 0.2.1
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-04-02 15:41:09 -07:00
996a62f55a Improve rad to deg handling
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2025-04-02 15:39:29 -07:00
99b84272c6 Add configuration descriptions to ReadMe 2025-03-25 20:57:25 -07:00
48ec6cf8e6 Bump version to 0.2.0
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-20 09:26:56 -07:00
f08977da3c Add basic gltf format checking
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-20 09:08:35 -07:00
7daaf81280 Include fiedl of view in annotation data
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-19 20:01:45 -07:00
e92b319a85 Create new views and annotation sets
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-19 08:53:02 -07:00
0dd9aba2b6 Refactor js files (#48)
This pr is a significant reorg of the js files.

1) All non-basic functions have been moved out of the main glmv.js
2) 'Metadata' has been universally changed to mvconfig to avoid conflict with actual file metadata
3) Configuration changing functions have been split off into glmv-mvconfig.js
4) Hotspot modification functions have been split off into glmv-hs.js

Reviewed-on: #48
2024-11-18 03:54:29 +00:00
d419f6fe7e Add file page link model when not on file page
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-16 16:45:11 -07:00
3afe0ca143 Add view config selection to preview editor
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-16 14:57:28 -07:00
8032d0a16d Add camera control toggle to preview edit functions
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-16 10:23:36 -07:00
e9dc663c0f Add random annotation set transform
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-15 08:59:50 -07:00
9f8995dcf3 Include field of view in default view and config
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-15 08:21:30 -07:00
dd475f29c7 Add annotation set transform framework
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-14 08:41:59 -07:00
7cba86ce30 Improve camera orbit and target settings
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-13 16:28:49 -07:00
4400412441 Fix hidden preview edit menu
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-13 15:44:31 -07:00
85757bc0b3 Make addition of config object specific to gltf files
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-13 15:11:24 -07:00
4f7febcd26 Add mvconfig tag
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-13 09:27:45 -07:00
cf5deba5fa Add default configopn model load
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-13 08:00:31 -07:00
0b37b9e0de Use poster for thumbnail
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-12 21:06:33 -07:00
6787cdf00a Remove deleted annotation from sets
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-12 20:26:12 -07:00
9c77018a8d Fix reversed size value in metadata
Signed-off-by: Justin Georgi <justin.georgi@gmail.com>
2024-11-12 19:25:44 -07:00
17 changed files with 2213 additions and 540 deletions

View File

@@ -3,5 +3,6 @@ $magicWords = [];
$magicWords['en'] = [
'glmv_view' => [ 1, "view=$1" ],
'glmv_hsset' => [1, "set=$1"]
'glmv_hsset' => [1, "set=$1"],
'glmv_settrans' => [1, "transform=$1"]
];

View File

@@ -1,3 +1,47 @@
# GlModelViewer
MediaWiki extension for using model-viewer to display and annotate 3D models
MediaWiki extension for using model-viewer to display and annotate 3D models
## Metadata
Model-viewer configurations and annotations can be added as TOML in the file's description.
### Viewer Configuration
Configuration of the model-viewer instance is contained in the `[viewerConfig]` object.
This object can contain any number of child objects each of which describes a different possible configuration.
Each configuration can have properties matching the any of the attributes of the model-viewer element except for `src`, `class`, `interpolation-delay`, and `interaction-prompt`.
If adding a poster image, the value of the `poster` property must be the name of an image in the Wiki's File namespace (without the namespace header, e.g., `"My poster.jpg"`).
### Annotations
Annotations can be configured using the `[annotations]` object.
This object can contain any number of child objects each of which describes a different possible annotation.
The label for each annotation is the name of the child object (`[annotations."Label here"]`). Each object has two required properties and three optional properties.
| Property | Required | Description | Example |
|----------|----------|-------------|---------|
| **data-position** | Yes | String containing the X, Y, and Z coordinates of the annotation. | "0.02297m -0.02544m 0.01780m" |
| **data-normal** | Yes | String containing the X, Y, and Z coordinates of the normal vector of the annotation. | "0.78966m -0.53116m 0.30709m" |
| **data-orbit** | No | String containing the orbit vector of the camera to view annotation. | "2.31rad 2.01rad 0.65m" |
| **data-target** | No | String containing the X, Y, and Z coordinates of the camera target to view annotation. | "-0.01029m 0.00071m 0.01138m" |
| **field-of-view** | No | String containing the angle of the field of view in degrees. | "35.25deg" |
### Annotation sets
Annotations sets are specific subsets of the listed annotations that can be shown on the model.
They are configured using the `[annotationSets]` object.
This object can contain any number properties each of which contains an array of strings matching the names of the annotation to be included.
The property name is used as the annotation set identifier.
By default, all annotations are rendered on a model.
## Linking
When adding the model file to a page, use the standard `[[File:Model name.glb]]` internal file link.
The link supports three optional properties.
| Property | Description | Default |
|----------|-------------|---------|
| **view** | Use one of the non-default configurations specified in the `[viewerConfig]` object. | default viewer configuration |
| **set** | Display only the annotations listed in the specified annotation set from the `[annotationSets]` object. | show all annotations |
| **transform** | Reorder the displayed annotations. Can be either *rand* or *alpha*. | annotations are numbered in the order they are listed in the `[annotations]` object |

5
composer.json Normal file
View File

@@ -0,0 +1,5 @@
{
"require": {
"devium/toml": "*"
}
}

View File

@@ -3,7 +3,7 @@
"author": "Justin Georgi",
"url": "https://gitea.azgeorgis.net/jgeorgi/mwModelViewer",
"description": "This extension allows .glb and .gltf files to be added, displayed, and annotated in MediaWiki",
"version": "0.1.0",
"version": "0.2.1",
"license-name": "MIT",
"type": "media",
"manifest_version": 2,
@@ -18,7 +18,10 @@
"MimeMagicInit": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onMimeMagicInit",
"MimeMagicImproveFromExtension": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onMimeMagicImproveFromExtension",
"BeforePageDisplay": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onBeforePageDisplay",
"AlternateEditPreview": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onAlternateEditPreview"
"AlternateEditPreview": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onAlternateEditPreview",
"UploadForm:BeforeProcessing": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onUploadFormBeforeProcessing",
"ParserFirstCallInit": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onParserFirstCallInit",
"UploadVerifyUpload": "MediaWiki\\Extension\\GlModelViewer\\GlModelHooks::onUploadVerifyUpload"
},
"ExtensionMessagesFiles": {
"GlModelHandlerMagic": "GlModelHandler.i18n.magic.php"
@@ -62,11 +65,18 @@
"oojs-ui-core",
"oojs-ui-toolbars",
"oojs-ui.styles.icons-interactions",
"oojs-ui.styles.icons-accessibility",
"oojs-ui.styles.icons-media",
"oojs-ui.styles.icons-location"
],
"styles": [
"glmv-prev.css"
],
"packageFiles": [
"glmv-prev.js"
"glmv-prev.js",
"mini-st.js",
"glmv-mvconfig.js",
"glmv-hs.js"
]
}
}

View File

@@ -3,5 +3,6 @@
"exif-glmv-textures": "Number of textures",
"exif-glmv-version": "GLTF version",
"exif-glmv-generator": "Model generator",
"exif-glmv-metadata": "GLMV metadata version"
"exif-glmv-metadata": "GLMV metadata version",
"glmv-load-error-format": "File $1 is improperly formatted"
}

View File

@@ -6,7 +6,7 @@ use BitmapMetadataHandler;
use Html;
class GlModelHandler extends ImageHandler {
private const GLMV_VERSION = '0.2';
private const GLMV_VERSION = '0.3';
/**
* Model cannot be displayed directly in a browser but can be rendered.
@@ -62,9 +62,9 @@ class GlModelHandler extends ImageHandler {
* @return bool
*/
public function validateParam( $name, $value ) {
if (in_array($name, ['width', 'height'])) {
if (in_array($name, ['width', 'height'])) {
return $value > 0;
} else if (in_array($name, ['view', 'hsset'])) {
} else if (in_array($name, ['view', 'hsset', 'settrans'])) {
return true;
} else {
return false;
@@ -80,10 +80,13 @@ class GlModelHandler extends ImageHandler {
public function makeParamString( $params ) {
$res = parent::makeParamString( $params );
if ( $res && isset( $params['hsset'] ) ) {
$res = "hs{$params['hsset']}-$res";
$res = "glhs{$params['hsset']}-$res";
}
if ( $res && isset( $params['view'] ) ) {
$res = "v{$params['view']}-$res";
$res = "glv{$params['view']}-$res";
}
if ( $res && isset( $params['settrans'] ) ) {
$res = "gltr{$params['settrans']}-$res";
}
return $res;
}
@@ -95,12 +98,14 @@ class GlModelHandler extends ImageHandler {
* @return array|false Array of parameters or false on failure
*/
public function parseParamString( $str ) {
preg_match( '/v([^-]*?)-/', $str, $view );
preg_match( '/hs([^-]*?)-/', $str, $hsset );
$otherParams = preg_replace('/(?:(?:hs[^-]*?-)|(?:v[^-]*?-))*(.*)$/', '/$1/', $str);
preg_match( '/glv([^-]*?)-/', $str, $view );
preg_match( '/glhs([^-]*?)-/', $str, $hsset );
preg_match( '/gltr([^-]*?)-/', $str, $settrans );
$otherParams = preg_replace('/(?:gl(?:hs[^-]*?-)|(?:v[^-]*?-)|(?:tr[^-]*?-))*(.*)$/', '/$1/', $str);
$parsedParams = parent::parseParamString( $otherParams );
$parsedParams['view'] = $view;
$parsedParams['hsset'] = $hsset;
$parsedParams['settrans'] = $settrans;
return $res;
}
@@ -118,6 +123,9 @@ class GlModelHandler extends ImageHandler {
if ( isset( $params['hsset'] ) ) {
$res['hsset'] = $params['hsset'];
}
if ( isset( $params['settrans'] ) ) {
$res['settrans'] = $params['settrans'];
}
return $res;
}
@@ -130,7 +138,8 @@ class GlModelHandler extends ImageHandler {
return [
'img_width' => 'width',
'glmv_view' => 'view',
'glmv_hsset' => 'hsset'
'glmv_hsset' => 'hsset',
'glmv_settrans' => 'settrans'
];
}
@@ -157,8 +166,8 @@ class GlModelHandler extends ImageHandler {
$glMeta = json_decode(fread($gltf,$glMetaLength['int']),true);
fclose($gltf);
$newMeta = array(
'glmv-meshes' => count($glMeta['meshes']),
'glmv-textures' => count($glMeta['textures'])
'glmv-meshes' => count($glMeta['meshes'] ?? []),
'glmv-textures' => count($glMeta['textures'] ?? [])
);
foreach($glMeta['asset'] as $key => $value) {
switch ($key) {
@@ -180,8 +189,8 @@ class GlModelHandler extends ImageHandler {
$metaHandler = new BitmapMetadataHandler;
$metaHandler->addMetadata($newMeta,'native');
return array(
'width' => 600,
'height' => 800,
'width' => 800,
'height' => 600,
'metadata' => $newMeta
);
}

View File

@@ -34,6 +34,28 @@ class GlModelHooks {
}
}
/**
* MWHook: Called when the parser initializes for the first time
*
* @param Parser $parser: Parser object being initialized
*/
static function onParserFirstCallInit( $parser ) {
$parser->setHook('mvconfig', array( __CLASS__, 'renderConfigTag'));
}
/**
* Render the config toml in a <pre> tag
*
* @param $input The text inside the custom tag
* @param array $args Any attributes given in the tag
* @param Parser $parser The parser object
* @param PPFrame $frame The parent frame calling the parser
* @return string HTML string of output
*/
static function renderConfigTag( $input, array $args, $parser, $frame ) {
return '<pre mvconfig>' . $input . '</pre>';
}
/**
* MWHook: Load the js and css modules if model-viewer element is found in the html output
*
@@ -102,6 +124,61 @@ class GlModelHooks {
$out->addModules('ext.glmv.upl');
}
/**
* Perform upload verification, based on both file properties like MIME type
* (same as UploadVerifyFile) and the information entered by the user (upload
* comment, file page contents etc.).
*
* @param UploadBase $upload Instance of UploadBase, with all info about the upload
* @param User $user User uploading this file
* @param array | null $props File properties, as returned by MWFileProps::getPropsFromPath()
* @param string $comment Upload log comment (also used as edit summary)
* @param string | false $pageText File description page text (only used for new uploads)
* @param array | MessageSpecifier | null &$error Output: If the file upload should be prevented, set this to the reason
* @return bool|void True or no return value to continue or false to abort
*/
public static function onUploadVerifyUpload ($upload, $user, ?array $props, $comment, $pageText, &$error) {
if ($props['mime'] != 'model/gltf-binary') {
return true;
} else {
$path = $upload->getTempPath();
$gltf = fopen($path, 'r');
$glHeader = fread($gltf, 4);
fclose($gltf);
if ($glHeader == 'glTF') {
return true;
} else {
$error = array(
"glmv-load-error-format",
$upload->getTitle()->getRootText()
);
return false;
}
}
}
/**
* MWHook: Called just before the upload data, like wpUploadDescription, are processed, so extensions get a chance to manipulate them.
*
* @param SpecialUpload $uploadFormObj current SpecialUpload object
*/
public static function onUploadFormBeforeProcessing( $uploadFormObj ) {
preg_match('/(glb|gltf)$/', $uploadFormObj->mUpload->getTitle(), $isGltf);
if ($isGltf) {
$uploadFormObj->mComment .= <<<CONF
<mvconfig>
[viewerConfig.default]
camera-controls = true
</mvconfig>
CONF;
}
return true;
}
/**
* Small helper function to display information on the browser console
*

View File

@@ -1,10 +1,13 @@
<?php
namespace MediaWiki\Extension\GlModelViewer;
require_once __DIR__ . '/../vendor/autoload.php';
use Devium\Toml\Toml;
use MediaWiki\MediaWikiServices;
use MediaTransformOutput;
use ConfigFactory;
use OutputPage;
use RequestContext;
use Html;
class GlModelTransformOutput extends MediaTransformOutput {
@@ -23,6 +26,7 @@ class GlModelTransformOutput extends MediaTransformOutput {
$this->height = $parameters['height'];
$this->view = (isset($parameters['view'])) ? $parameters['view'] : 'default';
$this->hsset = (isset($parameters['hsset'])) ? $parameters['hsset'] : 'all';
$this->transform = (isset($parameters['settrans'])) ? preg_split("/[\s,]+/", $parameters['settrans']) : [];
$this->url = $file->getFullUrl();
$this->thumb = isset($parameters['isFilePageThumb']) && $parameters['isFilePageThumb'];
}
@@ -46,14 +50,29 @@ class GlModelTransformOutput extends MediaTransformOutput {
* @return string HTML
*/
public function toHtml($options = []) {
$descriptText = $this->file->getDescriptionText();
preg_match('/<pre mvconfig.*?>([\S\s]*?)<\/pre>/',$descriptText,$modelDescript);
$metadata = toml_decode($modelDescript[1], true);
if ($this->thumb) {
$mainConfig = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
$thumbUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/model_thumb.svg';
$attrMenuImg = array (
$poster = $metadata['viewerConfig'][$this->view]['poster'] ?? false;
if ($poster) {
$posterFile = MediaWikiServices::getInstance()->getRepoGroup()->findFile(`File:` . $poster);
}
if ($poster && $posterFile) {
$thumbUrl = $posterFile->getFullUrl();
} else {
$mainConfig = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
$thumbUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/model_thumb.svg';
}
$attrThumbImg = array (
'src' => $thumbUrl,
'style' => 'width: ' . $this->width . 'px; height: ' . $this->height . 'px;'
'style' => 'width: ' . $this->width . 'px; height: ' . $this->height . 'px; ' . 'object-fit: contain;'
);
return Html::rawElement('img', $attrMenuImg, '');
return Html::rawElement('img', $attrThumbImg, '');
}
if (isset($options['img-class'])) {
@@ -64,7 +83,7 @@ class GlModelTransformOutput extends MediaTransformOutput {
$this->parameters['preview'] = $options['preview'];
}
return self::buildViewer($this->file->getDescriptionText(),$this->url,$this->parameters);
return self::buildViewer($metadata,$this->url,$this->parameters);
}
/**
@@ -74,17 +93,17 @@ class GlModelTransformOutput extends MediaTransformOutput {
* and produces the html string for the model-viewer and all relevant child
* elements.
*
* @param string $inText The metadata text which must include a json formatted string inside a pre tag
* @param string $metadata The metadata object parsed from the text
* @param string $srcUrl The full url pointing to the model file
* @param array $frameParams The additional user defined parameters for the viewer such as hotspot and view classes
* @return string Html string of the complete model-viewer element inside a div container
*/
private function buildViewer($inText, $srcUrl, $viewParams) {
private function buildViewer($metadata, $srcUrl, $viewParams) {
//Gather basic data
preg_match('/<pre>([\S\s]*?)<\/pre>/',$inText,$modelDescript);
$metadata = json_decode($modelDescript[1], true);
$hsSet = (isset($metadata['annotationSets']) && isset($metadata['annotationSets'][$this->hsset])) ? $this->hsset : 'default';
$view = (isset($metadata['viewerConfig']) && isset($metadata['viewerConfig'][$this->view])) ? $this->view : 'default';
$mainConfig = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
$context = RequestContext::getMain();
//Handle annotations and annotation sets
if (isset($metadata['annotations'])) {
@@ -99,6 +118,27 @@ class GlModelTransformOutput extends MediaTransformOutput {
} else {
$annotations = $metadata['annotations'];
}
//Apply any requested transformations
if ($this->transform) {
if (in_array('rand',$this->transform)) {
$subKey = array_search('rand',$this->transform);
$subs = is_numeric($this->transform[$subKey + 1]) ? $this->transform[$subKey + 1] : count($annotations);
$keys = array_keys($annotations);
shuffle($keys);
$keys = array_slice($keys, 0 , $subs);
$newAnnot = array();
foreach ($keys as $key) {
$newAnnot[$key] = $annotations[$key];
}
$annotations = $newAnnot;
}
if (in_array('alpha',$this->transform)) {
ksort($annotations);
}
}
//Create hotspot elements
foreach($annotations as $label => $an) {
$elAnnot = Html::rawElement('div',['class' => 'HotspotAnnotation HiddenAnnotation'],$label);
$hsDefault = array(
@@ -127,7 +167,6 @@ class GlModelTransformOutput extends MediaTransformOutput {
if ($posterFile) {
$attrModelView['poster'] = $posterFile->getFullUrl();
$attrModelView['reveal'] = 'manual';
$mainConfig = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
$interactUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/interact.svg';
$attrInteractButton = array (
'class' => 'glmv-act-button',
@@ -144,10 +183,20 @@ class GlModelTransformOutput extends MediaTransformOutput {
$attrModelView = array_merge(['src' => $srcUrl, 'class' => 'mv-model', 'interpolation-decay' => '100', 'interaction-prompt' => 'none'], $attrModelView);
$attrModelView['style'] = 'width: 100%; height: 100%;';
$attrModelView['onload'] = 'modelLoaded(event)';
$hotspotHtml = (isset($hotspots)) ? implode($hotspots) : '';
$hotspotHtml = (!empty($hotspots)) ? implode($hotspots) : '';
$elModel = Html::rawElement('model-viewer', $attrModelView, $hotspotHtml);
$elMenu = self::buildViewMenu();
$elMenu = self::buildViewMenu(!empty($hotspots));
$elFileLink = '';
if (!isset($viewParams['preview']) && $context->getTitle() != $this->file->getTitle()) {
$attrFileLink = array(
'class' => 'glmv-file-link',
'href' => $this->file->getDescriptionUrl()
);
$fileLinkUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/file_link.svg';
$elFileLink = Html::rawElement('a', $attrFileLink, '<img src="' . $fileLinkUrl . '"></image>');
}
//Render and return container element with model-viewer
$attrContainer = array(
@@ -158,15 +207,16 @@ class GlModelTransformOutput extends MediaTransformOutput {
'onfullscreenchange' => 'toggleFullScreen(event)'
);
return Html::rawElement('div', $attrContainer, $elModel . $elMenu . $interactButton);
return Html::rawElement('div', $attrContainer, $elModel . $elMenu . $elFileLink . $interactButton);
}
/**
* Build the button menu used for viewer actions
*
* @param bool $annotsDrawn true to enable the annotation buttons
* @return string
*/
private static function buildViewMenu() {
private static function buildViewMenu($annotsDrawn) {
$attrMenu = array(
'class' => 'glmv-menu awaiting-model',
'style' => 'display: none;'
@@ -181,13 +231,15 @@ class GlModelTransformOutput extends MediaTransformOutput {
$gotoUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/goto_hs.svg';
$attrMenuButtonPrev = array (
'class' => 'glmv-menu-button prev-hs disable-on-hide',
'class' => 'glmv-menu-button prev-hs disable-on-hide disable-on-none',
'disabled' => !$annotsDrawn,
'onclick' => 'prevAnnotation(event.target.closest(".glmv-container").querySelector("model-viewer"))',
'onmousedown' => 'event.stopPropagation()',
'ontouchstart' => 'event.stopPropagation()'
);
$attrMenuButtonNext = array (
'class' => 'glmv-menu-button next-hs disable-on-hide',
'class' => 'glmv-menu-button next-hs disable-on-hide disable-on-none',
'disabled' => !$annotsDrawn,
'onclick' => 'nextAnnotation(event.target.closest(".glmv-container").querySelector("model-viewer"))',
'onmousedown' => 'event.stopPropagation()',
'ontouchstart' => 'event.stopPropagation()'
@@ -195,7 +247,8 @@ class GlModelTransformOutput extends MediaTransformOutput {
$slideUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/hs_slideshow.svg';
$attrMenuButtonSlides = array (
'class' => 'glmv-menu-button disable-on-hide',
'class' => 'glmv-menu-button disable-on-hide disable-on-none',
'disabled' => !$annotsDrawn,
'onclick' => 'event.target.toggleAttribute("toggled"); slideshowAnnotations(event.target.closest(".glmv-container").querySelector("model-viewer"))',
'onmousedown' => 'event.stopPropagation()',
'ontouchstart' => 'event.stopPropagation()'
@@ -203,7 +256,8 @@ class GlModelTransformOutput extends MediaTransformOutput {
$hideUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/hs_hide.svg';
$attrMenuButtonHide = array (
'class' => 'glmv-menu-button',
'class' => 'glmv-menu-button disable-on-none',
'disabled' => !$annotsDrawn,
'onclick' => 'event.target.toggleAttribute("toggled"); toggleAnnotations(event.target.closest(".glmv-container").querySelector("model-viewer"))',
'onmousedown' => 'event.stopPropagation()',
'ontouchstart' => 'event.stopPropagation()'
@@ -218,12 +272,21 @@ class GlModelTransformOutput extends MediaTransformOutput {
'ontouchstart' => 'event.stopPropagation()'
);
$resetUrl = $mainConfig->get( 'ExtensionAssetsPath' ) . '/GlModelViewer/resources/reset.svg';
$attrMenuButtonReset = array (
'class' => 'glmv-menu-button',
'onclick' => 'resetView(event.target.closest(".glmv-container").querySelector("model-viewer"))',
'onmousedown' => 'event.stopPropagation()',
'ontouchstart' => 'event.stopPropagation()'
);
$menuButtons = array(
Html::rawElement('div', $attrMenuButtonPrev, '<img class="awaiting-model" src="' . $gotoUrl . '"></image>'),
Html::rawElement('div', $attrMenuButtonSlides, '<img class="awaiting-model" src="' . $slideUrl . '"></image>'),
Html::rawElement('div', $attrMenuButtonNext, '<img class="awaiting-model" src="' . $gotoUrl . '"></image>'),
Html::rawElement('div', $attrMenuButtonHide, '<img class="awaiting-model" src="' . $hideUrl . '"></image>'),
Html::rawElement('div', $attrMenuButtonScreen, '<img class="awaiting-model full-hide" src="' . $screenUpUrl . '"></image><img class="awaiting-model full-show" src="' . $screenDownUrl . '"></image>')
Html::rawElement('div', $attrMenuButtonScreen, '<img class="awaiting-model full-hide" src="' . $screenUpUrl . '"></image><img class="awaiting-model full-show" src="' . $screenDownUrl . '"></image>'),
Html::rawElement('div', $attrMenuButtonReset, '<img class="awaiting-model" src="' . $resetUrl . '"></image>')
);
return Html::rawElement('div', $attrMenu, $menuImg . implode($menuButtons));

168
modules/glmv-hs.js Normal file
View File

@@ -0,0 +1,168 @@
const TOML = require('./mini-st.js')
let deleteHotspot = null
let grabHotspot = null
/**
* Sets listener and attributes on model-viewer to
* allow for click registering of a new hotspot
*/
readyAddHotspot = function() {
disableViewer('AddingHotspot', clickAddHotspot)
}
/**
* Event listener callback to retrieve the info
* about the model surface point selected by the
* mouse and add that information to the editor
* text input
*
* @param {PointerEvent} e
*/
clickAddHotspot = function(e) {
let hsPosition = null
let targetModel = enableViewer()
if (targetModel) {
hsPosition = targetModel.positionAndNormalFromPoint(e.clientX, e.clientY)
}
if (hsPosition) {
let currentText = $('#wpTextbox1').val()
let [_, mvconfig] = extractMvconfig(currentText)
let hsOutput = {}
hsOutput['data-position'] = hsPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
hsOutput['data-normal'] = hsPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
hsOutput['data-orbit'] = orb2degree(targetModel.getCameraOrbit().toString(),[2,2,5])
let targetObj = targetModel.getCameraTarget()
hsOutput['data-target'] = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m`
hsOutput['field-of-view'] = Number.parseFloat(targetModel.getFieldOfView()).toFixed(5) + 'deg'
mvconfig.annotations['Hotspot ' + (Object.keys(mvconfig.annotations).length + 1)] = hsOutput
let newText = currentText.replace(/(.*?<mvconfig>)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${TOML.stringify(mvconfig, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
}
readMvconfig()
}
/**
* Set flag and attributes on model-viewer to
* delete the next hotspot that is clicked
*/
readyDelHotspot = function() {
deleteHotspot = true
disableViewer('DeletingHotspot', cancelDeleteHotspot)
}
/**
* Unset deleting flag and return normal
* function and style to model viewer
*/
cancelDeleteHotspot = function() {
deleteHotspot = null
enableViewer()
}
/**
* Delete the selected hotspot
*
* @param {element} hs hotspot element to delete
*/
clickDeleteHotspot = function (hs) {
deleteHotspot = null
enableViewer()
const anName = hs.target.childNodes[0].innerText
hs.target.remove()
let currentText = $('#wpTextbox1').val()
let [_, mvconfig] = extractMvconfig(currentText)
delete mvconfig.annotations[anName]
for (anSet in mvconfig.annotationSets) {
mvconfig.annotationSets[anSet]=mvconfig.annotationSets[anSet].filter( x => x !== anName )
}
let newText = currentText.replace(/(.*?<mvconfig>)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${TOML.stringify(mvconfig, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
readMvconfig()
}
/**
* Check status of delete function
*/
isDeleting = function() {
return deleteHotspot
}
/**
* Prepare to drag a hotspot
*
* @param {MouseEvent} event
*/
grabAnnotation = function(e) {
if (e.ctrlKey) {
grabHotspot = {x: e.x, y: e.y, target: e.target}
const contEl = $('.glmv-container')[0]
contEl.addEventListener('mousemove', moveAnnotation)
const mvEl = $('model-viewer')[0]
} else {
grabHotspot = null
}
}
/**
* Drag currently clicked hotspot
*
* @param {MouseEvent} event
*/
moveAnnotation = function(e) {
if (grabHotspot) {
grabHotspot.move = true
grabHotspot.target.style['transform'] = `translate(${e.x - grabHotspot.x}px, ${e.y - grabHotspot.y}px) scale(1.1,1.1)`
}
}
/**
* End dragging a hotspot and update information
*
* @param {MouseEvent} event
*/
releaseAnnotation = function(e) {
if (grabHotspot && grabHotspot.move) {
e.target.style['transform']=''
const contEl = $('.glmv-container')[0]
contEl.removeEventListener('mousemove', moveAnnotation)
const mvEl = $('model-viewer')[0]
let newPosition = mvEl.positionAndNormalFromPoint(e.clientX, e.clientY)
const newPos = newPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
const newNorm = newPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
mvEl.updateHotspot({
name: e.target.slot,
position: newPos,
normal: newNorm
})
const newOrb = orb2degree(mvEl.getCameraOrbit().toString(),[2,2,5])
e.target.setAttribute('data-orbit', newOrb)
let targetObj = mvEl.getCameraTarget()
const newTarg = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m`
e.target.setAttribute('data-target', newTarg)
const newFov = Number.parseFloat(mvEl.getFieldOfView()).toFixed(5) + 'deg'
e.target.setAttribute('field-of-view', newFov)
let currentText = $('#wpTextbox1').val()
let [_, mvconfig] = extractMvconfig(currentText)
mvconfig.annotations[e.target.childNodes[0].innerText] = {
"data-position": newPos,
"data-normal": newNorm,
"data-orbit": newOrb,
"data-target": newTarg,
"field-of-view": newFov
}
const newText = currentText.replace(/(.*?<mvconfig>)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${TOML.stringify(mvconfig, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
}
grabHotspot = null
}
/**
* Change the currently selected annotation set
*
* @param {string} newSet name of annotation set to select
*/
selectAnnotationSet = function(newSet) {
$('model-viewer').attr('currentSet',newSet)
readMvconfig()
}

293
modules/glmv-mvconfig.js Normal file
View File

@@ -0,0 +1,293 @@
const TOML = require('./mini-st.js')
/**
* Convert text in the preview text editor to js object
*
* @return [string, object] full mvconfig text string and mvconfig object
*/
extractMvconfig = function() {
const editText = $('#wpTextbox1').val()
const extractConfig = editText.match(/<mvconfig>([\S\s]*?)<\/mvconfig>/)
let mvconfig = (extractConfig.length >= 2) ? TOML.parse(extractConfig[1]) : {viewerConfig: {}, annotations: {}, annotationSets: {}}
if (mvconfig.viewerConfig === undefined) {
mvconfig.viewerConfig = {
default: {
"camera-controls": true
}
}
}
if (mvconfig.annotations === undefined) {
mvconfig.annotations = {}
}
const mView = $('model-viewer')[0]
const hsButtons = [...mView.parentElement.querySelectorAll('.disable-on-none')]
hsButtons.forEach( mb => {
mb.toggleAttribute('disabled',$.isEmptyObject(mvconfig.annotations))
})
if (mvconfig.annotationSets === undefined) {
mvconfig.annotationSets = {}
}
return [editText, mvconfig]
}
/**
* Reads the TOML string in the edit panel
* and updates hotspot elements and menu settings
*
* @return {bool|object} arrays of view and set names on successful read and update false on failure
*/
readMvconfig = function() {
let hotspotsObj = []
let mvconfig
let slotNum = 1
createHotspot = function(hsLabel, hsSlot, hsTag, hsSkip) {
let newHs = document.createElement('button')
newHs.classList.add('Hotspot')
newHs.setAttribute('slot',`hotspot-${hsSlot}`)
newHs.setAttribute('ontouchstart', 'event.stopPropagation()')
newHs.setAttribute('onclick', 'onAnnotation(event)')
newHs.setAttribute('onmousedown', 'grabAnnotation(event)')
newHs.setAttribute('onmouseup', 'releaseAnnotation(event)')
newHs.toggleAttribute('seq-skip', !!hsSkip)
Object.keys(mvconfig.annotations[hsLabel]).forEach((prop) => {
newHs.setAttribute(prop, mvconfig.annotations[hsLabel][prop])
})
let newAn = document.createElement('div')
newAn.classList.add('HotspotAnnotation', 'HiddenAnnotation')
newAn.innerText = hsLabel
newHs.appendChild(newAn)
newLabel = document.createElement('span')
newLabel.innerText = hsTag || (hsSlot)
newHs.appendChild(newLabel)
hotspotsObj.push(newHs)
}
try {
[_, mvconfig] = extractMvconfig()
} catch (err) {
console.warn('Failed to read model config:' + err.message)
return false
}
const currentSet = $('model-viewer').attr('currentSet') || 'default'
if (currentSet != 'default' && mvconfig.annotationSets[currentSet]) {
mvconfig.annotationSets[currentSet].forEach(hs => {
createHotspot(hs, slotNum)
slotNum += 1
})
}
Object.keys(mvconfig.annotations).forEach(hs => {
if (currentSet != 'default' && mvconfig.annotationSets[currentSet] && mvconfig.annotationSets[currentSet].includes(hs)) {
return
}
let label = (currentSet != 'default' && mvconfig.annotationSets[currentSet]) ? '-' : null
createHotspot(hs, slotNum, label, true)
slotNum += 1
})
$('model-viewer button').remove()
const mView = $('model-viewer')[0]
hotspotsObj.forEach(hs => {
mView.appendChild(hs)
})
return {
set: Object.keys(mvconfig.annotationSets),
view: Object.keys(mvconfig.viewerConfig)
}
}
/**
* Parses the current hotspots into TOML object
* and writes the TOML string to the edit panel
*
* @return {bool} true on successful write to edit panel
*/
writeMvconfig = function () {
let annotationsObj = {}
currentButtons = $('.Hotspot').each(function() {
let buttonEl = $(this)[0]
annotationsObj[buttonEl.childNodes[0].innerText] = {
"data-position": buttonEl.getAttribute('data-position'),
"data-normal": buttonEl.getAttribute('data-normal'),
"data-orbit": buttonEl.getAttribute('data-orbit'),
"data-target": buttonEl.getAttribute('data-target')
}
})
if (Object.keys(annotationsObj).length === 0) return false
const [currentText, mvconfig] = extractMvconfig()
mvconfig.annotations = annotationsObj
const newText = currentText.replace(/(.*?<mvconfig>)[\S\s]*?(<\/mvconfig>.*)/,`$1\n${TOML.stringify(mvconfig, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
return true
}
/**
* Convert all radian values returned by model-viewer to degrees
*
* @param {string} orbString string with any number of `(number)rad` sub strings
* @param {number|array} fix Optional: number of decimal places to return in converted numbers
* @return {string} string with all radian values and units converted to degrees
*/
orb2degree = function(orbString, fix = null) {
let degArray = orbString.split(' ').map(s => {
if (s.includes('rad')) {
return (Number.parseFloat(s) / Math.PI * 180) + 'deg'
} else {
return s
}
})
if (fix && !['number', 'object'].includes(typeof fix)) {
console.warn('orb2degree: fix parameter invalid type. Ignoring.')
fix = null
}
if (fix) {
degArray = degArray.map((v, idx) => {
//let fixReg = new RegExp('(-?\\d*\\.\\d{' + (((typeof fix) == 'object') ? fix[idx] : fix) + '})(\\d*)([a-z]*)')
//return v.replace(fixReg,'$1$3')
return Number.parseFloat(v).toFixed((typeof fix == 'object') ? fix[idx] : fix) + v.substring(v.search(/[a-z]/))
})
}
return degArray.join(' ')
}
/**
* Set camera control setting for the current view
*
* @param {string} view
* @return {bool} new camera-controls setting
*/
toggleCameraControl = function(view) {
let [currentText, mvconfig] = extractMvconfig()
const currentView = (mvconfig.viewerConfig[view]) ? view : 'default'
const newControl = !mvconfig.viewerConfig[currentView]['camera-controls']
if (newControl) {
mvconfig.viewerConfig[currentView]['camera-controls'] = newControl
} else {
delete mvconfig.viewerConfig[currentView]['camera-controls']
}
const textUpdate = currentText.replace(/(?<=<mvconfig>)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${TOML.stringify(mvconfig, null, 2)}\n`)
$('#wpTextbox1').val(textUpdate)
return newControl
}
/**
* Add a new annoation set object to annotatsionSets array
*
* @param {string} newSet name of new view config
*/
addAnnotationSet = function(newSet) {
const mView = $('model-viewer')[0]
let [currentText, mvconfig] = extractMvconfig()
mvconfig.annotationSets[newSet] = []
const textUpdate = currentText.replace(/(?<=<mvconfig>)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${TOML.stringify(mvconfig, null, 2)}\n`)
$('#wpTextbox1').val(textUpdate)
selectAnnotationSet(newSet)
}
/**
* Add a new set of view configurations to viewerConfig
*
* @param {string} newView name of new view config
*/
addViewConfig = function(newView) {
const mView = $('model-viewer')[0]
let [currentText, mvconfig] = extractMvconfig()
mvconfig.viewerConfig[newView] = { "camera-controls": true }
const textUpdate = currentText.replace(/(?<=<mvconfig>)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${TOML.stringify(mvconfig, null, 2)}\n`)
$('#wpTextbox1').val(textUpdate)
selectViewConfig(newView)
}
/**
* Switch the current model-viewer attributes to a different
* set of configurations in the mvconfig data
*
* @param {string} view the view name (viewerConfig object key)
*/
selectViewConfig = function(view) {
const mView = $('model-viewer')[0]
let [_, mvconfig] = extractMvconfig()
const selectView = (mvconfig.viewerConfig[view]) ? view : 'default'
const viewConfig = mvconfig.viewerConfig[selectView]
const settings = [
"camera-controls",
"disable-pan",
"disable-tap",
"touch-action",
"disable-zoom",
"orbit-sensitivity",
"zoom-sensitivity",
"pan-sensitivity",
"auto-rotate",
"auto-rotate-delay",
"rotation-per-second",
"interaction-prompt-style",
"interaction-prompt-threshold",
"camera-orbit",
"camera-target",
"field-of-view",
"max-camera-orbit",
"min-camera-orbit",
"max-field-of-view",
"min-field-of-view",
"poster",
"ar",
"ar-modes",
"ar-scale",
"ar-placement"
]
settings.forEach(s => {
if (viewConfig[s]) {
mView.setAttribute(s,viewConfig[s])
} else {
mView.removeAttribute(s)
}
})
mView.setAttribute('current-view',selectView)
}
/**
* Set new default camera orbit and send values to the preview
* editor
*/
writeCameraOrbit = function() {
const mView = $('model-viewer')[0]
const currentView = mView.getAttribute('current-view') ? mView.getAttribute('current-view') : 'default'
const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5])
mView.setAttribute('camera-orbit', newOrbit)
const targetObj = mView.getCameraTarget()
const newTarget = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m`
mView.setAttribute('camera-target', newTarget)
const newField = mView.getFieldOfView().toFixed(5) + 'deg'
mView.setAttribute('field-of-view',newField)
let [currentText, mvconfig] = extractMvconfig()
mvconfig.viewerConfig[currentView]['camera-orbit'] = newOrbit
mvconfig.viewerConfig[currentView]['camera-target'] = newTarget
mvconfig.viewerConfig[currentView]['field-of-view'] = newField
const textUpdate = currentText.replace(/(?<=<mvconfig>)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${TOML.stringify(mvconfig, null, 2)}\n`)
$('#wpTextbox1').val(textUpdate)
}
/**
* Set new camera orbit limits and send values to the preview
* editor
*
* @param {string} axis [yaw|pitch] orbit value to set
* @param {string} limit [max|min] limit value to set
*/
writeCameraLimit = function(axis, limit) {
const mView = $('model-viewer')[0]
const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5])
const newOrbitVals = newOrbit.split(' ')
const valueIndex = (axis == 'yaw') ? 0 : 1
let [currentText, mvconfig] = extractMvconfig()
const oldOrbit = mvconfig.viewerConfig.default[`${limit}-camera-orbit`]
let oldOrbitVals = (oldOrbit) ? oldOrbit.split(' ') : Array(3).fill('auto')
oldOrbitVals[valueIndex] = newOrbitVals[valueIndex]
mvconfig.viewerConfig.default[`${limit}-camera-orbit`] = oldOrbitVals.join(' ')
mView.setAttribute(`${limit}-camera-orbit`, oldOrbitVals.join(' '))
const textUpdate = currentText.replace(/(?<=<mvconfig>)([\S\s]*?)(?=<\/mvconfig>)/gm,`\n${TOML.stringify(mvconfig, null, 2)}\n`)
$('#wpTextbox1').val(textUpdate)
}

3
modules/glmv-prev.css Normal file
View File

@@ -0,0 +1,3 @@
.oo-ui-menuSelectWidget {
z-index: 10 !important;
}

View File

@@ -1,166 +1,350 @@
let [_, origMetadata] = extractMetadata()
//Initialize globals
require('./glmv-mvconfig.js')
require('./glmv-hs.js')
//Annotation Edit Controls
const addHS = new OO.ui.ButtonWidget({
icon: 'mapPinAdd',
label: 'Add annotation',
invisibleLabel: true,
class: 'edit-button'
})
addHS.on('click', readyAddHotspot)
addHS.setDisabled(true)
/**
* Use the OOui js library to create wikis-style menu
* options on the preview edits page for interaction
* with the model
*/
buildPreviewMenu = function() {
let [_, origMetadata] = extractMvconfig()
const updateHS = new OO.ui.ButtonWidget({
icon: 'reload',
label: 'Update annotations',
invisibleLabel: true
})
updateHS.on('click', readMetadata)
updateHS.setDisabled(true)
//Annotation Edit Controls
const addHS = new OO.ui.ButtonWidget({
icon: 'mapPinAdd',
label: 'Add annotation',
invisibleLabel: true,
class: 'edit-button'
})
addHS.on('click', readyAddHotspot)
addHS.setDisabled(true)
const deleteHS = new OO.ui.ButtonWidget({
icon: 'cancel',
label: 'Delete annotation',
invisibleLabel: true
})
deleteHS.on('click', readyDelHotspot)
deleteHS.setDisabled(true)
const deleteHS = new OO.ui.ButtonWidget({
icon: 'cancel',
label: 'Delete annotation',
invisibleLabel: true
})
deleteHS.on('click', readyDelHotspot)
deleteHS.setDisabled(true)
const setOptions = ['default', ...Object.keys(origMetadata.annotationSets)]
let setOptionItems = []
setOptions.forEach(opt => {
setOptionItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt}))
})
const setOptions = ['default', ...Object.keys(origMetadata.annotationSets), 'Add new']
let setOptionItems = []
setOptions.forEach(opt => {
setOptionItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt}))
})
const setSelectHS = new OO.ui.ButtonMenuSelectWidget({
icon: 'mapTrail',
label: 'Select annotation set',
invisibleLabel: true,
menu: {
items: setOptionItems,
width: 'min-content'
},
$overlay: $('#bodyContent')
})
setSelectHS.getMenu().on( 'choose', selSet => {
selectAnnotationSet(selSet.data)
})
setSelectHS.setDisabled(true)
const setSelectHS = new OO.ui.ButtonMenuSelectWidget({
icon: 'mapTrail',
label: 'Select annotation set',
invisibleLabel: true,
menu: {
items: setOptionItems,
width: 'min-content'
},
$overlay: $('#bodyContent')
})
setSelectHS.getMenu().on( 'choose', selSet => {
onSetMenu(selSet.data)
})
setSelectHS.setDisabled(true)
const hotspotButtons = new OO.ui.ButtonGroupWidget({
items: [ addHS, updateHS, deleteHS, setSelectHS ]
})
const hotspotButtons = new OO.ui.ButtonGroupWidget({
items: [ addHS, deleteHS, setSelectHS ]
})
//View Edit Controls
const downloadViewerImage = new OO.ui.ButtonWidget({
icon: 'imageAdd',
label: 'Download current image',
invisibleLabel: true
})
downloadViewerImage.on('click', () => {
downloadImage(mw.config.values.wgTitle)
})
downloadViewerImage.setDisabled(true)
//View Edit Controls
const setView = new OO.ui.ButtonWidget({
icon: 'camera',
label: 'Set Initial View',
invisibleLabel: true
})
setView.on('click', writeCameraOrbit)
setView.setDisabled(true)
const setView = new OO.ui.ButtonWidget({
icon: 'camera',
label: 'Set Initial View',
invisibleLabel: true
})
setView.on('click', writeCameraOrbit)
setView.setDisabled(true)
const setControl = new OO.ui.ButtonWidget({
icon: 'hand',
label: 'Toggle camera control',
invisibleLabel: true
})
setControl.on('click', () => $('model-viewer')[0].toggleAttribute('camera-controls', toggleCameraControl()))
setControl.setDisabled(true)
//View Limit Controls
const setMinYaw = new OO.ui.ButtonWidget({
label: 'Min'
})
setMinYaw.on('click', () => {
limitCameraOrbit('yaw','min')
})
//View Limit Controls
const setMinYaw = new OO.ui.ButtonWidget({
label: 'Min'
})
setMinYaw.on('click', () => {
writeCameraLimit('yaw','min')
})
const setMaxYaw = new OO.ui.ButtonWidget({
label: 'Max'
})
setMaxYaw.on('click', () => {
limitCameraOrbit('yaw','max')
})
const setMaxYaw = new OO.ui.ButtonWidget({
label: 'Max'
})
setMaxYaw.on('click', () => {
writeCameraLimit('yaw','max')
})
const yawLimitButtons = new OO.ui.ButtonGroupWidget({
items: [ setMinYaw, setMaxYaw ]
})
const yawLimitButtons = new OO.ui.ButtonGroupWidget({
items: [ setMinYaw, setMaxYaw ]
})
const labelYaw = new OO.ui.LabelWidget({
label: "Yaw:"
})
const labelYaw = new OO.ui.LabelWidget({
label: "Yaw:"
})
const yawButtons = new OO.ui.HorizontalLayout({
items: [
labelYaw,
yawLimitButtons
],
id: 'yaw-limits'
})
const yawButtons = new OO.ui.HorizontalLayout({
items: [
labelYaw,
yawLimitButtons
],
id: 'yaw-limits'
})
const setMinPitch = new OO.ui.ButtonWidget({
label: 'Min'
})
setMinPitch.on('click', () => {
limitCameraOrbit('pitch','min')
})
const setMinPitch = new OO.ui.ButtonWidget({
label: 'Min'
})
setMinPitch.on('click', () => {
writeCameraLimit('pitch','min')
})
const setMaxPitch = new OO.ui.ButtonWidget({
label: 'Max'
})
setMaxPitch.on('click', () => {
limitCameraOrbit('pitch','max')
})
const setMaxPitch = new OO.ui.ButtonWidget({
label: 'Max'
})
setMaxPitch.on('click', () => {
writeCameraLimit('pitch','max')
})
const pitchLimitButtons = new OO.ui.ButtonGroupWidget({
items: [ setMinPitch, setMaxPitch ]
})
const pitchLimitButtons = new OO.ui.ButtonGroupWidget({
items: [ setMinPitch, setMaxPitch ]
})
const labelPitch = new OO.ui.LabelWidget({
label: "Pitch:"
})
const labelPitch = new OO.ui.LabelWidget({
label: "Pitch:"
})
const pitchButtons = new OO.ui.HorizontalLayout({
items: [
labelPitch,
pitchLimitButtons
],
id: 'pitch-limits'
})
const pitchButtons = new OO.ui.HorizontalLayout({
items: [
labelPitch,
pitchLimitButtons
],
id: 'pitch-limits'
})
const setLims = new OO.ui.PopupButtonWidget({
label: 'Set View Limits',
invisibleLabel: true,
icon: 'tableMergeCells',
popup: {
$content: yawButtons.$element.add(pitchButtons.$element),
padded: true,
position: 'above'
},
$overlay: $('#bodyContent')
})
setLims.setDisabled(true)
const setLims = new OO.ui.PopupButtonWidget({
label: 'Set View Limits',
invisibleLabel: true,
icon: 'tableMergeCells',
popup: {
$content: yawButtons.$element.add(pitchButtons.$element),
padded: true,
position: 'above'
},
$overlay: $('#bodyContent')
})
setLims.setDisabled(true)
const cameraButtons = new OO.ui.ButtonGroupWidget({
items: [ downloadViewerImage, setView, setLims ]
})
const setViewConfig = [...Object.keys(origMetadata.viewerConfig), 'Add new']
let setViewItems = []
setViewConfig.forEach(opt => {
setViewItems.push(new OO.ui.MenuOptionWidget({data: opt, label: opt}))
})
//Main Menu
const modelMenu = new OO.ui.HorizontalLayout({
items: [
hotspotButtons,
cameraButtons
],
id: 'edit-model-menu'
})
const selectVC = new OO.ui.ButtonMenuSelectWidget({
icon: 'eye',
label: 'Select view configuration',
invisibleLabel: true,
menu: {
items: setViewItems,
width: 'min-content'
},
$overlay: $('#bodyContent')
})
selectVC.getMenu().on( 'choose', selSet => {
onViewMenu(selSet.data)
})
selectVC.setDisabled(true)
$('#wikiPreview').after(modelMenu.$element)
const cameraButtons = new OO.ui.ButtonGroupWidget({
items: [ setControl, setView, setLims, selectVC ]
})
//General controls
const downloadViewerImage = new OO.ui.ButtonWidget({
icon: 'imageAdd',
label: 'Download current image',
invisibleLabel: true
})
downloadViewerImage.on('click', () => {
downloadImage(mw.config.values.wgTitle)
})
downloadViewerImage.setDisabled(true)
const updateViewer = new OO.ui.ButtonWidget({
icon: 'reload',
label: 'Update from text',
invisibleLabel: true
})
updateViewer.on('click', refreshConfigs)
updateViewer.setDisabled(true)
const generalButtons = new OO.ui.ButtonGroupWidget({
items: [ downloadViewerImage, updateViewer ]
})
//Main Menu
const modelMenu = new OO.ui.HorizontalLayout({
items: [
hotspotButtons,
cameraButtons,
generalButtons
],
id: 'edit-model-menu'
})
$('#wikiPreview').after(modelMenu.$element)
return [modelMenu, selectVC, setSelectHS]
}
/**
* Enable all the preview menu widgets (called by
* model load event)
*/
enableMenu = function() {
modelMenu.items.forEach(group => {
group.items.forEach(el => el.setDisabled(false))
});
}
}
/**
* Disable general interaction with model
* viewer for specific additional function
*
* @param {string} fnClass class to add to model-viewer
* @param {callback} viewCall callback function to add to model-viewer
* @return {Element} model-viewer element
*/
disableViewer = function(fnClass, viewCall) {
const previewMv = $('model-viewer')
if (viewCall) previewMv.one('click', viewCall)
if (fnClass) previewMv.addClass(fnClass)
previewMv[0].disableTap = true
previewMv[0].toggleAttribute('camera-controls', false)
return previewMv[0]
}
/**
* Enable general interaction with model
* viewer
*
* @return {Element} model-viewer element
*/
enableViewer = function() {
const previewMv = $('model-viewer')
previewMv.off('click', clickAddHotspot)
previewMv.off('click', cancelDeleteHotspot)
previewMv.removeClass('AddingHotspot DeletingHotspot')
previewMv[0].disableTap = false
previewMv[0].toggleAttribute('camera-controls', true)
return previewMv[0]
}
/**
* Use the model viewer methods to get image
* of current view and download
*
* @param {string} defName wiki page name to use as base file name
*/
downloadImage = function(defName) {
const imgName = defName.split('.')[0]
const mView = $('model-viewer')[0]
const dlA = document.createElement('a')
dlA.setAttribute('download',imgName + '.png')
const reader = new FileReader()
reader.addEventListener("load", () => {
dlA.setAttribute('href',reader.result)
dlA.click()
},{once: true})
mView.toBlob(null, null, true)
.then(imgBlob => {
reader.readAsDataURL(imgBlob)
})
}
/**
* Process view selection menu select event
*
* @param {string} selectData data associated with the selected menu label
*/
onViewMenu = function(selectData) {
if (selectData == 'Add new') {
const newSelectIdx = viewSelector.menu.items.length
const newView = `View${newSelectIdx}`
viewSelector.menu.addItems([new OO.ui.MenuOptionWidget({data: newView, label: newView})], newSelectIdx - 1)
addViewConfig(newView)
} else {
selectViewConfig(selectData)
}
}
/**
* Process annotation set menu select event
*
* @param {string} selectData data associated with the selected menu label
*/
onSetMenu = function(selectData) {
if (selectData == 'Add new') {
const newSelectIdx = setSelector.menu.items.length
const newSet = `Set${newSelectIdx - 1}`
setSelector.menu.addItems([new OO.ui.MenuOptionWidget({data: newSet, label: newSet})], newSelectIdx - 1)
addAnnotationSet(newSet)
} else {
selectAnnotationSet(selectData)
}
}
/**
* Update the available menu options in the given menu
* from an array
*
* @param {string} menuTYpe 'set'|'view' to determine which menu to update
* @param {array} newOpts array of strings containing new menu options
*/
updateMenu = function(menuType, newOpts) {
let menuObj
switch (menuType) {
case 'set':
menuObj = setSelector
menuOpts = ['default', ...newOpts, 'Add new']
break
case 'view':
menuObj = viewSelector
menuOpts = [...newOpts, 'Add new']
break
}
menuObj.menu.clearItems()
menuOpts.forEach(opt => {
menuObj.menu.addItems([new OO.ui.MenuOptionWidget({data: opt, label: opt})])
})
}
/**
* Refresh all viewer settings and menus from the edit input text
*/
refreshConfigs = function() {
const newLists = readMvconfig()
if (newLists) {
for (let lst in newLists) {
updateMenu(lst, newLists[lst])
}
}
}
//Initialize the menu and get required global objects
const [modelMenu,
viewSelector,
setSelector] = buildPreviewMenu()

View File

@@ -117,7 +117,7 @@
justify-content: flex-start;
&:hover {
width: 196px;
width: 232px;
& .glmv-menu-image {
transform: rotate(180deg);
@@ -185,6 +185,12 @@
margin: 2px;
}
.glmv-file-link {
position: absolute;
right: 0;
bottom: 0;
}
@media (pointer: coarse) {
.Hotspot {
width: 1.25em;
@@ -203,7 +209,7 @@
border-radius: 9px;
&:hover {
width: 294px;
width: 342px;
}
}

View File

@@ -1,7 +1,4 @@
let slideShowInterval = null
let grabHotspot = null
let deleteHotspot = null
let currentSet = 'default'
/**
* Disables hiding of various child items
@@ -12,164 +9,6 @@ modelLoaded = function(e) {
if (typeof enableMenu != 'undefined') {enableMenu()}
}
/**
* Reads the json string in the edit panel
* and updates hotspot elements
*
* @return {bool} true on successful read and update
*/
readMetadata = function() {
let hotspotsObj = []
let metadata
let slotNum = 1
createHotspot = function(hsLabel, hsSlot, hsTag) {
let newHs = document.createElement('button')
newHs.classList.add('Hotspot')
newHs.setAttribute('slot',`hotspot-${hsSlot}`)
newHs.setAttribute('ontouchstart', 'event.stopPropagation()')
newHs.setAttribute('onclick', 'onAnnotation(event)')
newHs.setAttribute('onmousedown', 'grabAnnotation(event)')
newHs.setAttribute('onmouseup', 'releaseAnnotation(event)')
Object.keys(metadata.annotations[hsLabel]).forEach((prop) => {
newHs.setAttribute(prop, metadata.annotations[hsLabel][prop])
})
let newAn = document.createElement('div')
newAn.classList.add('HotspotAnnotation', 'HiddenAnnotation')
newAn.innerText = hsLabel
newHs.appendChild(newAn)
newLabel = document.createElement('span')
newLabel.innerText = hsTag || (hsSlot)
newHs.appendChild(newLabel)
hotspotsObj.push(newHs)
}
try {
[_, metadata] = extractMetadata()
} catch (err) {
console.warn('Failed to read model metadata:' + err.message)
return false
}
if (currentSet != 'default' && metadata.annotationSets[currentSet]) {
metadata.annotationSets[currentSet].forEach(hs => {
createHotspot(hs, slotNum)
slotNum += 1
})
}
Object.keys(metadata.annotations).forEach(hs => {
if (currentSet != 'default' && metadata.annotationSets[currentSet] && metadata.annotationSets[currentSet].includes(hs)) {
return
}
let label = (currentSet != 'default' && metadata.annotationSets[currentSet]) ? '-' : null
createHotspot(hs, slotNum, label)
slotNum += 1
})
$('model-viewer button').remove()
const mView = $('model-viewer')[0]
hotspotsObj.forEach(hs => {
mView.appendChild(hs)
})
return true
}
/**
* Parses the current hotspots into json object
* and writes the json string to the edit panel
*
* @return {bool} true on successful write to edit panel
*/
writeMetadata = function () {
let annotationsObj = {}
currentButtons = $('.Hotspot').each(function() {
let buttonEl = $(this)[0]
annotationsObj[buttonEl.childNodes[0].innerText] = {
"data-position": buttonEl.getAttribute('data-position'),
"data-normal": buttonEl.getAttribute('data-normal'),
"data-orbit": buttonEl.getAttribute('data-orbit'),
"data-target": buttonEl.getAttribute('data-target')
}
})
if (Object.keys(annotationsObj).length === 0) return false
const [currentText, metadata] = extractMetadata()
metadata.annotations = annotationsObj
const newText = currentText.replace(/(.*?<pre>)[\S\s]*?(<\/pre>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
return true
}
/**
* Convert text in the preview text editor to js object
*
* @return object containing metadata information
*/
extractMetadata = function() {
const editText = $('#wpTextbox1').val()
const extractMetadata = editText.match(/<pre>([\S\s]*?)<\/pre>/)
let metadata = (extractMetadata.length >= 2) ? JSON.parse(extractMetadata[1]) : {viewerConfig: {}, annotations: {}, annotationSets: {}}
if (metadata.annotations === undefined) {
metadata.annotations = {}
}
return [editText, metadata]
}
/**
* Sets listener and attributes on model-viewer to
* allow for click registering of a new hotspot
*/
readyAddHotspot = function() {
disableViewer('AddingHotspot', clickAddHotspot)
}
/**
* Event listener callback to retrieve the info
* about the model surface point selected by the
* mouse and add that information to the editor
* text input
*
* @param {PointerEvent} e
*/
clickAddHotspot = function(e) {
let hsPosition = null
let targetModel = enableViewer()
if (targetModel) {
hsPosition = targetModel.positionAndNormalFromPoint(e.clientX, e.clientY)
}
if (hsPosition) {
let currentText = $('#wpTextbox1').val()
let [_, metadata] = extractMetadata(currentText)
let hsOutput = {}
hsOutput['data-position'] = hsPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
hsOutput['data-normal'] = hsPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
hsOutput['data-orbit'] = orb2degree(targetModel.getCameraOrbit().toString(),[2,2,5])
let targetObj = targetModel.getCameraTarget()
hsOutput['data-target'] = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m`
metadata.annotations['Hotspot ' + (Object.keys(metadata.annotations).length + 1)] = hsOutput
let newText = currentText.replace(/(.*?<pre>)[\S\s]*?(<\/pre>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
}
readMetadata()
}
/**
* Set flag and attributes on model-viewer to
* delete the next hotspot that is clicked
*/
readyDelHotspot = function() {
deleteHotspot = true
disableViewer('DeletingHotspot', cancelDeleteHotspot)
}
/**
* Unset deleting flag and return normal
* function and style to model viewer
*/
cancelDeleteHotspot = function() {
deleteHotspot = null
enableViewer()
}
/**
* Event listener callback to toggle the visibility
* of a hotspot's annotation when the hotspot is
@@ -179,13 +18,9 @@ cancelDeleteHotspot = function() {
*/
onAnnotation = function(e) {
e.stopPropagation()
if (deleteHotspot) {
deleteHotspot = null
enableViewer()
e.target.remove()
writeMetadata()
readMetadata()
return
if (typeof isDeleting != 'undefined' && isDeleting()) {
clickDeleteHotspot(e)
return
}
let targetAnnotation = Number.parseInt(e.target.slot.split('-')[1])
let targetModel = e.target.closest('model-viewer')
@@ -218,6 +53,8 @@ selectAnnotation = function(mView, annotId) {
an.classList.remove('HiddenAnnotation');
if (an.parentElement.dataset.target) {mView.cameraTarget = an.parentElement.dataset.target}
if (an.parentElement.dataset.orbit) {mView.cameraOrbit = an.parentElement.dataset.orbit}
const anFov = an.parentElement.getAttribute('field-of-view')
if (anFov) {mView.fieldOfView = anFov}
anSelected = true
} else {
an.classList.add('HiddenAnnotation');
@@ -235,7 +72,7 @@ selectAnnotation = function(mView, annotId) {
*/
nextAnnotation = function(mView) {
let incrAnnotation = 0
const numSpots = [...mView.querySelectorAll('Button')].length
const numSpots = [...mView.querySelectorAll('Button:not([seq-skip])')].length
const currentAnnotation = mView.querySelectorAll('Button:has(.HotspotAnnotation:not(.HiddenAnnotation))')[0]
if (!currentAnnotation) {
incrAnnotation = 1
@@ -258,7 +95,7 @@ nextAnnotation = function(mView) {
*/
prevAnnotation = function(mView) {
let decrAnnotation = 0
const numSpots = [...mView.querySelectorAll('Button')].length
const numSpots = [...mView.querySelectorAll('Button:not([seq-skip])')].length
const currentAnnotation = mView.querySelectorAll('Button:has(.HotspotAnnotation:not(.HiddenAnnotation))')[0]
if (!currentAnnotation) {
decrAnnotation = numSpots
@@ -310,130 +147,6 @@ toggleAnnotations = function(mView) {
})
}
/**
* Prepare to drag a hotspot
*
* @param {MouseEvent} event
*/
grabAnnotation = function(e) {
if (e.ctrlKey) {
grabHotspot = {x: e.x, y: e.y}
const contEl = $('.glmv-container')[0]
contEl.addEventListener('mousemove', moveAnnotation)
const mvEl = $('model-viewer')[0]
} else {
grabHotspot = null
}
}
/**
* Drag currently clicked hotspot
*
* @param {MouseEvent} event
*/
moveAnnotation = function(e) {
if (grabHotspot) {
grabHotspot.move = true
e.target.style['transform'] = `translate(${e.x - grabHotspot.x}px, ${e.y - grabHotspot.y}px) scale(1.1,1.1)`
}
}
/**
* End dragging a hotspot and update information
*
* @param {MouseEvent} event
*/
releaseAnnotation = function(e) {
if (grabHotspot && grabHotspot.move) {
e.target.style['transform']=''
const contEl = $('.glmv-container')[0]
contEl.removeEventListener('mousemove', moveAnnotation)
const mvEl = $('model-viewer')[0]
let newPosition = mvEl.positionAndNormalFromPoint(e.clientX, e.clientY)
const newPos = newPosition.position.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
const newNorm = newPosition.normal.toString().replaceAll(/(\d{5})(\d*?m)/g,"$1m")
mvEl.updateHotspot({
name: e.target.slot,
position: newPos,
normal: newNorm
})
const newOrb = orb2degree(mvEl.getCameraOrbit().toString(),[2,2,5])
e.target.setAttribute('data-orbit', newOrb)
let targetObj = mvEl.getCameraTarget()
const newTarg = `${targetObj.x.toFixed(5)}m ${targetObj.y.toFixed(5)}m ${targetObj.z.toFixed(5)}m`
e.target.setAttribute('data-target', newTarg)
let currentText = $('#wpTextbox1').val()
let [_, metadata] = extractMetadata(currentText)
metadata.annotations[e.target.childNodes[0].innerText] = {
"data-position": newPos,
"data-normal": newNorm,
"data-orbit": newOrb,
"data-target": newTarg
}
const newText = currentText.replace(/(.*?<pre>)[\S\s]*?(<\/pre>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
}
grabHotspot = null
}
/**
* Disable general interaction with model
* viewer for specific additional function
*
* @param {string} fnClass class to add to model-viewer
* @param {callback} viewCall callback function to add to model-viewer
* @return {Element} model-viewer element
*/
disableViewer = function(fnClass, viewCall) {
const previewMv = $('model-viewer')
if (viewCall) previewMv.one('click', viewCall)
if (fnClass) previewMv.addClass(fnClass)
previewMv[0].disableTap = true
previewMv[0].toggleAttribute('camera-controls', false)
return previewMv[0]
}
/**
* Enable general interaction with model
* viewer
*
* @return {Element} model-viewer element
*/
enableViewer = function() {
const previewMv = $('model-viewer')
previewMv.off('click', clickAddHotspot)
previewMv.off('click', cancelDeleteHotspot)
previewMv.removeClass('AddingHotspot DeletingHotspot')
previewMv[0].disableTap = false
previewMv[0].toggleAttribute('camera-controls', true)
return previewMv[0]
}
/**
* Use the model viewer methods to get image
* of current view and download
*
* @param {string} defName wiki page name to use as base file name
*/
downloadImage = function(defName) {
const imgName = defName.split('.')[0]
const mView = $('model-viewer')[0]
const dlA = document.createElement('a')
dlA.setAttribute('download',imgName + '.png')
const reader = new FileReader()
reader.addEventListener("load", () => {
dlA.setAttribute('href',reader.result)
dlA.click()
},{once: true})
mView.toBlob(null, null, true)
.then(imgBlob => {
reader.readAsDataURL(imgBlob)
})
}
/**
* Respond to full screen changes on the gl container
*
@@ -448,71 +161,15 @@ toggleFullScreen = function(glCont) {
}
/**
* Send new default camera orbit values to the preview editor
*/
writeCameraOrbit = function() {
const mView = $('model-viewer')[0]
let newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5])
const textUpdate = $('#wpTextbox1').val().replace(/([\S\s]*?default[\S\s]*?"camera-orbit": ")(.*?)(",$[\S\s]*)/gm,'$1' + newOrbit + '$3')
$('#wpTextbox1').val(textUpdate)
}
/**
* Set new camera orbit limits and send values to the preview
* editor
* Reset view to initial position
*
* @param {string} axis [yaw|pitch] orbit value to set
* @param {string} limit [max|min] limit value to set
* @param {ModelViewer} mView
*/
limitCameraOrbit = function(axis, limit) {
const mView = $('model-viewer')[0]
const newOrbit = orb2degree(mView.getCameraOrbit().toString(),[2,2,5])
const newOrbitVals = newOrbit.split(' ')
const valueIndex = (axis == 'yaw') ? 0 : 1
let [currentText, metadata] = extractMetadata()
const oldOrbit = metadata.viewerConfig.default[`${limit}-camera-orbit`]
let oldOrbitVals = (oldOrbit) ? oldOrbit.split(' ') : Array(3).fill('auto')
oldOrbitVals[valueIndex] = newOrbitVals[valueIndex]
metadata.viewerConfig.default[`${limit}-camera-orbit`] = oldOrbitVals.join(' ')
mView.setAttribute(`${limit}-camera-orbit`, oldOrbitVals.join(' '))
const newText = currentText.replace(/(.*?<pre>)[\S\s]*?(<\/pre>.*)/,`$1\n${JSON.stringify(metadata, null, 2)}\n$2`)
$('#wpTextbox1').val(newText)
}
/**
* Convert all radian values returned by model-viewer to degrees
*
* @param {string} orbString string with any number of `(number)rad` sub strings
* @param {number|array} fix Optional: number of decimal places to return in converted numbers
* @return {string} string with all radian values and units converted to degrees
*/
orb2degree = function(orbString, fix = null) {
let degArray = orbString.split(' ').map(s => {
if (s.includes('rad')) {
return (Number.parseFloat(s) / Math.PI * 180) + 'deg'
} else {
return s
}
})
if (fix && !['number', 'object'].includes(typeof fix)) {
console.warn('orb2degree: fix parameter invalid type. Ignoring.')
fix = null
}
if (fix) {
degArray = degArray.map((v, idx) => {
let fixReg = new RegExp('(\\d*.\\d{' + (((typeof fix) == 'object') ? fix[idx] : fix) + '})(\\d*)([a-z]*)')
return v.replace(fixReg,'$1$3')
})
}
return degArray.join(' ')
}
/**
* Change the currently selected annotation set
*
* @param {string} newSet name of annotation set to select
*/
selectAnnotationSet = function(newSet) {
currentSet = newSet
readMetadata()
resetView = function(mView) {
const resetOrb = mView.getAttribute('camera-orbit') || 'auto auto auto'
const resetTarg = mView.getAttribute('camera-target') || 'auto auto auto'
const resetFov = mView.getAttribute('field-of-view') || 'auto'
mView.cameraOrbit = resetOrb
mView.cameraTarget = resetTarg
mView.fieldOfView = resetFov
}

1150
modules/mini-st.js Normal file

File diff suppressed because it is too large Load Diff

1
resources/file_link.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#5f6368"><path d="M263.72-96Q234-96 213-117.15T192-168v-624q0-29.7 21.16-50.85Q234.32-864 264.04-864h432.24Q726-864 747-842.85T768-792v624q0 29.7-21.16 50.85Q725.68-96 695.96-96H263.72Zm.28-72h432v-624H264v624Zm48-72h336L528-400l-84 112-48-64-84 112Zm-48 72v-624 624Z"/></svg>

After

Width:  |  Height:  |  Size: 374 B

1
resources/reset.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M320-280q-33 0-56.5-23.5T240-360v-240q0-33 23.5-56.5T320-680h40l40-40h160l40 40h40q33 0 56.5 23.5T720-600v240q0 33-23.5 56.5T640-280H320Zm0-80h320v-240H320v240Zm160-40q33 0 56.5-23.5T560-480q0-33-23.5-56.5T480-560q-33 0-56.5 23.5T400-480q0 33 23.5 56.5T480-400ZM342-940q34-11 68.5-15.5T480-960q94 0 177.5 33.5t148 93Q870-774 911-693.5T960-520h-80q-7-72-38-134.5t-79.5-110Q714-812 651-842t-135-36l62 62-56 56-180-180ZM618-20Q584-9 549.5-4.5T480 0q-94 0-177.5-33.5t-148-93Q90-186 49-266.5T0-440h80q8 72 38.5 134.5t79 110Q246-148 309-118t135 36l-62-62 56-56L618-20ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 696 B