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

80
.gitattributes vendored Normal file
View file

@ -0,0 +1,80 @@
# Default behavior
* text=auto
# Code files use LF line endings
*.js text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.php text eol=lf
*.module text eol=lf
*.yml text eol=lf
*.twig text eol=lf
*.json text eol=lf
# BUILD
dist/** text eol=lf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.svg binary
*.webp binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.mp4 binary
*.mp3 binary
*.ogg binary
*.wav binary
*.glb binary
*.gltf binary
*.bin binary
*.zip binary
*.pdf binary
*.exe binary
*.dll binary
*.obj binary
*.mtl binary
*.3ds binary
*.fbx binary
*.dae binary
*.stl binary
*.ply binary
*.hdr binary
*.pic binary
*.tga binary
*.psd binary
*.raw binary
*.arw binary
*.cr2 binary
*.nef binary
*.orf binary
*.sr2 binary
*.xmp binary
*.db binary
*.sav binary
*.blend binary
*.3dm binary
*.skp binary
*.max binary
*.c4d binary
*.lwo binary
*.lws binary
*.ma binary
*.mb binary
*.dxf binary
*.dwg binary
*.igs binary
*.iges binary
*.step binary
*.stp binary
*.sldprt binary
*.sldasm binary
*.wasm binary
*.pcd binary
*.xyz binary
*.ifc binary

38
.github/workflows/build-release.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Build and release library
on:
push:
tags:
- 'v*'
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install deps
run: npm ci
- name: Build library
run: npm run build:library
- name: Pack library zip
run: node ./scripts/pack-library.js
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
files: ./dfg-3dviewer-library.zip

38
.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: Playwright Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Build test dist
run: npm run build:test
- name: Run Playwright tests
run: npx playwright test
env:
CI: true
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: playwright-report/
retention-days: 30

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# editor backups
*.yml~
*.js~
# config
viewer/viewer-settings.json
viewer-settings.json
# node
node_modules/
/.parcel-cache
.pnpm-store/
# build
/dist/
/dfg-3dviewer-library.zip
/dfg_3dviewer-dist.zip
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
/viewer/admin/admin.sqlite

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# DFG 3D Viewer — JavaScript Library
Three.js-based 3D viewer. Ships as a minified bundle for `web/libraries/dfg-3dviewer/` or standalone HTML embeds.
**Drupal integration** is provided by the separate [`dfg_3dviewer`](https://github.com/your-org/dfg_3dviewer) module.
## Build
```bash
npm install
npm run build:library
npm run pack:library
```
Output:
- `dist/library/dfg_3dviewer.min.js`
- `dist/library/assets/` — CSS, draco, IFC WASM, fonts
- `dfg-3dviewer-library.zip` — extract to `web/libraries/dfg-3dviewer/` on Drupal
## Local development
```bash
cp viewer/viewer-settings-example.json viewer-settings.json
npm run dev:test
# http://localhost:1234
```
## Install on Drupal
1. Extract `dfg-3dviewer-library.zip` to `web/libraries/dfg-3dviewer/`
2. Enable the `dfg_3dviewer` Drupal module and configure at `/admin/config/dfg_3dviewer`
## Standalone embed
```html
<div id="DFG_3DViewer" 3d="./examples/box.stl"></div>
<script type="module" src="./dfg_3dviewer.min.js"></script>
```
Or pass config in code: `await Viewer.MainInit({ ... })`.
## Repository layout
- `viewer/` — source
- `rollup.config.js` — production builds
- `tests/` — Playwright E2E

54
embed.html Normal file
View file

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>DFG 3D Viewer Embed</title>
<link rel="shortcut icon" href="assets/img/dfgviewerFavicon.png" type="image/png">
<link href="assets/css/main.css" rel="stylesheet" />
<link href="assets/css/spinner.css" rel="stylesheet" />
<link href="assets/css/theme.css" rel="stylesheet" />
<link href="assets/css/external-sources.css" rel="stylesheet"/>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body {
min-height: 100dvh;
}
#DFG_3DViewer {
width: 100%;
height: 100dvh;
min-height: 240px;
}
</style>
</head>
<body class="viewer-embed-page">
<div id="DFG_3DViewer"></div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const hasExplicitSource = params.has("model") || params.has("src") || params.has("id");
if (hasExplicitSource) return;
const isLocalPreviewHost = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
if (!isLocalPreviewHost) return;
const container = document.getElementById("DFG_3DViewer");
if (!container || container.hasAttribute("3d")) return;
container.setAttribute("3d", "./examples/box.stl");
})();
</script>
<script type="module" src="dfg_3dviewer-module.js"></script>
</body>
</html>

44
index.html Normal file
View file

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>DLF DFG 3D Viewer testing environment</title>
<link rel="shortcut icon" href="assets/img/dfgviewerFavicon.png" type="image/png">
<link href="assets/css/main.css" rel="stylesheet" />
<link href="assets/css/spinner.css" rel="stylesheet" />
<link href="assets/css/theme.css" rel="stylesheet" />
<link href="assets/css/external-sources.css" rel="stylesheet"/>
</head>
<body>
<div id="example-model-picker">
<label for="example-model-select">Load example model</label>
<select id="example-model-select">
<option value="./examples/box.dae">DAE</option>
<option value="./examples/box.stl" selected>STL</option>
<option value="./examples/box.ply">PLY</option>
<option value="./examples/box.obj">OBJ</option>
<option value="./examples/box.xyz">XYZ</option>
<option value="./examples/box.pcd">PCD</option>
<option value="./examples/box.3ds">3DS</option>
<option value="./examples/box.ifc">IFC</option>
<option value="./examples/box.fbx">FBX</option>
<option value="./examples/box.glb">GLB</option>
<option value="./examples/box-missing-mtl.obj">OBJ (missing MTL)</option>
<option value="./examples/broken.glb">Broken GLB</option>
</select>
<button type="button" id="example-theme-toggle" title="Toggle dark mode">🌙</button>
</div>
<!--For in-built examples use one of the following 3d="./examples/box.obj"
OR
USE external samples:
3d="https://3d-repository.hs-mainz.de/sites/default/files/2024-01/gltf/test2.glb"
3d="https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb"-->
<div
id="DFG_3DViewer"
3d="./examples/box.stl"
style="height: 50vh"
>
</div>
<script type="module" src="dfg_3dviewer-module.js"></script>
</body>
</html>

15
jsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"module": "esnext",
"target": "es6",
"moduleResolution": "node",
"jsx": "preserve",
"esModuleInterop": true,
"allowJs": true,
"sourceMap": true,
"lib": ["es6", "dom"],
"rootDir": "viewer"
},
"exclude": ["node_modules"]
}

6849
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

50
package.json Executable file
View file

@ -0,0 +1,50 @@
{
"name": "dfg-3dviewer",
"version": "1.0.2",
"description": "DFG 3D Viewer — three.js library for web and Drupal",
"type": "module",
"scripts": {
"dev:test": "cross-env BUILD_SOURCE=IIIF BUILD=test parcel index.html --port 1234",
"dev:dev": "cross-env BUILD_SOURCE='' BUILD=test parcel index.html --port 1234",
"build:test": "cross-env BUILD_SOURCE='' BUILD=test rollup -c",
"build:dev": "cross-env BUILD_SOURCE='IIIF' BUILD=dev rollup -c",
"build:prod": "cross-env BUILD_SOURCE='' BUILD=prod rollup -c",
"build:library": "cross-env BUILD_SOURCE='' BUILD=library IS_PROD=true rollup -c",
"pack:library": "node ./scripts/pack-library.js",
"pack-dist": "node ./scripts/pack-dist.js",
"serve:dist": "cross-env BUILD=test serve dist",
"test:local": "sh ./scripts/run-local-tests.sh",
"watch": "cross-env BUILD_SOURCE=IIIF BUILD=test rollup -c -w",
"dev:tauri": "npm run build:dev && npx serve ./dist/dev -l 1234",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@rollup/plugin-commonjs": "^29.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-url": "^8.0.2",
"@types/node": "^25.7.0",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"parcel": "^2.16.4",
"rollup": "^4.60.3",
"serve": "^14.2.6"
},
"dependencies": {
"@iiif/3d-manifesto-dev": "^0.7.0",
"@rollup/plugin-alias": "^6.0.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/cli": "^2.11.1",
"fflate": "^0.8.3",
"jquery": "^4.0.0",
"lru-cache": "^11.3.6",
"stats.js": "^0.17.0",
"three": "^0.184.0",
"toastify-js": "^1.12.0",
"web-ifc": "^0.0.77"
}
}

74
playwright.config.js Normal file
View file

@ -0,0 +1,74 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
workers: process.env.CI ? 1 : undefined,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI
? [
['list'],
['github'],
['html', { outputFolder: 'playwright-report' }],
]
: 'html',
use: {
baseURL: process.env.CI
? 'http://localhost:4173'
: 'http://localhost:1234',
// WebGL STABILITY
launchOptions: {
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-webgl',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-gpu-driver-bug-workarounds',
'--disable-dev-shm-usage',
],
},
deviceScaleFactor: 1,
viewport: { width: 1280, height: 800 },
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
colorScheme: 'light',
reducedMotion: 'reduce',
},
projects: [
{
name: 'chromium-webgl',
use: {
...devices['Desktop Chrome'],
},
},
],
webServer: process.env.CI
? {
command: 'HOST=127.0.0.1 PORT=4173 DIST_DIR=dist/test node scripts/serve-dist.js',
port: 4173,
timeout: 120_000,
}
: {
command: 'npm run dev',
port: 1234,
reuseExistingServer: true,
},
});

142
rollup.config.js Normal file
View file

@ -0,0 +1,142 @@
import url from '@rollup/plugin-url';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
import replace from '@rollup/plugin-replace';
import path from 'path';
import fs from 'fs/promises';
const source = process.env.BUILD_SOURCE ?? "IIIF";
const envBuild = process.env.BUILD ?? "test";
const production = process.env.IS_PROD === 'true';
const outDistDir = path.join('dist', envBuild);
const isLibraryBuild = envBuild === 'library';
const entryFileName = isLibraryBuild && production
? 'dfg_3dviewer.min.js'
: 'dfg_3dviewer-module.js';
console.log('[rollup] build:', envBuild);
console.log('[rollup] outDir:', outDistDir);
console.log('[rollup] entry:', entryFileName);
async function copyDirectory(sourceDir, target) {
await fs.cp(sourceDir, target, { recursive: true });
}
function copyBuildAssets() {
return {
name: 'copy-build-assets',
async writeBundle() {
await fs.mkdir(outDistDir, { recursive: true });
const assetCopyTasks = [
copyDirectory('node_modules/three/examples/jsm/libs/draco', path.join(outDistDir, 'assets/draco')),
copyDirectory('node_modules/web-ifc', path.join(outDistDir, 'assets/ifc')),
copyDirectory('viewer/css', path.join(outDistDir, 'assets/css')),
copyDirectory('viewer/img', path.join(outDistDir, 'assets/img')),
copyDirectory('viewer/fonts', path.join(outDistDir, 'assets/fonts')),
copyDirectory('viewer/js/maps', path.join(outDistDir, 'assets/maps')),
];
if (!isLibraryBuild) {
assetCopyTasks.push(
copyDirectory('viewer/examples', path.join(outDistDir, 'examples')),
fs.copyFile('index.html', path.join(outDistDir, 'index.html')),
fs.copyFile('embed.html', path.join(outDistDir, 'embed.html')),
);
}
await Promise.all(assetCopyTasks);
const viewerSettingsTarget = path.join(outDistDir, 'viewer-settings.json');
const viewerSettings = JSON.parse(
await fs.readFile('viewer/viewer-settings-example.json', 'utf8')
);
viewerSettings.viewer.lightweight = 1;
let viewerSettingsMain;
try {
viewerSettingsMain = JSON.parse(await fs.readFile('viewer-settings.json', 'utf8'));
} catch {
viewerSettingsMain = { ...viewerSettings };
}
if (isLibraryBuild) {
viewerSettingsMain.baseModulePath = '/libraries/dfg-3dviewer/assets';
} else if (envBuild === 'test' || envBuild === 'dev') {
viewerSettingsMain.viewer.gallery.build = false;
viewerSettingsMain.viewer.editor = true;
viewerSettingsMain.viewer.lightweight = true;
viewerSettingsMain.mainUrl = 'localhost';
}
await fs.writeFile(viewerSettingsTarget, JSON.stringify(viewerSettingsMain, null, 2), { flag: 'wx' })
.catch(err => {
if (err.code !== 'EEXIST') {
throw err;
}
});
},
};
}
export default {
input: 'viewer/main.js',
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
tryCatchDeoptimization: false,
},
plugins: [
replace({
preventAssignment: true,
values: {
__BUILD_SOURCE__: JSON.stringify(source),
__BUILD__: JSON.stringify(envBuild),
__IS_PROD__: JSON.stringify(production),
__MODULES_PATH__: JSON.stringify(''),
__ENV_SUBDIR__: JSON.stringify(''),
},
}),
resolve({
browser: true,
preferBuiltins: false,
mainFields: ['module', 'browser', 'main'],
extensions: ['.js'],
dedupe: ['three'],
preserveSymlinks: false,
exportConditions: ['module'],
}),
commonjs({
include: [/node_modules/],
exclude: ['node_modules/three/**'],
transformMixedEsModules: true,
ignoreDynamicRequires: true,
requireReturnsDefault: 'auto',
}),
json(),
url({
include: ['viewer/**/*.{svg,png,jpg,gif,hdr}'],
limit: 0,
fileName: 'assets/[name][extname]',
publicPath: 'assets/',
}),
copyBuildAssets(),
production && terser(),
].filter(Boolean),
output: {
dir: outDistDir,
entryFileNames: entryFileName,
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name][extname]',
sourcemapFileNames: 'assets/[name].js.map',
format: 'es',
manualChunks(id) {
if (id.includes('node_modules/three')) {
return 'three';
}
},
sourcemap: true,
},
};

51
scripts/pack-dist.js Normal file
View file

@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const distDir = path.resolve(__dirname, '..', 'dist');
const outFile = path.resolve(__dirname, '..', 'dfg_3dviewer-dist.zip');
if (!fs.existsSync(distDir)) {
console.error('dist directory not found. Run `npm run build` first.');
process.exit(1);
}
// Normalize index.html asset paths from absolute (/file) to relative (./file)
const indexPath = path.join(distDir, 'index.html');
if (fs.existsSync(indexPath)) {
let html = fs.readFileSync(indexPath, 'utf8');
// replace href=/file or href="/file" or href='/file' with href="./file"
html = html.replace(/href=\/?"?\/?([^\s'">]+)"?/g, (m, p1) => `href="./${p1.replace(/^\/+/, '')}"`);
html = html.replace(/src=\/?"?\/?([^\s'">]+)"?/g, (m, p1) => `src="./${p1.replace(/^\/+/, '')}"`);
fs.writeFileSync(indexPath, html, 'utf8');
console.log('Rewrote asset paths in dist/index.html to use relative paths');
}
if (fs.existsSync(outFile)) {
fs.rmSync(outFile);
}
const zipProcess = spawnSync('zip', ['-rq', outFile, '.'], {
cwd: distDir,
stdio: 'inherit',
});
if (zipProcess.error) {
if (zipProcess.error.code === 'ENOENT') {
console.error('`zip` command not found. Install it or use an environment that provides it before running `npm run pack-dist`.');
process.exit(1);
}
throw zipProcess.error;
}
if (zipProcess.status !== 0) {
process.exit(zipProcess.status ?? 1);
}
const archiveSize = fs.statSync(outFile).size;
console.log(`Created ${outFile} (${archiveSize} total bytes)`);

41
scripts/pack-library.js Normal file
View file

@ -0,0 +1,41 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const libraryDir = path.resolve(__dirname, '..', 'dist', 'library');
const outFile = path.resolve(__dirname, '..', 'dfg-3dviewer-library.zip');
if (!fs.existsSync(libraryDir)) {
console.error('dist/library directory not found. Run `npm run build:library` first.');
process.exit(1);
}
if (fs.existsSync(outFile)) {
fs.rmSync(outFile);
}
const zipProcess = spawnSync('zip', ['-rq', outFile, '.'], {
cwd: libraryDir,
stdio: 'inherit',
});
if (zipProcess.error) {
if (zipProcess.error.code === 'ENOENT') {
console.error('`zip` command not found. Install it before running `npm run pack:library`.');
process.exit(1);
}
throw zipProcess.error;
}
if (zipProcess.status !== 0) {
process.exit(zipProcess.status ?? 1);
}
const archiveSize = fs.statSync(outFile).size;
console.log(`Created ${outFile} (${archiveSize} total bytes)`);
console.log('Install by extracting to web/libraries/dfg-3dviewer/ on your Drupal site.');

13
scripts/run-local-tests.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT_DIR"
echo "[local-tests] building test bundle"
npm run build:test
echo "[local-tests] running Playwright viewer tests"
CI=1 npx playwright test tests/viewer.spec.ts --project chromium-webgl --workers=1 "$@"

53
scripts/serve-dist.js Normal file
View file

@ -0,0 +1,53 @@
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = process.env.DIST_DIR || 'dist'
const root = path.resolve(__dirname, '..', distDir)
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || 8080
const mime = {
'.js': 'application/javascript',
'.css': 'text/css',
'.html': 'text/html',
'.wasm': 'application/wasm',
'.json': 'application/json',
'.svg': 'image/svg+xml'
}
const server = http.createServer((req, res) => {
try {
let reqUrl = decodeURI(new URL(req.url, `http://localhost`).pathname)
if (reqUrl === '/') reqUrl = '/index.html'
const safePath = path.normalize(path.join(root, reqUrl))
if (!safePath.startsWith(root)) {
res.statusCode = 403
return res.end('Forbidden')
}
fs.stat(safePath, (err, stats) => {
if (err || !stats.isFile()) {
res.statusCode = 404
return res.end('Not found')
}
const ext = path.extname(safePath).toLowerCase()
res.setHeader('Content-Type', mime[ext] || 'application/octet-stream')
res.setHeader('Content-Length', stats.size)
const stream = fs.createReadStream(safePath)
stream.pipe(res)
stream.on('error', () => {
res.statusCode = 500
res.end()
})
})
} catch (e) {
res.statusCode = 500
res.end('Server error')
}
})
server.listen(port, host, () => console.log(`Serving ${root} at http://${host}:${port}/`))

4
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

5280
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

25
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

16
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

37
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,37 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "dfg_3dviewer",
"version": "0.1.0",
"identifier": "com.dworak.aim3dviewer",
"build": {
"frontendDist": "../dist/dev",
"devUrl": "http://127.0.0.1:1234",
"beforeDevCommand": "npm run build:dev",
"beforeBuildCommand": "npm run build:dev"
},
"app": {
"windows": [
{
"title": "AIM 3D Viewer",
"width": 1680,
"height": 1040,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self' blob: data:;"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

276
tests/viewer.spec.ts Normal file
View file

@ -0,0 +1,276 @@
// @ts-check
import { test, expect } from '@playwright/test';
const defaultModel = '/examples/box.stl';
const supportedFormatsText = 'GLB, GLTF, OBJ, DAE, FBX, PLY, IFC, STL, XYZ, JSON, 3DS, PCD';
const sandboxDropMessage = 'Drag and drop a 3D model into the viewer.';
const sandboxSupportedFormatsNotice = `<strong>Supported formats</strong>: ${supportedFormatsText}\n`;
const sandboxSupportedArchiveFormatsNotice = 'and <strong>archive formats</strong>: ZIP, RAR, TAR, XZ, GZ.';
const sandboxDropNotice = `${sandboxDropMessage} ${sandboxSupportedFormatsNotice} ${sandboxSupportedArchiveFormatsNotice}`;
const supportedExamples = [
{ format: 'dae', path: '/examples/box.dae' },
{ format: 'stl', path: '/examples/box.stl' },
{ format: 'ply', path: '/examples/box.ply' },
{ format: 'obj', path: '/examples/box.obj' },
{ format: 'xyz', path: '/examples/box.xyz' },
{ format: 'pcd', path: '/examples/box.pcd' },
{ format: '3ds', path: '/examples/box.3ds' },
{ format: 'ifc', path: '/examples/box.ifc' },
{ format: 'fbx', path: '/examples/box.fbx' },
{ format: 'glb', path: '/examples/box.glb' },
];
async function openViewer(page, modelPath = defaultModel) {
await page.addInitScript(() => {
window.__E2E__ = true;
});
await page.goto(`/?e2eModel=${encodeURIComponent(modelPath)}`);
await page.waitForSelector('#MainCanvas', { state: 'attached' });
}
async function openSandboxViewer(page) {
await page.addInitScript(() => {
window.__E2E__ = true;
});
await page.goto('/?sandbox=1');
await page.waitForSelector('#MainCanvas', { state: 'attached' });
}
async function waitForModel(page) {
await page.waitForFunction(() => window.viewer?.modelLoaded === true, {
timeout: 15_000,
});
}
async function waitForViewerIssue(page) {
await page.waitForFunction(
() =>
(window.viewer?.errors?.length ?? 0) > 0 ||
(window.viewer?.toasts?.length ?? 0) > 0,
{ timeout: 15_000 }
);
}
test('viewer runs in E2E mode', async ({ page }) => {
await openViewer(page);
const canvas = page.locator('#MainCanvas');
await expect(canvas).toBeVisible();
const hasWebGL = await canvas.evaluate((el) => {
const gl = el.getContext('webgl2') || el.getContext('webgl');
return !!gl;
});
expect(hasWebGL).toBe(true);
await expect.poll(() => page.evaluate(() => window.__E2E__)).toBe(true);
});
test('sandbox mode starts without loading a model', async ({ page }) => {
await openSandboxViewer(page);
await page.waitForFunction(
(msg) => window.viewer?.toasts?.some((t) => t.includes(msg)),
sandboxDropMessage
);
await page.waitForTimeout(3_000);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
toasts: window.viewer.toasts ?? [],
guiHidden: document.querySelector('#guiContainer')?.hidden,
sandboxNoticeVisible:
document.querySelector('#viewerStatusNotice[data-variant="sandbox"].is-visible')?.hidden === false,
noticeContainerCentered:
document.querySelector('#viewerNoticeContainer')?.classList.contains('viewer-notice-container--sandbox'),
}));
expect(state.modelLoaded).toBe(false);
expect(state.toasts.some((t) => t.includes(sandboxDropMessage))).toBe(true);
expect(state.guiHidden).toBe(true);
expect(state.sandboxNoticeVisible).toBe(true);
expect(state.noticeContainerCentered).toBe(true);
});
test('sandbox notice updates after language changes', async ({ page }) => {
await openSandboxViewer(page);
const notice = page.locator('#viewerStatusNotice[data-variant="sandbox"]');
await expect(notice.locator('.viewer-notice-message')).toHaveText(sandboxDropMessage);
// details are rendered as separate lines/spans: label, formats list, archives
await expect(notice.locator('.viewer-notice-detail').nth(0)).toContainText('Supported formats');
await expect(notice.locator('.viewer-notice-detail').nth(1)).toHaveText(supportedFormatsText);
await expect(notice.locator('.viewer-notice-detail').nth(2)).toContainText('archive formats');
await page.evaluate(() => {
document.querySelector<HTMLElement>('#viewerLanguageMode')?.click();
document.querySelector<HTMLElement>('.language-dropdown-item-polish')?.click();
});
await expect(notice.locator('.viewer-notice-message')).toHaveText("Przeciągnij i upuść model 3D w oknie viewer'a.");
await expect(notice.locator('.viewer-notice-detail').nth(0)).toContainText('formaty');
await expect(notice.locator('.viewer-notice-detail').nth(1)).toHaveText(supportedFormatsText);
await expect(notice.locator('.viewer-notice-detail').nth(2)).toContainText('archiwa');
});
for (const example of supportedExamples) {
test(`loads ${example.format.toUpperCase()} example into scene`, async ({ page }) => {
await openViewer(page, example.path);
await waitForModel(page);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
objectCount: window.viewer.scene.children.length,
}));
expect(state.modelLoaded).toBe(true);
expect(state.objectCount).toBeGreaterThan(0);
});
}
test('camera rotates on mouse drag', async ({ page }) => {
await openViewer(page);
await waitForModel(page);
await page.waitForFunction(() => window.viewer?.camera);
const canvas = page.locator('#MainCanvas');
const box = await canvas.boundingBox();
if (!box) {
throw new Error('MainCanvas bounding box is unavailable');
}
const before = await page.evaluate(() => ({
x: window.viewer.camera.position.x,
y: window.viewer.camera.position.y,
z: window.viewer.camera.position.z,
}));
const startX = box.x + box.width * 0.5;
const startY = box.y + box.height * 0.5;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 200, startY, { steps: 10 });
await page.mouse.up();
await expect
.poll(() =>
page.evaluate(() => ({
x: window.viewer.camera.position.x,
y: window.viewer.camera.position.y,
z: window.viewer.camera.position.z,
}))
)
.not.toEqual(before);
});
test('reports unsupported format without loading a model', async ({ page }) => {
await openViewer(page, '/examples/box.txt');
await waitForViewerIssue(page);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
errors: window.viewer.errors ?? [],
toasts: window.viewer.toasts ?? [],
}));
expect(state.modelLoaded).toBe(false);
expect(state.errors).toEqual([]);
expect(state.toasts).toContain('File extension is not supported yet.');
});
test('reports a missing model file instead of hanging', async ({ page }) => {
await openViewer(page, '/examples/does-not-exist.stl');
await waitForViewerIssue(page);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
errors: window.viewer.errors ?? [],
toasts: window.viewer.toasts ?? [],
}));
expect(state.modelLoaded).toBe(false);
expect(state.errors.length).toBeGreaterThan(0);
await expect
.poll(() => page.evaluate(() => window.viewer.errors.join(' ')))
.toContain('404');
});
test('loads OBJ even when the referenced MTL file is missing', async ({ page }) => {
await openViewer(page, '/examples/box-missing-mtl.obj');
await waitForModel(page);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
objectCount: window.viewer.scene.children.length,
toasts: window.viewer.toasts ?? [],
}));
expect(state.modelLoaded).toBe(true);
expect(state.objectCount).toBeGreaterThan(0);
expect(state.toasts).toContain('Error occurred while loading attached MTL file.');
});
test('reports a corrupted model file instead of hanging', async ({ page }) => {
await openViewer(page, '/examples/broken.glb');
await waitForViewerIssue(page);
const state = await page.evaluate(() => ({
modelLoaded: window.viewer.modelLoaded,
errors: window.viewer.errors ?? [],
toasts: window.viewer.toasts ?? [],
}));
expect(state.modelLoaded).toBe(false);
expect(state.errors.length).toBeGreaterThan(0);
});
test('loads config from drupalSettings when present', async ({ page }) => {
await page.addInitScript(() => {
window.__E2E__ = true;
window.drupalSettings = {
dfg_3dviewer: {
mainUrl: 'http://localhost',
baseNamespace: 'http://localhost',
metadataUrl: 'http://localhost',
baseModulePath: '/assets',
entity: {
bundle: 'test-bundle',
fieldDf: 'field_df',
idUri: '/wisski/navigate/(.*)/view',
viewEntityPath: '/wisski/navigate/',
attributeId: 'wisski_id',
exportViewer: 'field_df',
exportViewerUrl: '',
metadata: { source: 'Drupal', sourceType: '', url: '' },
},
viewer: {
container: 'DFG_3DViewer',
fileUpload: 'upload',
fileName: 'name',
imageGeneration: 'image',
lightweight: true,
editor: false,
scaleContainer: { x: '1', y: '1' },
gallery: { container: '', imageClass: '', imageId: '', build: false },
},
},
};
});
await page.goto(`/?e2eModel=${encodeURIComponent(defaultModel)}`);
await page.waitForSelector('#MainCanvas', { state: 'attached' });
const configSource = await page.evaluate(() => ({
bundle: window.viewer?.configBundle,
fromDrupal: window.viewer?.configFromDrupal,
}));
await page.waitForFunction(() => window.viewer?.configFromDrupal === true, {
timeout: 10_000,
});
expect(configSource.bundle).toBe('test-bundle');
});

View file

@ -0,0 +1,48 @@
{
"baseNamespace": "",
"mainUrl": "https:\\/\\/your.domain.com",
"metadataUrl": "",
"baseModulePath": "\\/modules\\/dfg_3dviewer\\/viewer",
"entity": {
"bundle": "bd1220d6ec7f07e726c65fd215d8e493",
"fieldDf": "field_df",
"exportViewer": "field_df",
"exportViewerUrl": "https:\\/\\/your.domain.com",
"idUri": "\\/wisski\\/navigate\\/(.*)\\/view",
"viewEntityPath": "\\/wisski\\/navigate\\/",
"attributeId": "wisski_id",
"metadata": {
"sourceType": "Drupal",
"url": "https://repository.covher.eu/api/digital_reconstruction/record/"
}
},
"viewer": {
"container": "DFG_3DViewer",
"fileUpload": "fad29437cb2a561b91b26aca5dbb7c42",
"fileName": "fb76901eb219495fee0512b5cdfdaa18",
"imageGeneration": "fd6a974b7120d422c7b21b5f1f2315d9",
"presentationMode": false,
"sandboxMode": false,
"lightweight": true,
"scaleContainer": {
"x": "1.0",
"y": "1.0"
},
"editor": true,
"gallery": {
"build": true,
"container": "block-bootstrap5-content",
"imageClass": "field--name-fd6a974b7120d422c7b21b5f1f2315d9",
"imageId": "field--name-fd6a974b7120d422c7b21b5f1f2315d9",
"buildFake": true,
"testImages": []
},
"background": "radial-gradient(circle, #ffffff 0%, #999999 100%)",
"performanceMode": {
"Performance": "high-performance"
},
"measurement": {
"modelUnitInMeters": 1
}
}
}

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

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