Initial commit
80
.gitattributes
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
50
package.json
Executable 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
5280
src-tauri/Cargo.lock
generated
Normal file
25
src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
16
src-tauri/src/lib.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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');
|
||||
});
|
||||
48
viewer-settings-example.json
Normal 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
|
|
@ -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 it’s 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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
89
viewer/admin/api/actions.php
Normal 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']);
|
||||
62
viewer/admin/api/backups.php
Normal 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']);
|
||||
55
viewer/admin/api/common.php
Normal 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
|
|
@ -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']);
|
||||
17
viewer/admin/api/env_schema.php
Normal 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
|
|
@ -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']);
|
||||
41
viewer/admin/api/settings.php
Normal 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']);
|
||||
24
viewer/admin/api/settings_schema.php
Normal 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;
|
||||
24
viewer/admin/api/upload_schema.php
Normal 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);
|
||||
24
viewer/admin/create_admin.php
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 <user> <pass></code></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
6
viewer/admin/logout.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
session_start();
|
||||
session_unset();
|
||||
session_destroy();
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
119
viewer/admin/settings.php
Normal 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
|
|
@ -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
|
|
@ -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}
|
||||
}
|
||||
14
viewer/config.json.example
Normal 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
|
|
@ -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
|
|
@ -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)}
|
||||
1263
viewer/css/editor-toolbar.css
Normal file
226
viewer/css/embed-configurator.css
Normal 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;
|
||||
}
|
||||
}
|
||||
335
viewer/css/external-sources.css
Normal 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
959
viewer/css/spinner.css
Executable 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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
@import "./main.css";
|
||||
@import "./embed-configurator.css";
|
||||
@import "./spinner.css";
|
||||
@import "./theme.css";
|
||||
@import "./external-sources.css";
|
||||
8
viewer/dfg_3dviewer.code-workspace
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
1243
viewer/editor-toolbar.js
Normal file
1144
viewer/editor/annotations.js
Normal file
546
viewer/editor/materials-editor.js
Normal 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")}">×</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;
|
||||
},
|
||||
});
|
||||
}
|
||||
90
viewer/editor/measurement.js
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
192
viewer/editor/metadata-persistence.js
Normal 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
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
52
viewer/editor/thumbnail-capture.js
Normal 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);
|
||||
}
|
||||
8
viewer/examples/box-missing-mtl.obj
Normal 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
BIN
viewer/examples/box.abc
Normal file
70
viewer/examples/box.dae
Normal 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
BIN
viewer/examples/box.glb
Normal file
43
viewer/examples/box.ifc
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
BIN
viewer/examples/box.stl
Normal file
99
viewer/examples/box.xyz
Normal 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
|
||||
1
viewer/examples/broken.glb
Normal file
|
|
@ -0,0 +1 @@
|
|||
this-is-not-a-valid-glb-file
|
||||
117
viewer/extract-helper.js
Normal 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
|
|
@ -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
|
|
@ -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
|
||||