Initial commit

This commit is contained in:
Robert Nasarek 2026-06-25 09:11:23 +02:00
commit 05c65aad4d
155 changed files with 93617 additions and 0 deletions

239
viewer/FUNCTIONS.md Normal file
View file

@ -0,0 +1,239 @@
# Viewer Function Reference
This file documents the main exported viewer functions and helpers used by the viewer runtime.
It is intended as a quick reference for the current `viewer/` module exports and their roles.
## `viewer/main.js`
`viewer/main.js` exposes the exported `Viewer` object and core runtime entry points.
- `Viewer.MainInit()`
- Initialize the viewer runtime.
- Loads `viewer-settings.json`, configures the DOM container, sets up core state, starts the animation loop, and prepares the viewer for model loading.
- `Viewer.mainLoadModel()`
- Load the current model from `core.fileObject`.
- Handles all supported file formats, archive transformations, GLTF/GLB loading, and post-load scene preparation.
- `Viewer.mainLoadModelWrapper()`
- Wrapper for `mainLoadModel()`.
- Normalizes auto-paths and archive paths before delegating to the loader.
- `Viewer.prepareSandboxScene()`
- Reset the viewer for sandbox mode.
- Clears loaded objects, resets camera and controls, and shows sandbox UI hints.
- `Viewer.setModelPaths()`
- Parse and populate `core.fileObject` from `core.fileObject.originalPath`.
- Sets `filename`, `basename`, `extension`, `path`, `uri`, and `relativePath`.
- `Viewer.normalizeFileUrl(rawUrl)`
- Normalize source URLs and Drupal `public://` paths.
- Converts relative model attributes into canonical browser URLs.
- `Viewer.normalizeDrupalFilesPath(path)`
- Normalize Drupal file paths by stripping `sites/default/files` prefixes and trimming slashes.
- `Viewer.normalizeArchiveModelPath(path)`
- Fix archive-derived model paths by injecting a `/gltf/` segment when needed.
- `Viewer.applyCameraOverridesFromUrl()`
- Apply camera position, camera target, and FOV overrides from URL query parameters.
- `Viewer.toggleFullscreen()`
- Enter or exit fullscreen mode for the viewer container.
- `Viewer.updateSize()`
- Resize the renderer, camera, GUI, metadata, and action menu when the viewport changes.
- `Viewer.setCameraProjection(projection)`
- Switch the viewer camera between perspective and orthographic projection.
- `Viewer.toHexColor(input)` / `Viewer.toThreeColor(input)`
- Normalize different color input formats into numeric or `THREE.Color` values.
- `Viewer.disposeObjectResources(object)`
- Dispose of mesh geometry and material resources for an object tree.
- `Viewer.removeAndDisposeFromScene(object)`
- Remove an object or object array from the scene and dispose its resources.
- `Viewer.resetLoadedModelState()`
- Clear viewer state related to loaded models, annotations, selection, and helper objects.
- `Viewer.reportError(error, options)`
- Report viewer errors consistently to console, toast notifications, and E2E state.
- URL and parameter parsing utilities:
- `Viewer.parseBooleanParam(value)`
- `Viewer.parseFloatParam(value)`
- `Viewer.parseVector2Param(value)`
- `Viewer.parseVector3Param(value)`
- `Viewer.formatVector3Param(vector)`
- Viewer support utilities:
- `Viewer.getSupportedFormatsText()`
- `Viewer.getSupportedArchiveFormatsText()`
- `Viewer.getDistanceMeasurementScaleMeters()`
- `Viewer.formatMeasuredDistance(rawDistanceInModelUnits)`
- `Viewer.toggleAutoRotateByKeyboard()`
- `Viewer.onViewerKeyDown(event)`
## `viewer/loaders.js`
`viewer/loaders.js` contains model loaders, environment sync logic, and loading progress/error handlers.
- `loadDDSLoader()` / `loadMTLLoader()` / `loadOBJLoader()` / `loadFBXLoader()`
- `loadPLYLoader()` / `loadColladaLoader()` / `loadSTLLoader()` / `loadXYZLoader()`
- `loadTDSLoader()` / `loadPCDLoader()` / `loadGLTFLoader()` / `loadDRACOLoader()`
- `loadIFCLoader()` / `loadRoomEnvironment()` / `loadHDRLoader()`
- Dynamically import the corresponding Three.js loader or environment helper.
- `syncSceneEnvironment(enabled = true, preset = null)`
- Load the environment texture for the scene and apply it to `core.scene`.
- Supports built-in studio and HDR presets.
- `loadModel()`
- Main engine for model loading.
- Detects file type, chooses the correct loader, applies geometry/material setup, adds the object to the scene, and triggers metadata loading.
- `getModuleAssetBasePath()`
- Resolve the runtime asset base path based on build target, Drupal environment, and local preview mode.
- `onError(_event)`
- Generic loader error handler.
- `onErrorMTL(_event)`
- Fallback loader error handler for OBJ/MTL bundles.
- `onErrorGLB(_event, params, loadedTimes)`
- Retry logic for GLB loading errors.
- `onProgress(xhr)`
- Update progress UI while a model is downloading.
## `viewer/metadata.js`
`viewer/metadata.js` handles metadata fetching, metadata panel rendering, and IIIF support.
- `addWissKIMetadata(label, value)`
- Map WissKI field names to localized metadata display labels.
- `lilGUIhasFolder(folder, name)` / `lilGUIgetFolder(gui, name)`
- Find lil-gui folders by title.
- `expandMetadata()`
- Toggle the metadata panel open/closed.
- `appendMetadata(metadataContent)`
- Render metadata HTML into the viewer metadata container.
- `fetchMetadata(_object, _type)`
- Count vertices or faces for a mesh object.
- `handleMetadataResponse(data, metadata, object, hierarchyMain)`
- Build the metadata panel and edit/hierarchy UI after loading a model.
- `settingsHandler(object, hierarchyMain, data)`
- Apply object and camera settings after metadata has been loaded.
- `traverseObject(object)`
- Run object traversal logic for metadata and camera setup.
- `presentationMode(object)`
- Apply presentation-mode-specific scene setup.
- `fetchSettings(object)`
- Fetch remote metadata payloads and dispatch metadata handling.
- `createIIIFDropdown(iiifConfigURL)`
- Render a dropdown for selectable IIIF manifests.
- `createAIM3IFDropdown()`
- Render a dropdown for selectable AIM³IF manifests.
- `createManifestUI()`
- Create the IIIF/AIM³IF manifest loader UI panel.
## `viewer/viewer-utils.js`
`viewer/viewer-utils.js` contains viewer helpers for clipping planes, UI notifications, camera setup, and background rendering.
- `initClippingPlanes()`
- Initialize the three clipping planes used by the viewer.
- `toastHelper(key, toneOrOptions, maybeOptions)`
- Show a translated status toast by key.
- `showToast(message, toneOrOptions, maybeOptions)`
- Show a message or translated toast directly.
- `getErrorMessage(error)`
- Normalize error objects or strings into a message.
- `reportViewerError(error, options)`
- Report errors through console, toast, and E2E state.
- `setupObject(_object, _metadata)`
- Adjust object position, rotation, scale, and camera based on metadata or configuration.
- `setupCamera(_object, _data)`
- Set up camera, controls, lights, and background for the loaded object.
- `applyGradientCss(gradient)`
- Set a CSS gradient background from parsed gradient values.
- `changeBackground(_type, _color1, _color2 = _color1, _alpha = 100)`
- Set viewer canvas background color or gradient.
- `invertHexColor(hexTripletColor)`
- Return the inverted value for a hex color string.
- `getOrAddGuiController(object, prop)`
- Find or create a lil-gui controller by property name.
## `viewer/utils.js`
`viewer/utils.js` exposes generic utility functions used throughout the viewer.
- `distanceBetweenPoints(pointA, pointB)`
- `distanceBetweenPointsVector(vector)`
- `vectorBetweenPoints(pointA, pointB)`
- `halfwayBetweenPoints(pointA, pointB)`
- `interpolateDistanceBetweenPoints(pointA, vector, length, scalar)`
- Geometry helpers for basic vector math.
- `detectColorFormat(color)`
- `hexToRgb(hex)`
- `parseCssColor(str)`
- `normalizeColor(value)`
- Color parsing and normalization helpers.
- `isValidUrl(urlString)`
- `truncateString(str, n)`
- String and URL utilities.
- `getProxyPath(url, config)`
- Build proxied asset URLs for server-side proxy support.
## `viewer/viewer-settings.js`
- `loadSettings()`
- Load `viewer-settings.json` relative to the current module path.
- Used by source-mode development and runtime config loading.
## scripts/convert.sh
- ```handle_file``` - uses python script (downloaded and modified version of https://github.com/ux3d/2gltf2/tree/master) triggered by blender
```${BLENDER_PATH}blender -b -P ${SPATH}/scripts/2gltf2/2gltf2.py -- "$INPATH/$FILENAME" "$GLTF" "$COMPRESSION" "$COMPRESSION_LEVEL" "$OUTPUT$OUTPUTPATH"```
- ```handle_ifc_file``` - uses IfcConvert script (available at https://ifcopenshell.sourceforge.net/ifcconvert.html)
```${SPATH}/scripts/IfcConvert "$INPATH/$FILENAME" "$INPATH/gltf/$NAME.glb"```
- ```handle_blend_file``` - uses python script triggered by blender
```${BLENDER_PATH}blender -b -P ${SPATH}/scripts/convert-blender-to-gltf.py "$INPATH/$FILENAME" "$INPATH/gltf/$NAME.glb"```
- automatic rendering of 3D model for thumbnails - function ```render_preview```, which uses wrapper for triggering virtual environment (xvfb-run) for blender and its python script
```xvfb-run --auto-servernum --server-args="-screen 0 512x512x16" sudo ${BLENDER_PATH}blender -b -P ${SPATH}/scripts/render.py -- "$INPATH/$NAME.glb" "glb" $1 "$INPATH/views/" $IS_ARCHIVE -E BLENDER_EEVEE -f 1```

154
viewer/IIIF/iiif-api.js Normal file
View file

@ -0,0 +1,154 @@
import {} from "@iiif/3d-manifesto-dev";
import { IIIFManifest } from "./iiif";
export async function loadIIIFManifest(manifestUrlOrJson) {
let iiifManifest = new IIIFManifest(manifestUrlOrJson);
await iiifManifest.loadManifest();
let modelTarget;
let filteredAnnos;
let i = 0;
iiifManifest.modelUrls = new Array();
if (iiifManifest.scenes.length > 0) {
for (const [i, scene] of iiifManifest.scenes.entries()) { //TODO: support multiple scenes const manifestScene = scene;
//if (!scene) return;
// Root scene
const manifestScene = iiifManifest.scenes[i];
// Add scene BG color
iiifManifest.scenes[i].background = await manifestScene.getBackgroundColor();
// Load individual model annotations
const annos = iiifManifest.annotationsFromScene(manifestScene);
filteredAnnos = annos.filter((anno) => {
const body = anno.getBody()[0];
return (
anno.getMotivation()?.[0] === "painting" &&
(body.isSpecificResource || body?.getType() === "model")
);
});
filteredAnnos.forEach((modelAnnotation) => {
let modelUrl;
if (modelAnnotation.getBody()[0].isSpecificResource) {
modelUrl = modelAnnotation.getBody()[0].getSource()?.id;
} else {
modelUrl = modelAnnotation.getBody()[0].id;
}
modelTarget = modelAnnotation.getTarget();
if (modelUrl && modelTarget) {
iiifManifest.modelUrls.push(modelUrl);
}
});
}
}
return {
manifest: iiifManifest.manifest,
scenes: iiifManifest.scenes,
annotations: filteredAnnos,
modelUrls: iiifManifest.modelUrls,
modelTarget: modelTarget
};
}
export async function getAnnotations(iiifManifest, objectsConfig) {
let ind = objectsConfig.index || 0;
const modelAnnotations = iiifManifest.annotations[ind];
if (!modelAnnotations) return;
const target = iiifManifest.annotations?.[ind];
if (target == null) {
// handle missing (out-of-range or undefined)
throw new Error(`No annotation at index ${ind}`);
}
// make sure we have an array to map over
const items = Array.isArray(target) ? target : [target];
await Promise.all(
items.map(async (modelAnnotation) => {
if (modelAnnotation.getBody()[0].isSpecificResource) {
let transforms = new Array();
try {
const body = modelAnnotation.getBody?.();
const first = Array.isArray(body) ? body[0] : null;
transforms = first?.getTransform?.() || [];
} catch (e) {
// No transforms present so keep defaults
//objectsConfig.models[ind].scale = {x: 1, y: 1, z: 1};
//objectsConfig.models[ind].rotation = {x: 0, y: 0, z: 0};
//objectsConfig.models[ind].position = {x: 0, y: 0, z: 0};
//console.log("No transform present in specific resource body");
}
// Correct use of async-safe loop
for (const transform of transforms) {
if (!transform.isTransform) continue;
const transformHandlers = [
{
key: "isScaleTransform",
action: () => {
const scale = transform.getScale();
if (scale) {
objectsConfig.models[ind].scale = scale;
}
else {
objectsConfig.models[ind].scale = {x: 1, y: 1, z: 1};
console.log("No scale defined in scale transform");
}
},
},
{
key: "isRotateTransform",
action: () => {
const rotation = transform.getRotation();
if (rotation) {
objectsConfig.models[ind].rotation = rotation;
}
else {
objectsConfig.models[ind].rotation = {x: 0, y: 0, z: 0};
console.log("No rotation defined in rotate transform");
}
},
},
{
key: "isTranslateTransform",
action: () => {
const translation = transform.getTranslation();
if (translation) {
objectsConfig.models[ind].position = translation;
}
else {
objectsConfig.models[ind].position = {x: 0, y: 0, z: 0};
}
},
},
];
for (const { key, action } of transformHandlers) {
if (transform[key]) {
action();
}
}
}
}
// Position model within target scene if position selector present
if (iiifManifest.modelTarget.isSpecificResource == true) {
const selector = iiifManifest.modelTarget.getSelector();
if (selector && selector.isPointSelector) {
const position = selector.getLocation();
if (position) {
objectsConfig.models[ind].position.x += position.x;
objectsConfig.models[ind].position.y += position.y;
objectsConfig.models[ind].position.z += position.z;
}
}
}
}));
return iiifManifest.annotations;
}

40
viewer/IIIF/iiif.js Normal file
View file

@ -0,0 +1,40 @@
//import manifest from "@iiif/3d-manifesto-dev/dist-esmodule/";
import * as manifesto from "@iiif/3d-manifesto-dev/dist-esmodule";
export class IIIFManifest {
constructor(manifest) {
// Is manifest JSON or URL?
if (isJsonString(manifest)) {
this.manifestJson = manifest;
this.manifestUrl = null;
this.manifest = manifesto.parseManifest(manifest);
} else {
this.manifestJson = null;
this.manifestUrl = manifest;
}
}
async loadManifest() {
if (this.manifestUrl)
this.manifestJson = await manifesto.loadManifest(this.manifestUrl);
if (this.manifestJson)
this.manifest = await manifesto.parseManifest(this.manifestJson);
if (this.manifest)
this.scenes = this.manifest?.getSequences()[0]?.getScenes() || [];
}
annotationsFromScene(scene) {
return scene?.getContent() || [];
}
}
function isJsonString(str) {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}

59
viewer/admin/actions.php Normal file
View file

@ -0,0 +1,59 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maintenance</title>
<link rel="stylesheet" href="style.css">
<style>button{margin:.3rem}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Maintenance</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<h2>Cache & Rebuilds</h2>
<div>
<button data-action="clear_cache">Clear Cache</button>
<button data-action="rebuild_thumbs">Rebuild Thumbnails</button>
<button data-action="diagnostics">Diagnostics</button>
</div>
<pre id="out" style="margin-top:1rem;padding:1rem;border:1px solid #ddd;background:#f9f9f9;white-space:pre-wrap"></pre>
</div>
<div class="card">
<h2>Entity Re-save (Drupal)</h2>
<p class="muted">Trigger re-save for a specific entity to queue it for model conversion.</p>
<form id="entityResaveForm">
<label>Entity ID<input name="entity_id" type="text" placeholder="e.g. 12345" required></label>
<label>Entity Type<input name="entity_type" type="text" placeholder="e.g. wisski_individual" value="wisski_individual"></label>
<div style="margin-top:1rem"><button type="submit">Re-save Entity</button></div>
</form>
<pre id="entityOut" style="margin-top:1rem;padding:1rem;border:1px solid #ddd;background:#f9f9f9;white-space:pre-wrap;display:none"></pre>
</div>
</div>
<script>
document.querySelectorAll('button[data-action]').forEach(b=>b.addEventListener('click', async ()=>{
const action=b.getAttribute('data-action');
const out=document.getElementById('out'); out.textContent='...';
const res=await fetch('api/actions.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action})});
const j=await res.json(); out.textContent=JSON.stringify(j, null, 2);
}));
document.getElementById('entityResaveForm').addEventListener('submit', async (e)=>{
e.preventDefault();
const entity_id = document.querySelector('input[name="entity_id"]').value;
const entity_type = document.querySelector('input[name="entity_type"]').value;
const outEl = document.getElementById('entityOut');
outEl.style.display='block';
outEl.textContent='...';
const res = await fetch('api/actions.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'entity_resave', entity_id, entity_type})});
const j = await res.json();
outEl.textContent = (j.output || JSON.stringify(j, null, 2));
});
</script>
</body>
</html>

View file

@ -0,0 +1,89 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data || !isset($data['action'])) { http_response_code(400); json_response(['error'=>'invalid_request']); }
$action = $data['action'];
if ($action === 'clear_cache') {
$target = $root . '/viewer/build';
$out = '';
if (is_dir($target)) {
// remove all files
$cmd = 'rm -rf ' . escapeshellarg($target) . '/*';
$out = shell_exec($cmd . ' 2>&1');
} else { $out = 'no build dir'; }
json_response(['ok'=>true,'output'=>$out]);
}
if ($action === 'rebuild_thumbs') {
$script = $root . '/scripts/render.sh';
if (file_exists($script) && is_executable($script)) {
$out = shell_exec(escapeshellcmd($script) . ' 2>&1');
json_response(['ok'=>true,'output'=>$out]);
}
http_response_code(500); json_response(['error'=>'script_missing']);
}
if ($action === 'diagnostics') {
$out = [];
$out['php'] = shell_exec('php -v 2>&1');
$out['uname'] = shell_exec('uname -a 2>&1');
$out['df'] = shell_exec('df -h 2>&1');
json_response(['ok'=>true,'output'=>$out]);
}
if ($action === 'entity_resave') {
$entity_id = $data['entity_id'] ?? null;
$entity_type = $data['entity_type'] ?? 'wisski_individual';
if (!$entity_id) { http_response_code(400); json_response(['error'=>'missing_entity_id']); }
$out = "Attempting to re-save entity $entity_type:$entity_id ...\n\n";
// Try to bootstrap Drupal if available
$drupal_root = $root;
if (file_exists($drupal_root . '/index.php')) {
$out .= "Found index.php, attempting Drupal bootstrap...\n";
try {
// Push to Drupal context
$cwd = getcwd();
chdir($drupal_root);
// Simple Drupal load without full bootstrap (safer)
if (function_exists('drush_main')) {
$out .= "Drush detected, will attempt re-save.\n";
}
// Try using Drupal's entity loader via autoloader
if (file_exists($drupal_root . '/vendor/autoload.php')) {
require_once $drupal_root . '/vendor/autoload.php';
$out .= "Drupal autoloader loaded.\n";
// Simple check: can we access Drupal\Core ?
if (class_exists('Drupal\Core\Entity\EntityTypeManager')) {
$out .= "Drupal namespace available - attempting entity save.\n";
// Real bootstrap would require more setup
$out .= "INFO: Full entity re-save requires Drupal context.\n";
}
}
chdir($cwd);
$out .= "\nTo re-save this entity, use Drupal CLI:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
$out .= "Or access Drupal admin panel and update the entity.\n";
} catch (\Throwable $e) {
$out .= "Bootstrap check failed: " . $e->getMessage() . "\n";
$out .= "\nTo re-save this entity, use Drupal CLI:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
}
} else {
$out .= "Drupal not detected in this directory.\n";
$out .= "To re-save an entity, use:\n";
$out .= " drush entity:save $entity_type $entity_id\n";
$out .= "Or trigger via Drupal admin panel.\n";
}
json_response(['ok'=>true,'output'=>$out]);
}
http_response_code(400); json_response(['error'=>'unknown_action']);

View file

@ -0,0 +1,62 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
// GET ?target=settings|env -> list backups (most recent first, limited to 5)
// POST restore {target, file}
function list_backups($target) {
global $root;
if ($target === 'settings') {
$path = $root . '/viewer-settings.json';
$base = 'viewer-settings.json';
} elseif ($target === 'env') {
$path = $root . '/scripts/.env';
$base = '.env';
$dir = dirname($path);
$base = basename($path);
} else {
return ['error'=>'invalid_target'];
}
$dir = dirname($path);
$pattern = $dir . '/' . $base . '.*';
$files = glob($pattern);
// filter timestamped copies only (YYYYmmdd-HHMMSS)
$backups = array_filter($files, function($f) use ($base){ return preg_match('/' . preg_quote($base, '/') . '\.\d{8}-\d{6}$/', $f); });
usort($backups, function($a,$b){ return filemtime($b) - filemtime($a); });
$backups = array_slice($backups, 0, 5);
$out = [];
foreach($backups as $b) $out[] = ['file'=>basename($b),'path'=>$b,'ts'=>date('c', filemtime($b))];
return ['backups'=>$out];
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$target = $_GET['target'] ?? null;
if (!$target) { http_response_code(400); json_response(['error'=>'missing_target']); }
json_response(list_backups($target));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data || !isset($data['target']) || !isset($data['file'])) { http_response_code(400); json_response(['error'=>'invalid_request']); }
$target = $data['target'];
$file = basename($data['file']);
if ($target === 'settings') {
$path = $root . '/viewer-settings.json';
$dir = dirname($path);
$src = $dir . '/' . $file;
} elseif ($target === 'env') {
$path = $root . '/scripts/.env';
$dir = dirname($path);
$src = $dir . '/' . $file;
} else { http_response_code(400); json_response(['error'=>'invalid_target']); }
if (!file_exists($src)) { http_response_code(404); json_response(['error'=>'not_found']); }
// make a backup of current file before restore
if (file_exists($path)) backup_file($path);
if (!copy($src, $path)) { http_response_code(500); json_response(['error'=>'restore_failed']); }
json_response(['ok'=>true]);
}
http_response_code(405); json_response(['error'=>'method_not_allowed']);

View file

@ -0,0 +1,55 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) {
http_response_code(401);
echo json_encode(['error' => 'unauthorized']);
exit;
}
function json_response($data) {
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function workspace_root() {
// admin/api is at viewer/admin/api -> go up 3
return realpath(__DIR__ . '/../../../');
}
function backup_file($path, $keep = 5) {
if (!file_exists($path)) return;
$dir = dirname($path);
$base = basename($path);
// simple .bak (latest)
copy($path, $dir . '/' . $base . '.bak');
// timestamped
$ts = date('Ymd-His');
$copy = $dir . '/' . $base . '.' . $ts;
copy($path, $copy);
// rotate old backups: match $base.* in same dir (exclude .bak)
$pattern = $dir . '/' . $base . '.*';
$files = glob($pattern);
// filter timestamped copies only (YYYYmmdd-HHMMSS)
$backups = array_filter($files, function($f) use ($base){ return preg_match('/' . preg_quote($base, '/') . '\.\d{8}-\d{6}$/', $f); });
usort($backups, function($a,$b){ return filemtime($b) - filemtime($a); });
if (count($backups) > $keep) {
$remove = array_slice($backups, $keep);
foreach($remove as $r) @unlink($r);
}
}
function save_uploaded_schema($targetPath) {
if (!isset($_FILES['file'])) return ['error'=>'no_file'];
$f = $_FILES['file'];
if ($f['error'] !== UPLOAD_ERR_OK) return ['error'=>'upload_error'];
// validate json
$content = file_get_contents($f['tmp_name']);
if (json_decode($content) === null) return ['error'=>'invalid_json'];
// backup existing
if (file_exists($targetPath)) backup_file($targetPath, intval(getenv('ADMIN_BACKUP_KEEP') ?: 10));
if (!is_dir(dirname($targetPath))) mkdir(dirname($targetPath), 0755, true);
if (!move_uploaded_file($f['tmp_name'], $targetPath)) return ['error'=>'move_failed'];
return ['ok'=>true];
}

53
viewer/admin/api/env.php Normal file
View file

@ -0,0 +1,53 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$target = $root . '/scripts/.env';
$example = $root . '/scripts/.env.example';
// Ensure .env exists by copying example
if (!file_exists($target)) {
if (file_exists($example)) copy($example, $target);
else file_put_contents($target, "# .env\n");
}
function parse_env($path) {
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$out = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || strpos($line, '#') === 0) continue;
if (strpos($line, '=') !== false) {
list($k,$v) = explode('=', $line, 2);
$out[trim($k)] = trim($v);
}
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
json_response(parse_env($target));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
http_response_code(400);
json_response(['error' => 'invalid_json']);
}
// backup
backup_file($target);
// write env
$lines = [];
foreach ($decoded as $k => $v) {
$lines[] = $k . '=' . $v;
}
$tmp = $target . '.tmp';
file_put_contents($tmp, implode("\n", $lines) . "\n");
rename($tmp, $target);
json_response(['ok' => true]);
}
http_response_code(405);
json_response(['error' => 'method_not_allowed']);

View file

@ -0,0 +1,17 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$p = $root . '/scripts/.env.schema.json';
if (file_exists($p)) {
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (@unlink($p)) json_response(['ok'=>true]);
http_response_code(500); json_response(['error'=>'delete_failed']);
}
header('Content-Type: application/json');
echo file_get_contents($p);
exit;
}
http_response_code(204);
exit;

36
viewer/admin/api/hdri.php Normal file
View file

@ -0,0 +1,36 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$dir = $root . '/viewer/hdri';
if (!is_dir($dir)) mkdir($dir, 0755, true);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$files = array_values(array_filter(scandir($dir), function($f){ return !in_array($f, ['.','..']); }));
json_response(['files' => $files]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// If multipart upload
if (!empty($_FILES['file'])) {
$f = $_FILES['file'];
if ($f['error'] !== UPLOAD_ERR_OK) { http_response_code(400); json_response(['error'=>'upload_error']); }
$name = basename($f['name']);
$target = $dir . '/' . $name;
if (!move_uploaded_file($f['tmp_name'], $target)) { http_response_code(500); json_response(['error'=>'move_failed']); }
json_response(['ok'=>true,'file'=>$name]);
}
// JSON action (delete)
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) { http_response_code(400); json_response(['error'=>'invalid_json']); }
if (isset($data['action']) && $data['action'] === 'delete' && isset($data['file'])) {
$file = basename($data['file']);
$path = $dir . '/' . $file;
if (file_exists($path)) { unlink($path); json_response(['ok'=>true]); }
http_response_code(404); json_response(['error'=>'not_found']);
}
http_response_code(400); json_response(['error'=>'unknown_action']);
}
http_response_code(405); json_response(['error'=>'method_not_allowed']);

View file

@ -0,0 +1,41 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$target = $root . '/viewer-settings.json';
$example1 = $root . '/viewer-settings-example.json';
$example2 = $root . '/viewer/viewer-settings-example.json';
// Ensure file exists by copying example if needed
if (!file_exists($target)) {
if (file_exists($example1)) copy($example1, $target);
elseif (file_exists($example2)) copy($example2, $target);
else file_put_contents($target, json_encode(new stdClass()));
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$data = file_get_contents($target);
header('Content-Type: application/json');
echo $data;
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$body = file_get_contents('php://input');
// validate JSON
$decoded = json_decode($body, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
json_response(['error' => 'invalid_json']);
}
// backup
backup_file($target);
// atomic write
$tmp = $target . '.tmp';
file_put_contents($tmp, json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
rename($tmp, $target);
json_response(['ok' => true]);
}
http_response_code(405);
json_response(['error' => 'method_not_allowed']);

View file

@ -0,0 +1,24 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
$candidates = [
$root . '/viewer-settings.schema.json',
$root . '/viewer/viewer-settings.schema.json',
];
foreach ($candidates as $p) {
if (file_exists($p)) {
if (isset($_GET['delete']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (@unlink($p)) json_response(['ok'=>true]);
http_response_code(500); json_response(['error'=>'delete_failed']);
}
header('Content-Type: application/json');
echo file_get_contents($p);
exit;
}
}
// no schema found
http_response_code(204);
exit;

View file

@ -0,0 +1,24 @@
<?php
require __DIR__ . '/common.php';
$root = workspace_root();
// expects multipart/form-data with field 'file' and 'target' in POST (values: 'settings' or 'env')
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); json_response(['error'=>'method_not_allowed']);
}
$target = $_POST['target'] ?? $_GET['target'] ?? null;
if (!$target) { http_response_code(400); json_response(['error'=>'missing_target']); }
if ($target === 'settings') {
$dest = $root . '/viewer-settings.schema.json';
} elseif ($target === 'env') {
$dest = $root . '/scripts/.env.schema.json';
} else {
http_response_code(400); json_response(['error'=>'invalid_target']);
}
$res = save_uploaded_schema($dest);
if (isset($res['ok'])) json_response($res);
http_response_code(400); json_response($res);

View file

@ -0,0 +1,24 @@
<?php
// CLI script: php create_admin.php username password
if (php_sapi_name() !== 'cli') {
echo "This script must be run from CLI\n";
exit(1);
}
if ($argc < 3) {
echo "Usage: php create_admin.php username password\n";
exit(1);
}
$username = $argv[1];
$password = $argv[2];
$pdo = require __DIR__ . '/db.php';
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO admins (username, password, created_at) VALUES (?, ?, ?)');
try {
$stmt->execute([$username, $hash, date('c')]);
echo "Admin user created: $username\n";
} catch (Exception $e) {
echo "Error creating admin: " . $e->getMessage() . "\n";
exit(1);
}

15
viewer/admin/db.php Normal file
View file

@ -0,0 +1,15 @@
<?php
// Simple SQLite PDO connection for admin panel
$dbFile = __DIR__ . '/admin.sqlite';
$pdo = new PDO('sqlite:' . $dbFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Ensure table exists when connection is first used
$pdo->exec("CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL
)");
return $pdo;

100
viewer/admin/env.php Normal file
View file

@ -0,0 +1,100 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Environment</title>
<link rel="stylesheet" href="style.css">
<style>table{border-collapse:collapse} td,th{padding:.3rem;border:1px solid #ddd}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Environment (.env)</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<table id="tbl"><thead><tr><th>Key</th><th>Value</th><th></th></tr></thead><tbody></tbody></table>
<div style="margin-top:.5rem"><button id="add">Dodaj</button> <button id="save">Zapisz</button> <a href="index.php">Wróć</a></div>
<hr>
<h3>Env Schema</h3>
<form id="uploadEnvSchema" enctype="multipart/form-data">
<input type="file" name="file" accept="application/json"> <input type="hidden" name="target" value="env"> <button>Upload Env Schema</button>
</form>
<button id="deleteEnvSchema">Delete Env Schema</button>
<div id="envSchemaMsg" style="margin-top:.5rem;color:green"></div>
<h3>Backups</h3>
<div id="envBackups">Loading backups...</div>
<div id="envBackupMsg" style="margin-top:.5rem;color:green"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ajv@8.12.0/dist/ajv7.min.js"></script>
<script>
let envSchema = null;
async function loadSchema(){
try{
const res = await fetch('api/env_schema.php', {credentials:'same-origin'});
if (res.status === 200) { envSchema = await res.json(); const el=document.createElement('div'); el.style.fontSize='90%'; el.style.margin='0.5rem 0'; el.textContent='Env schema loaded — validation enabled.'; document.body.insertBefore(el, document.getElementById('tbl')) }
}catch(e){/* ignore */}
}
async function load(){
const res = await fetch('api/env.php', {credentials:'same-origin'});
const data = await res.json();
const tbody = document.querySelector('#tbl tbody'); tbody.innerHTML='';
for(const k of Object.keys(data)){
addRow(k, data[k]);
}
}
function addRow(k='',v=''){ const tbody=document.querySelector('#tbl tbody'); const tr=document.createElement('tr'); tr.innerHTML=`<td><input class="k" value="${k}"></td><td><input class="v" value="${v}"></td><td><button class="del">X</button></td>`; tbody.appendChild(tr); tr.querySelector('.del').addEventListener('click',()=>tr.remove()); }
document.getElementById('add').addEventListener('click',()=>addRow());
document.getElementById('save').addEventListener('click', async ()=>{
const rows = document.querySelectorAll('#tbl tbody tr'); const out={};
rows.forEach(r=>{ const k=r.querySelector('.k').value; const v=r.querySelector('.v').value; if(k) out[k]=v; });
// client-side validation
if (envSchema) {
const Ajv = window.ajv7.Ajv; const ajv = new Ajv({allErrors:true});
const validate = ajv.compile(envSchema);
const valid = validate(out);
if (!valid) {
alert('Env validation errors:\n' + validate.errors.map(e=>`${e.instancePath} ${e.message}`).join('\n'));
return;
}
} else {
// basic validation: keys uppercase letters, digits and underscore
for(const k of Object.keys(out)){
if (!/^[A-Z0-9_]+$/.test(k)) { alert('Invalid key: ' + k + '\nKeys should be uppercase letters, digits or underscore'); return; }
}
}
const res = await fetch('api/env.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify(out)});
const j = await res.json(); alert(JSON.stringify(j));
});
(async ()=>{
await loadSchema();
load();
document.getElementById('uploadEnvSchema').addEventListener('submit', async (e)=>{
e.preventDefault(); const fd = new FormData(e.target); const res = await fetch('api/upload_schema.php',{method:'POST',credentials:'same-origin', body: fd}); const j = await res.json(); document.getElementById('envSchemaMsg').textContent = JSON.stringify(j); if (j.ok) setTimeout(()=>location.reload(),700);
});
document.getElementById('deleteEnvSchema').addEventListener('click', async ()=>{
if (!confirm('Delete env schema file?')) return; const res = await fetch('api/env_schema.php?delete=1',{method:'POST',credentials:'same-origin'}); const j = await res.json(); alert(JSON.stringify(j)); if (j.ok) setTimeout(()=>location.reload(),700);
});
async function loadEnvBackups(){
const res = await fetch('api/backups.php?target=env',{credentials:'same-origin'});
const j = await res.json();
const el = document.getElementById('envBackups');
if (j.error) { el.textContent = JSON.stringify(j); return; }
if (!j.backups || j.backups.length===0) { el.textContent='No backups found'; return; }
el.innerHTML='';
j.backups.forEach(b=>{
const div=document.createElement('div'); div.textContent = b.file + ' ('+b.ts+') '; const btn=document.createElement('button'); btn.textContent='Restore'; btn.addEventListener('click', async ()=>{ if(!confirm('Restore '+b.file+' ?')) return; const r = await fetch('api/backups.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({target:'env', file: b.file})}); const R=await r.json(); document.getElementById('envBackupMsg').textContent = JSON.stringify(R); if(R.ok) setTimeout(()=>location.reload(),700); }); div.appendChild(btn); el.appendChild(div);
});
}
loadEnvBackups();
})();
</script>
</div> <!-- .wrap -->
</body>
</html>

40
viewer/admin/hdri.php Normal file
View file

@ -0,0 +1,40 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HDRI</title>
<link rel="stylesheet" href="style.css">
<style>li{margin:.3rem 0}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>HDRI</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<form id="upload" enctype="multipart/form-data">
<input type="file" name="file"> <button>Upload</button>
</form>
<ul id="list"></ul>
</div>
<p class="footer">Manage HDRI files used by the viewer.</p>
</div>
<script>
async function load(){
const res = await fetch('api/hdri.php', {credentials:'same-origin'});
const data = await res.json();
const ul = document.getElementById('list'); ul.innerHTML='';
data.files.forEach(f=>{ const li=document.createElement('li'); li.textContent=f + ' '; const del=document.createElement('button'); del.textContent='Usuń'; del.addEventListener('click', ()=> delFile(f)); li.appendChild(del); ul.appendChild(li); });
}
async function delFile(filename){ if(!confirm('Usuń '+filename+' ?')) return; const res=await fetch('api/hdri.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:'delete',file:filename})}); const j=await res.json(); alert(JSON.stringify(j)); load(); }
document.getElementById('upload').addEventListener('submit', async (e)=>{
e.preventDefault(); const f=e.target.file.files[0]; if(!f){alert('Wybierz plik'); return;} const fd=new FormData(); fd.append('file', f); const res=await fetch('api/hdri.php',{method:'POST',credentials:'same-origin', body: fd}); const j=await res.json(); alert(JSON.stringify(j)); load();
});
load();
</script>
</body>
</html>

30
viewer/admin/index.php Normal file
View file

@ -0,0 +1,30 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) {
header('Location: login.php');
exit;
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Panel</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="site"><h1>Admin Panel</h1><nav class="admin-links"><a href="logout.php">Logout</a></nav></header>
<div class="card">
<p>Logged in as <strong><?php echo htmlentities($_SESSION['admin']) ?></strong></p>
<div class="flex">
<a class="small" href="settings.php">Viewer Settings</a>
<a class="small" href="env.php">Environment</a>
<a class="small" href="hdri.php">HDRI</a>
<a class="small" href="actions.php">Maintenance</a>
</div>
</div>
</div>
</body>
</html>

45
viewer/admin/login.php Normal file
View file

@ -0,0 +1,45 @@
<?php
session_start();
if (isset($_SESSION['admin'])) {
header('Location: index.php');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$pdo = require __DIR__ . '/db.php';
$stmt = $pdo->prepare('SELECT * FROM admins WHERE username = ?');
$stmt->execute([$username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && password_verify($password, $row['password'])) {
$_SESSION['admin'] = $row['username'];
header('Location: index.php');
exit;
}
$error = 'Invalid login credentials. Please try again.';
}
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Login</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrap">
<header class="site"><h1>Panel Admin</h1></header>
<div class="card">
<?php if ($error): ?><div class="msg err"><?php echo htmlentities($error) ?></div><?php endif ?>
<form method="post" class="login-form">
<label>Username<input name="username" required></label>
<label>Password<input name="password" type="password" required></label>
<div style="margin-top:1rem"><button type="submit">Login</button></div>
</form>
</div>
<div class="footer">If you don't have an account, run <code>php viewer/admin/create_admin.php &lt;user&gt; &lt;pass&gt;</code></div>
</div>
</body>
</html>

6
viewer/admin/logout.php Normal file
View file

@ -0,0 +1,6 @@
<?php
session_start();
session_unset();
session_destroy();
header('Location: login.php');
exit;

119
viewer/admin/settings.php Normal file
View file

@ -0,0 +1,119 @@
<?php
session_start();
if (!isset($_SESSION['admin'])) { header('Location: login.php'); exit; }
?>
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewer Settings</title>
<link href="https://cdn.jsdelivr.net/npm/jsoneditor@9.9.0/dist/jsoneditor.min.css" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<style>#editor{height:60vh;border:1px solid #ddd}</style>
</head>
<body>
<div class="wrap">
<header class="site"><h1>Viewer Settings</h1><nav class="admin-links"><a href="index.php">Wróć</a></nav></header>
<div class="card">
<div style="margin-bottom:.5rem">
<button id="viewTree">Tree View</button>
<button id="viewForm">Form View</button>
</div>
<div id="editor"></div>
<div style="margin-top:1rem">
<button id="save">Zapisz</button>
<button id="reload">Wczytaj</button>
<a href="index.php">Wróć</a>
</div>
<hr>
<h3>Schema management</h3>
<form id="uploadSchema" enctype="multipart/form-data">
<input type="file" name="file" accept="application/json"> <input type="hidden" name="target" value="settings"> <button>Upload Schema</button>
</form>
<button id="deleteSchema">Delete Schema</button>
<div id="schemaMsg" style="margin-top:.5rem;color:green"></div>
<h3>Backups</h3>
<div id="backupsList">Loading backups...</div>
<div id="backupMsg" style="margin-top:.5rem;color:green"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jsoneditor@9.9.0/dist/jsoneditor.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ajv@8.12.0/dist/ajv7.min.js"></script>
<script>
(async ()=>{
const container = document.getElementById('editor');
let editor = null;
let schema = null;
function createEditor(mode){
if(editor) editor.destroy();
const options = {mode: mode};
if (mode === 'form' && schema) options.schema = schema;
editor = new JSONEditor(container, options);
}
async function loadSchema() {
const res = await fetch('api/settings_schema.php', {credentials:'same-origin'});
if (res.status === 200) {
schema = await res.json();
const el = document.createElement('div'); el.style.fontSize='90%'; el.style.margin='0.5rem 0'; el.textContent = 'Schema loaded — validation enabled.'; document.body.insertBefore(el, container);
}
}
async function load() {
const res = await fetch('api/settings.php', {credentials: 'same-origin'});
const data = await res.json();
if (!editor) createEditor('tree');
try { editor.set(data); } catch(e) { console.error(e); }
}
document.getElementById('reload').addEventListener('click', load);
document.getElementById('viewTree').addEventListener('click', ()=>{ createEditor('tree'); load(); });
document.getElementById('viewForm').addEventListener('click', ()=>{ createEditor('form'); load(); });
document.getElementById('save').addEventListener('click', async ()=>{
const data = editor.get();
if (schema) {
const Ajv = window.ajv7.Ajv; const ajv = new Ajv({allErrors:true});
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
alert('Validation errors:\n' + validate.errors.map(e=>`${e.instancePath} ${e.message}`).join('\n'));
return;
}
}
const res = await fetch('api/settings.php', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
const j = await res.json();
alert(JSON.stringify(j));
});
// load schema then data
await loadSchema();
createEditor('tree');
load();
// upload schema
document.getElementById('uploadSchema').addEventListener('submit', async (e)=>{
e.preventDefault(); const fd = new FormData(e.target); const res = await fetch('api/upload_schema.php',{method:'POST',credentials:'same-origin', body: fd}); const j = await res.json(); document.getElementById('schemaMsg').textContent = JSON.stringify(j); if (j.ok) setTimeout(()=>location.reload(),700);
});
document.getElementById('deleteSchema').addEventListener('click', async ()=>{
if (!confirm('Delete schema file?')) return; const res = await fetch('api/settings_schema.php?delete=1',{method:'POST',credentials:'same-origin'}); const j = await res.json(); alert(JSON.stringify(j)); if (j.ok) setTimeout(()=>location.reload(),700);
});
// backups
async function loadBackups(){
const res = await fetch('api/backups.php?target=settings',{credentials:'same-origin'});
const j = await res.json();
const el = document.getElementById('backupsList');
if (j.error) { el.textContent = JSON.stringify(j); return; }
if (!j.backups || j.backups.length===0) { el.textContent='No backups found'; return; }
el.innerHTML='';
j.backups.forEach(b=>{
const div=document.createElement('div'); div.textContent = b.file + ' ('+b.ts+') '; const btn=document.createElement('button'); btn.textContent='Restore'; btn.addEventListener('click', async ()=>{ if(!confirm('Restore '+b.file+' ?')) return; const r = await fetch('api/backups.php',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'}, body: JSON.stringify({target:'settings', file: b.file})}); const R=await r.json(); document.getElementById('backupMsg').textContent = JSON.stringify(R); if(R.ok) setTimeout(()=>location.reload(),700); }); div.appendChild(btn); el.appendChild(div);
});
}
loadBackups();
})();
</script>
</div> <!-- .wrap -->
</body>
</html>

34
viewer/admin/style.css Normal file
View file

@ -0,0 +1,34 @@
/* Basic admin panel styling */
:root{
--bg:#f6f8fb;
--card:#ffffff;
--muted:#6b7280;
--accent:#0b74de;
--danger:#dc2626;
}
body{font-family:Inter, Roboto, Arial, Helvetica, sans-serif;background:var(--bg);color:#111;margin:0;padding:0}
.wrap{max-width:1100px;margin:28px auto;padding:20px}
.card{background:var(--card);border:1px solid #e6e9ef;border-radius:8px;padding:18px;box-shadow:0 1px 2px rgba(16,24,40,0.03)}
header.site{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
header.site h1{margin:0;font-size:1.25rem}
nav.admin-links a{margin-right:10px;color:var(--accent);text-decoration:none}
nav.admin-links a:hover{text-decoration:underline}
form.login-form{max-width:360px}
label{display:block;margin-bottom:8px;font-size:.95rem}
input[type=text],input[type=password],input[type=file],textarea,select{width:100%;padding:8px 10px;border:1px solid #d6d9e0;border-radius:6px;background:#fff}
button{background:var(--accent);color:#fff;border:none;padding:8px 12px;border-radius:6px;cursor:pointer}
button.secondary{background:#e6eefb;color:var(--accent)}
button.danger{background:var(--danger)}
table{width:100%;border-collapse:collapse}
td,th{padding:8px;border-bottom:1px solid #f1f3f6}
#editor{min-height:420px;border-radius:6px}
.muted{color:var(--muted)}
.msg{padding:8px;border-radius:6px;margin-top:8px}
.msg.ok{background:#ecfdf5;color:#065f46}
.msg.err{background:#fff1f2;color:#7f1d1d}
.flex{display:flex;gap:10px;align-items:center}
.small{font-size:.9rem}
.backups div{margin-bottom:8px}
.footer{margin-top:20px;color:var(--muted);font-size:.9rem}
@media (max-width:600px){.wrap{padding:12px;margin:12px}header.site{flex-direction:column;align-items:flex-start} }

14
viewer/config.json Normal file
View file

@ -0,0 +1,14 @@
{
"domain": "https://3d-repository.hs-mainz.de",
"metadataDomain": "https://3d-repository.hs-mainz.de",
"container": "DFG_3DViewer",
"galleryContainer": "block-bootstrap5-content",
"galleryImageClass": "field--type-image",
"galleryImageID": "",
"basePath": "/modules/dfg_3dviewer/viewer",
"entityIdUri": "/wisski/navigate/(.*)/view",
"viewEntityPath": "/wisski/navigate/",
"attributeId": "wisski_id",
"lightweight": false,
"scaleContainer": {"x": 1, "y": 1.4}
}

View file

@ -0,0 +1,14 @@
{
"domain": "https://your.domain.com,
"metadataDomain": "https://your.domain.com",
"container": "DFG_3DViewer",
"galleryContainer": "block-bootstrap5-content",
"galleryImageClass": "field--type-image",
"galleryImageID": "",
"basePath": "/modules/dfg_3dviewer/viewer",
"entityIdUri": "/wisski/navigate/(.*)/view",
"viewEntityPath": "/wisski/navigate/",
"attributeId": "wisski_id",
"lightweight": false,
"scaleContainer": {"x": 1, "y": 1.0}
}

41
viewer/core.js Normal file
View file

@ -0,0 +1,41 @@
import THREE from "./init.js";
import TWEEN from "three/examples/jsm/libs/tween.module.js";
// core.js
export const core = {
clippingPlanes: null,
materialsFolder: null,
materialsPropertiesText: null,
camera: null,
colors: {},
intensity: {},
ambientLight: null,
cameraLight: null,
mainCanvas: null,
noticeContainer: null,
statusNotice: null,
gridSize: null,
dirLightTarget: null,
lightHelper: null,
scene: new THREE.Scene(),
basicGrid: new THREE.Group(),
axesHelper: new THREE.AxesHelper(),
cameraCoords: null,
tween: new TWEEN.Tween(),
controls: null,
transformControlClippingPlaneY: null,
transformControlClippingPlaneX: null,
transformControlClippingPlaneZ: null,
planeHelpers: null,
outlineClipping: null,
sceneBackgroundColor: null,
distanceGeometry: null,
planeParams: null,
clippingFolder: null,
helperObjects: []
// Add other shared state here
};
export const setCore = (key, value) => {
core[key] = value;
};

1
viewer/css/Toast.min.css vendored Normal file
View file

@ -0,0 +1 @@
.toastjs-container{position:absolute;position:fixed;bottom:30px;left:30px;width:calc(100% - 60px);max-width:400px;transform:translateX(-150%);transition:transform 1s;z-index:100}.toastjs-container[aria-hidden=false]{transform:translateX(0)}.toastjs{background:#fff;padding:10px 15px 0;border-left-style:solid;border-left-width:5px;border-radius:4px;box-shadow:0 2px 5px 0 rgba(0,0,0,.2)}.toastjs.default{border-left-color:#AAA}.toastjs.success{border-left-color:#2ECC40}.toastjs.warning{border-left-color:#FF851B}.toastjs.danger{border-left-color:#FF4136}.toastjs-btn{background:#f0f0f0;padding:5px 10px;border:0;border-radius:4px;font-family:'Source Sans Pro',sans-serif;font-size:14px;display:inline-block;margin-right:10px;margin-bottom:10px;cursor:pointer}.toastjs-btn--custom{background:#323232;color:#fff}.toastjs-btn:focus,.toastjs-btn:hover{outline:0;box-shadow:0 2px 5px 0 rgba(0,0,0,.2)}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,226 @@
#embedConfiguratorPanel[hidden] {
display: none !important;
}
#embedConfiguratorPanel {
position: absolute;
left: 50%;
top: 1%;
transform: translateX(-50%);
z-index: 119;
width: min(980px, calc(100vw - 36px));
max-height: min(72vh, 760px);
overflow: auto;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--viewer-panel-border);
background: var(--viewer-panel-embed-bg);
color: var(--viewer-panel-text);
box-shadow: var(--viewer-panel-shadow);
font-size: 13px;
line-height: 1.35;
z-index: 99999;
font-family: -apple-system, BlinkMacSystemFont, "Lucida Grande",
"Segoe UI", Roboto, Arial, sans-serif;
}
.embed-config-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-weight: 700;
}
.embed-config-header button {
border: 1px solid var(--viewer-panel-border);
border-radius: 6px;
background: var(--viewer-panel-bg);
color: var(--viewer-panel-text);
font: inherit;
padding: 4px 8px;
cursor: pointer;
}
.embed-config-header button:hover {
background: var(--viewer-panel-bg-hover);
}
.embed-config-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
color: var(--viewer-panel-text);
}
.embed-config-layout {
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(300px, 0.8fr);
gap: 12px;
align-items: stretch;
color: var(--viewer-panel-text);
}
.embed-config-main {
min-width: 0;
}
.embed-config-grid label,
.embed-config-field {
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 600;
color: color-mix(
in srgb,
var(--viewer-panel-bg) 10%,
var(--viewer-panel-text)
);
}
.embed-config-grid input,
.embed-config-grid select,
.embed-config-field textarea,
.embed-config-actions button {
border: 1px solid var(--viewer-panel-border);
border-radius: 6px;
background: var(--viewer-panel-bg);
color: var(--viewer-panel-text);
font: inherit;
padding: 6px 8px;
}
.embed-config-grid input.embed-input-invalid,
.embed-config-grid select.embed-input-invalid {
border-color: #d54646;
box-shadow: 0 0 0 1px rgba(213, 70, 70, 0.25);
}
.embed-config-grid input,
.embed-config-grid select {
min-width: 0;
color: var(--viewer-panel-text);
}
.embed-config-checks {
margin-top: 8px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.embed-config-checks label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: color-mix(
in srgb,
var(--viewer-panel-bg) 10%,
var(--viewer-panel-text)
);
}
.embed-config-actions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.embed-config-actions button {
cursor: pointer;
}
.embed-config-actions button:hover {
background: var(--viewer-panel-bg-hover);
}
.embed-config-field {
margin-top: 8px;
}
.embed-config-field textarea {
width: 100%;
min-height: 96px;
resize: vertical;
}
#embedIframeOutput {
min-height: 140px;
}
.embed-config-preview-wrap {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-weight: 600;
}
#embedPreviewFrame {
width: 100%;
height: 100%;
min-height: 520px;
flex: 1 0 520px;
border: 1px solid var(--viewer-panel-border);
border-radius: 6px;
background: transparent;
}
.embed-config-preview-side {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 10px;
font-weight: 700;
min-height: 100%;
padding-left: 10px;
padding-right: 6px;
align-self: stretch;
overflow-y: auto;
overscroll-behavior: contain;
}
.fullscreen-icon {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='1.9'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M8 3H5a2 2 0 0 0-2 2v3m16 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M8 21H5a2 2 0 0 1-2-2v-3'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='1.9'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M8 3H5a2 2 0 0 0-2 2v3m16 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M8 21H5a2 2 0 0 1-2-2v-3'/%3E%3C/svg%3E");
}
.fullscreen-exit-icon {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='1.9'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 3H5a2 2 0 0 0-2 2v4m14-6h2a2 2 0 0 1 2 2v4m-6 12h4a2 2 0 0 0 2-2v-4m-18 0v4a2 2 0 0 0 2 2h4M9 9 3 3m12 6 6-6M9 15l-6 6m12-6 6 6'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='black' stroke-width='1.9'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 3H5a2 2 0 0 0-2 2v4m14-6h2a2 2 0 0 1 2 2v4m-6 12h4a2 2 0 0 0 2-2v-4m-18 0v4a2 2 0 0 0 2 2h4M9 9 3 3m12 6 6-6M9 15l-6 6m12-6 6 6'/%3E%3C/svg%3E");
}
.viewer-embed-page #metadata-container {
top: 0 !important;
}
.viewer-embed-page #metadata-card {
max-width: min(320px, 42vw);
width: min(188px, 24vw);
}
.viewer-embed-page #metadata-card.metadata-open {
width: min(320px, 42vw);
}
@media (max-width: 640px) {
.embed-config-layout {
grid-template-columns: 1fr;
}
.embed-config-preview-side {
position: static;
min-height: 260px;
overflow: visible;
padding-right: 0;
}
.embed-config-grid,
.embed-config-checks {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,335 @@
#form-manifesto {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: min(640px, 95vw);
background: rgba(250, 247, 242, 0.94);
backdrop-filter: blur(8px);
border-radius: 10px;
border: 1px solid rgba(164, 151, 132, 0.22);
box-shadow: 0 14px 30px rgba(129, 116, 96, 0.14);
padding: 14px;
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
z-index: 1;
}
#form-manifesto * {
box-sizing: border-box;
}
.form-manifesto-group {
display: flex;
gap: 10px;
margin-bottom: 12px;
min-width: 0;
}
.form-manifesto-group.column {
flex-direction: column;
}
#form-manifesto textarea {
min-width: 0;
}
#form-manifesto input,
#form-manifesto textarea {
padding: 8px 10px;
font-size: 14px;
border-radius: 6px;
border: 1px solid rgba(164, 151, 132, 0.22);
background: rgba(255, 252, 247, 0.96);
transition: border-color .15s, box-shadow .15s;
}
#form-manifesto textarea {
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
/* focus */
#form-manifesto input:focus,
#form-manifesto textarea:focus {
outline: none;
border-color: #4c6ef5;
box-shadow: 0 0 0 2px rgba(76,110,245,.2);
}
#form-manifesto button {
white-space: nowrap;
padding: 8px 14px;
font-size: 13px;
border-radius: 6px;
border: none;
cursor: pointer;
transition: background .15s, transform .05s;
}
#form-manifesto button:active {
transform: translateY(1px);
}
/* primary */
#form-manifesto button.primary {
background: #4c6ef5;
color: #fff;
}
#form-manifesto button.primary:hover {
background: #3b5bdb;
}
/* secondary */
#form-manifesto button.secondary {
background: #e9ecef;
color: #333;
}
#form-manifesto button.secondary:hover {
background: #dee2e6;
}
/* Text area actions */
#form-manifesto .actions {
display: flex;
justify-content: flex-end;
}
.form-manifesto-label {
font-size: 13px;
color: #605242;
white-space: nowrap;
align-self: center;
}
#form-manifesto select {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 13px;
border-radius: 6px;
border: 1px solid rgba(164, 151, 132, 0.22);
background-color: rgba(255, 252, 247, 0.96);
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #666 50%),
linear-gradient(135deg, #666 50%, transparent 50%);
background-position:
calc(100% - 16px) 50%,
calc(100% - 11px) 50%;
background-size: 5px 5px;
background-repeat: no-repeat;
transition: border-color .15s, box-shadow .15s;
}
#form-manifesto select:focus {
outline: none;
border-color: #4c6ef5;
box-shadow: 0 0 0 2px rgba(76,110,245,.2);
}
.form-manifesto-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.form-manifesto-header .title {
font-size: 13px;
font-weight: 600;
color: #25211c;
}
.form-manifesto-header .tools {
display: flex;
gap: 6px;
}
.form-manifesto-header button {
background: transparent;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px 6px;
border-radius: 4px;
}
.form-manifesto-header button:hover {
background: rgba(96, 82, 66, .08);
}
#form-manifesto.collapsed .form-manifesto-group {
display: none;
}
#form-manifesto.collapsed {
padding-bottom: 8px;
}
#form-manifesto[data-viewer-theme="dark"] {
background: rgba(30, 32, 36, 0.95);
color: #eee;
}
#form-manifesto[data-viewer-theme="dark"] .form-manifesto-header .title {
color: #eee;
}
#form-manifesto[data-viewer-theme="dark"] .form-manifesto-label {
color: #cfd3d9;
}
/* inputs */
#form-manifesto[data-viewer-theme="dark"] input,
#form-manifesto[data-viewer-theme="dark"] textarea,
#form-manifesto[data-viewer-theme="dark"] select {
background: #1e2024;
color: #eee;
border-color: #444;
}
#form-manifesto select {
flex: 1;
min-width: 0;
padding: 8px 36px 8px 10px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #cfd3d9;
background-color: #fff;
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #666 50%),
linear-gradient(135deg, #666 50%, transparent 50%);
background-position:
calc(100% - 20px) 50%,
calc(100% - 14px) 50%;
background-size: 7px 7px;
background-repeat: no-repeat;
}
#form-manifesto[data-viewer-theme="dark"] select {
appearance: auto;
background-image: none;
}
#form-manifesto[data-viewer-theme="dark"] input::placeholder,
#form-manifesto[data-viewer-theme="dark"] textarea::placeholder {
color: #888;
}
/* buttons */
#form-manifesto[data-viewer-theme="dark"] button.primary {
background: #5c7cfa;
}
#form-manifesto[data-viewer-theme="dark"] button.secondary {
background: #343a40;
color: #eee;
}
#form-manifesto[data-viewer-theme="dark"] .form-manifesto-header button:hover {
background: rgba(255,255,255,.12);
}
#example-model-picker {
display: none;
align-items: center;
gap: 12px;
margin: 5px 0px;
padding: 4px 10px;
border: 1px solid rgba(164, 151, 132, 0.24);
border-radius: 10px;
background: rgba(250, 247, 242, 0.94);
box-shadow: 0 12px 24px rgba(129, 116, 96, 0.12);
font:
12px/1.2 "Segoe UI", "Helvetica Neue", Arial, sans-serif;
color: #111827;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
color 0.2s ease;
}
#example-model-picker label {
font-weight: 600;
color: #42382d;
}
#example-model-picker .example-model-picker-spacer {
flex: 1 1 auto;
}
#example-model-picker select {
min-width: 220px;
padding: 8px 10px;
border: 1px solid rgba(164, 151, 132, 0.24);
border-radius: 8px;
background: rgba(255, 252, 247, 0.96);
color: #25211c;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
#example-theme-toggle {
width: 30px;
height: 30px;
border: 1px solid rgba(164, 151, 132, 0.24);
border-radius: 999px;
background: rgba(255, 252, 247, 0.96);
color: #25211c;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
display: none;
}
#example-theme-toggle:hover {
transform: translateY(-1px);
}
body[data-viewer-theme="dark"] #example-model-picker {
border-color: #444;
background: rgba(30, 32, 36, 0.95);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
color: #eee;
}
body[data-viewer-theme="dark"] #example-model-picker label {
color: #eee;
}
body[data-viewer-theme="dark"] #example-model-picker select {
border-color: #444;
background: #1e2024;
color: #eee;
}
body[data-viewer-theme="dark"] #example-theme-toggle {
border-color: #444;
background: #1e2024;
color: #eee;
}

1651
viewer/css/main.css Executable file

File diff suppressed because it is too large Load diff

959
viewer/css/spinner.css Executable file
View file

@ -0,0 +1,959 @@
.lv-mid {
margin-left: auto;
margin-right: auto;
}
.lv-left {
margin-right: auto;
margin-left: 0;
}
.lv-right {
margin-left: auto;
margin-right: 0;
}
.lvt-1 {
margin-top: 10px;
}
.lvt-2 {
margin-top: 30px;
}
.lvt-3 {
margin-top: 50px;
}
.lvt-4 {
margin-top: 80px;
}
.lvt-5 {
margin-top: 100px;
}
.lvb-1 {
margin-bottom: 10px;
}
.lvb-2 {
margin-bottom: 30px;
}
.lvb-3 {
margin-bottom: 50px;
}
.lvb-4 {
margin-bottom: 80px;
}
.lvb-5 {
margin-bottom: 100px;
}
.lvl-1 {
margin-left: 10px;
}
.lvl-2 {
margin-left: 30px;
}
.lvl-3 {
margin-left: 50px;
}
.lvl-4 {
margin-left: 80px;
}
.lvl-5 {
margin-left: 100px;
}
.lvr-1 {
margin-right: 10px;
}
.lvr-2 {
margin-right: 30px;
}
.lvr-3 {
margin-right: 50px;
}
.lvr-4 {
margin-right: 80px;
}
.lvr-5 {
margin-right: 100px;
}
.lv-bars,
.lv-circles,
.lv-dots,
.lv-squares,
.lv-determinate_circle,
.lv-spinner,
.lv-dashed {
height: 100%;
width: 100%;
}
.lv-bars.tiniest,
.lv-circles.tiniest,
.lv-dots.tiniest,
.lv-squares.tiniest,
.lv-determinate_circle.tiniest,
.lv-spinner.tiniest,
.lv-dashed.tiniest {
height: 20px;
width: 20px;
}
.lv-bars.tiny,
.lv-circles.tiny,
.lv-dots.tiny,
.lv-squares.tiny,
.lv-determinate_circle.tiny,
.lv-spinner.tiny,
.lv-dashed.tiny {
height: 30px;
width: 30px;
}
.lv-bars.sm,
.lv-circles.sm,
.lv-dots.sm,
.lv-squares.sm,
.lv-determinate_circle.sm,
.lv-spinner.sm,
.lv-dashed.sm {
height: 50px;
width: 50px;
}
.lv-bars.md,
.lv-circles.md,
.lv-dots.md,
.lv-squares.md,
.lv-determinate_circle.md,
.lv-spinner.md,
.lv-dashed.md {
height: 100px;
width: 100px;
}
.lv-bars.lg,
.lv-circles.lg,
.lv-dots.lg,
.lv-squares.lg,
.lv-determinate_circle.lg,
.lv-spinner.lg,
.lv-dashed.lg {
height: 200px;
width: 200px;
}
.lv-bars[data-label].tiny:after,
.lv-circles[data-label].tiny:after,
.lv-dots[data-label].tiny:after,
.lv-squares[data-label].tiny:after,
.lv-determinate_circle[data-label].tiny:after,
.lv-spinner[data-label].tiny:after,
.lv-dashed[data-label].tiny:after {
padding: 0 120%;
margin-top: 20%;
}
.lv-bars[data-label].sm:after,
.lv-circles[data-label].sm:after,
.lv-dots[data-label].sm:after,
.lv-squares[data-label].sm:after,
.lv-determinate_circle[data-label].sm:after,
.lv-spinner[data-label].sm:after,
.lv-dashed[data-label].sm:after {
padding: 0 120%;
margin-top: 35%;
}
.lv-bordered_line,
.lv-determinate_bordered_line {
width: 100%;
height: 21px;
border-radius: 10px;
box-sizing: border-box;
}
.lv-line,
.lv-determinate_line {
height: 5px;
width: 100%;
background-color: darkgray;
}
.lv-bars,
.lv-circles,
.lv-determinate_line,
.lv-bordered_line,
.lv-determinate_bordered_line,
.lv-dots,
.lv-squares,
.lv-line,
.lv-spinner,
.lv-determinate_circle,
.lv-dashed {
position: relative;
}
.lv-bars div,
.lv-circles div,
.lv-determinate_line div,
.lv-bordered_line div,
.lv-determinate_bordered_line div,
.lv-dots div,
.lv-squares div,
.lv-line div,
.lv-spinner div,
.lv-determinate_circle div,
.lv-dashed div {
position: absolute;
}
.lv-determinate_bordered_line[data-percentage="true"] div:nth-child(2),
.lv-determinate_line[data-percentage="true"] div:nth-child(2) {
visibility: visible;
}
.lv-line.sm,
.lv-determinate_line.sm,
.lv-determinate_bordered_line.sm,
.lv-bordered_line.sm {
width: 300px;
}
.lv-line.md,
.lv-determinate_line.md,
.lv-determinate_bordered_line.md,
.lv-bordered_line.md {
width: 600px;
}
.lv-line.lg,
.lv-determinate_line.lg,
.lv-determinate_bordered_line.lg,
.lv-bordered_line.lg {
width: 1000px;
}
.lv-line[data-label]:after,
.lv-determinate_line[data-label]:after,
.lv-determinate_bordered_line[data-label]:after,
.lv-bordered_line[data-label]:after {
content: attr(data-label);
display: block;
padding-top: 20px;
overflow: hidden;
}
*[data-label] {
text-align: center;
}
.lv-spinner[data-label]:after,
.lv-circles[data-label]:after,
.lv-determinate_circle[data-label]:after,
.lv-dashed[data-label]:after {
content: attr(data-label);
display: inline-block;
padding: 40% 0 40% 0;
overflow: hidden;
}
.lv-bars[data-label]:after,
.lv-squares[data-label]:after,
.lv-dots[data-label]:after {
content: attr(data-label);
display: inline-block;
overflow: hidden;
}
.lv-squares[data-label]:after,
.lv-bars[data-label]:after {
margin-top: 100%;
}
.lv-determinate_line div:nth-child(1) {
background-color: #343a40;
height: 100%;
width: 0;
}
.lv-determinate_line div:nth-child(2) {
color: #343a40;
left: 101%;
top: -6px;
visibility: hidden;
}
.lv-determinate_line[data-label]:after {
color: #343a40;
}
.lv-spinner[data-label]:after {
color: #343a40;
}
.lv-spinner div {
height: inherit;
width: inherit;
box-sizing: border-box;
border: 10px solid darkgrey;
border-top: 10px solid #343a40;
border-radius: 50%;
animation: lv-spinner 2s ease-in-out infinite;
}
.lv-determinate_circle {
height: 100%;
width: 100%;
}
.lv-determinate_circle[data-label]:after {
color: #343a40;
}
.lv-determinate_circle div:nth-child(1) {
height: inherit;
width: inherit;
box-sizing: border-box;
transform: rotate(-45deg);
border: 10px solid darkgrey;
border-radius: 50%;
}
.lv-determinate_circle div:nth-child(3) {
height: inherit;
width: inherit;
box-sizing: border-box;
transform: rotate(-45deg);
border: 10px solid transparent;
border-top: 10px solid #343a40;
border-radius: 50%;
}
.lv-determinate_circle div:nth-child(2) {
height: inherit;
width: inherit;
box-sizing: border-box;
transform: rotate(-45deg);
border: 10px solid transparent;
border-top: 10px solid darkgrey;
border-radius: 50%;
z-index: 10;
}
.lv-determinate_circle div:nth-child(4) {
visibility: hidden;
}
.lv-determinate_circle[data-percentage="true"] div:nth-child(4) {
visibility: visible;
height: inherit;
width: inherit;
box-sizing: border-box;
text-align: center;
margin-top: 20%;
color: #343a40;
}
.lv-determinate_circle[data-percentage="true"].sm div:nth-child(4) {
margin-top: 15px;
}
.lv-determinate_circle[data-percentage="true"].tiny div:nth-child(4) {
visibility: hidden;
}
.lv-dashed[data-label]:after {
color: #138d75;
}
.lv-dashed div {
border: 12px dashed #138d75;
height: inherit;
width: inherit;
box-sizing: border-box;
animation: lv-dashed_animation 3s ease-in-out infinite;
}
/* BORDERLESS LINE ANIMATED */
.lv-line[data-label]:after {
color: #343a40;
}
.lv-line div {
background-color: #343a40;
height: 100%;
width: 0;
animation: lv-line_animation 3s ease-in-out infinite;
}
/* DETERMINATE LINE WITH BORDER */
.lv-determinate_bordered_line {
border: 5px #067861 solid;
}
.lv-determinate_bordered_line[data-label]:after {
color: #138d75;
}
.lv-determinate_bordered_line div:nth-child(1) {
height: 11px;
width: 0;
background-color: #138d75;
border-radius: 3px;
}
.lv-determinate_bordered_line div:nth-child(2) {
color: #138d75;
left: 103%;
top: -3px;
visibility: hidden;
}
/* LINE */
.lv-bordered_line {
border: 5px solid #138d75;
}
.lv-bordered_line[data-label]:after {
color: #138d75;
}
.lv-bordered_line div {
height: 5px;
background-color: #138d75;
left: 2px;
top: 3px;
border-radius: 3px;
animation: lv-bordered_line_animation 2s linear infinite;
}
/* BARS */
.lv-bars[data-label]:after {
color: #0b5345;
}
.lv-bars div {
width: 5%;
height: 40%;
top: 30%;
animation: lv-bar_animation 1s ease-in-out infinite;
}
.lv-bars div:nth-child(1) {
left: 12.5%;
background: #2de3c0;
animation-delay: -0.7s;
}
.lv-bars div:nth-child(2) {
left: 22.5%;
background: #1ddab5;
animation-delay: -0.6s;
}
.lv-bars div:nth-child(3) {
left: 32.5%;
background: #1ac4a3;
animation-delay: -0.5s;
}
.lv-bars div:nth-child(4) {
left: 42.5%;
background: #17ad90;
animation-delay: -0.4s;
}
.lv-bars div:nth-child(5) {
left: 52.5%;
background: #14977d;
animation-delay: -0.3s;
}
.lv-bars div:nth-child(6) {
left: 62.5%;
background: #11806a;
animation-delay: -0.2s;
}
.lv-bars div:nth-child(7) {
left: 72.5%;
background: #0e6a58;
animation-delay: -0.1s;
}
.lv-bars div:nth-child(8) {
left: 82.5%;
background: #0b5345;
}
/* PULSATING DOTS */
.lv-dots[data-label]:after {
margin-top: 65%;
color: #0b5345;
}
.lv-dots div {
width: 19%;
height: 19%;
top: 43.75%;
border-radius: 50%;
transform: scale(0.01);
animation: lv-dots_pulsate_animation 1s ease-in-out infinite;
}
.lv-dots div:nth-child(1) {
left: 10%;
background-color: #1ddab5;
}
.lv-dots div:nth-child(2) {
left: 32.5%;
background-color: #17ad90;
animation-delay: 0.1s;
}
.lv-dots div:nth-child(3) {
left: 55%;
background-color: #11806a;
animation-delay: 0.2s;
}
.lv-dots div:nth-child(4) {
left: 77.5%;
background-color: #0b5345;
animation-delay: 0.3s;
}
/* CIRCLES */
.lv-circles[data-label]:after {
color: #138d75;
}
.lv-circles.tiniest div:before {
width: 3px;
height: 3px;
}
.lv-circles.tiny div:before {
width: 5px;
height: 5px;
}
.lv-circles.sm div:before {
width: 8px;
height: 8px;
}
.lv-circles.md div:before {
width: 15px;
height: 15px;
}
.lv-circles.lg div:before {
width: 30px;
height: 30px;
}
.lv-circles div {
width: 100%;
height: 100%;
}
.lv-circles div:before {
content: "";
display: block;
margin: 0 auto;
border-radius: 50%;
background-color: #138d75;
}
.lv-circles div:nth-child(1) {
animation: lv-circles_move_1 1.2s infinite linear;
}
.lv-circles div:nth-child(2) {
transform: rotate(30deg);
opacity: 0.08;
animation: lv-circles_move_2 1.2s infinite linear;
}
.lv-circles div:nth-child(3) {
transform: rotate(60deg);
opacity: 0.16;
animation: lv-circles_move_3 1.2s infinite linear;
}
.lv-circles div:nth-child(4) {
transform: rotate(90deg);
opacity: 0.24;
animation: lv-circles_move_4 1.2s infinite linear;
}
.lv-circles div:nth-child(5) {
transform: rotate(120deg);
opacity: 0.32;
animation: lv-circles_move_5 1.2s infinite linear;
}
.lv-circles div:nth-child(6) {
transform: rotate(150deg);
opacity: 0.4;
animation: lv-circles_move_6 1.2s infinite linear;
}
.lv-circles div:nth-child(7) {
transform: rotate(180deg);
opacity: 0.48;
animation: lv-circles_move_7 1.2s infinite linear;
}
.lv-circles div:nth-child(8) {
transform: rotate(210deg);
opacity: 0.56;
animation: lv-circles_move_8 1.2s infinite linear;
}
.lv-circles div:nth-child(9) {
transform: rotate(240deg);
opacity: 0.64;
animation: lv-circles_move_9 1.2s infinite linear;
}
.lv-circles div:nth-child(10) {
transform: rotate(270deg);
opacity: 0.72;
animation: lv-circles_move_10 1.2s infinite linear;
}
.lv-circles div:nth-child(11) {
transform: rotate(300deg);
opacity: 0.8;
animation: lv-circles_move_11 1.2s infinite linear;
}
.lv-circles div:nth-child(12) {
transform: rotate(330deg);
opacity: 0.88;
animation: lv-circles_move_12 1.2s infinite linear;
}
/* SQUARES */
.lv-squares[data-label]:after {
color: #0b5345;
}
.lv-squares div {
width: 40%;
height: 40%;
border-radius: 10%;
/* top left corner */
/* top right corner */
/* bottom right corner */
/* bottom left corner */
}
.lv-squares div:nth-child(1) {
background-color: #1ddab5;
top: 7%;
left: 7%;
animation: lv-square1_move 2s ease-in-out infinite;
}
.lv-squares div:nth-child(3) {
background-color: #17ad90;
top: 7%;
right: 7%;
animation: lv-square2_move 2s ease-in-out infinite;
}
.lv-squares div:nth-child(2) {
background-color: #11806a;
bottom: 7%;
right: 7%;
animation: lv-square3_move 2s ease-in-out infinite;
}
.lv-squares div:nth-child(4) {
background-color: #0b5345;
bottom: 7%;
left: 7%;
animation: lv-square4_move 2s ease-in-out infinite;
}
/* animations */
@keyframes lv-spinner {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes lv-circles_move_1 {
0% {
opacity: 0;
}
0% {
opacity: 0;
}
8.3333333333% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes lv-circles_move_2 {
0% {
opacity: 0.0833333333;
}
8.3333333333% {
opacity: 0;
}
16.6666666667% {
opacity: 1;
}
100% {
opacity: 0.0833333333;
}
}
@keyframes lv-circles_move_3 {
0% {
opacity: 0.1666666667;
}
16.6666666667% {
opacity: 0;
}
25% {
opacity: 1;
}
100% {
opacity: 0.1666666667;
}
}
@keyframes lv-circles_move_4 {
0% {
opacity: 0.25;
}
25% {
opacity: 0;
}
33.3333333333% {
opacity: 1;
}
100% {
opacity: 0.25;
}
}
@keyframes lv-circles_move_5 {
0% {
opacity: 0.3333333333;
}
33.3333333333% {
opacity: 0;
}
41.6666666667% {
opacity: 1;
}
100% {
opacity: 0.3333333333;
}
}
@keyframes lv-circles_move_6 {
0% {
opacity: 0.4166666667;
}
41.6666666667% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4166666667;
}
}
@keyframes lv-circles_move_7 {
0% {
opacity: 0.5;
}
50% {
opacity: 0;
}
58.3333333333% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
@keyframes lv-circles_move_8 {
0% {
opacity: 0.5833333333;
}
58.3333333333% {
opacity: 0;
}
66.6666666667% {
opacity: 1;
}
100% {
opacity: 0.5833333333;
}
}
@keyframes lv-circles_move_9 {
0% {
opacity: 0.6666666667;
}
66.6666666667% {
opacity: 0;
}
75% {
opacity: 1;
}
100% {
opacity: 0.6666666667;
}
}
@keyframes lv-circles_move_10 {
0% {
opacity: 0.75;
}
75% {
opacity: 0;
}
83.3333333333% {
opacity: 1;
}
100% {
opacity: 0.75;
}
}
@keyframes lv-circles_move_11 {
0% {
opacity: 0.8333333333;
}
83.3333333333% {
opacity: 0;
}
91.6666666667% {
opacity: 1;
}
100% {
opacity: 0.8333333333;
}
}
@keyframes lv-circles_move_12 {
0% {
opacity: 0.9166666667;
}
91.6666666667% {
opacity: 0;
}
100% {
opacity: 1;
}
100% {
opacity: 0.9166666667;
}
}
@keyframes lv-square1_move {
0%,
100% {
transform: translate(0, 0);
}
25% {
transform: translate(116%, 0);
}
50% {
transform: translate(116%, 116%);
}
75% {
transform: translate(0, 116%);
}
}
@keyframes lv-square2_move {
0%,
100% {
transform: translate(0, 0);
}
25% {
transform: translate(0, 116%);
}
50% {
transform: translate(-116%, 116%);
}
75% {
transform: translate(-116%, 0);
}
}
@keyframes lv-square3_move {
0%,
100% {
transform: translate(0, 0);
}
25% {
transform: translate(-116%, 0);
}
50% {
transform: translate(-116%, -116%);
}
75% {
transform: translate(0, -116%);
}
}
@keyframes lv-square4_move {
0%,
100% {
transform: translate(0, 0);
}
25% {
transform: translate(0, -116%);
}
50% {
transform: translate(116%, -116%);
}
75% {
transform: translate(116%, 0);
}
}
@keyframes lv-dots_pulsate_animation {
0% {
transform: scale(0.01);
background-color: #1ddab5;
}
50% {
transform: scale(1);
background-color: #0b5345;
}
100% {
transform: scale(0.01);
background-color: #1ddab5;
}
}
@keyframes lv-line_animation {
0% {
left: 0;
width: 0;
}
25% {
left: 0;
width: 100%;
}
50% {
left: 100%;
width: 0;
}
75% {
left: 0;
width: 100%;
}
100% {
left: 0;
width: 0;
}
}
@keyframes lv-bordered_line_animation {
0% {
left: 1%;
width: 0;
}
10% {
left: 1%;
width: 20%;
}
90% {
left: 79%;
width: 20%;
}
100% {
width: 0;
left: 99%;
}
}
@keyframes lv-bar_animation {
0%,
100% {
top: 37.5%;
height: 25%;
bottom: 37.5%;
width: 2.5%;
}
50% {
top: 12.5%;
height: 75%;
bottom: 12.5%;
width: 5%;
}
}
@keyframes lv-dashed_animation {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(90deg);
}
50% {
transform: rotate(180deg);
}
75% {
transform: rotate(270deg);
}
100% {
transform: rotate(360deg);
}
}
/*# sourceMappingURL=main.css.map */

131
viewer/css/theme.css Normal file
View file

@ -0,0 +1,131 @@
.flex-container {
display: flex;
gap: 0.5rem;
}
.half-width {
min-width: 0;
flex: 0 0 auto;
max-width: 100%;
width: 200px;
}
.scale-fields-wrapper {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
background-color: #f9f9f9;
margin-top: 1rem;
max-width: 100%;
width: 500px;
}
.gallery-container {
display: block;
gap: 0.5rem;
}
.gallery-fields-wrapper {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
background-color: #f9f9f9;
margin-top: 1rem;
max-width: 100%;
width: 500px;
}
#ultra-loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 99999;
}
#ultra-loader-bar {
width: 0%;
height: 100%;
background: linear-gradient(90deg,#4facfe,#00f2fe);
box-shadow: 0 0 8px rgba(0,150,255,0.6);
transition: width .35s ease;
}
#ultra-loader-panel {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(17, 24, 39, 0.92);
color: rgba(255, 255, 255, 0.94);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.28);
font-size: 14px;
opacity: 0;
transition: opacity 2.8s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 99999;
pointer-events: none;
}
body[data-viewer-theme="light"] #ultra-loader-panel {
background: rgba(250, 247, 242, 0.97);
color: rgba(37, 33, 28, 0.92);
border-color: rgba(164, 151, 132, 0.24);
box-shadow: 0 14px 28px rgba(129, 116, 96, 0.14);
}
#ultra-loader-panel.show {
opacity: 1;
z-index: 99999;
}
#ultra-loader-header {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.96);
font-size: 13px;
font-weight: 700;
text-align: center;
}
body[data-viewer-theme="light"] #ultra-loader-header {
border-bottom-color: rgba(164, 151, 132, 0.24);
color: rgba(37, 33, 28, 0.94);
}
.ultra-step {
margin: 4px 0;
}
.ultra-step.done {
color: #3ba55d;
}
.ultra-step.active {
color: #007bff;
}
.ultra-step.pending {
color: #999;
}
.ultra-step.error {
color: #d93025;
}
#ultra-loader-error {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
font-size: 12px;
color: #d93025;
font-weight: 600;
}
body[data-viewer-theme="light"] #ultra-loader-error {
border-top-color: rgba(164, 151, 132, 0.24);
}

5
viewer/css/viewer.css Normal file
View file

@ -0,0 +1,5 @@
@import "./main.css";
@import "./embed-configurator.css";
@import "./spinner.css";
@import "./theme.css";
@import "./external-sources.css";

View file

@ -0,0 +1,8 @@
{
"folders": [
{
"path": ".."
}
],
"settings": {}
}

1243
viewer/editor-toolbar.js Normal file

File diff suppressed because it is too large Load diff

1144
viewer/editor/annotations.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,546 @@
import { core } from "../core.js";
import { showToast, toastHelper } from "../viewer-utils.js";
import { t } from "../i18n-utils.js";
import THREE from "../init.js";
export function attachMaterialsEditor(Viewer) {
Object.assign(Viewer, {
openMaterialsFolder(materialUuid = null) {
this.buildMaterialsDialog();
if (!this.materialsDialog) return;
if (!this.materialsEditorObject || !this.materialsList?.length) {
showToast(t("gui.selectByMaterial", "select by material"), "warning");
return;
}
this.syncMaterialsDialogSelectionOptions();
const nextUuid =
materialUuid && materialUuid !== ""
? materialUuid
: this.selectedMaterialUuid || this.materialsList[0]?.uuid || "";
if (nextUuid) {
this.selectMaterialInEditor(this.materialsEditorObject, nextUuid);
}
this.updateMaterialsDialogBounds();
this.materialsDialog.hidden = false;
this.syncMaterialsDialogFields();
this.closeActionMenu();
this.materialsDialogSelect?.focus();
},
destroyMaterialGuiControls() {
if (this.materialGuiControls) {
Object.values(this.materialGuiControls).forEach((controller) => {
if (controller?.destroy) {
controller.destroy();
}
});
this.materialGuiControls = null;
}
},
destroyMaterialSelectionController() {
this.materialsEditorObject = null;
this.selectedMaterialUuid = null;
this.materialsList = [];
if (this.materialsDialogSelect) {
this.materialsDialogSelect.innerHTML = "";
}
this.syncMaterialsDialogFields();
},
getMaterialByUuid(object, uuid) {
if (!object || !uuid) return null;
let found = null;
object.traverse((child) => {
if (
child.isMesh &&
child.material
) {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((material) => {
if (material?.isMaterial && material.uuid === uuid) {
found = material;
}
});
}
});
return found;
},
normalizeMaterialColorValue(value, fallback = "#ffffff") {
if (!value) return fallback;
if (typeof value === "string") {
if (value.startsWith("0x")) {
return `#${value.slice(2).padStart(6, "0")}`;
}
return value.startsWith("#") ? value : `#${value}`;
}
if (typeof value.getHexString === "function") {
return `#${value.getHexString()}`;
}
return fallback;
},
syncMaterialsDialogSelectionOptions() {
if (!this.materialsDialogSelect) return;
const currentValue = this.selectedMaterialUuid || "";
this.materialsDialogSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = t("gui.selectByMaterial", "select by material");
this.materialsDialogSelect.appendChild(placeholder);
this.materialsList.forEach((item) => {
const option = document.createElement("option");
option.value = item.uuid;
option.textContent = item.label;
this.materialsDialogSelect.appendChild(option);
});
this.materialsDialogSelect.value = currentValue;
},
updateMaterialsDialogLabels() {
if (!this.materialsDialog) return;
const title = this.materialsDialog.querySelector("#materialsDialogTitle");
const closeButton = this.materialsDialog.querySelector(".materials-dialog__close");
const fieldLabels = Array.from(
this.materialsDialog.querySelectorAll(".materials-dialog__field > span")
);
const hint = this.materialsDialogInputs?.hint;
const emptyState = this.materialsDialogInputs?.emptyState;
if (title) {
title.textContent = t("gui.materials", "Materials");
}
if (closeButton) {
closeButton.setAttribute("aria-label", t("gui.materials", "Materials"));
}
if (fieldLabels.length > 0) {
fieldLabels[0].textContent = t("gui.editMaterial", "Edit material");
}
if (fieldLabels.length > 1) {
fieldLabels[1].textContent = t("gui.color", "Color");
}
if (fieldLabels.length > 2) {
fieldLabels[2].textContent = t("gui.emissive", "Emissive");
}
if (fieldLabels.length > 3) {
fieldLabels[3].textContent = t("gui.intensity", "Intensity");
}
if (fieldLabels.length > 4) {
fieldLabels[4].textContent = t("gui.metalness", "Metalness");
}
if (this.materialsDialogSelect) {
const placeholderOption = this.materialsDialogSelect.querySelector('option[value=""]');
if (placeholderOption) {
placeholderOption.textContent = t("gui.selectByMaterial", "select by material");
}
}
if (hint) {
hint.textContent = t("gui.selectByMaterial", "select by material");
}
if (emptyState) {
emptyState.textContent = t("gui.selectByMaterial", "select by material");
}
},
syncMaterialsDialogFields() {
if (!this.materialsDialogInputs) return;
const material = this.materialsEditorObject && this.selectedMaterialUuid
? this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid)
: null;
const hasMaterial = Boolean(material);
const {
color,
emissiveColor,
emissive,
metalness,
emptyState,
hint,
} = this.materialsDialogInputs;
if (this.materialsDialogSelect) {
this.materialsDialogSelect.disabled = this.materialsList.length === 0;
this.materialsDialogSelect.value = this.selectedMaterialUuid || "";
}
[color, emissiveColor, emissive, metalness].forEach((input) => {
if (input) input.disabled = !hasMaterial;
});
if (!hasMaterial) {
if (color) color.value = "#ffffff";
if (emissiveColor) emissiveColor.value = "#000000";
if (emissive) emissive.value = "0";
if (metalness) metalness.value = "0";
if (emptyState) emptyState.hidden = this.materialsList.length > 0;
if (hint) hint.textContent = t("gui.selectByMaterial", "select by material");
return;
}
if (color) color.value = this.normalizeMaterialColorValue(material.color, "#ffffff");
if (emissiveColor) emissiveColor.value = this.normalizeMaterialColorValue(material.emissive, "#000000");
if (emissive) emissive.value = String(material.emissiveIntensity ?? 0);
if (metalness) metalness.value = String(material.metalness ?? 0);
if (emptyState) emptyState.hidden = true;
if (hint) {
hint.textContent =
this.materialsList.find((item) => item.uuid === material.uuid)?.label || material.uuid;
}
},
selectMaterialInEditor(object, value) {
if (!object) return;
if (!value) {
this.destroyMaterialGuiControls();
this.selectedMaterialUuid = null;
this.refreshMaterialsToolbarMenu();
this.syncMaterialsDialogFields();
return;
}
this.destroyMaterialGuiControls();
const material = this.getMaterialByUuid(object, value);
if (!material) {
this.selectedMaterialUuid = null;
this.refreshMaterialsToolbarMenu();
this.syncMaterialsDialogFields();
return;
}
core.materialProperties.color = this.normalizeMaterialColorValue(material.color, "#ffffff");
core.materialProperties.emissiveColor = this.normalizeMaterialColorValue(material.emissive, "#000000");
core.materialProperties.emissive = material.emissiveIntensity ?? 0;
core.materialProperties.metalness = material.metalness ?? 0;
this.selectedMaterialUuid = value;
this.syncMaterialsDialogSelectionOptions();
this.refreshMaterialsToolbarMenu();
this.syncMaterialsDialogFields();
},
initializeMaterialsEditor(object) {
if (!object) return;
this.destroyMaterialGuiControls();
this.destroyMaterialSelectionController();
const materials = new Map();
const registerMaterial = (material) => {
if (!material || !material.isMaterial || !material.uuid) return;
if (!materials.has(material.uuid)) {
materials.set(material.uuid, material);
}
};
const gatherMaterials = (mesh) => {
if (!mesh.material) return;
if (Array.isArray(mesh.material)) {
mesh.material.forEach(registerMaterial);
} else {
registerMaterial(mesh.material);
}
};
if (object.isMesh) {
gatherMaterials(object);
}
object.traverse((child) => {
if (child.isMesh) {
gatherMaterials(child);
}
});
const options = {
[t("gui.selectByMaterial", "select by material")]: "",
};
this.materialsList = [];
materials.forEach((material, uuid) => {
const label = material.name?.trim() ? material.name : uuid;
options[label] = uuid;
this.materialsList.push({ uuid, label });
});
core.materialsPropertiesText["Edit material"] = "";
this.materialsEditorObject = object;
this.refreshMaterialsToolbarMenu();
this.syncMaterialsDialogSelectionOptions();
this.syncMaterialsDialogFields();
},
refreshMaterialsToolbarMenu() {
if (!this.materialsSubmenu) return;
this.materialsSubmenu.innerHTML = "";
const list = this.materialsList?.length ? this.materialsList : [];
if (!list.length) {
const emptyButton = document.createElement("button");
emptyButton.type = "button";
emptyButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-tool_submenu-control viewer-editor-tool_submenu-materials";
emptyButton.disabled = true;
const label = t("gui.selectByMaterial", "Select by material");
const text = document.createElement("span");
text.textContent = label;
emptyButton.appendChild(text);
this.materialsSubmenu.appendChild(emptyButton);
return;
}
list.forEach((item) => {
const subButton = document.createElement("button");
subButton.type = "button";
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-tool_submenu-control viewer-editor-tool_submenu-materials";
if (item.uuid === this.selectedMaterialUuid) {
subButton.classList.add("is-active");
}
subButton.dataset.materialUuid = item.uuid;
subButton.setAttribute("title", item.label);
subButton.setAttribute("aria-label", item.label);
const iconSpan = document.createElement("span");
iconSpan.className = "viewer-editor-tool_icon";
iconSpan.setAttribute("aria-hidden", "true");
iconSpan.innerHTML = this.getEditorToolbarIcon("color");
subButton.appendChild(iconSpan);
const labelSpan = document.createElement("span");
labelSpan.className = "viewer-editor-tool_label";
labelSpan.textContent = item.label;
subButton.appendChild(labelSpan);
this.bindEventListener(subButton, "click", (event) => {
event.stopPropagation();
this.openMaterialsFolder(item.uuid);
});
this.materialsSubmenu.appendChild(subButton);
});
},
buildMaterialsDialog() {
if (!core.container || this.materialsDialog) return;
const dialog = document.createElement("div");
dialog.id = "materialsDialog";
dialog.className = "materials-dialog";
dialog.hidden = true;
dialog.innerHTML = `
<div class="materials-dialog__backdrop" data-materials-dismiss="true"></div>
<div class="materials-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="materialsDialogTitle">
<div class="materials-dialog__header">
<h3 id="materialsDialogTitle">${t("gui.materials", "Materials")}</h3>
<button type="button" class="materials-dialog__close" data-materials-dismiss="true" aria-label="${t("gui.materials", "Materials")}">&times;</button>
</div>
<div class="materials-dialog__body">
<label class="materials-dialog__field">
<span>${t("gui.editMaterial", "Edit material")}</span>
<select id="materialsDialogSelect"></select>
</label>
<p id="materialsDialogHint" class="materials-dialog__hint">${t("gui.selectByMaterial", "select by material")}</p>
<p id="materialsDialogEmpty" class="materials-dialog__empty">${t("gui.selectByMaterial", "select by material")}</p>
<div class="materials-dialog__grid">
<label class="materials-dialog__field">
<span>${t("gui.color", "Color")}</span>
<input id="materialsDialogColor" type="color" />
</label>
<label class="materials-dialog__field">
<span>${t("gui.emissive", "Emissive")}</span>
<input id="materialsDialogEmissiveColor" type="color" />
</label>
<label class="materials-dialog__field">
<span>${t("gui.intensity", "Intensity")}</span>
<input id="materialsDialogEmissive" type="range" min="0" max="1" step="0.01" />
</label>
<label class="materials-dialog__field">
<span>${t("gui.metalness", "Metalness")}</span>
<input id="materialsDialogMetalness" type="range" min="0" max="1" step="0.01" />
</label>
</div>
</div>
</div>
`;
document.body.appendChild(dialog);
this.materialsDialog = dialog;
this.materialsDialogPosition = null;
this.materialsDialogSelect = dialog.querySelector("#materialsDialogSelect");
const panel = dialog.querySelector(".materials-dialog__panel");
const header = dialog.querySelector(".materials-dialog__header");
this.materialsDialogInputs = {
color: dialog.querySelector("#materialsDialogColor"),
emissiveColor: dialog.querySelector("#materialsDialogEmissiveColor"),
emissive: dialog.querySelector("#materialsDialogEmissive"),
metalness: dialog.querySelector("#materialsDialogMetalness"),
emptyState: dialog.querySelector("#materialsDialogEmpty"),
hint: dialog.querySelector("#materialsDialogHint"),
};
this.bindEventListener(dialog, "click", (event) => {
const dismissTrigger = event.target?.closest?.("[data-materials-dismiss='true']");
if (dismissTrigger) {
this.closeMaterialsDialog();
}
});
this.bindEventListener(document, "keydown", (event) => {
if (event.key !== "Escape") return;
if (!this.materialsDialog || this.materialsDialog.hidden) return;
event.preventDefault();
this.closeMaterialsDialog();
});
const handleMaterialSelect = (event) => {
this.selectMaterialInEditor(this.materialsEditorObject, event.target.value);
};
this.bindEventListener(this.materialsDialogSelect, "change", handleMaterialSelect);
this.bindEventListener(this.materialsDialogSelect, "input", handleMaterialSelect);
this.bindEventListener(this.materialsDialogInputs.color, "input", (event) => {
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
if (!material?.color) return;
const value = event.target.value;
core.materialProperties.color = value;
material.color = new THREE.Color(value);
});
this.bindEventListener(this.materialsDialogInputs.emissiveColor, "input", (event) => {
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
if (!material || material.emissive === undefined) return;
const value = event.target.value;
core.materialProperties.emissiveColor = value;
material.emissive = new THREE.Color(value);
});
this.bindEventListener(this.materialsDialogInputs.emissive, "input", (event) => {
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
if (!material) return;
const value = parseFloat(event.target.value);
core.materialProperties.emissive = value;
material.emissiveIntensity = value;
});
this.bindEventListener(this.materialsDialogInputs.metalness, "input", (event) => {
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
if (!material) return;
const value = parseFloat(event.target.value);
core.materialProperties.metalness = value;
material.metalness = value;
});
this.bindEventListener(header, "pointerdown", (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.(".materials-dialog__close")) return;
const targetRect =
Viewer.mainCanvas?.getBoundingClientRect?.() ||
core.container?.getBoundingClientRect?.();
const panelRect = panel?.getBoundingClientRect?.();
if (!targetRect || !panelRect) return;
this.materialsDialogDragging = {
offsetX: event.clientX - panelRect.left,
offsetY: event.clientY - panelRect.top,
};
panel.setPointerCapture?.(event.pointerId);
panel.classList.add("is-dragging");
event.preventDefault();
});
this.bindEventListener(document, "pointermove", (event) => {
if (!this.materialsDialogDragging || !this.materialsDialog || this.materialsDialog.hidden) return;
const targetRect =
Viewer.mainCanvas?.getBoundingClientRect?.() ||
core.container?.getBoundingClientRect?.();
const panelRect = panel?.getBoundingClientRect?.();
if (!targetRect || !panelRect) return;
const nextLeft = event.clientX - this.materialsDialogDragging.offsetX;
const nextTop = event.clientY - this.materialsDialogDragging.offsetY;
const minLeft = targetRect.left + 12;
const maxLeft = targetRect.right - panelRect.width - 12;
const minTop = targetRect.top + 12;
const maxTop = targetRect.bottom - panelRect.height - 12;
this.materialsDialogPosition = {
left: Math.min(Math.max(nextLeft, minLeft), Math.max(minLeft, maxLeft)),
top: Math.min(Math.max(nextTop, minTop), Math.max(minTop, maxTop)),
};
this.updateMaterialsDialogBounds();
});
const stopMaterialsDialogDrag = () => {
this.materialsDialogDragging = false;
panel?.classList.remove("is-dragging");
};
this.bindEventListener(document, "pointerup", stopMaterialsDialogDrag);
this.bindEventListener(document, "pointercancel", stopMaterialsDialogDrag);
this.bindEventListener(window, "resize", () => this.updateMaterialsDialogBounds());
this.bindEventListener(window, "scroll", () => this.updateMaterialsDialogBounds(), true);
this.bindEventListener(document, "fullscreenchange", () => this.updateMaterialsDialogBounds());
this.syncMaterialsDialogSelectionOptions();
this.syncMaterialsDialogFields();
},
updateMaterialsDialogBounds() {
if (!this.materialsDialog) return;
const targetRect =
Viewer.mainCanvas?.getBoundingClientRect?.() ||
core.container?.getBoundingClientRect?.();
if (!targetRect) return;
const left = Math.max(0, Math.round(targetRect.left));
const top = Math.max(0, Math.round(targetRect.top));
const width = Math.max(0, Math.round(targetRect.width));
const height = Math.max(0, Math.round(targetRect.height));
const panel = this.materialsDialog.querySelector(".materials-dialog__panel");
const panelWidth = panel?.offsetWidth || Math.min(360, width - 24);
const panelHeight = panel?.offsetHeight || Math.min(520, height - 24);
if (!this.materialsDialogPosition) {
this.materialsDialogPosition = {
left: Math.max(left + 12, left + width - panelWidth - 16),
top: Math.max(top + 16, top + Math.min(40, Math.max(16, height * 0.08))),
};
} else {
const minLeft = left + 12;
const maxLeft = left + width - panelWidth - 12;
const minTop = top + 12;
const maxTop = top + height - panelHeight - 12;
this.materialsDialogPosition = {
left: Math.min(Math.max(this.materialsDialogPosition.left, minLeft), Math.max(minLeft, maxLeft)),
top: Math.min(Math.max(this.materialsDialogPosition.top, minTop), Math.max(minTop, maxTop)),
};
}
this.materialsDialog.style.left = `${left}px`;
this.materialsDialog.style.top = `${top}px`;
this.materialsDialog.style.width = `${width}px`;
this.materialsDialog.style.height = `${height}px`;
if (panel) {
panel.style.left = `${this.materialsDialogPosition.left - left}px`;
panel.style.top = `${this.materialsDialogPosition.top - top}px`;
panel.style.right = "auto";
panel.style.transform = "none";
}
},
closeMaterialsDialog() {
if (!this.materialsDialog) return;
this.materialsDialog.hidden = true;
},
});
}

View file

@ -0,0 +1,90 @@
import { core } from "../core.js";
import { distanceBetweenPointsVector, vectorBetweenPoints, halfwayBetweenPoints, interpolateDistanceBetweenPoints } from "../utils.js";
import THREE from "../init.js";
export function attachMeasurement(Viewer) {
Object.assign(Viewer, {
buildRuler(_id) {
Viewer.rulerObject = new THREE.Object3D();
const gridSize = Viewer.gridSize || core.gridSize || 1;
const sphereRadius = Math.max(gridSize / 150, 0.001);
const textScale = Math.max(gridSize / 100, 0.01);
const measureSize = Math.max(gridSize / 200, 0.01);
var sphere = new THREE.Mesh(
new THREE.SphereGeometry(sphereRadius, 7, 7),
new THREE.MeshStandardMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.85,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
})
);
var newPoint = new THREE.Vector3(_id.point.x, _id.point.y, _id.point.z);
sphere.position.set(newPoint.x, newPoint.y, newPoint.z);
Viewer.rulerObject.add(sphere);
Viewer.linePoints.push(newPoint);
const lineGeometry = new THREE.BufferGeometry().setFromPoints(Viewer.linePoints);
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff });
const line = new THREE.Line(lineGeometry, lineMaterial);
Viewer.rulerObject.add(line);
var lineMtr = new THREE.LineBasicMaterial({
color: 0x0000ff,
linewidth: 1,
opacity: 1,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
});
if (Viewer.linePoints.length > 1) {
var vectorPoints = vectorBetweenPoints(
Viewer.linePoints[Viewer.linePoints.length - 2],
newPoint
);
var distancePoints = distanceBetweenPointsVector(vectorPoints);
const measuredDistance = Viewer.formatMeasuredDistance(distancePoints);
//var distancePoints = distanceBetweenPoints(Viewer.linePoints[Viewer.linePoints.length-2], newPoint);
var halfwayPoints = halfwayBetweenPoints(
Viewer.linePoints[Viewer.linePoints.length - 2],
newPoint
);
Viewer.addTextPoint(measuredDistance.text, textScale, halfwayPoints);
var rulerI = 0;
// `measureSize` was already precomputed outside, keep same scale
while (rulerI <= distancePoints * 100) {
const geoSegm = [];
var interpolatePoints = interpolateDistanceBetweenPoints(
Viewer.linePoints[Viewer.linePoints.length - 2],
vectorPoints,
distancePoints,
rulerI / 100
);
geoSegm.push(
new THREE.Vector3(
interpolatePoints.x,
interpolatePoints.y,
interpolatePoints.z
)
);
geoSegm.push(
new THREE.Vector3(
interpolatePoints.x + measureSize,
interpolatePoints.y + measureSize,
interpolatePoints.z + measureSize
)
);
const geometryLine = new THREE.BufferGeometry().setFromPoints(geoSegm);
var lineSegm = new THREE.Line(geometryLine, lineMtr);
Viewer.rulerObject.add(lineSegm);
rulerI += 10;
}
}
Viewer.rulerObject.renderOrder = 10;
core.scene.add(Viewer.rulerObject);
Viewer.ruler.push(Viewer.rulerObject);
},
});
}

View file

@ -0,0 +1,192 @@
import THREE from "../init.js";
import { core } from "../core.js";
import { toastHelper } from "../viewer-utils.js";
function pickMetadataValue(save, current, original) {
return save ? current : original;
}
export function buildEditorMetadata(viewer, rotateMetadata) {
const originalMetadata = viewer.originalMetadata;
const saveProperties = viewer.saveProperties;
const metadata = {};
metadata.objPosition = pickMetadataValue(
saveProperties.Position,
[
core.helperObjects[0].position.x,
core.helperObjects[0].position.y,
core.helperObjects[0].position.z
],
originalMetadata.objPosition
);
metadata.objRotation = pickMetadataValue(
saveProperties.Rotation,
[rotateMetadata.x, rotateMetadata.y, rotateMetadata.z],
originalMetadata.objRotation
);
metadata.objScale = pickMetadataValue(
saveProperties.Scale,
[
core.helperObjects[0].scale.x,
core.helperObjects[0].scale.y,
core.helperObjects[0].scale.z
],
originalMetadata.objScale
);
metadata.cameraPosition = pickMetadataValue(
saveProperties.Camera,
[
core.camera.position.x,
core.camera.position.y,
core.camera.position.z
],
originalMetadata.cameraPosition
);
metadata.controlsTarget = pickMetadataValue(
saveProperties.Camera,
[
core.controls.target.x,
core.controls.target.y,
core.controls.target.z
],
originalMetadata.controlsTarget
);
metadata.controlsZoom = pickMetadataValue(
saveProperties.Camera,
[
core.camera.position.distanceTo(core.controls.target)
],
originalMetadata.controlsZoom
);
metadata.lightPosition = pickMetadataValue(
saveProperties.DirectionalLight,
[
core.dirLight.position.x,
core.dirLight.position.y,
core.dirLight.position.z
],
originalMetadata.lightPosition
);
metadata.lightTarget = pickMetadataValue(
saveProperties.DirectionalLight,
[
core.dirLight.rotation._x,
core.dirLight.rotation._y,
core.dirLight.rotation._z
],
originalMetadata.lightTarget
);
metadata.lightColor = pickMetadataValue(
saveProperties.DirectionalLight,
["#" + core.dirLight.color.getHexString().toUpperCase()],
originalMetadata.lightColor
);
metadata.lightIntensity = pickMetadataValue(
saveProperties.DirectionalLight,
[core.dirLight.intensity],
originalMetadata.lightIntensity
);
metadata.lightAmbientColor = pickMetadataValue(
saveProperties.AmbientLight,
["#" + core.ambientLight.color.getHexString().toUpperCase()],
originalMetadata.lightAmbientColor
);
metadata.lightAmbientIntensity = pickMetadataValue(
saveProperties.AmbientLight,
[core.ambientLight.intensity],
originalMetadata.lightAmbientIntensity
);
metadata.lightCameraColor = pickMetadataValue(
saveProperties.CameraLight,
["#" + core.cameraLight.color.getHexString().toUpperCase()],
originalMetadata.lightCameraColor
);
metadata.lightCameraIntensity = pickMetadataValue(
saveProperties.CameraLight,
[core.cameraLight.intensity],
originalMetadata.lightCameraIntensity
);
metadata.background = saveProperties.BackgroundColor
? [window.getComputedStyle(viewer.mainCanvas).background]
: originalMetadata.background;
metadata.annotationEntries = viewer.getAnnotationEntriesForPersistence();
metadata.iiifAnnotationsXml = viewer.exportAnnotationsToIIIFXml();
return metadata;
}
export async function saveEditorMetadata(viewer) {
if (!core.EDITOR || core.isLightweight || !core.helperObjects?.[0]) return;
const rotateMetadata = new THREE.Vector3(
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.x),
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.y),
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.z)
);
if (core.CONFIG.entity.proxyPath !== undefined) {
core.CONFIG.metadataUrl = core.getProxyPath(core.CONFIG.metadataUrl);
}
let fetchedMetadata = {};
try {
if (core.CONFIG?.metadataUrl) {
const response = await fetch(core.CONFIG.metadataUrl, { cache: "no-cache" });
if (response.ok) {
fetchedMetadata = await response.json();
}
}
} catch (err) {
console.warn("Metadata fetch failed, continuing with save", err);
}
viewer.originalMetadata = {
...viewer.originalMetadata,
...fetchedMetadata
};
const newMetadata = buildEditorMetadata(viewer, rotateMetadata);
try {
const token = await fetch("/session/token").then((response) => response.text());
await fetch(core.CONFIG.mainUrl + "/api/editor/save-metadata", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token
},
body: JSON.stringify({
filename: core.fileObject.filename,
path:
viewer.archiveType !== ""
? core.fileObject.relativePath + core.fileObject.basename + core.loadedFile
: core.fileObject.relativePath,
content: JSON.stringify(newMetadata, null, "\t")
})
});
toastHelper("settingsSaved", "success");
} catch (err) {
console.error(err);
toastHelper("settingsSaveError", "error");
}
}

468
viewer/editor/picking.js Normal file
View file

@ -0,0 +1,468 @@
import { core } from "../core.js";
import THREE from "../init.js";
function getNormalizedPointerPosition(Viewer, clientX, clientY, targetVector) {
targetVector.x =
((clientX - Viewer.mainCanvas.getBoundingClientRect().left) /
core.renderer.domElement.clientWidth) *
2 -
1;
targetVector.y =
-(
(clientY - Viewer.mainCanvas.getBoundingClientRect().top) /
core.renderer.domElement.clientHeight
) *
2 +
1;
}
function getPrimaryModelIntersection(Viewer, pointerVector) {
Viewer.raycaster.setFromCamera(pointerVector, core.camera);
let intersects = [];
if (core.mainObject.length > 1) {
for (let ii = 0; ii < core.mainObject.length; ii++) {
intersects.push(
...Viewer.raycaster.intersectObjects(
core.mainObject[ii].children,
true
)
);
}
if (intersects.length <= 0) {
intersects = Viewer.raycaster.intersectObjects(core.mainObject, true);
}
} else if (core.mainObject[0]) {
intersects = Viewer.raycaster.intersectObject(core.mainObject[0], true);
}
return Viewer.getPrimaryIntersection(intersects);
}
function getPoiHit(Viewer, pointerVector) {
Viewer.raycaster.setFromCamera(pointerVector, core.camera);
const poiIntersects = Viewer.raycaster.intersectObjects(
Viewer.annotationPOIMarkers || [],
true
);
return poiIntersects.find(
(entry) => entry?.object?.userData?.isAnnotationPOI === true
) || null;
}
export function attachPicking(Viewer) {
Object.assign(Viewer, {
createTriangleGeometry(intersection) {
const position = intersection?.object?.geometry?.attributes?.position;
const face = intersection?.face;
if (!position || !face) return null;
const trianglePositions = new Float32Array([
position.getX(face.a), position.getY(face.a), position.getZ(face.a),
position.getX(face.b), position.getY(face.b), position.getZ(face.b),
position.getX(face.c), position.getY(face.c), position.getZ(face.c),
]);
const triangleGeometry = new THREE.BufferGeometry();
triangleGeometry.setAttribute("position", new THREE.BufferAttribute(trianglePositions, 3));
triangleGeometry.computeVertexNormals();
return triangleGeometry;
},
createPickingFaceOverlay(intersection, options = {}) {
const triangleGeometry = Viewer.createTriangleGeometry(intersection);
if (!triangleGeometry) return null;
const fillColor = options.fillColor ?? 0xff0000;
const lineColor = options.lineColor ?? 0xffffff;
const opacity = options.opacity ?? 0.65;
const overlayMaterial = new THREE.MeshBasicMaterial({
color: fillColor,
side: THREE.DoubleSide,
transparent: true,
opacity,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -2,
toneMapped: false,
});
const fillMesh = new THREE.Mesh(triangleGeometry, overlayMaterial);
fillMesh.renderOrder = 999;
const lineGeometry = new THREE.EdgesGeometry(triangleGeometry);
const lineMaterial = new THREE.LineBasicMaterial({
color: lineColor,
transparent: true,
opacity: Math.min(opacity + 0.2, 1),
depthTest: false,
depthWrite: false,
toneMapped: false,
});
const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial);
lineSegments.renderOrder = 1000;
const overlayGroup = new THREE.Group();
overlayGroup.name = "picking-face-overlay";
overlayGroup.userData.isPickingOverlay = true;
fillMesh.userData.isPickingOverlay = true;
lineSegments.userData.isPickingOverlay = true;
overlayGroup.add(fillMesh);
overlayGroup.add(lineSegments);
return overlayGroup;
},
isPickingOverlayObject(object) {
let current = object;
while (current) {
if (current.userData?.isPickingOverlay === true || current.name === "picking-face-overlay") {
return true;
}
current = current.parent;
}
return false;
},
getPrimaryIntersection(intersections) {
if (!Array.isArray(intersections) || intersections.length === 0) return null;
return intersections.find((entry) => !Viewer.isPickingOverlayObject(entry?.object)) ?? null;
},
getFaceSelectionKey(targetId, faceIndex) {
if (!targetId || faceIndex === null || faceIndex === undefined) return "";
return `${targetId}:${faceIndex}`;
},
findSelectedFaceIndex(targetId, faceIndex) {
const key = Viewer.getFaceSelectionKey(targetId, faceIndex);
return Viewer.selectedFaces.findIndex((entry) => entry.key === key);
},
updateSelectedFacesCount() {
Viewer.pickingStats["Selected faces"] = Array.isArray(Viewer.selectedFaces)
? Viewer.selectedFaces.length
: 0;
const selectedFacesCount = Array.isArray(Viewer.selectedFaces) ? Viewer.selectedFaces.length : 0;
if (selectedFacesCount < 1 && Viewer.annotationDialog && Viewer.annotationDialog.hidden === false) {
Viewer.closeAnnotationDialog();
}
Viewer.updateAddAnnotationControllerState();
Viewer.updatePickingHintVisibility();
},
toStableIdToken(value) {
const normalized = String(value || "")
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return normalized || "target";
},
getSelectionRootObject(object) {
let current = object;
while (current?.parent && current.parent !== core.scene) {
current = current.parent;
}
return current || object;
},
getSelectionRootSlot(rootObject) {
if (!rootObject || !Array.isArray(core.mainObject)) return -1;
return core.mainObject.findIndex((entry) => {
if (entry === rootObject) return true;
return Array.isArray(entry) && entry.includes(rootObject);
});
},
getObjectHierarchyPath(object, rootObject) {
if (!object || !rootObject) return "";
const path = [];
let current = object;
while (current && current !== rootObject) {
const parent = current.parent;
if (!parent) break;
const index = parent.children.indexOf(current);
path.push(index >= 0 ? String(index) : "x");
current = parent;
}
return path.reverse().join(".") || "root";
},
resolveFaceTargetId(object) {
if (!object) return "";
const explicitId = object.userData?.annotationTargetId || object.userData?.id;
if (explicitId) return String(explicitId);
const rootObject = Viewer.getSelectionRootObject(object);
const rootSlot = Viewer.getSelectionRootSlot(rootObject);
const path = Viewer.getObjectHierarchyPath(object, rootObject);
let rootTag = rootSlot >= 0 ? `m${rootSlot}` : "m0";
if (rootSlot >= 0 && Array.isArray(core.mainObject?.[rootSlot])) {
const rootIndex = core.mainObject[rootSlot].indexOf(rootObject);
if (rootIndex >= 0) {
rootTag = `${rootTag}.${rootIndex}`;
}
}
const targetId = `${rootTag}:${path}`;
object.userData ??= {};
object.userData.annotationTargetId = targetId;
return targetId;
},
resolveObjectByTargetId(targetId) {
const raw = String(targetId || "").trim();
const match = raw.match(/^m(\d+)(?:\.(\d+))?:(.+)$/);
if (!match) return null;
const slot = Number.parseInt(match[1], 10);
const rootIndex = Number.parseInt(match[2] || "0", 10);
const path = match[3] || "root";
if (!Number.isInteger(slot) || slot < 0) return null;
const entry = core.mainObject?.[slot];
if (!entry) return null;
let rootObject = Array.isArray(entry) ? entry[rootIndex] : entry;
if (!rootObject) return null;
if (path === "root") return rootObject;
const segments = path.split(".").filter(Boolean);
for (const segment of segments) {
const childIndex = Number.parseInt(segment, 10);
if (!Number.isInteger(childIndex) || childIndex < 0) return null;
rootObject = rootObject.children?.[childIndex];
if (!rootObject) return null;
}
return rootObject;
},
getFaceCentroidWorld(object, faceIndex) {
const geometry = object?.geometry;
if (!geometry || !geometry.getAttribute) return null;
const position = geometry.getAttribute("position");
if (!position) return null;
const face = Number(faceIndex);
if (!Number.isInteger(face) || face < 0) return null;
let ia = face * 3;
let ib = ia + 1;
let ic = ia + 2;
const index = geometry.getIndex?.() || geometry.index || null;
if (index?.array) {
const arr = index.array;
if (ic >= arr.length) return null;
ia = arr[ia];
ib = arr[ib];
ic = arr[ic];
} else if (ic >= position.count) {
return null;
}
const va = new THREE.Vector3().fromBufferAttribute(position, ia);
const vb = new THREE.Vector3().fromBufferAttribute(position, ib);
const vc = new THREE.Vector3().fromBufferAttribute(position, ic);
const center = va.add(vb).add(vc).multiplyScalar(1 / 3);
object.updateMatrixWorld?.(true);
center.applyMatrix4(object.matrixWorld);
return center;
},
clearSelectedFaces() {
if (!Array.isArray(Viewer.selectedFaces) || Viewer.selectedFaces.length === 0) {
Viewer.updateSelectedFacesCount();
return;
}
Viewer.selectedFaces.forEach((entry) => {
Viewer.disposeFaceOverlay(entry);
});
Viewer.selectedFaces.length = 0;
Viewer.updateSelectedFacesCount();
},
restoreLastPickedFace() {
if (!Viewer.lastPickedFace.overlay) {
Viewer.lastPickedFace = { id: "", object: "", faceIndex: null, overlay: null };
return;
}
Viewer.disposeFaceOverlay(Viewer.lastPickedFace);
Viewer.lastPickedFace = { id: "", object: "", faceIndex: null, overlay: null };
},
pickFaces(intersection) {
const hoveredObjectId = intersection?.object?.id ?? "";
const hoveredFaceIndex = intersection?.faceIndex ?? null;
if (!hoveredObjectId) {
Viewer.restoreLastPickedFace();
return;
}
if (
Viewer.lastPickedFace.object === hoveredObjectId &&
Viewer.lastPickedFace.faceIndex === hoveredFaceIndex
) {
return;
}
Viewer.restoreLastPickedFace();
const overlay = Viewer.createPickingFaceOverlay(intersection, {
fillColor: 0xff3b30,
lineColor: 0xffffff,
opacity: 0.4,
});
if (!overlay) return;
Viewer.lastPickedFace = {
id: hoveredObjectId,
object: hoveredObjectId,
faceIndex: hoveredFaceIndex,
overlay,
};
intersection.object.add(overlay);
},
toggleSelectedFace(intersection, options = {}) {
const targetId = Viewer.resolveFaceTargetId(intersection?.object);
const runtimeObjectId = intersection?.object?.id ?? "";
const faceIndex = intersection?.faceIndex ?? null;
if (!targetId || faceIndex === null) return;
const multiSelect = options.multiSelect === true;
const selectedFaceIndex = Viewer.findSelectedFaceIndex(targetId, faceIndex);
if (!multiSelect) {
const clickedFaceKey = Viewer.getFaceSelectionKey(targetId, faceIndex);
const clickedFaceAlreadySelected =
selectedFaceIndex >= 0 && Viewer.selectedFaces.length === 1 &&
Viewer.selectedFaces[0]?.key === clickedFaceKey;
Viewer.clearSelectedFaces();
if (clickedFaceAlreadySelected) {
return;
}
}
if (selectedFaceIndex >= 0) {
const [selectedFace] = Viewer.selectedFaces.splice(selectedFaceIndex, 1);
Viewer.disposeFaceOverlay(selectedFace);
Viewer.updateSelectedFacesCount();
return;
}
const overlay = Viewer.createPickingFaceOverlay(intersection, {
fillColor: 0x00c853,
lineColor: 0xe8ffe8,
opacity: 0.5,
});
if (!overlay) return;
intersection.object.add(overlay);
Viewer.selectedFaces.push({
key: Viewer.getFaceSelectionKey(targetId, faceIndex),
targetId,
object: targetId,
runtimeObjectId,
faceIndex,
overlay,
});
Viewer.updateSelectedFacesCount();
},
onPointerDown(e) {
Viewer.disableInteractionHint();
e.stopPropagation();
if (e.button === 0) {
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.onDownPosition);
}
},
onPointerUp(e) {
if (e.button !== 0) return;
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.onUpPosition);
if (
Viewer.onUpPosition.x !== Viewer.onDownPosition.x ||
Viewer.onUpPosition.y !== Viewer.onDownPosition.y
) {
return;
}
if (!Viewer.pickingMode && !Viewer.RULER_MODE) {
const poiHit = getPoiHit(Viewer, Viewer.onUpPosition);
if (poiHit?.object) {
Viewer.openAnnotationDialogFromPOIMarker(poiHit.object);
return;
}
Viewer.closeAnnotationPOITooltip();
}
if (Viewer.pickingMode || Viewer.RULER_MODE) {
const primaryIntersection = getPrimaryModelIntersection(Viewer, Viewer.onUpPosition);
if (!primaryIntersection) return;
if (Viewer.RULER_MODE) {
Viewer.buildRuler(primaryIntersection);
} else if (Viewer.pickingMode) {
Viewer.toggleSelectedFace(primaryIntersection, {
multiSelect: e.shiftKey,
});
}
}
},
onPointerMove(e) {
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.pointer);
if (e.buttons !== 0) {
Viewer.disableInteractionHint();
Viewer.closeAnnotationPOITooltip();
}
if (e.buttons == 1) {
if (Viewer.pointer.x !== Viewer.onDownPosition.x && Viewer.pointer.y !== Viewer.onDownPosition.y) {
Viewer.cameraLight.position.set(
core.camera.position.x,
core.camera.position.y,
core.camera.position.z
);
}
return;
}
if (!Viewer.pickingMode && !Viewer.RULER_MODE) {
if (Viewer.annotationDialog && Viewer.annotationDialog.hidden === false) {
Viewer.closeAnnotationPOITooltip();
} else {
const poiHit = getPoiHit(Viewer, Viewer.pointer);
if (poiHit?.object) {
Viewer.openAnnotationPOITooltip(poiHit.object);
} else {
Viewer.closeAnnotationPOITooltip();
}
}
}
if (Viewer.pickingMode) {
const primaryIntersection = getPrimaryModelIntersection(Viewer, Viewer.pointer);
if (primaryIntersection) {
Viewer.pickFaces(primaryIntersection);
} else {
Viewer.pickFaces("");
}
}
},
});
}

View file

@ -0,0 +1,52 @@
import { core } from "../core.js";
export function captureAndUploadThumbnail(viewer) {
core.camera.aspect = 1;
core.camera.updateProjectionMatrix();
core.renderer.setSize(256, 256);
core.renderer.render(core.scene, core.camera);
viewer.mainCanvas.toBlob((imgBlob) => {
if (!imgBlob) {
console.error("Failed to capture screenshot");
return;
}
if (!(imgBlob instanceof Blob) || imgBlob.size === 0) {
console.error("Invalid blob data");
return;
}
if (!["image/png", "image/jpeg"].includes(imgBlob.type)) {
console.error("Invalid blob type:", imgBlob.type);
return;
}
const fileform = new FormData();
fileform.append("path", core.fileObject.path);
fileform.append("filename", core.fileObject.basename);
fileform.append("data", imgBlob, "thumbnail.png");
console.log("Uploading thumbnail for entity ID:", core.CONFIG.entity.id);
fileform.append("wisski_individual", core.CONFIG.entity.id);
fetch(core.CONFIG.mainUrl + "/api/editor/upload-thumbnail", {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRF-Token": window.CSRF_TOKEN || window.drupalSettings?.dfg_3dviewer?.csrfToken
},
body: fileform
})
.then(async (res) => {
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) throw new Error(data.error || "Upload failed");
return data;
});
}, "image/png");
core.renderer.setPixelRatio(devicePixelRatio);
core.camera.aspect = core.CONFIG.viewer.canvasDimensions.x / core.CONFIG.viewer.canvasDimensions.y;
core.camera.updateProjectionMatrix();
core.renderer.setSize(core.CONFIG.viewer.canvasDimensions.x, core.CONFIG.viewer.canvasDimensions.y);
}

View file

@ -0,0 +1,8 @@
# Test fixture for OBJ fallback when the referenced material file is missing.
mtllib missing-material.mtl
o Triangle
v 0.000000 0.000000 0.000000
v 1.000000 0.000000 0.000000
v 0.000000 1.000000 0.000000
vn 0.0000 0.0000 1.0000
f 1//1 2//1 3//1

BIN
viewer/examples/box.3ds Normal file

Binary file not shown.

BIN
viewer/examples/box.abc Normal file

Binary file not shown.

70
viewer/examples/box.dae Normal file
View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
<asset>
<contributor>
<author>DFG Viewer</author>
<authoring_tool>TXT</authoring_tool>
</contributor>
<created>2026-03-13T00:00:00Z</created>
<modified>2026-03-13T00:00:00Z</modified>
<unit name="meter" meter="1"/>
<up_axis>Z_UP</up_axis>
</asset>
<library_effects>
<effect id="Material-effect">
<profile_COMMON>
<technique sid="common">
<lambert>
<diffuse>
<color>0.2 0.5 0.9 1</color>
</diffuse>
</lambert>
</technique>
</profile_COMMON>
</effect>
</library_effects>
<library_materials>
<material id="Material-material" name="Material">
<instance_effect url="#Material-effect"/>
</material>
</library_materials>
<library_geometries>
<geometry id="Cube-geometry" name="Cube">
<mesh>
<source id="Cube-positions">
<float_array id="Cube-positions-array" count="24">1 2 -1 1 0 -1 1 2 1 1 0 1 -1 2 -1 -1 0 -1 -1 2 1 -1 0 1</float_array>
<technique_common>
<accessor source="#Cube-positions-array" count="8" stride="3">
<param name="X" type="float"/>
<param name="Y" type="float"/>
<param name="Z" type="float"/>
</accessor>
</technique_common>
</source>
<vertices id="Cube-vertices">
<input semantic="POSITION" source="#Cube-positions"/>
</vertices>
<triangles count="12" material="Material-material">
<input semantic="VERTEX" source="#Cube-vertices" offset="0"/>
<p>0 4 6 0 6 2 3 2 6 3 6 7 7 6 4 7 4 5 5 1 3 5 3 7 1 0 2 1 2 3 5 4 0 5 0 1</p>
</triangles>
</mesh>
</geometry>
</library_geometries>
<library_visual_scenes>
<visual_scene id="Scene" name="Scene">
<node id="Cube-node" name="Cube" type="NODE">
<instance_geometry url="#Cube-geometry">
<bind_material>
<technique_common>
<instance_material symbol="Material-material" target="#Material-material"/>
</technique_common>
</bind_material>
</instance_geometry>
</node>
</visual_scene>
</library_visual_scenes>
<scene>
<instance_visual_scene url="#Scene"/>
</scene>
</COLLADA>

BIN
viewer/examples/box.fbx Normal file

Binary file not shown.

BIN
viewer/examples/box.glb Normal file

Binary file not shown.

43
viewer/examples/box.ifc Normal file
View file

@ -0,0 +1,43 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('box.ifc','2026-03-12T00:00:00',('DFG 3DViewer'),('OpenAI'),'DFG 3DViewer','DFG 3DViewer','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPERSON($,$,'DFG 3DViewer',$,$,$,$,$);
#2=IFCORGANIZATION($,'DFG 3DViewer',$,$,$);
#3=IFCPERSONANDORGANIZATION(#1,#2,$);
#4=IFCAPPLICATION(#2,'1.0','DFG 3DViewer','DFG 3DViewer');
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);
#10=IFCCARTESIANPOINT((0.,0.,0.));
#11=IFCDIRECTION((0.,0.,1.));
#12=IFCDIRECTION((1.,0.,0.));
#13=IFCAXIS2PLACEMENT3D(#10,#11,#12);
#14=IFCLOCALPLACEMENT($,#13);
#15=IFCDIRECTION((1.,0.));
#16=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.00001,#13,$);
#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#16,$,.MODEL_VIEW.,$);
#18=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
#19=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#20=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#21=IFCUNITASSIGNMENT((#18,#19,#20));
#22=IFCPROJECT('0$C0D3XPR0J3CT0000001',#5,'Project',$,$,$,$,(#16),#21);
#23=IFCSITE('0$C0D3XS1T30000000001',#5,'Site',$,$,#14,$,$,.ELEMENT.,$,$,$,$,$);
#24=IFCBUILDING('0$C0D3XBUILD1NG000001',#5,'Building',$,$,#14,$,$,.ELEMENT.,$,$,$);
#25=IFCBUILDINGSTOREY('0$C0D3XST0R3Y0000001',#5,'Storey',$,$,#14,$,$,.ELEMENT.,0.);
#26=IFCRELAGGREGATES('0$C0D3XAGRPROJ000001',#5,$,$,#22,(#23));
#27=IFCRELAGGREGATES('0$C0D3XAGRS1TE000001',#5,$,$,#23,(#24));
#28=IFCRELAGGREGATES('0$C0D3XAGRBLDG000001',#5,$,$,#24,(#25));
#30=IFCCARTESIANPOINT((0.,0.));
#31=IFCAXIS2PLACEMENT2D(#30,$);
#32=IFCRECTANGLEPROFILEDEF(.AREA.,'BoxProfile',#31,1.,1.);
#33=IFCAXIS2PLACEMENT3D(#10,#11,#12);
#34=IFCDIRECTION((0.,0.,1.));
#35=IFCEXTRUDEDAREASOLID(#32,#33,#34,1.);
#36=IFCSHAPEREPRESENTATION(#17,'Body','SweptSolid',(#35));
#37=IFCPRODUCTDEFINITIONSHAPE($,$,(#36));
#38=IFCBUILDINGELEMENTPROXY('0$C0D3XPR0XY00000001',#5,'Box',$,$,#14,#37,$,$);
#39=IFCRELCONTAINEDINSPATIALSTRUCTURE('0$C0D3XRELSTR0000001',#5,$,$,(#38),#25);
ENDSEC;
END-ISO-10303-21;

12
viewer/examples/box.mtl Normal file
View file

@ -0,0 +1,12 @@
# Blender 5.0.0 MTL File: 'None'
# www.blender.org
newmtl Material
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Kd 0.000922 0.000000 0.800016
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2

40
viewer/examples/box.obj Normal file
View file

@ -0,0 +1,40 @@
# Blender 5.0.0
# www.blender.org
mtllib box.mtl
o Cube
v 1.000000 2.000000 -1.000000
v 1.000000 0.000000 -1.000000
v 1.000000 2.000000 1.000000
v 1.000000 0.000000 1.000000
v -1.000000 2.000000 -1.000000
v -1.000000 0.000000 -1.000000
v -1.000000 2.000000 1.000000
v -1.000000 0.000000 1.000000
vn -0.0000 1.0000 -0.0000
vn -0.0000 -0.0000 1.0000
vn -1.0000 -0.0000 -0.0000
vn -0.0000 -1.0000 -0.0000
vn 1.0000 -0.0000 -0.0000
vn -0.0000 -0.0000 -1.0000
vt 0.625000 0.500000
vt 0.875000 0.500000
vt 0.875000 0.750000
vt 0.625000 0.750000
vt 0.375000 0.750000
vt 0.625000 1.000000
vt 0.375000 1.000000
vt 0.375000 0.000000
vt 0.625000 0.000000
vt 0.625000 0.250000
vt 0.375000 0.250000
vt 0.125000 0.500000
vt 0.375000 0.500000
vt 0.125000 0.750000
s 0
usemtl Material
f 1/1/1 5/2/1 7/3/1 3/4/1
f 4/5/2 3/4/2 7/6/2 8/7/2
f 8/8/3 7/9/3 5/10/3 6/11/3
f 6/12/4 2/13/4 4/5/4 8/14/4
f 2/13/5 1/1/5 3/4/5 4/5/5
f 6/11/6 5/10/6 1/1/6 2/13/6

109
viewer/examples/box.pcd Normal file
View file

@ -0,0 +1,109 @@
# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z
SIZE 4 4 4
TYPE F F F
COUNT 1 1 1
WIDTH 98
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 98
DATA ascii
-1 0 -1
-1 0 -0.5
-1 0 0
-1 0 0.5
-1 0 1
-0.5 0 -1
-0.5 0 -0.5
-0.5 0 0
-0.5 0 0.5
-0.5 0 1
0 0 -1
0 0 -0.5
0 0 0
0 0 0.5
0 0 1
0.5 0 -1
0.5 0 -0.5
0.5 0 0
0.5 0 0.5
0.5 0 1
1 0 -1
1 0 -0.5
1 0 0
1 0 0.5
1 0 1
-1 2 -1
-1 2 -0.5
-1 2 0
-1 2 0.5
-1 2 1
-0.5 2 -1
-0.5 2 -0.5
-0.5 2 0
-0.5 2 0.5
-0.5 2 1
0 2 -1
0 2 -0.5
0 2 0
0 2 0.5
0 2 1
0.5 2 -1
0.5 2 -0.5
0.5 2 0
0.5 2 0.5
0.5 2 1
1 2 -1
1 2 -0.5
1 2 0
1 2 0.5
1 2 1
-1 0.5 -1
-1 0.5 -0.5
-1 0.5 0
-1 0.5 0.5
-1 0.5 1
-1 1 -1
-1 1 -0.5
-1 1 0
-1 1 0.5
-1 1 1
-1 1.5 -1
-1 1.5 -0.5
-1 1.5 0
-1 1.5 0.5
-1 1.5 1
1 0.5 -1
1 0.5 -0.5
1 0.5 0
1 0.5 0.5
1 0.5 1
1 1 -1
1 1 -0.5
1 1 0
1 1 0.5
1 1 1
1 1.5 -1
1 1.5 -0.5
1 1.5 0
1 1.5 0.5
1 1.5 1
-0.5 0.5 -1
0 0.5 -1
0.5 0.5 -1
-0.5 1 -1
0 1 -1
0.5 1 -1
-0.5 1.5 -1
0 1.5 -1
0.5 1.5 -1
-0.5 0.5 1
0 0.5 1
0.5 0.5 1
-0.5 1 1
0 1 1
0.5 1 1
-0.5 1.5 1
0 1.5 1
0.5 1.5 1

BIN
viewer/examples/box.ply Normal file

Binary file not shown.

BIN
viewer/examples/box.stl Normal file

Binary file not shown.

99
viewer/examples/box.xyz Normal file
View file

@ -0,0 +1,99 @@
# Cube surface points matching the GLB example bounds: x,z in [-1,1], y in [0,2]
-1 0 -1
-1 0 -0.5
-1 0 0
-1 0 0.5
-1 0 1
-0.5 0 -1
-0.5 0 -0.5
-0.5 0 0
-0.5 0 0.5
-0.5 0 1
0 0 -1
0 0 -0.5
0 0 0
0 0 0.5
0 0 1
0.5 0 -1
0.5 0 -0.5
0.5 0 0
0.5 0 0.5
0.5 0 1
1 0 -1
1 0 -0.5
1 0 0
1 0 0.5
1 0 1
-1 2 -1
-1 2 -0.5
-1 2 0
-1 2 0.5
-1 2 1
-0.5 2 -1
-0.5 2 -0.5
-0.5 2 0
-0.5 2 0.5
-0.5 2 1
0 2 -1
0 2 -0.5
0 2 0
0 2 0.5
0 2 1
0.5 2 -1
0.5 2 -0.5
0.5 2 0
0.5 2 0.5
0.5 2 1
1 2 -1
1 2 -0.5
1 2 0
1 2 0.5
1 2 1
-1 0.5 -1
-1 0.5 -0.5
-1 0.5 0
-1 0.5 0.5
-1 0.5 1
-1 1 -1
-1 1 -0.5
-1 1 0
-1 1 0.5
-1 1 1
-1 1.5 -1
-1 1.5 -0.5
-1 1.5 0
-1 1.5 0.5
-1 1.5 1
1 0.5 -1
1 0.5 -0.5
1 0.5 0
1 0.5 0.5
1 0.5 1
1 1 -1
1 1 -0.5
1 1 0
1 1 0.5
1 1 1
1 1.5 -1
1 1.5 -0.5
1 1.5 0
1 1.5 0.5
1 1.5 1
-0.5 0.5 -1
0 0.5 -1
0.5 0.5 -1
-0.5 1 -1
0 1 -1
0.5 1 -1
-0.5 1.5 -1
0 1.5 -1
0.5 1.5 -1
-0.5 0.5 1
0 0.5 1
0.5 0.5 1
-0.5 1 1
0 1 1
0.5 1 1
-0.5 1.5 1
0 1.5 1
0.5 1.5 1

View file

@ -0,0 +1 @@
this-is-not-a-valid-glb-file

117
viewer/extract-helper.js Normal file
View file

@ -0,0 +1,117 @@
import { unzipSync } from 'fflate';
import { loadDroppedModel } from "./sandbox.js";
import { core } from "./core.js";
import { toastHelper } from './viewer-utils.js';
let archiveFileMap = {};
async function extractZip (archiveFile) {
const buffer = await archiveFile.arrayBuffer();
const unzipped = unzipSync(new Uint8Array(buffer));
const files = [];
Object.entries(unzipped).forEach(([path, data]) => {
// Skip folders
if (path.endsWith('/')) {
return;
}
files.push(
new File(
[data],
path,
{
type: 'application/octet-stream'
}
)
);
});
return files;
};
function cleanupArchiveUrls() {
Object.values(archiveFileMap).forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (e) {}
});
archiveFileMap = {};
};
function buildArchiveFileMap (files) {
cleanupArchiveUrls();
files.forEach(file => {
const normalized = file.name.replace(/^\/+/, '');
archiveFileMap[normalized] = URL.createObjectURL(file);
});
};
export async function loadDroppedArchive (archiveFile) {
try {
const extension = archiveFile.name.split('.').pop().toLowerCase();
let extractedFiles = [];
switch (extension) {
case 'zip':
extractedFiles = await extractZip(archiveFile);
break;
default:
throw new Error(`Unsupported archive type: ${extension}`);
}
if (!extractedFiles.length) {
throw new Error('Archive is empty');
}
const modelFile = findMainModelFile(extractedFiles);
if (!modelFile) {
throw new Error('No supported model file found in archive');
}
buildArchiveFileMap(extractedFiles);
await loadDroppedModel(modelFile);
} catch (err) {
console.error(err);
toastHelper("unsupportedFormat", "error");
}
};
function findMainModelFile (files) {
const priority = core.SUPPORTED_EXTENSIONS;
for (const ext of priority) {
const found = files.find(file => {
const fileExt = file.name.split('.').pop().toLowerCase();
return fileExt === ext;
});
if (found) {
return found;
}
}
return null;
};
function resolveArchiveUrl (url) {
if (!url) {
return url;
}
const clean = url
.replace(/^(\.\/)+/, '')
.replace(/^\/+/, '');
return archiveFileMap[clean] || url;
};

13
viewer/fonts/LICENSE Normal file
View file

@ -0,0 +1,13 @@
Copyright @ 2004 by MAGENTA Ltd. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing the word "MgOpen", or if the modifications are accepted for inclusion in the Font Software itself by the each appointed Administrator.
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "MgOpen" name.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL MAGENTA OR PERSONS OR BODIES IN CHARGE OF ADMINISTRATION AND MAINTENANCE OF THE FONT SOFTWARE BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

11
viewer/fonts/README.md Normal file
View file

@ -0,0 +1,11 @@
## MgOpen typefaces
# Source and License
https://web.archive.org/web/20050528114140/https://ellak.gr/fonts/mgopen/index.en
# Usage
Use Facetype.js to generate typeface.json fonts: https://gero3.github.io/facetype.js/
Collection of Google fonts as typeface data for usage with three.js: https://github.com/components-ai/typefaces

190
viewer/fonts/droid/NOTICE Normal file
View file

@ -0,0 +1,190 @@
Copyright (c) 2005-2008, The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View file

@ -0,0 +1,18 @@
Copyright (C) 2008 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
##########
This directory contains the fonts for the platform. They are licensed
under the Apache 2 license.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
# Kenney Fonts
## Source
https://www.kenney.nl/assets/kenney-fonts
## License
CC0 1.0 Universal (CC0 1.0) Public Domain Dedication (https://creativecommons.org/publicdomain/zero/1.0/)

Binary file not shown.

48
viewer/i18n-utils.js Normal file
View file

@ -0,0 +1,48 @@
import { VIEWER_I18N } from "./i18n.js";
import { core } from './core.js';
const DEFAULT_LANG = "en";
export const t=function(key, varsOrFallback = {}, maybeFallback = "") {
const lang = ["pl", "de"].includes(core.currentLanguage)
? core.currentLanguage
: "en";
const dictionary = VIEWER_I18N[lang] || VIEWER_I18N.en;
const value = String(key || "")
.split(".")
.reduce(
(acc, part) =>
acc && typeof acc === "object" ? acc[part] : undefined,
dictionary
);
let vars = {};
let fallback = "";
if (typeof varsOrFallback === "string") {
fallback = varsOrFallback;
} else {
vars = varsOrFallback || {};
fallback = maybeFallback;
}
const template = typeof value === "string" ? value : fallback || key;
return template.replace(/\{(\w+)\}/g, (_, v) => {
let val = vars[v];
if (typeof val === "boolean") {
val = t(`state.${val ? "enabled" : "disabled"}`);
} else if (typeof val === "string") {
if (val === "enabled" || val === "disabled") {
val = t(`state.${val}`);
} else if (val.startsWith("state.")) {
val = t(val);
}
}
return val ?? `{${v}}`;
});
}

880
viewer/i18n.js Normal file
View file

@ -0,0 +1,880 @@
export const VIEWER_I18N = {
en: {
menu: {
actions: "Viewer actions",
openActions: "Open viewer actions",
embed: "Embed",
exitEmbed: "Exit embed",
openEmbedOptions: "Open embed options",
exitEmbedMode: "Exit embed mode",
download: "Download",
},
theme: {
lightMode: "Light mode",
darkMode: "Dark mode",
switchToLightMode: "Switch to light mode",
switchToDarkMode: "Switch to dark mode",
},
fullscreen: {
mode: "Fullscreen mode",
enter: "Fullscreen",
exit: "Exit fullscreen",
exitMode: "Exit fullscreen mode",
},
language: {
label: "Language: English",
switchToPolish: "Switch to Polish",
switchToEnglish: "Switch to English",
switchToGerman: "Switch to German",
},
hints: {
pickingSelect: "Select at least one face to add annotation.",
picking: "Shift + click to select multiple faces",
clipping: "Drag active clipping plane helper to adjust cut",
},
controls: {
enablePickingMode: "Enable picking mode",
disablePickingMode: "Disable picking mode",
enableDistanceMeasurement: "Enable distance measurement",
disableDistanceMeasurement: "Disable distance measurement",
selectedFaces: "Selected faces",
},
gui: {
moveToolbar: "Move toolbar",
orbit: "Navigation mode",
controls: "Controls",
editor: "Editor",
transform3dObject: "Transform 3D Object",
transformLight: "Transform Light",
transformMode: "Transform Mode",
directionalLight: "Directional Light",
ambientLight: "Ambient Light",
ambient: "Ambient",
lights: "Lights",
cameraLight: "Camera Light",
backgroundColor: "Background Color",
BackgroundColorOuter: "Background Color Outer",
backgroundType: "Background Type",
clippingPlanes: "Clipping Planes",
clippingFolder: "Clipping Planes",
displayHelperX: "Show X helper",
displayHelperY: "Show Y helper",
displayHelperZ: "Show Z helper",
constantX: "Constant X",
constantY: "Constant Y",
constantZ: "Constant Z",
visible: "Visible cutting",
materials: "Materials",
metadata: "Annotations",
saveProperties: "Save properties",
hierarchy: "Hierarchy",
statistics: "Statistics",
clearSelectedFaces: "Clear selected faces",
clearSelectedHierarchy: "Clear selected objects",
addAnnotations: "Add annotations",
exportAnnotationsXml: "Export annotations XML",
importAnnotationsXml: "Import annotations XML",
IIIFexport: "Export IIIF",
resetCameraPosition: "Reset camera position",
saveSettings: "Save settings",
renderPreview: "Render preview",
color: "Color",
intensity: "Intensity",
metalness: "Metalness",
emissive: "Emissive",
position: "Position",
rotation: "Rotation",
camera: "Camera",
performance: "Performance",
highPerformance: "High-performance",
lowPower: "Low-power",
default: "Default",
none: "None",
move: "Move",
rotate: "Rotate",
scale: "Scale",
target: "Target",
local: "Local",
global: "Global",
linear: "Linear",
gradient: "Gradient",
editMaterial: "Edit material",
selectByMaterial: "select by material",
hideAdvancedEditor: "Hide advanced editor",
showAdvancedEditor: "Show advanced editor",
showLoadingLogs: "Show loading logs",
hideLoadingLogs: "Hide loading logs",
hierarchy: "Hierarchy",
expand: "Expand toolbar",
collapse: "Collapse toolbar",
orthographicProjection: "Switch to orthographic projection",
perspectiveProjection: "Switch to perspective projection",
environmentMap: "Environment map",
environmentMapToggle: "Toggle environment map",
environmentMapIntensity: "Environment map intensity",
environmentMapStyleNeutral: "Neutral",
environmentMapStyleSunny: "Sunny",
environmentMapStyleStudio: "Studio",
environmentMapStyleGoldenHour: "Golden Hour",
enableWireframeMode: "Enable wireframe mode",
disableWireframeMode: "Disable wireframe mode",
download: "Download model",
},
metadata: {
modelDetails: "Model details",
metadata: "Metadata",
visualizedFile: "Visualized file",
vertices: "Vertices",
faces: "Faces",
title: "Title",
author: "Author",
authorAffiliation: "Author affiliation",
license: "License",
description: "Description",
objectType: "Object type",
reconstructionAuthors: "Reconstruction authors",
reconstructionPeriod: "Reconstruction period",
},
manifesto: {
invalidUrl: "Please enter a valid manifesto URL.",
invalidJson: "Please enter a valid manifesto JSON text.",
},
iiif: {
loader: "IIIF Loader",
collapse: "Collapse",
expand: "Expand",
manifest: "IIIF manifest",
loadFromUrl: "Load from URL",
loadFromText: "Load from Text",
manifestUrlPlaceholder: "https://example.org/iiif/manifest.json",
manifestTextPlaceholder: "Paste IIIF manifest JSON here...",
invalidUrl: "Please enter a valid IIIF manifest URL.",
invalidJson: "Please enter a valid IIIF JSON text.",
optionModelPositionScale: "Model Position and Scale",
optionModelOrigin: "Model Origin",
optionModelOriginBg: "Model Origin with background color",
optionModelPosition: "Model Position",
},
localPreview: {
loadExampleModel: "Load example model",
},
embedPanel: {
title: "Embed options",
closeAria: "Close embed options",
modelUrl: "Model URL",
modelUrlPlaceholder: "/examples/box.glb",
entityId: "Entity ID",
theme: "Theme",
themeDark: "Dark",
themeLight: "Light",
autoRotateSpeed: "Auto-rotate speed",
cameraPosition: "Camera position",
cameraTarget: "Camera target",
cameraVectorPlaceholder: "x,y,z",
fov: "FOV",
autoRotate: "Auto-rotate",
disableInteraction: "Disable interaction",
hideActionMenu: "Hide action menu",
hideMetadata: "Hide metadata",
presentationMode: "Presentation mode",
useCurrentCamera: "Use Current Camera",
resetFromViewer: "Reset From Viewer",
copyUrl: "Copy URL",
copyIframe: "Copy Iframe",
embedUrl: "Embed URL",
iframeCode: "Iframe code",
preview: "Preview",
previewTitle: "Embed preview",
},
loadingLog: {
title: "Loading process log",
loadingModel: "Loading 3D model...",
modelLoaded: "Model has been loaded.",
loadingAssets: "Loading assets...",
parsingScene: "Parsing scene...",
loadingTextures: "Loading textures...",
preparingGeometry: "Preparing geometry...",
settingUpLighting: "Setting up lighting...",
settingUpMaterials: "Setting up materials...",
buildingBvh: "Building BVH...",
compilingShaders: "Compiling shaders...",
initializingRenderer: "Initializing renderer...",
uploadingBuffers: "Uploading buffers...",
fetchingMetadata: "Fetching metadata...",
},
processingHeader: "Processing progress",
processingSteps: {
preparingModel: "Preparing model",
convertingToTransmissionFormat: "Converting to transmission format",
renderingThumbnails: "Rendering thumbnails",
savingEntity: "Saving entity",
finalizing3dModel: "Finalizing 3D model",
initializingViewer: "Initializing viewer",
},
state: {
enabled: "enabled",
disabled: "disabled"
},
toasts: {
transformMove: "Move: drag axis arrows to reposition the object.",
transformRotate: "Rotate: drag rotation rings to rotate the object.",
transformScale: "Scale: drag axis handles to resize the object.",
transformLightMove: "Transform Light - Move: drag axis arrows to move the directional light.",
transformLightTarget: "Transform Light - Target: drag axis arrows to reposition the light target.",
distanceEnabled: "Distance measurement is enabled.",
distanceHint: "Select a start point and an end point to measure distance.",
noIiiifModelFallback: "No 3D model found in IIIF manifest, loading example model.",
facePickingEnabled: "Face picking is enabled",
facePickingDisabled: "Face picking is disabled",
distanceModeEnabled: "Distance measurement mode is enabled",
distanceModeDisabled: "Distance measurement mode is disabled",
settingsFound: "Settings {filename}_viewer.json found",
settingsNotFound: "No settings {filename}_viewer.json found",
metadataFetchError: "Error fetching metadata: {error}",
presentationModeError: "An error occurred during presentation mode setup.",
objLoaded: "OBJ model has been loaded.",
mtlLoadError: "Error occurred while loading attached MTL file.",
glbLoadError: "Error occurred while loading attached GLB file.",
unsupportedExtension: "File extension is not supported yet.",
modelLoaded: "Model {filename} has been loaded.",
modelLoadedSimple: "Model loaded successfully.",
unsupportedFormat: "Unsupported file format.",
presentationModeReady: "Presentation mode is ready.",
presentationModeError: "An error occurred during presentation mode setup.",
sandboxDropModel: "Drag and drop a 3D model into the viewer.",
supportedFormats: "<strong>Supported formats</strong>:\n{formats}\nand <strong>archive formats</strong>: {archives}.",
embedSourceMissing: "Set Model URL or Entity ID for embed.",
embedUrlCopied: "Embed URL copied.",
embedUrlCopyError: "Could not copy embed URL.",
embedIframeCopied: "Embed iframe copied.",
embedIframeCopyError: "Could not copy embed iframe.",
embedCodeCopied: "Embed code copied to clipboard.",
embedCodeCopyError: "Could not copy embed code.",
annotationDataMissing: "Annotation data not found for this POI.",
selectFaceRequired: "Select at least one face to add annotation.",
selectFaceRequiredAgain: "Select at least one face, then run Add annotations again.",
noFacesSelected: "No faces selected for annotation.",
titleRequired: "Title is required.",
facesInactive: "Selected faces are no longer active.",
annotationsSaved: "Annotations saved for {count} face{plural}.",
annotationsImported: "Imported {count} annotation{plural}.",
annotationsExported: "Annotations XML exported.",
noAnnotationsToExport: "No annotations to export.",
noValidAnnotations: "No valid annotations found in XML.",
annotationsImportError: "Failed to import annotations XML.",
settingsSaved: "Settings have been saved.",
settingsSaveError: "Error saving settings.",
featureToggle: "{feature} is {state}.",
clippingHelperToggle: "Clipping plane {axis} helper {state}.",
clippingPlanes: "Drag active clipping plane helper to adjust cut",
downloadSuccess: "Model downloaded successfully.",
downloadError: "Failed to download model.",
iiifManifestGenerated: "IIIF manifest has been generated.",
iiifManifestGenerationError: "Error generating IIIF manifest.",
containerNotFound: "Container element not found. Please check the Viewer configuration.",
missingFiles: "Missing required files for the Viewer. Please check the Viewer configuration.",
unsupportedFileFormat: "Unsupported file format.",
},
shortcuts: {
mouse: "Mouse: drag orbit, wheel zoom, right-drag pan",
keyboard: "Keyboard: Arrows orbit, Shift+Arrows faster, Ctrl/Cmd+Arrows pan, +/- zoom, Space toggle auto-rotate",
touch: "Touch: pinch-to-zoom, drag to orbit, double-tap-and-hold pan",
dragAndDrop: "Or drag and drop a 3D model into the viewer",
},
},
pl: {
menu: {
actions: "Akcje podglądu",
openActions: "Otworz akcje podglądu",
embed: "Osadź",
exitEmbed: "Wyjdź z osadzania",
openEmbedOptions: "Otwórz opcje osadzania",
exitEmbedMode: "Wyjdź z trybu osadzania",
download: "Pobierz",
},
theme: {
lightMode: "Tryb jasny",
darkMode: "Tryb ciemny",
switchToLightMode: "Przelącz na tryb jasny",
switchToDarkMode: "Przelącz na tryb ciemny",
},
fullscreen: {
mode: "Tryb pełnoekranowy",
enter: "Pełny ekran",
exit: "Wyjdź z pełnego ekranu",
exitMode: "Wyjdź z trybu pełnoekranowego",
},
language: {
label: "Language: Polski",
switchToPolish: "Przelącz na język polski",
switchToEnglish: "Przelącz na język angielski",
switchToGerman: "Przelącz na język niemiecki",
},
hints: {
picking: "Shift + klik, aby wybrać wiele scian",
pickingSelect: "Wybierz co najmniej jedną ścianę, aby dodać adnotację.",
clipping: "Przeciągnij aktywną plaszczyznę przycinania, aby dostosować cięcie",
},
controls: {
enablePickingMode: "Włącz tryb wyboru",
disablePickingMode: "Wyłącz tryb wyboru",
enableDistanceMeasurement: "Włącz pomiar odległości",
disableDistanceMeasurement: "Wyłącz pomiar odległości",
selectedFaces: "Wybrane sciany",
},
gui: {
moveToolbar: "Przesuń pasek narzędzi",
orbit: "Tryb nawigacji",
controls: "Sterowanie",
editor: "Edytor",
transform3dObject: "Transformacja obiektu 3D",
transformLight: "Transformacja światła",
transformMode: "Tryb transformacji",
directionalLight: "Światło kierunkowe",
ambientLight: "Światło otaczające",
ambient: "Ambient",
lights: "Światła",
cameraLight: "Światło kamery",
backgroundColor: "Kolor tła",
backgroundColorOuter: "Kolor tła zewnętrznego",
backgroundType: "Typ tła",
clippingPlanes: "Płaszczyzny przycinania",
clippingFolder: "Płaszczyzny przycinania",
displayHelperX: "Pokaż oś X",
displayHelperY: "Pokaż oś Y",
displayHelperZ: "Pokaż oś Z",
constantX: "Stała X",
constantY: "Stała Y",
constantZ: "Stała Z",
visible: "Widoczne cięcie",
materials: "Materiały",
metadata: "Annotacje",
saveProperties: "Zapisz właściwości",
hierarchy: "Hierarchia",
statistics: "Statystyki",
clearSelectedFaces: "Wyczyść wybrane ściany",
clearSelectedHierarchy: "Odznacz wybrane obiekty",
addAnnotations: "Dodaj annotacje",
exportAnnotationsXml: "Eksportuj annotacje XML",
importAnnotationsXml: "Importuj annotacje XML",
IIIFexport: "Eksportuj do IIIF",
resetCameraPosition: "Resetuj pozycję kamery",
saveSettings: "Zapisz ustawienia",
renderPreview: "Renderuj podgląd",
color: "Kolor",
intensity: "Intensywność",
metalness: "Metaliczność",
emissive: "Emisja",
position: "Pozycja",
rotation: "Rotacja",
camera: "Kamera",
performance: "Wydajność",
highPerformance: "Wysoka wydajność",
lowPower: "Niski pobór mocy",
default: "Domyślny",
none: "Brak",
move: "Przesuń",
rotate: "Obróć",
scale: "Skaluj",
target: "Cel",
local: "Lokalny",
global: "Globalny",
linear: "Liniowe",
gradient: "Gradient",
editMaterial: "Edytuj materiał",
selectByMaterial: "wybierz według materiału",
hideAdvancedEditor: "Ukryj tryb zaawansowany",
showAdvancedEditor: "Pokaż tryb zaawansowany",
showLoadingLogs: "Pokaż logi ładowania",
hideLoadingLogs: "Ukryj logi ładowania",
hierarchy: "Hierarchia",
collapse: "Zwiń pasek narzędzi",
expand: "Rozwiń pasek narzędzi",
orthographicProjection: "Przełącz na projekcję ortograficzną",
perspectiveProjection: "Przełącz na projekcję perspektywiczną",
environmentMap: "Mapa otoczenia",
environmentMapToggle: "Przełącz mapę otoczenia",
environmentMapIntensity: "Intensywność mapy otoczenia",
environmentMapStyleNeutral: "Neutralny",
environmentMapStyleSunny: "Słoneczny",
environmentMapStyleStudio: "Studio",
environmentMapStyleGoldenHour: "Złota godzina",
enableWireframeMode: "Włącz tryb siatki",
disableWireframeMode: "Wyłącz tryb siatki",
download: "Pobierz model",
},
metadata: {
modelDetails: "Szczegóły modelu",
metadata: "Metadane",
visualizedFile: "Wizualizowany plik",
vertices: "Wierzchołki",
faces: "Ściany",
title: "Tytuł",
author: "Autor",
authorAffiliation: "Afiliacja autora",
license: "Licencja",
description: "Opis",
objectType: "Typ obiektu",
reconstructionAuthors: "Autorzy rekonstrukcji",
reconstructionPeriod: "Okres rekonstrukcji",
},
manifesto: {
invalidUrl: "Podaj poprawny URL manifestu.",
invalidJson: "Podaj poprawny tekst JSON.",
},
iiif: {
loader: "Ładowanie IIIF",
collapse: "Zwiń",
expand: "Rozwiń",
manifest: "Manifest IIIF",
loadFromUrl: "Wczytaj z URL",
loadFromText: "Wczytaj z tekstu",
manifestUrlPlaceholder: "https://example.org/iiif/manifest.json",
manifestTextPlaceholder: "Wklej tutaj JSON manifestu IIIF...",
invalidUrl: "Podaj poprawny URL manifestu IIIF.",
invalidJson: "Podaj poprawny tekst JSON IIIF.",
optionModelPositionScale: "Pozycja i skala modelu",
optionModelOrigin: "Punkt początkowy modelu",
optionModelOriginBg: "Punkt początkowy modelu z kolorem tła",
optionModelPosition: "Pozycja modelu",
},
localPreview: {
loadExampleModel: "Wczytaj model przykładowy",
},
embedPanel: {
title: "Opcje osadzania",
closeAria: "Zamknij opcje osadzania",
modelUrl: "URL modelu",
modelUrlPlaceholder: "/examples/box.glb",
entityId: "ID encji",
theme: "Motyw",
themeDark: "Ciemny",
themeLight: "Jasny",
autoRotateSpeed: "Prędkość auto-obrotu",
cameraPosition: "Pozycja kamery",
cameraTarget: "Cel kamery",
cameraVectorPlaceholder: "x,y,z",
fov: "FOV",
autoRotate: "Auto-obrót",
disableInteraction: "Wyłącz interakcję",
hideActionMenu: "Ukryj menu akcji",
hideMetadata: "Ukryj metadane",
presentationMode: "Tryb prezentacji",
useCurrentCamera: "Użyj bieżącej kamery",
resetFromViewer: "Przywróć z viewera",
copyUrl: "Kopiuj URL",
copyIframe: "Kopiuj iframe",
embedUrl: "URL osadzenia",
iframeCode: "Kod iframe",
preview: "Podgląd",
previewTitle: "Podgląd osadzenia",
},
loadingLog: {
title: "Log procesu ładowania",
loadingModel: "Ładowanie modelu 3D...",
modelLoaded: "Model został załadowany.",
loadingAssets: "Ładowanie zasobów...",
parsingScene: "Analizowanie sceny...",
loadingTextures: "Ładowanie tekstur...",
preparingGeometry: "Przygotowywanie geometrii...",
settingUpLighting: "Konfigurowanie oświetlenia...",
settingUpMaterials: "Konfigurowanie materiałów...",
buildingBvh: "Budowanie BVH...",
compilingShaders: "Kompilowanie shaderów...",
initializingRenderer: "Inicjalizowanie renderera...",
uploadingBuffers: "Przesyłanie buforów...",
fetchingMetadata: "Pobieranie metadanych...",
},
processingHeader: "Postęp przetwarzania",
processingSteps: {
preparingModel: "Przygotowywanie modelu",
convertingToTransmissionFormat: "Konwersja do formatu docelowego",
renderingThumbnails: "Renderowanie miniaturek",
savingEntity: "Zapisywanie encji",
finalizing3dModel: "Finalizowanie modelu 3D",
initializingViewer: "Inicjalizowanie viewera",
},
state: {
enabled: "włączony",
disabled: "wyłączony"
},
toasts: {
transformMove: "Przesuwanie: przeciągnij strzalki osi, aby przesunąć obiekt.",
transformRotate: "Obracanie: przeciągnij pierscienie obrotu, aby obrócić obiekt.",
transformScale: "Skalowanie: przeciągnij uchwyty osi, aby zmienić rozmiar obiektu.",
transformLightMove: "Transformacja światła - Przesunięcie: przeciągnij strzalki osi, aby przesunąć światło kierunkowe.",
transformLightTarget: "Transformacja światła - Cel: przeciągnij strzałki osi, aby przesunąć punkt celu światła.",
distanceEnabled: "Pomiar odległości jest włączony.",
distanceHint: "Wybierz punkt początkowy i końcowy, aby zmierzyć odległość.",
noIiiifModelFallback: "Nie znaleziono modelu 3D w manifescie IIIF, ładuję model przykładowy.",
facePickingEnabled: "Tryb wyboru ścian jest włączony",
facePickingDisabled: "Tryb wyboru ścian jest wyłączony",
distanceModeEnabled: "Tryb pomiaru odległości jest włączony",
distanceModeDisabled: "Tryb pomiaru odległości jest wyłączony",
settingsFound: "Znaleziono ustawienia {filename}_viewer.json",
settingsNotFound: "Nie znaleziono ustawień {filename}_viewer.json",
metadataFetchError: "Błąd pobierania metadanych: {error}",
presentationModeError: "Wystąpił błąd podczas konfiguracji trybu prezentacji.",
objLoaded: "Model OBJ został załadowany.",
mtlLoadError: "Wystąpił błąd podczas ładowania pliku MTL.",
glbLoadError: "Wystąpił błąd podczas ładowania pliku GLB.",
unsupportedExtension: "Rozszerzenie pliku nie jest jeszcze obsługiwane.",
modelLoaded: "Model {filename} został załadowany.",
modelLoadedSimple: "Model został pomyślnie załadowany.",
unsupportedFormat: "Nieobsługiwany format pliku.",
presentationModeReady: "Tryb prezentacji jest gotowy.",
presentationModeError: "Wystąpił błąd podczas konfiguracji trybu prezentacji.",
sandboxDropModel: "Przeciągnij i upuść model 3D w oknie viewer'a.",
supportedFormats: "<strong>Obłsugiwane formaty</strong>:\n{formats}\ni <strong>archiwa</strong>: {archives}.",
embedSourceMissing: "Ustaw URL modelu lub ID encji do osadzenia.",
embedUrlCopied: "Skopiowano URL osadzenia.",
embedUrlCopyError: "Nie udało się skopiować URL osadzenia.",
embedIframeCopied: "Skopiowano iframe osadzenia.",
embedIframeCopyError: "Nie udało się skopiować iframe.",
embedCodeCopied: "Kod osadzenia został skopiowany do schowka.",
embedCodeCopyError: "Nie udało się skopiować kodu osadzenia.",
annotationDataMissing: "Nie znaleziono danych adnotacji dla tego punktu.",
selectFaceRequired: "Wybierz co najmniej jedną ścianę, aby dodać adnotację.",
selectFaceRequiredAgain: "Wybierz co najmniej jedną ścianę, a następnie ponownie dodaj adnotacje.",
noFacesSelected: "Nie wybrano ścian do adnotacji.",
titleRequired: "Tytuł jest wymagany.",
facesInactive: "Wybrane ściany nie są już aktywne.",
annotationsSaved: "Zapisano adnotacje dla {count} ścian{plural}.",
annotationsImported: "Zaimportowano {count} adnotacj{plural}.",
annotationsExported: "Wyeksportowano XML adnotacji.",
noAnnotationsToExport: "Brak adnotacji do eksportu.",
noValidAnnotations: "Nie znaleziono poprawnych adnotacji w XML.",
annotationsImportError: "Nie udało się zaimportować XML adnotacji.",
settingsSaved: "Ustawienia zostały zapisane.",
settingsSaveError: "Błąd zapisywania ustawień.",
featureToggle: "{feature} jest {state}.",
clippingHelperToggle: "Pomocnik płaszczyzny przycinania {axis} jest {state}.",
clippingPlanes: "Przeciągnij aktywną płaszczyznę przycinania, aby dostosować cięcie",
downloadSuccess: "Model został pomyślnie pobrany.",
downloadError: "Nie udało się pobrać modelu.",
iiifManifestGenerated: "Manifest IIIF został wygenerowany.",
iiifManifestGenerationError: "Błąd podczas generowania manifestu IIIF.",
containerNotFound: "Element kontenera nie został znaleziony. Proszę sprawdzić konfigurację Viewera.",
missingFiles: "Brak wymaganych plików dla Viewera. Proszę sprawdzić konfigurację Viewera.",
unsupportedFileFormat: "Nieobsługiwany format pliku.",
},
shortcuts: {
mouse: "Mysz: przeciągnij, aby obracać, rolka - zoom, prawy przycisk - przesuwanie",
keyboard: "Klawiatura: strzałki - obrót, Shift+strzałki - szybciej, Ctrl/Cmd+strzałki - przesuwanie, +/- — zoom, Spacja - auto-obrót",
touch: "Dotyk: szczypanie, aby przybliżyć, przeciągnij, aby obracać, dotknij i przytrzymaj, aby przesunąć",
dragAndDrop: "Lub przeciągnij i upuść model 3D w oknie viewer'a",
},
},
de: {
menu: {
actions: "Viewer-Aktionen",
openActions: "Viewer-Aktionen öffnen",
embed: "Einbetten",
exitEmbed: "Einbettung beenden",
openEmbedOptions: "Einbettungsoptionen öffnen",
exitEmbedMode: "Einbettungsmodus beenden",
download: "Herunterladen",
},
theme: {
lightMode: "Hellmodus",
darkMode: "Dunkelmodus",
switchToLightMode: "Zu Hellmodus wechseln",
switchToDarkMode: "Zu Dunkelmodus wechseln",
},
fullscreen: {
mode: "Vollbildmodus",
enter: "Vollbild",
exit: "Vollbild beenden",
exitMode: "Vollbildmodus beenden",
},
language: {
label: "Sprache: Deutsch",
switchToPolish: "Zu Polnisch wechseln",
switchToEnglish: "Zu Englisch wechseln",
},
hints: {
picking: "Umschalt + Klick, um mehrere Flächen auszuwählen",
pickingSelect: "Wählen Sie mindestens eine Fläche aus, um eine Anmerkung hinzuzufügen.",
clipping: "Ziehen Sie die aktive Schnittebene-Hilfe, um den Schnitt anzupassen",
},
controls: {
enablePickingMode: "Auswahlmodus aktivieren",
disablePickingMode: "Auswahlmodus deaktivieren",
enableDistanceMeasurement: "Entfernungsmessung aktivieren",
disableDistanceMeasurement: "Entfernungsmessung deaktivieren",
selectedFaces: "Ausgewählte Flächen",
},
gui: {
moveToolbar: "Werkzeugleiste verschieben",
orbit: "Navigationsmodus",
controls: "Steuerung",
editor: "Editor",
transform3dObject: "3D-Objekt transformieren",
transformLight: "Licht transformieren",
transformMode: "Transformationsmodus",
directionalLight: "Gerichtetes Licht",
ambientLight: "Umgebungslicht",
ambient: "Ambient",
lights: "Beleuchtung",
cameraLight: "Kamera-Licht",
backgroundColor: "Hintergrundfarbe",
backgroundColorOuter: "Äußere Hintergrundfarbe",
backgroundType: "Hintergrundtyp",
clippingPlanes: "Schnittebenen",
clippingFolder: "Schnittebenen",
displayHelperX: "X-Hilfe anzeigen",
displayHelperY: "Y-Hilfe anzeigen",
displayHelperZ: "Z-Hilfe anzeigen",
constantX: "Konstante X",
constantY: "Konstante Y",
constantZ: "Konstante Z",
visible: "Sichtbarer Schnitt",
materials: "Materialien",
metadata: "Anmerkungen",
saveProperties: "Eigenschaften speichern",
hierarchy: "Hierarchie",
statistics: "Statistiken",
clearSelectedFaces: "Ausgewählte Flächen löschen",
clearSelectedHierarchy: "Ausgewählte Objekte abwählen",
addAnnotations: "Anmerkungen hinzufügen",
exportAnnotationsXml: "Anmerkungen XML exportieren",
importAnnotationsXml: "Anmerkungen XML importieren",
IIIFexport: "IIIF exportieren",
resetCameraPosition: "Kameraposition zurücksetzen",
saveSettings: "Einstellungen speichern",
renderPreview: "Vorschau rendern",
color: "Farbe",
intensity: "Intensität",
metalness: "Metallizität",
emissive: "Emissiv",
position: "Position",
rotation: "Rotation",
camera: "Kamera",
performance: "Leistung",
highPerformance: "Hohe Leistung",
lowPower: "Niedriger Stromverbrauch",
default: "Standard",
none: "Keine",
move: "Bewegen",
rotate: "Drehen",
scale: "Skalieren",
target: "Ziel",
local: "Lokal",
global: "Global",
linear: "Linear",
gradient: "Farbverlauf",
editMaterial: "Material bearbeiten",
selectByMaterial: "nach Material auswählen",
hideAdvancedEditor: "Erweiterten Editor ausblenden",
showAdvancedEditor: "Erweiterten Editor anzeigen",
showLoadingLogs: "Ladeprotokolle anzeigen",
hideLoadingLogs: "Ladeprotokolle ausblenden",
hierarchy: "Hierarchie",
expand: "Werkzeugleiste erweitern",
collapse: "Werkzeugleiste zusammenklappen",
orthographicProjection: "Zur orthografischen Projektion wechseln",
perspectiveProjection: "Zur perspektivischen Projektion wechseln",
environmentMap: "Umgebungsmap",
environmentMapToggle: "Umgebungsmap wechseln",
environmentMapIntensity: "Intensität der Umgebungsmap",
environmentMapStyleNeutral: "Neutral",
environmentMapStyleSunny: "Sonnig",
environmentMapStyleStudio: "Studio",
environmentMapStyleGoldenHour: "Goldene Stunde",
enableWireframeMode: "Drahtgittermodus aktivieren",
disableWireframeMode: "Drahtgittermodus deaktivieren",
download: "Modell herunterladen",
},
metadata: {
modelDetails: "Modelldetails",
metadata: "Metadaten",
visualizedFile: "Visualisierte Datei",
vertices: "Vertices",
faces: "Flächen",
title: "Titel",
author: "Autor",
authorAffiliation: "Autoren-Zugehörigkeit",
license: "Lizenz",
description: "Beschreibung",
objectType: "Objekttyp",
reconstructionAuthors: "Rekonstruktionsautoren",
reconstructionPeriod: "Rekonstruktionsperiode",
},
manifesto: {
invalidUrl: "Bitte geben Sie eine gültige Manifest-URL ein.",
invalidJson: "Bitte geben Sie einen gültigen Manifest-JSON-Text ein.",
},
iiif: {
loader: "IIIF-Loader",
collapse: "Zusammenklappen",
expand: "Erweitern",
manifest: "IIIF-Manifest",
loadFromUrl: "Von URL laden",
loadFromText: "Von Text laden",
manifestUrlPlaceholder: "https://example.org/iiif/manifest.json",
manifestTextPlaceholder: "IIIF-Manifest-JSON hier einfügen...",
invalidUrl: "Bitte geben Sie eine gültige IIIF-Manifest-URL ein.",
invalidJson: "Bitte geben Sie einen gültigen IIIF-JSON-Text ein.",
optionModelPositionScale: "Modellposition und -skalierung",
optionModelOrigin: "Modellursprung",
optionModelOriginBg: "Modellursprung mit Hintergrundfarbe",
optionModelPosition: "Modellposition",
},
localPreview: {
loadExampleModel: "Beispielmodell laden",
},
embedPanel: {
title: "Einbettungsoptionen",
closeAria: "Einbettungsoptionen schließen",
modelUrl: "Modell-URL",
modelUrlPlaceholder: "/examples/box.glb",
entityId: "Entitäts-ID",
theme: "Thema",
themeDark: "Dunkel",
themeLight: "Hell",
autoRotateSpeed: "Geschwindigkeit der Auto-Rotation",
cameraPosition: "Kameraposition",
cameraTarget: "Kameraziel",
cameraVectorPlaceholder: "x,y,z",
fov: "FOV",
autoRotate: "Auto-Rotation",
disableInteraction: "Interaktion deaktivieren",
hideActionMenu: "Aktionsmenü ausblenden",
hideMetadata: "Metadaten ausblenden",
presentationMode: "Präsentationsmodus",
useCurrentCamera: "Aktuelle Kamera verwenden",
resetFromViewer: "Aus Viewer zurücksetzen",
copyUrl: "URL kopieren",
copyIframe: "Iframe kopieren",
embedUrl: "Einbettungs-URL",
iframeCode: "Iframe-Code",
preview: "Vorschau",
previewTitle: "Einbettungsvorschau",
},
loadingLog: {
title: "Protokoll des Ladeprozesses",
loadingModel: "3D-Modell wird geladen...",
modelLoaded: "Modell wurde geladen.",
loadingAssets: "Assets werden geladen...",
parsingScene: "Szene wird analysiert...",
loadingTextures: "Texturen werden geladen...",
preparingGeometry: "Geometrie wird vorbereitet...",
settingUpLighting: "Beleuchtung wird eingerichtet...",
settingUpMaterials: "Materialien werden eingerichtet...",
buildingBvh: "BVH wird erstellt...",
compilingShaders: "Shader werden kompiliert...",
initializingRenderer: "Renderer wird initialisiert...",
uploadingBuffers: "Buffer werden hochgeladen...",
fetchingMetadata: "Metadaten werden abgerufen...",
},
processingHeader: "Verarbeitungsschritte",
processingSteps: {
preparingModel: "Modell wird vorbereitet",
convertingToTransmissionFormat: "Konvertierung in das Übertragungsformat",
renderingThumbnails: "Vorschaubilder werden gerendert",
savingEntity: "Entität wird gespeichert",
finalizing3dModel: "3D-Modell wird finalisiert",
initializingViewer: "Viewer wird initialisiert",
},
state: {
enabled: "aktiviert",
disabled: "deaktiviert"
},
toasts: {
transformMove: "Bewegen: Ziehen Sie die Achsenpfeile, um das Objekt zu repositionieren.",
transformRotate: "Drehen: Ziehen Sie die Rotationsringe, um das Objekt zu drehen.",
transformScale: "Skalieren: Ziehen Sie die Achsengriffe, um die Größe des Objekts zu ändern.",
transformLightMove: "Licht transformieren - Bewegen: Ziehen Sie die Achsenpfeile, um das gerichtete Licht zu bewegen.",
transformLightTarget: "Licht transformieren - Ziel: Ziehen Sie die Achsenpfeile, um das Lichtziel zu repositionieren.",
distanceEnabled: "Entfernungsmessung ist aktiviert.",
distanceHint: "Wählen Sie einen Startpunkt und einen Endpunkt, um die Entfernung zu messen.",
noIiiifModelFallback: "Kein 3D-Modell im IIIF-Manifest gefunden, Beispielmodell wird geladen.",
facePickingEnabled: "Flächenauswahl ist aktiviert",
facePickingDisabled: "Flächenauswahl ist deaktiviert",
distanceModeEnabled: "Entfernungsmessungsmodus ist aktiviert",
distanceModeDisabled: "Entfernungsmessungsmodus ist deaktiviert",
settingsFound: "Einstellungen {filename}_viewer.json gefunden",
settingsNotFound: "Keine Einstellungen {filename}_viewer.json gefunden",
metadataFetchError: "Fehler beim Abrufen der Metadaten: {error}",
presentationModeError: "Beim Einrichten des Präsentationsmodus ist ein Fehler aufgetreten.",
objLoaded: "OBJ-Modell wurde geladen.",
mtlLoadError: "Fehler beim Laden der zugehörigen MTL-Datei.",
glbLoadError: "Fehler beim Laden der GLB-Datei.",
unsupportedExtension: "Dateierweiterung wird noch nicht unterstützt.",
modelLoaded: "Modell {filename} wurde geladen.",
modelLoadedSimple: "Modell wurde erfolgreich geladen.",
unsupportedFormat: "Nicht unterstütztes Dateiformat.",
presentationModeReady: "Präsentationsmodus ist bereit.",
presentationModeError: "Beim Einrichten des Präsentationsmodus ist ein Fehler aufgetreten.",
sandboxDropModel: "Ziehen Sie ein 3D-Modell per Drag-and-drop in den Viewer.",
supportedFormats: "<strong>Unterstützte Formate:</strong> {formats} \n und <strong>Unterstützte Archive:</strong> {archives}.",
embedSourceMissing: "Model-URL oder Entitäts-ID für die Einbettung festlegen.",
embedUrlCopied: "Einbettungs-URL kopiert.",
embedUrlCopyError: "Einbettungs-URL konnte nicht kopiert werden.",
embedIframeCopied: "Einbettungs-iframe kopiert.",
embedIframeCopyError: "Einbettungs-iframe konnte nicht kopiert werden.",
embedCodeCopied: "Einbettungscode in die Zwischenablage kopiert.",
embedCodeCopyError: "Einbettungscode konnte nicht kopiert werden.",
annotationDataMissing: "Keine Annotationsdaten für diesen Punkt gefunden.",
selectFaceRequired: "Wählen Sie mindestens eine Fläche aus, um eine Annotation hinzuzufügen.",
selectFaceRequiredAgain: "Wählen Sie mindestens eine Fläche und führen Sie dann „Annotationen hinzufügen“ erneut aus.",
noFacesSelected: "Keine Flächen für Annotation ausgewählt.",
titleRequired: "Titel ist erforderlich.",
facesInactive: "Ausgewählte Flächen sind nicht mehr aktiv.",
annotationsSaved: "Annotationen für {count} Fläche{plural} gespeichert.",
annotationsImported: "{count} Annotation{plural} importiert.",
annotationsExported: "Annotations-XML exportiert.",
noAnnotationsToExport: "Keine Annotationen zum Exportieren.",
noValidAnnotations: "Keine gültigen Annotationen im XML gefunden.",
annotationsImportError: "Import der Annotations-XML fehlgeschlagen.",
settingsSaved: "Einstellungen wurden gespeichert.",
settingsSaveError: "Fehler beim Speichern der Einstellungen.",
featureToggle: "{feature} ist {state}.",
clippingHelperToggle: "Clipping-Ebenen-Helfer {axis} ist {state}.",
clippingPlanes: "Ziehen Sie die aktive Schnittebene-Hilfe, um den Schnitt anzupassen",
downloadSuccess: "Modell erfolgreich heruntergeladen.",
downloadError: "Fehler beim Herunterladen des Modells.",
iiifManifestGenerated: "IIIF-Manifest wurde generiert.",
iiifManifestGenerationError: "Fehler beim Generieren des IIIF-Manifests.",
containerNotFound: "Container-Element nicht gefunden. Bitte überprüfen Sie die Viewer-Konfiguration.",
missingFiles: "Fehlende erforderliche Dateien für den Viewer. Bitte überprüfen Sie die Viewer-Konfiguration.",
unsupportedFileFormat: "Nicht unterstütztes Dateiformat.",
},
shortcuts: {
mouse: "Maus: ziehen zum Drehen, Mausrad - Zoom, Rechtsklick - Verschieben",
keyboard: "Tastatur: Pfeile - Drehen, Shift+Pfeile - schneller, Ctrl/Cmd+Pfeile - Verschieben, +/- - Zoom, Leertaste - Auto-Rotation",
touch: "Touch: Pinch-to-Zoom, ziehen zum Drehen, Doppeltippen und halten zum Verschieben",
dragAndDrop: "Oder ziehen Sie ein 3D-Modell per Drag-and-drop in den Viewer",
}
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1 @@
<svg class="svg-icon" style="width: 1em; height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M760.4736 493.9264a25.6 25.6 0 0 0-36.1984 0L512 706.2016V128a25.6 25.6 0 0 0-51.2 0v578.2016L248.5248 493.9264a25.6 25.6 0 0 0-36.1984 36.1984l256 256a25.4976 25.4976 0 0 0 36.2496-0.0512l256-256a25.6 25.6 0 0 0 0-36.1984z" fill="" /><path d="M896 972.8h-819.2C34.4576 972.8 0 938.3424 0 896v-102.4a25.6 25.6 0 0 1 51.2 0v102.4a25.6 25.6 0 0 0 25.6 25.6h819.2a25.6 25.6 0 0 0 25.6-25.6v-102.4a25.6 25.6 0 0 1 51.2 0v102.4c0 42.3424-34.4576 76.8-76.8 76.8z" fill="" /></svg>

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
viewer/img/fullscreen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
viewer/img/hand-hint.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

3
viewer/img/share.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-share" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM11 2.5a2.5 2.5 0 1 1 .603 1.628l-6.718 3.12a2.499 2.499 0 0 1 0 1.504l6.718 3.12a2.5 2.5 0 1 1-.488.876l-6.718-3.12a2.5 2.5 0 1 1 0-3.256l6.718-3.12A2.5 2.5 0 0 1 11 2.5zm-8.5 4a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm11 5.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

3
viewer/init.js Normal file
View file

@ -0,0 +1,3 @@
import * as THREE from "three";
window.THREE = THREE;
export default THREE;

2
viewer/jquery-3.7.1.min.js vendored Executable file

File diff suppressed because one or more lines are too long

1
viewer/js/Toast.min.js vendored Normal file
View file

@ -0,0 +1 @@
"use strict";function Toast(t){if(!t.message)throw new Error("Toast.js - You need to set a message to display");this.options=t,this.options.type=t.type||"default",this.toastContainerEl=document.querySelector(".toastjs-container"),this.toastEl=document.querySelector(".toastjs"),this._init()}Toast.prototype._createElements=function(){var t=this;return new Promise(function(e,o){t.toastContainerEl=document.createElement("div"),t.toastContainerEl.classList.add("toastjs-container"),t.toastContainerEl.setAttribute("role","alert"),t.toastContainerEl.setAttribute("aria-hidden",!0),t.toastEl=document.createElement("div"),t.toastEl.classList.add("toastjs"),t.toastContainerEl.appendChild(t.toastEl),document.body.appendChild(t.toastContainerEl),setTimeout(function(){return e()},500)})},Toast.prototype._addEventListeners=function(){var t=this;if(document.querySelector(".toastjs-btn--close").addEventListener("click",function(){t._close()}),this.options.customButtons){var e=Array.prototype.slice.call(document.querySelectorAll(".toastjs-btn--custom"));e.map(function(e,o){e.addEventListener("click",function(e){return t.options.customButtons[o].onClick(e)})})}},Toast.prototype._close=function(){var t=this;return new Promise(function(e,o){t.toastContainerEl.setAttribute("aria-hidden",!0),setTimeout(function(){t.toastEl.innerHTML="",t.toastEl.classList.remove("default","success","warning","danger"),t.focusedElBeforeOpen&&t.focusedElBeforeOpen.focus(),e()},1e3)})},Toast.prototype._open=function(){this.toastEl.classList.add(this.options.type),this.toastContainerEl.setAttribute("aria-hidden",!1);var t="";this.options.customButtons&&(t=this.options.customButtons.map(function(t,e){return'<button type="button" class="toastjs-btn toastjs-btn--custom">'+t.text+"</button>"}),t=t.join("")),this.toastEl.innerHTML="\n <p>"+this.options.message+'</p>\n <button type="button" class="toastjs-btn toastjs-btn--close">Close</button>\n '+t+"\n ",this.focusedElBeforeOpen=document.activeElement,document.querySelector(".toastjs-btn--close").focus()},Toast.prototype._init=function(){var t=this;Promise.resolve().then(function(){return t.toastContainerEl?Promise.resolve():t._createElements()}).then(function(){return"false"==t.toastContainerEl.getAttribute("aria-hidden")?t._close():Promise.resolve()}).then(function(){t._open(),t._addEventListeners()})};

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,565 @@
var __extends =
(this && this.__extends) ||
(function () {
var extendStatics = function (d, b) {
extendStatics =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (d, b) {
d.__proto__ = b;
}) ||
function (d, b) {
for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
};
return extendStatics(d, b);
};
return function (d, b) {
extendStatics(d, b);
function __() {
this.constructor = d;
}
d.prototype =
b === null
? Object.create(b)
: ((__.prototype = b.prototype), new __());
};
})();
var lv = /** @class */ (function () {
function lv() {
this.observer = new MutationObserver(this.callback);
}
/**
* iterates through all elements and calls function create on them
*/
lv.prototype.initLoaderAll = function () {
var divs = document.getElementsByTagName("DIV");
for (var i = 0; i < divs.length; i++) {
if (!divs[i].hasChildNodes()) {
lv.create(divs[i]);
}
}
};
/**
* returns list of non-main classes (every except the one that specifies the element)
* @param classList
* @param notIncludingClass
*/
lv.getModifyingClasses = function (classList, notIncludingClass) {
var modifyingClasses = [];
for (var i = 0; i < classList.length; i++) {
if (classList[i] != notIncludingClass) {
modifyingClasses.push(classList[i]);
}
}
return modifyingClasses;
};
/**
* decides type of passed element and returns its object
* @param element - pass existing element or null
* @param classString - classes separated with one space that specifies type of element, optional, only when passing null instead of element
*/
lv.create = function (element, classString) {
if (element === void 0) {
element = null;
}
var classes = [];
if (element != null) {
var listOfClasses = element.classList;
for (var i = 0; i < listOfClasses.length; i++) {
classes.push(listOfClasses[i]);
}
} else if (classString != null) {
classes = classString.split(" ");
}
for (var i = 0; i < classes.length; i++) {
switch (classes[i]) {
case "lv-bars":
return new lv.Circle(
element,
lv.CircleType.Bars,
lv.getModifyingClasses(classes, "lv-bars")
);
case "lv-squares":
return new lv.Circle(
element,
lv.CircleType.Squares,
lv.getModifyingClasses(classes, "lv-squares")
);
case "lv-circles":
return new lv.Circle(
element,
lv.CircleType.Circles,
lv.getModifyingClasses(classes, "lv-circles")
);
case "lv-dots":
return new lv.Circle(
element,
lv.CircleType.Dots,
lv.getModifyingClasses(classes, "lv-dots")
);
case "lv-spinner":
return new lv.Circle(
element,
lv.CircleType.Spinner,
lv.getModifyingClasses(classes, "lv-spinner")
);
case "lv-dashed":
return new lv.Circle(
element,
lv.CircleType.Dashed,
lv.getModifyingClasses(classes, "lv-dashed")
);
case "lv-determinate_circle":
return new lv.Circle(
element,
lv.CircleType.DeterminateCircle,
lv.getModifyingClasses(classes, "lv-determinate_circle")
);
case "lv-line":
return new lv.Bar(
element,
lv.BarType.Line,
lv.getModifyingClasses(classes, "lv-line")
);
case "lv-bordered_line":
return new lv.Bar(
element,
lv.BarType.BorderedLine,
lv.getModifyingClasses(classes, "lv-bordered_line")
);
case "lv-determinate_line":
return new lv.Bar(
element,
lv.BarType.DeterminateLine,
lv.getModifyingClasses(classes, "lv-determinate_line")
);
case "lv-determinate_bordered_line":
return new lv.Bar(
element,
lv.BarType.DeterminateBorderedLine,
lv.getModifyingClasses(classes, "lv-determinate_bordered_line")
);
}
}
return null;
};
/**
* observes for changes in DOM and creates new element's objects
* @param mutationList
* @param observer
*/
lv.prototype.callback = function (mutationList, observer) {
for (var i = 0; i < mutationList.length; i++) {
if (mutationList[i].type === "childList") {
try {
if (mutationList[i].addedNodes[0].classList.length > 0) {
// filling the node with divs when it is empty
lv.create(mutationList[i].addedNodes[0]);
}
} catch (error) {}
}
}
};
lv.prototype.startObserving = function () {
this.observer.observe(document.body, { childList: true, subtree: true });
};
lv.prototype.stopObserving = function () {
this.observer.disconnect();
};
return lv;
})();
(function (lv) {
/**
* specifies functions same for all elements
*/
var ElementBase = /** @class */ (function () {
function ElementBase(element) {
this.element = element === null ? document.createElement("div") : element;
}
ElementBase.prototype.show = function () {
this.element.style.display = null;
};
ElementBase.prototype.hide = function () {
this.element.style.display = "none";
};
ElementBase.prototype.remove = function () {
this.element.parentNode.removeChild(this.element);
};
ElementBase.prototype.setLabel = function (labelText) {
this.element.setAttribute("data-label", labelText);
};
ElementBase.prototype.removeLabel = function () {
this.element.removeAttribute("data-label");
};
ElementBase.prototype.showPercentage = function () {
this.element.setAttribute("data-percentage", "true");
};
ElementBase.prototype.hidePercentage = function () {
this.element.removeAttribute("data-percentage");
};
ElementBase.prototype.setId = function (idText) {
this.element.setAttribute("id", idText);
};
ElementBase.prototype.removeId = function () {
this.element.removeAttribute("id");
};
/**
* adds class or classes to element
* @param classString - string that contains classes separated with one space
*/
ElementBase.prototype.addClass = function (classString) {
var classList = classString.split(" ");
for (var i = 0; i < classList.length; i++) {
this.element.classList.add(classList[i]);
}
};
/**
* if element contains specified class or classes, it/they are removed
* @param classString - string that contains classes separated with one space
*/
ElementBase.prototype.removeClass = function (classString) {
var classList = classString.split(" ");
for (var i = 0; i < classList.length; i++) {
if (this.element.classList.contains(classList[i])) {
this.element.classList.remove(classList[i]);
}
}
};
/**
* returns DOM element - needed for placing or removing the element with jquery
*/
ElementBase.prototype.getElement = function () {
return this.element;
};
/**
* resets determinate element to 0
* @param maxValue
*/
ElementBase.prototype.reset = function (maxValue) {
this.update("set", 0, maxValue);
};
/**
* sets determinate element to 100%
* @param maxValue
*/
ElementBase.prototype.fill = function (maxValue) {
this.update("set", maxValue, maxValue);
};
/**
* adds positive or negative value to a determinate element
* @param addValue
* @param maxValue
*/
ElementBase.prototype.add = function (addValue, maxValue) {
this.update("add", addValue, maxValue);
};
/**
* sets loading bar to passed value
* @param value
* @param maxValue
*/
ElementBase.prototype.set = function (value, maxValue) {
this.update("set", value, maxValue);
};
/**
* initializes an element
* @param loaderElement
* @param description
*/
ElementBase.prototype.initLoader = function (loaderElement, description) {
// manual addition on specified object
if (!loaderElement.hasChildNodes()) {
this.fillElement(
loaderElement,
description.className,
description.divCount
);
}
};
/**
* fills element with appropriate number of divs
* @param element
* @param elementClass
* @param divNumber
*/
ElementBase.prototype.fillElement = function (
element,
elementClass,
divNumber
) {
for (var i = 0; i < divNumber; i += 1) {
element.appendChild(document.createElement("DIV"));
}
if (
elementClass === "lv-determinate_circle" ||
elementClass === "lv-determinate_line" ||
elementClass === "lv-determinate_bordered_line"
) {
element.lastElementChild.innerHTML = "0";
}
if (!element.classList.contains(elementClass)) {
element.classList.add(elementClass);
}
};
return ElementBase;
})();
lv.ElementBase = ElementBase;
/**
* class for linear elements
*/
var Bar = /** @class */ (function (_super) {
__extends(Bar, _super);
/**
* creates linear element
* @param element
* @param barType
* @param classes
*/
function Bar(element, barType, classes) {
if (classes === void 0) {
classes = null;
}
var _this = _super.call(this, element) || this;
_this.divCount = {};
_this.divCount[BarType.Line] = { className: "lv-line", divCount: 1 };
_this.divCount[BarType.BorderedLine] = {
className: "lv-bordered_line",
divCount: 1,
};
_this.divCount[BarType.DeterminateLine] = {
className: "lv-determinate_line",
divCount: 2,
};
_this.divCount[BarType.DeterminateBorderedLine] = {
className: "lv-determinate_bordered_line",
divCount: 2,
};
_this.initLoader(_this.element, _this.divCount[barType]);
for (var i = 0; i < classes.length; i++) {
_this.element.classList.add(classes[i]);
}
return _this;
}
/**
* type specific update function for linear element
* @param type
* @param newValue
* @param maxValue
*/
Bar.prototype.update = function (type, newValue, maxValue) {
// getting current width of line from the page
var line = this.element.firstElementChild;
var percentage = this.element.lastElementChild;
var currentWidth = parseFloat(line.style.width);
// protective condition for empty line
if (isNaN(currentWidth)) {
currentWidth = 0;
}
// end point of the animation
var goalWidth;
if (type === "add") {
goalWidth =
currentWidth + Math.round((newValue / maxValue) * 1000) / 10;
} else if (type === "set") {
goalWidth = Math.round((newValue / maxValue) * 1000) / 10;
}
// prevent overflow from both sides
if (goalWidth > 100) {
goalWidth = 100.0;
}
if (goalWidth < 0) {
goalWidth = 0;
}
var animation = setInterval(frame, 5);
function frame() {
if (currentWidth > goalWidth) {
// shortening the line
if (currentWidth < goalWidth + 0.01) {
clearInterval(animation);
} else {
currentWidth -= 0.1;
}
} else {
// extending the line
if (currentWidth > goalWidth - 0.01) {
clearInterval(animation);
} else {
currentWidth += 0.1;
}
}
line.style.width = currentWidth + "%";
// updating the percentage
percentage.innerHTML = currentWidth.toFixed(1);
}
};
return Bar;
})(ElementBase);
lv.Bar = Bar;
/**
* class for square or circular elements
*/
var Circle = /** @class */ (function (_super) {
__extends(Circle, _super);
/**
* creates square or circular element
* @param element
* @param circleType
* @param classes
*/
function Circle(element, circleType, classes) {
if (classes === void 0) {
classes = null;
}
var _this = _super.call(this, element) || this;
_this.divCount = {};
_this.divCount[CircleType.Bars] = { className: "lv-bars", divCount: 8 };
_this.divCount[CircleType.Squares] = {
className: "lv-squares",
divCount: 4,
};
_this.divCount[CircleType.Circles] = {
className: "lv-circles",
divCount: 12,
};
_this.divCount[CircleType.Dots] = { className: "lv-dots", divCount: 4 };
_this.divCount[CircleType.DeterminateCircle] = {
className: "lv-determinate_circle",
divCount: 4,
};
_this.divCount[CircleType.Spinner] = {
className: "lv-spinner",
divCount: 1,
};
_this.divCount[CircleType.Dashed] = {
className: "lv-dashed",
divCount: 1,
};
_this.initLoader(_this.element, _this.divCount[circleType]);
for (var i = 0; i < classes.length; i++) {
_this.element.classList.add(classes[i]);
}
return _this;
}
/**
* type specific update function for non-linear elements
* @param type
* @param newValue
* @param maxValue
*/
Circle.prototype.update = function (type, newValue, maxValue) {
var rotationOffset = -45; // initial rotation of the spinning div in css
// separating individual parts of the circle
var background = this.element.children[0];
var overlay = this.element.children[1];
var spinner = this.element.children[2];
var percentage = this.element.children[3];
// getting the colors defined in css
var backgroundColor = window.getComputedStyle(background).borderTopColor;
var spinnerColor = window.getComputedStyle(spinner).borderTopColor;
// computing current rotation of spinning part of circle using rotation matrix
var rotationMatrix = window
.getComputedStyle(spinner)
.getPropertyValue("transform")
.split("(")[1]
.split(")")[0]
.split(",");
var currentAngle =
Math.round(
Math.atan2(
parseFloat(rotationMatrix[1]),
parseFloat(rotationMatrix[0])
) *
(180 / Math.PI)
) - rotationOffset;
// safety conditions for full and empty circle (360 <=> 0 and that caused problems)
if (percentage.innerHTML === "100") {
currentAngle = 360;
}
if (currentAngle < 0) {
currentAngle += 360;
}
// end point of the animation
var goalAngle;
if (type === "add") {
goalAngle = currentAngle + Math.round((newValue / maxValue) * 360);
} else if (type === "set") {
goalAngle = Math.round((newValue / maxValue) * 360);
}
// prevent overflow to both sides
if (goalAngle > 360) {
goalAngle = 360;
}
if (goalAngle < 0) {
goalAngle = 0;
}
var id = setInterval(frame, 3);
function frame() {
if (currentAngle === goalAngle) {
// stopping the animation when end point is reached
clearInterval(id);
} else {
if (currentAngle < goalAngle) {
// "filling" the circle
if (currentAngle === 90) {
background.style.borderRightColor = spinnerColor;
overlay.style.borderTopColor = "transparent";
} else if (currentAngle === 180) {
background.style.borderBottomColor = spinnerColor;
} else if (currentAngle === 270) {
background.style.borderLeftColor = spinnerColor;
}
currentAngle += 1;
} else {
// "emptying the circle"
if (currentAngle === 270) {
background.style.borderLeftColor = backgroundColor;
} else if (currentAngle === 180) {
background.style.borderBottomColor = backgroundColor;
} else if (currentAngle === 90) {
background.style.borderRightColor = backgroundColor;
overlay.style.borderTopColor = backgroundColor;
}
currentAngle -= 1;
}
// rotating the circle
spinner.style.transform =
"rotate(" + (rotationOffset + currentAngle).toString() + "deg)";
// updating percentage
percentage.innerHTML = Math.round(
(currentAngle / 360) * 100
).toString();
}
}
};
return Circle;
})(ElementBase);
lv.Circle = Circle;
/**
* list of linear elements
*/
var BarType;
(function (BarType) {
BarType[(BarType["Line"] = 0)] = "Line";
BarType[(BarType["BorderedLine"] = 1)] = "BorderedLine";
BarType[(BarType["DeterminateLine"] = 2)] = "DeterminateLine";
BarType[(BarType["DeterminateBorderedLine"] = 3)] =
"DeterminateBorderedLine";
})((BarType = lv.BarType || (lv.BarType = {})));
/**
* list of non-linear elements
*/
var CircleType;
(function (CircleType) {
CircleType[(CircleType["Bars"] = 0)] = "Bars";
CircleType[(CircleType["Squares"] = 1)] = "Squares";
CircleType[(CircleType["Circles"] = 2)] = "Circles";
CircleType[(CircleType["Dots"] = 3)] = "Dots";
CircleType[(CircleType["DeterminateCircle"] = 4)] = "DeterminateCircle";
CircleType[(CircleType["Spinner"] = 5)] = "Spinner";
CircleType[(CircleType["Dashed"] = 6)] = "Dashed";
})((CircleType = lv.CircleType || (lv.CircleType = {})));
})(lv || (lv = {}));
export { lv };
//# sourceMappingURL=main.js.map

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

BIN
viewer/js/maps/neutral.hdr Normal file

Binary file not shown.

BIN
viewer/js/maps/sunny.hdr Normal file

Binary file not shown.

745
viewer/loaders.js Normal file
View file

@ -0,0 +1,745 @@
import THREE from "./init.js";
export const loadDDSLoader = async () => (await import("three/examples/jsm/loaders/DDSLoader.js")).DDSLoader;
export const loadMTLLoader = async () => (await import("three/examples/jsm/loaders/MTLLoader.js")).MTLLoader;
export const loadOBJLoader = async () => (await import("three/examples/jsm/loaders/OBJLoader.js")).OBJLoader;
export const loadFBXLoader = async () => (await import("three/examples/jsm/loaders/FBXLoader.js")).FBXLoader;
export const loadPLYLoader = async () => (await import("three/examples/jsm/loaders/PLYLoader.js")).PLYLoader;
export const loadColladaLoader = async () => (await import("three/examples/jsm/loaders/ColladaLoader.js")).ColladaLoader;
export const loadSTLLoader = async () => (await import("three/examples/jsm/loaders/STLLoader.js")).STLLoader;
export const loadXYZLoader = async () => (await import("three/examples/jsm/loaders/XYZLoader.js")).XYZLoader;
export const loadTDSLoader = async () => (await import("three/examples/jsm/loaders/TDSLoader.js")).TDSLoader;
export const loadPCDLoader = async () => (await import("three/examples/jsm/loaders/PCDLoader.js")).PCDLoader;
export const loadGLTFLoader = async () => (await import("three/examples/jsm/loaders/GLTFLoader.js")).GLTFLoader;
export const loadDRACOLoader = async () => (await import("three/examples/jsm/loaders/DRACOLoader.js")).DRACOLoader;
export const loadIFCLoader = async () => (await import("./js/loaders/IFCLoader.js")).IFCLoader;
export const loadRoomEnvironment = async () => (await import("three/examples/jsm/environments/RoomEnvironment.js")).RoomEnvironment;
export const loadHDRLoader = async () => (await import("three/examples/jsm/loaders/HDRLoader.js")).HDRLoader;
import { core } from './core.js';
import { fetchSettings, presentationMode } from "./metadata.js";
import { reportViewerError, showToast, toastHelper } from "./viewer-utils.js";
export var outlineClipping;
let environmentTextureCache = {};
const loaderMap = {
gltf: loadGLTFLoader,
glb: loadGLTFLoader,
obj: loadOBJLoader,
fbx: loadFBXLoader,
ply: loadPLYLoader,
stl: loadSTLLoader,
dae: loadColladaLoader,
xyz: loadXYZLoader,
'3ds': loadTDSLoader,
pcd: loadPCDLoader,
ifc: loadIFCLoader
};
async function createLoader(ext) {
const loadLoader = loaderMap[ext];
if (!loadLoader) {
throw new Error(`Unsupported format: ${ext}`);
}
const LoaderClass = await loadLoader();
return new LoaderClass();
}
const ENV_BUILD = __BUILD__;
const MODULES_PATH = __MODULES_PATH__;
const ENV_SUBDIR = __ENV_SUBDIR__;
console.log('[loaders] ENV_BUILD:', ENV_BUILD);
console.log('[loaders] MODULES_PATH:', MODULES_PATH);
console.log('[loaders] ENV_SUBDIR:', ENV_SUBDIR);
function normalizeWasmPath(path) {
if (typeof window === 'undefined' || !path) return path;
let normalized = path.trim();
// Force secure scheme for explicit http resources
if (normalized.startsWith('http://')) {
normalized = 'https://' + normalized.slice('http://'.length);
} else if (normalized.startsWith('//')) {
normalized = `${window.location.protocol}${normalized}`;
} else if (normalized.startsWith('/')) {
normalized = `${window.location.protocol}//${window.location.host}${normalized}`;
} else if (!/^[a-zA-Z][\w+-.]*:/.test(normalized)) {
normalized = new URL(normalized, window.location.href).href;
}
// Normalize duplicate slashes while keeping protocol separator intact
try {
const url = new URL(normalized);
url.pathname = url.pathname.replace(/\/\/{2,}/g, '/');
normalized = url.href;
} catch (err) {
normalized = normalized.replace(/\/\/{2,}/g, '/');
}
return normalized;
}
function sanitizeModuleAssetBasePath(input) {
if (!input || typeof input !== 'string') {
return '';
}
let basePath = input.trim().replace(/\/$/, '');
if (!basePath) {
return '';
}
if (/^[a-zA-Z][\w+-.]*:\/\//.test(basePath)) {
try {
const url = new URL(basePath);
const hostSegment = `/${url.host}`;
if (url.pathname.startsWith(hostSegment)) {
url.pathname = url.pathname.slice(hostSegment.length) || '/';
}
url.pathname = url.pathname.replace(/\/\/{2,}/g, '/');
return url.href.replace(/\/$/, '');
} catch (_err) {
return basePath;
}
}
if (/^[^\/]+\.[^\/]+\/.+/.test(basePath)) {
const parts = basePath.split('/');
const host = parts.shift();
const remainder = `/${parts.join('/')}`.replace(/\/\/{2,}/g, '/');
if (
host &&
typeof window !== 'undefined' &&
(host === window.location.host || /^\w[\w.-]*\.[a-z]{2,}$/i.test(host))
) {
return remainder.replace(/\/$/, '');
}
}
if (/^\/[^\/]+\.[^\/]+\/.+/.test(basePath)) {
const parts = basePath.split('/').filter(Boolean);
const host = parts.shift();
const remainder = `/${parts.join('/')}`.replace(/\/\/{2,}/g, '/');
if (
host &&
typeof window !== 'undefined' &&
(host === window.location.host || /^\w[\w.-]*\.[a-z]{2,}$/i.test(host))
) {
return remainder.replace(/\/$/, '');
}
}
return basePath.replace(/\/\/{2,}/g, '/');
}
function prepareOutlineClipping(_object) {
core.outlineClipping = _object.clone(true);
var gutsMaterial = new THREE.MeshBasicMaterial({
color: "crimson",
side: THREE.BackSide,
clippingPlanes: core.clippingPlanes,
clipShadows: true,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
});
core.outlineClipping.traverse(function (child) {
if (child.type == "Mesh" || child.type == "Object3D") {
child.material = gutsMaterial;
}
});
core.outlineClipping.visible = false;
return core.outlineClipping;
}
function setupSingleMaterial(materials, material) {
if (material.map) {
material.map.anisotropy = 16;
material.map.colorSpace = THREE.SRGBColorSpace;
}
material.envMapIntensity = 0.76;
material.roughness = Math.min(material.roughness * 1.35, 1);
material.clipShadows = true;
material.side = core.PRESENTATION_MODE ? THREE.DoubleSide : THREE.FrontSide;
material.clippingPlanes = core.PRESENTATION_MODE ? [] : core.clippingPlanes;
//material.clipIntersection = false;
material.onBeforeCompile = (shader) => {
shader.fragmentShader = shader.fragmentShader.replace(
'reflectedLight.directSpecular += directSpecular;',
`
reflectedLight.directSpecular += directSpecular * 0.15;
`
);
};
material.needsUpdate = true;
if (material.name === "") material.name = material.uuid;
var newMaterial = { name: material.name, uuid: material.uuid };
if (!materials.some((item) => item.uuid === newMaterial.uuid)) materials.push(newMaterial);
}
function setupMaterials(_object) {
var materials = [];
if (_object.isMesh) {
_object.castShadow = true;
_object.receiveShadow = true;
if (
_object.geometry &&
typeof _object.geometry.computeVertexNormals === "function" &&
!_object.geometry.getAttribute?.("normal")
) {
_object.geometry.computeVertexNormals();
}
if (_object.material.isMaterial) {
setupSingleMaterial(materials, _object.material);
} else if (Array.isArray(_object.material)) {
_object.material.forEach((material) =>
setupSingleMaterial(materials, material)
);
}
}
return materials;
}
function getMaterialByID(_object, _uuid) {
var _material;
_object.traverse(function (child) {
if (
child.isMesh &&
child.material.isMaterial &&
child.material.uuid === _uuid
) {
_material = child.material;
}
});
return _material;
}
function traverseMesh(object) {
setupMaterials(object);
object.traverse(function (child) {
setupMaterials(child);
});
if (window.Viewer?.initializeMaterialsEditor && core.PRESENTATION_MODE !== true) {
window.Viewer.initializeMaterialsEditor(object);
}
}
function getEnvironmentTextureForPreset(renderer, preset = "neutral") {
if (!renderer) return Promise.resolve(null);
core.scene.environmentIntensity = core.environmentMapIntensity || 0.5;
// Initialize cache for this preset if not exists
if (!environmentTextureCache[preset]) {
environmentTextureCache[preset] = (async () => {
const pmrem = new THREE.PMREMGenerator(renderer);
try {
if (preset === "studio") {
// Studio uses RoomEnvironment
const TempRoomEnvironment = await loadRoomEnvironment();
return pmrem.fromScene(new TempRoomEnvironment()).texture;
} else if (preset === "neutral" || preset === "sunny" || preset === "goldenHour") {
// Load HDR map for other presets
const HDRLoader = await loadHDRLoader();
const loader = new HDRLoader();
const baseModulePath = core.DFG_ASSETS || core.CONFIG?.baseModulePath || '/assets';
const mapFilename = preset === "goldenHour" ? "golden_hour.hdr" : `${preset}.hdr`;
const mapUrl = `${baseModulePath.replace(/\/$/, '')}/maps/${mapFilename}`;
const texture = await new Promise((resolve, reject) => {
loader.load(mapUrl, resolve, undefined, reject);
});
texture.mapping = THREE.EquirectangularReflectionMapping;
return pmrem.fromEquirectangular(texture).texture;
}
return null;
} finally {
pmrem.dispose();
}
})();
}
return environmentTextureCache[preset];
}
function getEnvironmentTexture(renderer) {
// Legacy function for backwards compatibility
return getEnvironmentTextureForPreset(renderer, "neutral");
}
function markEnvironmentMaterialsDirty(root) {
root?.traverse?.((child) => {
const materials = child?.material
? (Array.isArray(child.material) ? child.material : [child.material])
: [];
materials.forEach((material) => {
if (material?.isMeshStandardMaterial || material?.isMeshPhysicalMaterial) {
material.needsUpdate = true;
}
});
});
}
export async function syncSceneEnvironment(enabled = true, preset = null) {
if (!core.scene) return;
// Use provided preset or fall back to viewer's preset, then neutral
const effectivePreset = preset || window.viewer?.environmentMapPreset || "neutral";
if (enabled) {
core.scene.environment = await getEnvironmentTextureForPreset(core.renderer, effectivePreset);
if (core.scene.environmentIntensity === 0) {
core.scene.environmentIntensity = 0.5;
}
} else {
core.scene.environment = null;
core.scene.environmentIntensity = 0;
}
markEnvironmentMaterialsDirty(core.scene);
}
function reportLoadError(error, context = "") {
const message = reportViewerError(error, {
context,
consoleLabel: "Viewer load error:",
});
core.circle?.complete?.(1200);
if (typeof core.EXIT_CODE !== "undefined") core.EXIT_CODE = 1;
return message;
}
export async function loadModel() {
core.loadingLog?.start?.();
let modelPath = core.fileObject.filename.startsWith('blob:') ? core.fileObject.filename : core.fileObject.path + core.fileObject.filename;
if (core.CONFIG.entity.proxyPath !== undefined && !core.fileObject.filename.startsWith('blob:')) {
modelPath = core.getProxyPath(modelPath, core.CONFIG, core.fileObject);
}
function loadAsync(loader, url, progressHandler = onProgress) {
return new Promise((resolve, reject) => {
loader.load(url, resolve, progressHandler, reject);
});
}
function updateLoadingStage(stageKey, progressValue = null) {
core.circle?.setStage?.(stageKey, progressValue);
core.loadingLog?.setStage?.(stageKey, progressValue);
}
async function afterLoad({ object }) {
if (object === null || typeof object === "undefined") {
throw new Error("Loaded object is null or undefined.");
}
updateLoadingStage("loadingLog.loadingTextures", 99);
// Keep authoring transforms in presentation mode to avoid collapsing model parts.
if (!core.PRESENTATION_MODE) {
// Reset transform to ensure consistent positioning
if (Array.isArray(object)) {
object.forEach(obj => {
obj.position.set(0, 0, 0);
obj.rotation.set(0, 0, 0);
obj.scale.set(1, 1, 1);
obj.updateMatrixWorld(true);
});
} else {
object.position.set(0, 0, 0);
object.rotation.set(0, 0, 0);
object.scale.set(1, 1, 1);
object.updateMatrixWorld(true);
}
core.handHint.hidden = true;
}
window.viewer.modelLoaded = true;
updateLoadingStage("loadingLog.preparingGeometry", 99);
traverseMesh(object);
if (!core.PRESENTATION_MODE) {
const isArchiveDerivedPath = /\/[^/]+_(ZIP|RAR|TAR|XZ|GZ)\/gltf\/$/i.test(core.fileObject.path);
if (!isArchiveDerivedPath) {
if (core.fileObject.extension.toLowerCase() === "gltf" || core.fileObject.extension.toLowerCase() === "glb") {
core.fileObject.path = core.fileObject.path.replace("/gltf/", "/");
} else {
core.fileObject.path = core.fileObject.path.replace("gltf/", "");
}
}
updateLoadingStage("loadingLog.fetchingMetadata", 99);
await fetchSettings(object);
updateLoadingStage("loadingLog.settingUpMaterials", 99);
core.outlineClipping = prepareOutlineClipping(object);
if (Array.isArray(object)) {
core.helperObjects.push(object[0]);
} else {
core.helperObjects.push(object);
}
core.scene.add(core.outlineClipping);
} else {
updateLoadingStage("loadingLog.settingUpMaterials", 99);
presentationMode(object, null).catch(error => {
reportLoadError(error, "Presentation mode setup failed");
showToast("toasts.presentationModeError", "error");
});
}
updateLoadingStage("loadingLog.settingUpLighting", 99);
if (Array.isArray(object)) {
object.forEach(o => core.scene.add(o));
} else {
core.scene.add(object);
}
core.mainObject.push(object);
updateLoadingStage("loadingLog.compilingShaders", 99);
await syncSceneEnvironment(core.environmentMapEnabled !== false);
}
async function loadOBJWithMTL() {
const DDSLoader = await loadDDSLoader();
const MTLLoader = await loadMTLLoader();
const OBJLoader = await loadOBJLoader();
const manager = new THREE.LoadingManager();
manager.onLoad = () => toastHelper("objLoaded", "success");
manager.addHandler(/\.dds$/i, new DDSLoader());
const basename = core.fileObject.filename.replace(/\.[^/.]+$/, "");
const filename = core.fileObject.filename;
if (!core.CONFIG.noMTL) {
try {
const materials = await new Promise((resolve, reject) => {
new MTLLoader(manager)
.setPath(core.fileObject.path)
.load(basename + ".mtl", resolve, undefined, reject);
});
materials.preload();
const obj = await new Promise((resolve, reject) => {
new OBJLoader(manager)
.setMaterials(materials)
.setPath(core.fileObject.path)
.load(filename, resolve, onProgress, reject);
});
obj.position.set(0, 0, 0);
return obj;
} catch (error) {
core.CONFIG.noMTL = true;
toastHelper("mtlLoadError", "error");
console.warn("MTL load failed, falling back to OBJ-only load.", error);
}
}
const obj = await new Promise((resolve, reject) => {
new OBJLoader()
.setPath(core.fileObject.path)
.load(filename, resolve, onProgress, reject);
});
obj.position.set(0, 0, 0);
return obj;
}
function normalizePath(path) {
if (!path || typeof path !== 'string') {
return path;
}
if (/^[a-zA-Z][\w+-.]*:\/\//.test(path)) {
try {
const url = new URL(path);
url.pathname = url.pathname.replace(/\/{2,}/g, '/');
return url.href;
} catch (_err) {
return path;
}
}
return path.replace(/\/{2,}/g, '/');
}
async function resolveIfcWasmPath(basePath) {
const candidates = [
normalizePath(basePath.replace(/\/$/, '') + '/ifc/'),
normalizePath(basePath.replace(/\/$/, '') + '/ifc'),
];
for (const candidate of candidates) {
const wasmUrl = candidate.replace(/\/$/, '') + '/web-ifc.wasm';
try {
const res = await fetch(wasmUrl, { method: 'HEAD', cache: 'no-store' });
if (res.ok) {
return candidate;
}
} catch (err) {
// ignored, try next candidate
}
}
return null;
}
async function loadGLTFModel() {
let gltfModelPath = core.fileObject.filename.startsWith('blob:') ? core.fileObject.filename : core.fileObject.path + core.fileObject.basename + "." + core.fileObject.extension;
if (core.CONFIG.entity.proxyPath !== undefined && !core.fileObject.filename.startsWith('blob:')) {
gltfModelPath = core.getProxyPath(gltfModelPath);
}
const dracoBase = normalizePath(normalizeWasmPath(`${getModuleAssetBasePath()}/draco/gltf/`));
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const DRACOLoader = await loadDRACOLoader();
const draco = new DRACOLoader();
if (ENV_BUILD !== 'test' && ENV_BUILD !== 'dev') {
draco.setDecoderConfig({ type: 'js' });
}
draco.setDecoderPath(dracoBase);
loader.setDRACOLoader(draco);
try {
const gltf = await new Promise((resolve, reject) => {
loader.load(
gltfModelPath,
resolve,
(xhr) => {
progressLoaderHandler(xhr);
},
reject
);
});
return gltf.scene;
} finally {
draco.dispose();
}
}
try {
switch (core.fileObject.extension.toLowerCase()) {
case "obj": {
const object = await loadOBJWithMTL();
await afterLoad({ object });
break;
}
case "fbx": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const object = await loadAsync(loader, modelPath, onProgress);
object.position.set(0, 0, 0);
await afterLoad({ object });
break;
}
case "ply": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const geometry = await loadAsync(loader, modelPath, onProgress);
if (!geometry.getAttribute?.("normal")) {
geometry.computeVertexNormals();
}
const material = new THREE.MeshStandardMaterial({ color: 0x0055ff, flatShading: true });
const object = new THREE.Mesh(geometry, material);
object.position.set(0, 0, 0);
object.castShadow = true;
object.receiveShadow = true;
await afterLoad({ object });
break;
}
case "dae": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const collada = await loadAsync(loader, modelPath, onProgress);
const object = collada.scene;
object.position.set(0, 0, 0);
await afterLoad({ object });
break;
}
case "ifc": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const basePath = getModuleAssetBasePath();
let ifcWasmPath = await resolveIfcWasmPath(basePath);
if (!ifcWasmPath) {
const errorMsg = `[loadModel] IFC WASM not found in ${basePath}/ifc; please verify path and permissions`;
console.error(errorMsg);
throw new Error(errorMsg);
}
const normalizedIfcWasmPath = normalizeWasmPath(ifcWasmPath);
console.log('[loadModel] IFC WASM path:', normalizedIfcWasmPath);
loader.ifcManager.setWasmPath(normalizedIfcWasmPath, true);
const object = await loadAsync(loader, modelPath, onProgress);
await afterLoad({ object });
break;
}
case "stl": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const geometry = await loadAsync(loader, modelPath, onProgress);
let meshMaterial = new THREE.MeshPhongMaterial({ color: 0xff5533, specular: 0x111111, shininess: 200 });
if (geometry.hasColors) {
meshMaterial = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
}
const object = new THREE.Mesh(geometry, meshMaterial);
object.position.set(0, 0, 0);
object.castShadow = true;
object.receiveShadow = true;
await afterLoad({ object });
break;
}
case "xyz": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const geometry = await loadAsync(loader, modelPath, onProgress);
geometry.center();
const material = new THREE.PointsMaterial({ size: 0.1, vertexColors: geometry.hasAttribute("color") === true });
const object = new THREE.Points(geometry, material);
object.position.set(0, 0, 0);
await afterLoad({ object });
break;
}
case "pcd": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
const mesh = await loadAsync(loader, modelPath, onProgress);
mesh.geometry?.center?.();
if (mesh.material) {
mesh.material.size = Math.max(mesh.material.size ?? 0, 0.1);
}
await afterLoad({ object: mesh });
break;
}
case "json": {
const loader = new THREE.ObjectLoader();
const object = await loadAsync(loader, modelPath, onProgress);
object.position.set(0, 0, 0);
await afterLoad({ object });
break;
}
case "3ds": {
const loader = await createLoader(core.fileObject.extension.toLowerCase());
loader.setResourcePath(core.fileObject.path);
let mp = core.fileObject.path;
if (core.CONFIG.entity.proxyPath !== undefined) mp = core.getProxyPath(mp);
const object = await loadAsync(loader, mp + core.fileObject.basename + "." + core.fileObject.extension, onProgress);
await afterLoad({ object });
break;
}
case "glb":
case "gltf": {
const object = await loadGLTFModel();
await afterLoad({ object });
break;
}
default:
toastHelper("unsupportedExtension", "warning");
core.loadingLog?.fail?.();
return;
}
updateLoadingStage("loadingLog.modelLoaded", 100);
core.circle?.complete?.(2600);
core.editorToolbar?.classList.remove('editorToolbar-hidden');
core.editorToolbar?.classList.add('editorToolbar-visible');
core.loadingLog?.finish?.();
if (!core.PRESENTATION_MODE) {
toastHelper("modelLoaded", "success", {
filename: core.fileObject.filename
});
} else {
toastHelper("presentationModeReady", "success");
}
if (typeof core.EXIT_CODE !== "undefined") core.EXIT_CODE = 0;
core.UltraLoader?.finish();
core.poller?.updateSteps(2);
} catch (error) {
core.loadingLog?.fail?.();
reportLoadError(error, `Failed to load ${core.fileObject.filename}`);
throw error;
}
}
export const getModuleAssetBasePath = function() {
let basePath = sanitizeModuleAssetBasePath(core.DFG_ASSETS || core.CONFIG?.baseModulePath);
if (!basePath && typeof import.meta !== 'undefined' && import.meta.url) {
const moduleUrl = new URL(import.meta.url);
basePath = moduleUrl.pathname.includes('/assets/')
? new URL('../assets/', moduleUrl).pathname.replace(/\/$/, '')
: new URL('./assets/', moduleUrl).pathname.replace(/\/$/, '');
}
if (!basePath) {
basePath = '/assets';
}
// Override for localhost
if (core.isLocalPreview) {
basePath = '/assets';
}
basePath = sanitizeModuleAssetBasePath(basePath);
console.log('[loaders] resolved ModuleAssetBasePath:', basePath);
core.CONFIG.baseModulePath = basePath; // Cache for future use
core.DFG_ASSETS = basePath;
return basePath;
};
export const onError = function (_event) {
reportLoadError(_event, "Loader error");
};
export const onErrorMTL = async function (_event) {
core.CONFIG.noMTL = true;
toastHelper("mtlLoadError", "error");
await loadModel();
};
export const onErrorGLB = async function (_event, params, loadedTimes) {
console.log("Loader error: " + _event);
if (window.__E2E__ && window.viewer) {
window.viewer.errors ??= [];
window.viewer.errors.push(String(_event));
}
core.loadedFile = params.path + params.basename + core.loadedFile + "gltf/";
if (typeof _event !== undefined && loadedTimes <= 1 && window.viewer.modelLoaded === false) {
await loadModel();
loadedTimes++;
} else {
toastHelper("glbLoadError", "error");
}
};
export const onProgress = function (xhr) {
progressLoaderHandler(xhr);
};
const progressLoaderHandler = function (xhr) {
if (!core.circle) return;
const total = xhr.total || xhr.loaded || 1;
const percentComplete = Math.min((xhr.loaded / total) * 100, 99);
if (!Number.isFinite(percentComplete)) return;
core.circle.show();
core.circle.set(percentComplete, 100);
core.editorToolbar?.classList.remove('editorToolbar-hidden');
core.editorToolbar?.classList.add('editorToolbar-visible');
core.loadingLog?.update?.(percentComplete);
core.UltraLoader?.set(percentComplete);
}

4109
viewer/main.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,183 @@
{
"@context": "http://iiif.io/api/presentation/4/context.json",
"id": "http://127.0.0.1:8080/examples/box.stl/manifest.json",
"type": "Manifest",
"label": {
"en": [
"box"
]
},
"items": [
{
"id": "http://127.0.0.1:8080/examples/box.stl/scene",
"type": "Scene",
"label": {
"en": [
"box"
]
},
"backgroundColor": "#000000",
"items": [
{
"id": "http://127.0.0.1:8080/examples/box.stl/scene/page/model",
"type": "AnnotationPage",
"items": [
{
"id": "http://127.0.0.1:8080/examples/box.stl/scene/annotation/model",
"type": "Annotation",
"motivation": [
"painting"
],
"body": {
"id": "http://127.0.0.1:8080/examples/box.stl",
"type": "Model"
},
"target": {
"id": "http://127.0.0.1:8080/examples/box.stl/scene",
"type": "Scene"
}
}
]
}
],
"annotations": [
{
"id": "http://127.0.0.1:8080/examples/box.stl/scene/page/annotations",
"type": "AnnotationPage",
"items": []
}
]
}
],
"AIM3DViewer": {
"version": "1.0",
"generatedAt": "2026-06-19T09:34:04.459Z",
"camera": {
"position": [
-1.6694479537305902,
1.8347239768652952,
3.3388959074611804
],
"target": [
0,
1,
0
],
"up": [
0,
1,
0
],
"fov": 45,
"distance": 3.825185808379782,
"perspectiveMode": "orthographic"
},
"viewer": {
"container": "DFG_3DViewer",
"mailUrl": "localhost",
"baseNamespace": "https://localhost",
"metadataUrl": "https://localhost",
"environmentMap": {
"intensity": 1,
"preset": "neutral",
"enabled": true
},
"presentationMode": false,
"sandbox": false,
"scale": {
"x": "1.0",
"y": "1.0"
},
"performance": {
"Performance": "high-performance"
},
"gallery": {
"build": true,
"container": "block-bootstrap5-content",
"imageClass": "field--name-fd6a974b7120d422c7b21b5f1f2315d9",
"imageId": "field--name-fd6a974b7120d422c7b21b5f1f2315d9",
"buildFake": true,
"testImages": [
null
]
}
},
"integration": {
"type": "drupal",
"bundle": "bd1220d6ec7f07e726c65fd215d8e493",
"fieldDf": "field_df",
"exportViewer": "field_df",
"idUri": "\\/wisski\\/navigate\\/(.*)\\/view",
"viewEntityPath": "\\/wisski\\/navigate\\/",
"attributeId": "wisski_id",
"metadata": {
"source": ""
},
"fileUpload": "fad29437cb2a561b91b26aca5dbb7c42",
"fileName": "fb76901eb219495fee0512b5cdfdaa18",
"imageGeneration": "fd6a974b7120d422c7b21b5f1f2315d9"
},
"lights": [
{
"type": "AmbientLight",
"position": [
0,
0,
0
],
"color": "#404040",
"intensity": 1
},
{
"type": "DirectionalLight",
"position": [
0,
100,
50
],
"target": [
0,
0,
0
],
"color": "#ffffff",
"intensity": 1
},
{
"type": "DirectionalLight",
"position": [
0,
5.000799774279585e-11,
0.00010000444491510899
],
"target": [
0,
0,
0
],
"color": "#ffffff",
"intensity": 0.3
}
],
"modelTransform": {
"position": [
0,
0,
0
],
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"order": "XYZ"
},
"scale": [
1,
1,
1
],
"wireframe": false
}
},
"modified": "2026-06-19T09:34:04.459Z"
}

View file

@ -0,0 +1,77 @@
import { AIM3DManifest } from "./manifesto";
export async function loadAIM3IFManifest(manifestUrlOrJson) {
const aim3dManifest = new AIM3DManifest(manifestUrlOrJson);
await aim3dManifest.loadManifest();
const modelUrls = [];
let modelTarget = null;
let filteredAnnos = [];
for (const scene of aim3dManifest.scenes) {
scene.background =
scene.backgroundColor ||
"#000000";
const annos = aim3dManifest.annotationsFromScene(scene);
filteredAnnos = annos.filter(
anno =>
anno.motivation?.includes("painting") &&
anno.body?.type === "Model"
);
for (const anno of filteredAnnos) {
const modelUrl = anno.body?.id;
if (modelUrl) {
modelUrls.push(modelUrl);
}
modelTarget = anno.target;
}
}
aim3dManifest.modelUrls = modelUrls;
aim3dManifest.modelTarget = modelTarget;
return {
manifest: aim3dManifest.manifest,
scenes: aim3dManifest.scenes,
annotations: filteredAnnos,
modelUrls,
modelTarget
};
}
export function applyManifestConfig(manifest, objectsConfig) {
const transform =
manifest.AIM3DViewer?.modelTransform;
if (!transform) return;
const model = objectsConfig.models[0];
model.position = {
x: transform.position?.[0] ?? 0,
y: transform.position?.[1] ?? 0,
z: transform.position?.[2] ?? 0
};
model.rotation = {
x: transform.rotation?.x ?? 0,
y: transform.rotation?.y ?? 0,
z: transform.rotation?.z ?? 0
};
model.scale = {
x: transform.scale?.[0] ?? 1,
y: transform.scale?.[1] ?? 1,
z: transform.scale?.[2] ?? 1
};
model.wireframe =
transform.wireframe ?? false;
}

View file

@ -0,0 +1,41 @@
export class AIM3DManifest {
constructor(manifest) {
if (typeof manifest === "string" && manifest.trim().startsWith("{")) {
this.manifestJson = JSON.parse(manifest);
this.manifestUrl = null;
} else if (typeof manifest === "object") {
this.manifestJson = manifest;
this.manifestUrl = null;
} else {
this.manifestUrl = manifest;
this.manifestJson = null;
}
}
async loadManifest() {
if (this.manifestUrl) {
const response = await fetch(this.manifestUrl);
this.manifestJson = await response.json();
}
this.manifest = this.manifestJson;
this.scenes = this.manifest?.items?.filter(
item => item.type === "Scene"
) || [];
}
annotationsFromScene(scene) {
const result = [];
for (const page of scene?.items || []) {
if (page.type !== "AnnotationPage") continue;
for (const annotation of page.items || []) {
result.push(annotation);
}
}
return result;
}
}

634
viewer/metadata.js Normal file
View file

@ -0,0 +1,634 @@
import { truncateString } from "./utils.js";
import { setupObject, setupCamera, toastHelper } from './viewer-utils.js';
import { core } from './core.js';
import { t } from "./i18n-utils.js";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function buildMetadataRow(label, value) {
if (!label || typeof value === "undefined" || value === null || value === "") {
return "";
}
return (
'<div class="metadata-row">' +
'<span class="metadata-label">' + escapeHtml(label) + ':</span>' +
'<span class="metadata-value">' + escapeHtml(value) + '</span>' +
'</div>'
);
}
/**
* Formats WissKI metadata labels and values for display.
*/
export function addWissKIMetadata(label, value) {
if (typeof label !== "undefined" && typeof value !== "undefined") {
var _str = "";
label = label.replace("wisski_path_3d_model__", "");
switch (label) {
case "title":
_str = t("metadata.title", "Title");
break;
case "author_name":
_str = t("metadata.author", "Author");
break;
case "author_affiliation":
_str = t("metadata.authorAffiliation", "Author affiliation");
break;
case "license":
_str = t("metadata.license", "License");
break;
case "description":
_str = t("metadata.description", "Description");
break;
case "object_type":
_str = t("metadata.objectType", "Object type");
break;
case "reconstruction_authors":
_str = t("metadata.reconstructionAuthors", "Reconstruction authors");
break;
case "reconstruction_period":
_str = t("metadata.reconstructionPeriod", "Reconstruction period");
break;
default:
_str = "";
break;
}
if (_str !== "") {
return buildMetadataRow(_str, value);
}
}
}
export function lilGUIhasFolder(folder, name) {
return folder.folders.some(f => f._title === name);
}
export function lilGUIgetFolder(gui, name) {
return gui?.folders?.find(f => f._title === name) || null;
}
/**
* Expands/collapses the metadata panel.
*/
export function expandMetadata() {
const content = document.getElementById("metadata-content");
const toggle = document.getElementById("metadata-collapse");
const card = document.getElementById("metadata-card");
if (!content || !toggle) return;
const expanded = content.classList.toggle("expanded");
toggle.classList.toggle("metadata-collapsed", !expanded);
card?.classList.toggle("metadata-open", expanded);
// accessibility
toggle.setAttribute("aria-expanded", expanded);
if (!expanded) {
card?.classList.remove("metadata-card-overflowing");
content.querySelectorAll(".metadata-row-pinned").forEach((row) => {
row.classList.remove("metadata-row-pinned");
});
return;
}
updateMetadataOverflow();
}
function updateMetadataOverflow() {
const content = document.getElementById("metadata-content");
const card = document.getElementById("metadata-card");
if (!content || !card || !content.classList.contains("expanded")) return;
const hasOverflow = content.scrollHeight - content.clientHeight > 8;
card.classList.toggle("metadata-card-overflowing", hasOverflow);
content.querySelectorAll(".metadata-row").forEach((row) => {
const value = row.querySelector(".metadata-value");
if (!value) return;
const wasPinned = row.classList.contains("metadata-row-pinned");
row.classList.remove("metadata-row-pinned", "metadata-row-expandable");
const isExpandable = value.scrollHeight - value.clientHeight > 4;
row.classList.toggle("metadata-row-expandable", isExpandable);
if (wasPinned && isExpandable) {
row.classList.add("metadata-row-pinned");
}
});
}
function bindMetadataInteractions() {
if (core.metadataContainer.dataset.boundCollapse === "true") return;
core.metadataContainer.addEventListener("click", (e) => {
const toggle = e.target.closest("#metadata-collapse");
if (toggle) {
expandMetadata(e);
return;
}
const card = document.getElementById("metadata-card");
const content = document.getElementById("metadata-content");
if (!card || !content || !content.classList.contains("expanded")) return;
const row = e.target.closest(".metadata-row");
if (!row) return;
const willPin = !row.classList.contains("metadata-row-pinned");
content.querySelectorAll(".metadata-row-pinned").forEach((pinnedRow) => {
pinnedRow.classList.remove("metadata-row-pinned");
});
if (willPin) row.classList.add("metadata-row-pinned");
});
window.addEventListener("resize", updateMetadataOverflow);
core.metadataContainer.dataset.boundCollapse = "true";
}
/**
* Appends metadata HTML to the DOM.
*/
export function appendMetadata(
metadataContent
) {
core.metadataContainer.innerHTML = metadataContent;
if (!core.container.contains(core.metadataContainer)) {
core.container.appendChild(core.metadataContainer);
}
}
async function fetchEntityMetadata() {
if (!core.CONFIG.entity.metadata.sourceType || core.CONFIG.entity.metadata.url === "") {
return "";
}
const entityComponent = encodeURIComponent(core.CONFIG.entity.id) ?? core.CONFIG.entity.id ?? typeof core.CONFIG.entity.id === "undefined" ? "" : "";
if (!entityComponent) {
console.warn("Entity ID is missing or invalid. Skipping metadata fetch.");
return "";
}
const metadataUrl = core.CONFIG.entity.metadata.url + entityComponent;
try {
const response = await fetch(metadataUrl, { cache: "no-cache" });
if (!response.ok) {
console.warn("Metadata request failed with status:", response.status);
return "";
}
const responseText = await response.text();
try {
const jsonData = JSON.parse(responseText);
const record = Array.isArray(jsonData) ? jsonData[0] : jsonData;
if (!record || typeof record !== "object") {
return "";
}
console.log("Processing JSON metadata:", record);
const jsonFieldMap = {
title: "title",
reconstruction_authors: "author_name",
reconstruction_authors_affiliation: "author_affiliation",
reconstruction_license: "license",
reconstruction_time_frame: "reconstruction_period",
object_description: "description",
object_type: "object_type",
};
let entityMetadataContent = "";
for (const [jsonField, metadataLabel] of Object.entries(jsonFieldMap)) {
if (record[jsonField]) {
const fetchedValue = addWissKIMetadata(metadataLabel, record[jsonField]);
if (typeof fetchedValue !== "undefined") {
entityMetadataContent += fetchedValue;
}
}
}
return entityMetadataContent;
} catch (_jsonError) {
const parser = new DOMParser();
const doc = parser.parseFromString(responseText, "application/xml");
if (doc.documentElement.tagName === "parsererror") {
console.error("XML parsing error:", doc.documentElement.textContent);
return "";
}
let entityMetadataContent = "";
if (doc.documentElement.childNodes.length > 0) {
var data = doc.documentElement.childNodes[0].childNodes;
if (data !== undefined) {
for (var i = 0; i < data.length; i++) {
var fetchedValue = addWissKIMetadata(data[i].tagName, data[i].textContent);
if (typeof fetchedValue !== "undefined") {
entityMetadataContent += fetchedValue;
}
}
}
}
return entityMetadataContent;
}
} catch (error) {
console.error("Error processing metadata:", error);
return "";
}
}
export function fetchMetadata(_object, _type) {
if (!_object?.geometry) return 0;
const indexedCount = _object.geometry.index?.count;
const positionCount = _object.geometry.attributes?.position?.count ?? 0;
switch (_type) {
case "vertices":
return positionCount;
case "faces":
return (indexedCount ?? positionCount) / 3;
default:
return 0;
}
}
/**
* Handles metadata response and builds the metadata UI.
*/
export async function handleMetadataResponse(
data,
metadata,
object,
) {
Viewer.clearHierarchySubmenu();
var tempArray = [];
if (Array.isArray(object)) {
setupObject(object[0], data);
await setupCamera(object[0], data);
} else if (object.name === "Scene" || object.children.length > 0 || object.type == "Mesh") {
setupObject(object, data);
object.traverse(function (child) {
if (child.isMesh) {
metadata["vertices"] += fetchMetadata(child, "vertices");
metadata["faces"] += fetchMetadata(child, "faces");
if (child.name === "") child.name = "Mesh";
var shortChildName = truncateString(child.name, 35);
Viewer.addHierarchySubmenuItem(shortChildName, child.id);
child.traverse(function (children) {
if (children.isMesh && children.name !== child.name) {
if (children.name === "") children.name = "ChildrenMesh";
var shortChildrenName = truncateString(children.name, 35);
Viewer.addHierarchySubmenuItem(shortChildrenName, children.id);
}
});
}
});
await setupCamera(object, data);
} else {
setupObject(object, data);
await setupCamera(object, data);
metadata["vertices"] += fetchMetadata(object, "vertices");
metadata["faces"] += fetchMetadata(object, "faces");
if (object.name === "") {
Viewer.addHierarchySubmenuItem("Mesh", object.id);
object.name = object.id;
} else {
Viewer.addHierarchySubmenuItem(object.name, object.id);
}
}
if (!core.metadataContainer) {
core.metadataContainer = document.createElement("div");
core.metadataContainer.id = "metadata-container";
}
core.metadataContainer.setAttribute("data-viewer-theme", core.container?.closest(".viewer-wrapper")?.getAttribute("data-viewer-theme") || "dark");
var metadataContent =
'<div id="metadata-card">' +
'<button id="metadata-collapse" class="metadata-collapse metadata-collapsed" type="button" aria-expanded="false" aria-controls="metadata-content">' +
'<span class="metadata-toggle-icon" aria-hidden="true"></span>' +
'<span class="metadata-toggle-copy">' +
'<span class="metadata-toggle-eyebrow" data-i18n-key="metadata.modelDetails">' + escapeHtml(t("metadata.modelDetails", "Model details")) + '</span>' +
'<span class="metadata-toggle-title" data-i18n-key="metadata.metadata">' + escapeHtml(t("metadata.metadata", "Metadata")) + '</span>' +
'</span>' +
'<span class="metadata-toggle-chevron" aria-hidden="true"></span>' +
'</button>' +
'<div id="metadata-content" class="metadata-content">';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.visualizedFile">' + escapeHtml(t("metadata.visualizedFile", "Visualized file")) + ':</span>' +
'<span class="metadata-value">' +
escapeHtml(core.fileObject.basename) + '.' + escapeHtml(core.fileObject.extension) +
'</span>' +
'</div>';
metadataContent += '<div class="metadataSeparator"></div>';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.vertices">' + escapeHtml(t("metadata.vertices", "Vertices")) + ':</span>' +
'<span class="metadata-value">' + metadata["vertices"] + '</span>' +
'</div>';
metadataContent +=
'<div class="metadata-row">' +
'<span class="metadata-label" data-i18n-key="metadata.faces">' + escapeHtml(t("metadata.faces", "Faces")) + ':</span>' +
'<span class="metadata-value">' + metadata["faces"] + '</span>' +
'</div>';
metadataContent += await fetchEntityMetadata();
if (!core.downloadModel) {
core.downloadModel.hidden = true;
core.downloadModel.removeAttribute("href");
}
if (core.viewEntity) {
core.viewEntity.hidden = true;
core.viewEntity.removeAttribute("data-embed-url");
}
if (!core.isLightweight && core.downloadModel) {
const c_path = core.fileObject.path;
if (core.loadedFile !== "") {
core.fileObject.filename = core.fileObject.filename.replace(core.fileObject.orgExtension, core.fileObject.extension);
}
core.downloadModel.href = `blob:${encodeURI(c_path + core.fileObject.filename)}`;
core.downloadModel.setAttribute("download", core.fileObject.filename);
core.downloadModel.hidden = false;
window.Viewer?.updateDownloadMenuEntryLabel?.();
}
if (core.viewEntity && (core.CONFIG?.entity?.id || core.fileObject?.originalPath)) {
const sharePayload = window.Viewer?.getSharePayload?.();
if (sharePayload?.url) {
core.viewEntity.setAttribute("data-embed-url", sharePayload.url);
}
window.Viewer?.updateEmbedMenuEntryState?.();
core.viewEntity.hidden = false;
}
metadataContent +=
'</div>' + // #metadata-content
'</div>';
appendMetadata(metadataContent);
bindMetadataInteractions();
requestAnimationFrame(updateMetadataOverflow);
}
/**
* Handles settings for the loaded object and camera.
*/
export async function settingsHandler(object, hierarchyMain, data) {
if (Array.isArray(object)) {
setupObject(object[0], data);
await setupCamera(object[0], data);
} else if (object.name === "Scene" || object.children.length > 0) {
setupObject(object, data);
await setupCamera(object, data);
} else {
setupObject(object, data);
await setupCamera(object, data);
// Hierarchy is now managed by the editor toolbar submenu
}
}
async function loadMetadataData(metadataUrl) {
if (metadataUrl === null || metadataUrl === '') {
console.log("No metadata found due to null or empty metadata URL", metadataUrl);
return null;
}
try {
if (core.isLocalPreview) {
return null;
}
const response = await fetch(metadataUrl, { cache: "no-cache" });
if (response.status === 404) {
toastHelper("settingsNotFound", "info", {
filename: core.fileObject.filename
});
return null;
}
toastHelper("settingsFound", "success", {
filename: core.fileObject.filename
});
return response.json();
} catch (error) {
toastHelper("metadataFetchError", "error", {
error: error.message
});
return null;
}
}
export async function traverseObject (object) {
if (Array.isArray(object)) {
// Keep relative transforms between parts; centering each element separately
// collapses multi-part models into overlapping geometry.
object.forEach((obj) => {
obj.updateMatrixWorld(true);
});
await setupCamera(object, null);
} else if (object.name === "Scene" || object.children.length > 0 || object.type == "Mesh") {
setupObject(object, null);
await setupCamera(object, null);
} else {
setupObject(object, null);
await setupCamera(object, null);
}
}
export async function presentationMode (object) {
if (core.PRESENTATION_MODE) {
traverseObject(object);
} else { return; }
}
/**
* Fetches settings and metadata for the loaded model.
*/
export async function fetchSettings(object) {
var metadata = { vertices: 0, faces: 0 };
let metadataUrl = '';
// Skip metadata fetch for blob URLs (drag & drop files)
if (core.fileObject.filename.startsWith('blob:')) {
console.log("Skipping metadata fetch for local file");
} else if (core.CONFIG.metadataUrl && core.fileObject.uri && core.fileObject.filename) {
const metadataPrefix = new URL(core.CONFIG.metadataUrl).href.replace(/\/+$/, '');
let normalizedUri = new URL(core.fileObject.uri).href.replace(/\/+$/, '');
if (normalizedUri.startsWith(metadataPrefix)) {
normalizedUri = normalizedUri.slice(metadataPrefix.length);
}
normalizedUri = normalizedUri.replace(/^\/+/, '');
metadataUrl = new URL(
`${metadataPrefix}/${normalizedUri}/metadata/${core.fileObject.filename}_viewer.json`
).href;
console.log("Fetched metadata from:", metadataUrl);
} else {
console.warn("Metadata URL or file information is missing. Skipping metadata fetch.");
}
let hierarchyMain;
// Hierarchy is now managed by the editor toolbar submenu, not lilGUI
if (core.CONFIG.entity.metadata.sourceType === "IIIF") {
console.log("Fetching IIIF metadata from ", core.objectsConfig);
await handleMetadataResponse( core.CONFIG.model, metadata, object);
}
else if (metadataUrl) {
console.log("Loading metadata from URL:", metadataUrl);
if (core.CONFIG.entity.proxyPath !== undefined || core.isLightweight) {
metadataUrl = core.getProxyPath(metadataUrl, core.CONFIG);
const data = await loadMetadataData(metadataUrl);
window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(data);
await handleMetadataResponse(data, metadata, object);
settingsHandler(object, hierarchyMain, data);
} else {
const data = await loadMetadataData(metadataUrl);
window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(data);
await handleMetadataResponse(data, metadata, object);
settingsHandler(object, hierarchyMain, data);
}
} else {
window.Viewer?.hydrateAnnotationsFromMetadataPayload?.(null);
await handleMetadataResponse("", metadata, object);
}
}
export function createIIIFDropdown(iiifConfigURL) {
// list of candidate IIIF config URLs (add more as needed)
const iiifList = [
{ url: iiifConfigURL.url, name: iiifConfigURL.name },
{ url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_transform_scale_position.json", name: t("iiif.optionModelPositionScale", "Model Position and Scale") },
{ url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin.json", name: t("iiif.optionModelOrigin", "Model Origin") },
{ url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/1_basic_model_in_scene/model_origin_bgcolor.json", name: t("iiif.optionModelOriginBg", "Model Origin with background color") },
{ url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_position.json", name: t("iiif.optionModelPosition", "Model Position") },
].filter(Boolean);
const group = document.createElement("div");
group.className = "form-manifesto-group";
const label = document.createElement("label");
label.textContent = t("iiif.manifest", "IIIF manifest");
label.className = "form-manifesto-label";
const select = document.createElement("select");
select.id = "manifesto-manifest-select";
select.name = "manifesto-manifest-select";
iiifList.forEach(item => {
const opt = document.createElement("option");
opt.value = item.url;
opt.textContent = item.name;
select.appendChild(opt);
});
group.appendChild(label);
group.appendChild(select);
// add on the top
document.querySelector("#form-manifesto-content").prepend(group);
}
export function createAIM3IFDropdown(url) {
const group = document.createElement("div");
group.className = "form-manifesto-group";
const aim3ifList = [
{ url: url, name: t("aim3if.optionDefault", "Default configuration") },
{ url: "https://viewer.thedworak.com/manifests/box.json", name: t("aim3if.optionBox", "Box configuration") },
// Add more AIM3IF configurations here as needed
].filter(Boolean);
const label = document.createElement("label");
label.textContent = t("aim3if.modelConfig", "Model configuration");
label.className = "form-manifesto-label";
const select = document.createElement("select");
select.id = "manifesto-config-select";
select.name = "manifesto-config-select";
aim3ifList.forEach(item => {
const opt = document.createElement("option");
opt.value = item.url;
opt.textContent = item.name;
select.appendChild(opt);
});
group.appendChild(label);
const optDefault = document.createElement("option");
optDefault.value = url;
optDefault.textContent = t("aim3if.optionDefault", "Default configuration");
select.appendChild(optDefault);
group.appendChild(label);
group.appendChild(select);
// add on the top
document.querySelector("#form-manifesto-content").prepend(group);
}
export function createManifestUI(type = "iiif") {
const formContainer = document.createElement("div");
const className = type === "iiif" ? "IIIF" : "AIM3IF";
const titleKey = type === "iiif" ? "iiif" : "aim3if";
formContainer.id = `form-manifesto`;
/* header */
const header = document.createElement("div");
header.className = `form-manifesto-header`;
header.innerHTML = `
<span class="title">${escapeHtml(t(`${titleKey}.loader`, `${className} Loader`))}</span>
<div class="tools">
<button type="button" id="manifesto-toggle-collapse" title="${escapeHtml(t(`${titleKey}.collapse`, `Collapse`))}"></button>
</div>
`;
formContainer.appendChild(header);
/* content */
const content = document.createElement("div");
content.className = `form-manifesto-content`;
content.id = `form-manifesto-content`;
content.innerHTML = `
<div class="form-manifesto-group">
<input type="text" id="manifesto-manifest-url" placeholder="${escapeHtml(t(`${titleKey}.manifestUrlPlaceholder`, `https://example.org/manifesto/manifest.json`))}">
<button class="primary" id="load-manifesto-from-url">${escapeHtml(t(`${titleKey}.loadFromUrl`, `Load from URL`))}</button>
</div>
<div class="form-manifesto-group column">
<textarea id="manifesto-manifest-text" rows="8" placeholder="${escapeHtml(t(`${titleKey}.manifestTextPlaceholder`, `Paste ${className} manifest JSON here...`))}"></textarea>
<div class="actions">
<button class="secondary" id="load-manifesto-from-text">${escapeHtml(t(`${titleKey}.loadFromText`, `Load from Text`))}</button>
</div>
</div>
`;
formContainer.appendChild(content);
document.body.appendChild(formContainer);
}

55
viewer/object-settings.js Normal file
View file

@ -0,0 +1,55 @@
export let objectsConfig = {
models: [
{
name: "Astronaut_mesh", // optional unique id
url: "https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb", // required
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
},
/*{
name: "Synagogue",
url: "https://models.babylonjs.com/Synagogue.glb",
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 45, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}*/
],
camera: {
position: { x: 0, y: 5, z: 10 },
target: { x: 0, y: 0, z: 0 }
},
scene: {
background: "radial-gradient(circle, #ffffff 0%, #999999 100%)",
lights: [
{
type: "directional",
color: "0xffffff",
intensity: 1,
position: { x: 0, y: 100, z: 100 },
target: { x: 0, y: 0, z: 0 }
},
{
type: "ambient",
color: "0x404040",
intensity: 1
},
{
type: "point",
color: "0xffffff",
intensity: 1,
position: { x: 2, y: 3, z: 4 }
}
]
}
};
export function setObjectsConfig(cfg) {
objectsConfig = cfg;
}
export function getObjectsConfig() {
return objectsConfig;
}

22
viewer/php/editor.php Normal file
View file

@ -0,0 +1,22 @@
<?php
$configFile = 'modules/dfg_3dviewer/viewer/config.json';
if (!file_exists($configFile)) {
die("Error: Config file not found.");
}
$configData = json_decode(file_get_contents($configFile), true);
if (json_last_error() !== JSON_ERROR_NONE) {
die("Error decoding JSON: " . json_last_error_msg());
}
$path = './'.$_POST['path'];
$filename = $_POST['filename'];
file_put_contents($path . "metadata/" . $filename . "_viewer.json", $result);
}
?>

View file

@ -0,0 +1,10 @@
<?php
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Url;
\Drupal::messenger()->addMessage(serialize(Drupal\Core\Entity\EntityInterface $entity));
?>

Some files were not shown because too many files have changed in this diff Show more