diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 404efbf..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.eslintrc.json b/.eslintrc.json index f7c7c6b..f468a1c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,9 +3,7 @@ "ignorePatterns": [ "**/*" ], - "plugins": [ - "@nrwl/nx" - ], + "plugins": ["@nx"], "overrides": [ { "files": [ @@ -15,7 +13,7 @@ "*.jsx" ], "rules": { - "@nrwl/nx/enforce-module-boundaries": [ + "@nx/enforce-module-boundaries": [ "error", { "enforceBuildableLibDependency": true, @@ -38,7 +36,7 @@ "*.tsx" ], "extends": [ - "plugin:@nrwl/nx/typescript", + "plugin:@nx/typescript", "prettier" ], "plugins": [ @@ -96,7 +94,7 @@ "*.jsx" ], "extends": [ - "plugin:@nrwl/nx/javascript" + "plugin:@nx/javascript" ], "rules": {} } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5bd164a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +name: Release +"on": + push: + branches: + - main +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + always-auth: true + - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN_TECHUSER }}" > ~/.npmrc + - run: npm ci + - run: npm run test + - run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN_TECHUSER }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_TECHUSER }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..78d89d5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test +"on": + push: + branches: + - develop + - feature/** + - hotfix/** + - renovate/** + pull_request: + types: + - opened + - synchronize +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: npm + - run: npm ci + - run: npm run build + - run: npm run test + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: npm + - run: npm ci + - run: npm run build + - run: npm run lint diff --git a/.gitignore b/.gitignore index ad42b67..28f9699 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ testem.log # System Files .DS_Store Thumbs.db + +.nx/cache \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 07e0ba2..c7299ca 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,3 @@ - [submodule "libs/gltf-extension/glTF-spec"] path = libs/gltf-extension/glTF-spec url = git@github.com:alchemisten/glTF.git diff --git a/.prettierignore b/.prettierignore index d0b804d..85f8ece 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ /dist /coverage + +/.nx/cache \ No newline at end of file diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..7a35dcb --- /dev/null +++ b/.releaserc @@ -0,0 +1,39 @@ +{ + "branches": "main", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + [ + "@semantic-release/npm", + { + "publish": false + } + ], + "@semantic-release/github", + [ + "@semantic-release/exec", + { + "publishCmd": "nx run-many --target=publish --ver=${nextRelease.version} --exclude=3d-studio-example,gltf-extension,gltf-extension-validator" + } + ], + [ + "@semantic-release/git", + { + "assets": [ + "package.json", + "package-lock.json" + ], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@saithodev/semantic-release-backmerge", + { + "branches": [ + "develop" + ], + "backmergeStrategy": "merge" + } + ] + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d52ddb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the maintainers of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Submitting an Issue +Before you submit an issue, please search the issue tracker. An issue for your problem might already +exist and the discussion might inform you of workarounds readily available. + +We want to fix all the issues as soon as possible, but before fixing a bug, we need to reproduce +and confirm it. In order to reproduce bugs, we require that you provide a minimal reproduction. +Having a minimal reproducible scenario gives us a wealth of important information without going +back and forth to you with additional questions. + +## Pull Request Process +1. Please note that we use [semantic-release](https://github.com/semantic-release/semantic-release) to determine the next release version number. + Make sure to follow the [Angular commit message guidelines](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#-commit-message-format) to ensure the correct new version. +2. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +3. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +4. Your pull request will be merged by the project owners once it has been reviewed. + +## Code of Conduct + +### Our Pledge + +We as members, contributors, and project maintainers pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +### Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +### Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team via our [homepage](https://alchemisten.ag). +All complaints will be reviewed and investigated promptly and fairly. + +All project maintainers are obligated to respect the privacy and security of the +reporter of any incident. + +### Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, +available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct/](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) diff --git a/LICENSE b/LICENSE.md similarity index 96% rename from LICENSE rename to LICENSE.md index 38f732f..8a1bf0b 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Viktor Spadi +Copyright (c) 2022 Alchemisten AG Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index ab80f5d..b17cf30 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,26 @@ based on [Three JS](https://threejs.org). This mono repo contains the following packages. See their individual READMEs for more details: -* GLTF Extension: Contains the definition for the *.alcm file object which +* [Viewer Core](libs/viewer-core/README.md): + Contains the actual viewer and the core features. +* [Viewer UI](libs/viewer-ui/README.md): + Contains a UI for the viewer and the core features. +* [React Website](libs/react-website/README.md): + Contains a React Website which can be used with a single component with + minimal configuration. +* [Example](apps/3d-studio-example/README.md): + Contains an example application which is used mainly for development, but + also contains examples for the usage of the viewer and the viewer UI. +* GLTF Extension: Contains the definition for the *.alcm file object which extends the default *.gltf file definition with all configuration options necessary for the viewer. * GLTF Extension Validator: TODO -* [Viewer Core](libs/viewer-core/README.md): - Contains the actual viewer and the core features. Examples for the most - common use cases are available. + +## Development + +### Build +Run `npm run build` to create a new build. + +### New release +Merging to the `main` branch will automatically create a new release via +semantic-release. diff --git a/apps/3d-studio-example/README.md b/apps/3d-studio-example/README.md new file mode 100644 index 0000000..0fbf8f7 --- /dev/null +++ b/apps/3d-studio-example/README.md @@ -0,0 +1,10 @@ +# 3D Studio Example +This is an example application for the 3D Studio Viewer Core and UI. It is used for development and to provide usage examples. + +## Development + +Running `npm start` will serve a live reload development server on `localhost:4200`, +serving the files from the `3d-studio-example` app. The base files can be used for development, +further examples for specific functionality should receive their own subfolder in +`apps/3d-studio-example/src/examples`. To run any of these examples open the corresponding +index.html file in the browser, e.g. `localhost:4200/src/examples/multiple-viewers/index.html`. diff --git a/apps/3d-studio-example/project.json b/apps/3d-studio-example/project.json index 0991902..e504977 100644 --- a/apps/3d-studio-example/project.json +++ b/apps/3d-studio-example/project.json @@ -45,7 +45,7 @@ } }, "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { "lintFilePatterns": ["apps/3d-studio-example/**/*.ts"] @@ -53,7 +53,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["coverage/apps/3d-studio-example"], + "outputs": ["{workspaceRoot}/coverage/apps/3d-studio-example"], "options": { "jestConfig": "apps/3d-studio-example/jest.config.ts", "passWithNoTests": true diff --git a/apps/3d-studio-example/src/examples/custom-service/index.html b/apps/3d-studio-example/src/examples/custom-service/index.html new file mode 100644 index 0000000..a5f95fe --- /dev/null +++ b/apps/3d-studio-example/src/examples/custom-service/index.html @@ -0,0 +1,15 @@ + + + + + Custom Service Example + + + +
+
+
+ + + + diff --git a/apps/3d-studio-example/src/examples/custom-service/index.ts b/apps/3d-studio-example/src/examples/custom-service/index.ts new file mode 100644 index 0000000..1d44bcf --- /dev/null +++ b/apps/3d-studio-example/src/examples/custom-service/index.ts @@ -0,0 +1,35 @@ +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; +import { AlternativeLoggerService } from './logger'; + +(function () { + const container = document.getElementById('viewer-container'); + if (!container) { + return; + } + + const launcher = new ViewerLauncher({ + customManager: { + logger: AlternativeLoggerService, + }, + loggerOptions: { + environment: 'develop', + }, + }); + launcher.createCanvasViewer( + { + objects: [ + { + name: 'Milk-Truck', + path: 'assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }, + ], + render: { + continuousRendering: true, + }, + project: { + basedir: 'http://127.0.0.1:4200', + }, + }, + container + ); +})(); diff --git a/apps/3d-studio-example/src/examples/custom-service/logger.ts b/apps/3d-studio-example/src/examples/custom-service/logger.ts new file mode 100644 index 0000000..09ac477 --- /dev/null +++ b/apps/3d-studio-example/src/examples/custom-service/logger.ts @@ -0,0 +1,46 @@ +import { ILoggerService } from '@schablone/3d-studio-viewer-core'; +import { injectable } from 'inversify'; +import { ILogger, LoggerOptions } from '@schablone/logging'; + +@injectable() +export class AlternativeLoggerService implements ILoggerService { + public init(options?: LoggerOptions, logger?: ILogger): void { + // eslint-disable-next-line no-console + console.log( + "[CUSTOM LOGGER] This is a custom logger service. It does nothing but log to the console, so we won't be needing these: ", + options, + logger + ); + } + + public debug(message: string) { + // eslint-disable-next-line no-console + console.log(`[CUSTOM LOGGER] ${message}`); + } + public error(message: string) { + // eslint-disable-next-line no-console + console.error(`[CUSTOM LOGGER] ${message}`); + } + public fatal(message: string) { + // eslint-disable-next-line no-console + console.error(`[CUSTOM LOGGER] ${message}`); + } + public info(message: string) { + // eslint-disable-next-line no-console + console.info(`[CUSTOM LOGGER] ${message}`); + } + public trace(message: string) { + // eslint-disable-next-line no-console + console.trace(`[CUSTOM LOGGER] ${message}`); + } + public warn(message: string) { + // eslint-disable-next-line no-console + console.warn(`[CUSTOM LOGGER] ${message}`); + } + + public withOptions(options: LoggerOptions): ILogger { + // eslint-disable-next-line no-console + console.log('Those are some nice options. Would be a shame if nobody used them.', options); + return this; + } +} diff --git a/apps/3d-studio-example/src/examples/highlights/index.ts b/apps/3d-studio-example/src/examples/highlights/index.ts index ff47ac2..2d1a5d4 100644 --- a/apps/3d-studio-example/src/examples/highlights/index.ts +++ b/apps/3d-studio-example/src/examples/highlights/index.ts @@ -1,4 +1,4 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; (function () { const container = document.getElementById('viewer-container'); @@ -6,101 +6,108 @@ import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; return; } - const launcher = new ViewerLauncher(); - launcher.createHTMLViewer(container, { - features: { - HighlightFeature: { - enabled: true, - groupScale: 8, - highlightsVisible: true, - highlightSetup: [ - { - cam: { - x: -1.81269, - y: 1.29626, - z: 1.88639, - }, - i18n: { - de: { - headline: 'Beifahrertür', + const launcher = new ViewerLauncher({ + loggerOptions: { + environment: 'local', + }, + }); + launcher.createCanvasViewer( + { + features: { + HighlightFeature: { + enabled: true, + groupScale: 8, + highlightsVisible: true, + highlightSetup: [ + { + cam: { + x: -1.81269, + y: 1.29626, + z: 1.88639, }, - }, - id: 2074, - pos: { - x: -0.246847, - y: 0.21732, - z: 0.156787, - }, - scale: 0.04, - target: { - x: 4.17453, - y: -2.7536, - z: -5.02388, - }, - }, - { - cam: { - x: 2.20833, - y: -0.26136, - z: -0.061664, - }, - i18n: { - de: { - headline: 'Fahrzeugseite', + i18n: { + de: { + headline: 'Beifahrertür', + }, }, - }, - id: 2075, - pos: { - x: 0.345273, - y: 0.0670324, - z: 0.0150209, - }, - scale: 0.04, - target: { - x: -6.3605, - y: 4.89373, - z: -0.0458263, - }, - }, - { - cam: { - x: 0.10648, - y: -0.0680558, - z: 3.41046, - }, - i18n: { - de: { - headline: 'Unterboden', + id: 2074, + pos: { + x: -0.246847, + y: 0.21732, + z: 0.156787, + }, + scale: 0.04, + target: { + x: 4.17453, + y: -2.7536, + z: -5.02388, }, }, - id: 2076, - pos: { - x: 0, - y: 0, - z: 0.24555, + { + cam: { + x: 2.20833, + y: -0.26136, + z: -0.061664, + }, + i18n: { + de: { + headline: 'Fahrzeugseite', + }, + }, + id: 2075, + pos: { + x: 0.345273, + y: 0.0670324, + z: 0.0150209, + }, + scale: 0.04, + target: { + x: -6.3605, + y: 4.89373, + z: -0.0458263, + }, }, - scale: 0.04, - target: { - x: -0.205522, - y: 0.131358, - z: -6.58268, + { + cam: { + x: 0.10648, + y: -0.0680558, + z: 3.41046, + }, + i18n: { + de: { + headline: 'Unterboden', + }, + }, + id: 2076, + pos: { + x: 0, + y: 0, + z: 0.24555, + }, + scale: 0.04, + target: { + x: -0.205522, + y: 0.131358, + z: -6.58268, + }, }, - }, - ], + ], + }, }, - }, - objects: [ - { - name: 'Milk-Truck', - path: 'assets/models/milk-truck-draco/CesiumMilkTruck.gltf', - scale: 0.5, + objects: [ + { + name: 'Milk-Truck', + path: 'assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + scale: 0.5, + }, + ], + project: { + basedir: 'http://127.0.0.1:4200', + }, + render: { + continuousRendering: true, }, - ], - project: { - basedir: 'http://127.0.0.1:4200', - }, - render: { - continuousRendering: true, }, - }); + container + ); })(); diff --git a/apps/3d-studio-example/src/examples/image-viewer/index.ts b/apps/3d-studio-example/src/examples/image-viewer/index.ts index 7230a25..8689c36 100644 --- a/apps/3d-studio-example/src/examples/image-viewer/index.ts +++ b/apps/3d-studio-example/src/examples/image-viewer/index.ts @@ -1,4 +1,4 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; (function () { const container = document.getElementById('viewer-container'); @@ -6,19 +6,26 @@ import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; return; } - const launcher = new ViewerLauncher(); - const size = { width: 1024, height: 768 }; // Defines size of rendering - const images$ = launcher.createImageViewer(size, { + const launcher = new ViewerLauncher({ + loggerOptions: { + environment: 'local', + }, + }); + const renderSize = { width: 1024, height: 768 }; // Defines size of rendering + const images$ = launcher.createImageViewer({ objects: [ { name: 'Milk-Truck', path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', }, ], + render: { + renderSize, + }, }); const image = document.createElement('img'); - image.width = size.width; - image.height = size.height; + image.width = renderSize.width; + image.height = renderSize.height; container.appendChild(image); images$.subscribe((imageData) => { image.src = imageData; diff --git a/apps/3d-studio-example/src/examples/light-scenarios/index.ts b/apps/3d-studio-example/src/examples/light-scenarios/index.ts index 19d226f..4f6bbef 100644 --- a/apps/3d-studio-example/src/examples/light-scenarios/index.ts +++ b/apps/3d-studio-example/src/examples/light-scenarios/index.ts @@ -1,4 +1,4 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; (function () { const container = document.getElementById('viewer-container'); @@ -6,83 +6,90 @@ import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; return; } - const launcher = new ViewerLauncher(); - launcher.createHTMLViewer(container, { - features: { - CameraRotationFeature: { enabled: true, rotationSpeed: 1 }, - LightScenarioFeature: { - enabled: true, - initialScenarioId: 'points', - scenarios: [ - { - i18n: { - de: { - name: 'Punktlichter', - }, - }, - id: 'points', - lights: {}, - lightSetups: [ - { - color: '#FF0000', - name: 'red', - position: { - x: 5, - y: 5, - z: 0, + const launcher = new ViewerLauncher({ + loggerOptions: { + environment: 'local', + }, + }); + launcher.createCanvasViewer( + { + features: { + CameraRotationFeature: { enabled: true, rotationSpeed: 1 }, + LightScenarioFeature: { + enabled: true, + initialScenarioId: 'points', + scenarios: [ + { + i18n: { + de: { + name: 'Punktlichter', }, - type: 'point', }, - { - color: '#00FF00', - name: 'green', - position: { - x: -5, - y: 0, - z: 5, + id: 'points', + lights: {}, + lightSetups: [ + { + color: '#FF0000', + name: 'red', + position: { + x: 5, + y: 5, + z: 0, + }, + type: 'point', }, - type: 'point', - }, - { - color: '#0000FF', - name: 'blue', - position: { - x: 0, - y: -5, - z: -5, + { + color: '#00FF00', + name: 'green', + position: { + x: -5, + y: 0, + z: 5, + }, + type: 'point', }, - type: 'point', - }, - ], - }, - { - i18n: { - de: { - name: 'Ambient', - }, + { + color: '#0000FF', + name: 'blue', + position: { + x: 0, + y: -5, + z: -5, + }, + type: 'point', + }, + ], }, - id: 'ambient', - lights: {}, - lightSetups: [ - { - color: '#ffffff', - intensity: 0.5, - name: 'ambient', - type: 'ambient', + { + i18n: { + de: { + name: 'Ambient', + }, }, - ], - }, - ], + id: 'ambient', + lights: {}, + lightSetups: [ + { + color: '#ffffff', + intensity: 0.5, + name: 'ambient', + type: 'ambient', + }, + ], + }, + ], + }, }, - }, - objects: [ - { - name: 'Milk-Truck', - path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + objects: [ + { + name: 'Milk-Truck', + path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }, + ], + render: { + continuousRendering: true, }, - ], - render: { - continuousRendering: true, }, - }); + container + ); })(); diff --git a/apps/3d-studio-example/src/examples/multiple-viewers/index.ts b/apps/3d-studio-example/src/examples/multiple-viewers/index.ts index b74fd05..3e126c3 100644 --- a/apps/3d-studio-example/src/examples/multiple-viewers/index.ts +++ b/apps/3d-studio-example/src/examples/multiple-viewers/index.ts @@ -1,4 +1,4 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; import { Vector3 } from 'three'; (function () { @@ -8,33 +8,47 @@ import { Vector3 } from 'three'; return; } - const launcherOne = new ViewerLauncher(); - launcherOne.createHTMLViewer(containerOne, { - features: { - WireframeFeature: { enabled: true }, + const launcherOne = new ViewerLauncher({ + loggerOptions: { + environment: 'local', }, - objects: [ - { - name: 'Milk-Truck', - path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', - }, - ], }); - - const launcherTwo = new ViewerLauncher(); - launcherTwo.createHTMLViewer(containerTwo, { - camera: { - fov: 90, - position: new Vector3(-5, 2, -1), + launcherOne.createCanvasViewer( + { + features: { + WireframeFeature: { enabled: true }, + }, + objects: [ + { + name: 'Milk-Truck', + path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }, + ], }, - features: { - WireframeFeature: { enabled: false }, + containerOne + ); + + const launcherTwo = new ViewerLauncher({ + loggerOptions: { + environment: 'local', }, - objects: [ - { - name: 'Milk-Truck', - path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', - }, - ], }); + launcherTwo.createCanvasViewer( + { + camera: { + fov: 90, + position: new Vector3(-5, 2, -1), + }, + features: { + WireframeFeature: { enabled: false }, + }, + objects: [ + { + name: 'Milk-Truck', + path: '../../../assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }, + ], + }, + containerTwo + ); })(); diff --git a/apps/3d-studio-example/src/examples/skybox/index.html b/apps/3d-studio-example/src/examples/skybox/index.html index 8eb0312..5da9547 100644 --- a/apps/3d-studio-example/src/examples/skybox/index.html +++ b/apps/3d-studio-example/src/examples/skybox/index.html @@ -2,7 +2,7 @@ - Light Scenarios Example + Skybox Example diff --git a/apps/3d-studio-example/src/examples/skybox/index.ts b/apps/3d-studio-example/src/examples/skybox/index.ts index f1131c8..b86bd0f 100644 --- a/apps/3d-studio-example/src/examples/skybox/index.ts +++ b/apps/3d-studio-example/src/examples/skybox/index.ts @@ -1,4 +1,4 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; (function () { const container = document.getElementById('viewer-container'); @@ -6,26 +6,33 @@ import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; return; } - const launcher = new ViewerLauncher(); - launcher.createHTMLViewer(container, { - features: { - CameraRotationFeature: { enabled: true, rotationSpeed: 1 }, - SkyboxFeature: { - enabled: true, - skyboxPath: 'assets/textures/environments/blurry', - }, + const launcher = new ViewerLauncher({ + loggerOptions: { + environment: 'local', }, - objects: [ - { - name: 'Milk-Truck', - path: 'assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }); + launcher.createCanvasViewer( + { + features: { + CameraRotationFeature: { enabled: true, rotationSpeed: 1 }, + SkyboxFeature: { + enabled: true, + skyboxPath: 'assets/textures/environments/blurry', + }, + }, + objects: [ + { + name: 'Milk-Truck', + path: 'assets/models/milk-truck-draco/CesiumMilkTruck.gltf', + }, + ], + render: { + continuousRendering: true, + }, + project: { + basedir: 'http://127.0.0.1:4200', }, - ], - render: { - continuousRendering: true, - }, - project: { - basedir: 'http://127.0.0.1:4200', }, - }); + container + ); })(); diff --git a/apps/3d-studio-example/src/main.tsx b/apps/3d-studio-example/src/main.tsx index a1283fe..75b39b4 100644 --- a/apps/3d-studio-example/src/main.tsx +++ b/apps/3d-studio-example/src/main.tsx @@ -1,8 +1,7 @@ -import { ViewerLauncher } from '@alchemisten/3d-studio-viewer-core'; -import { ViewerUI } from '@alchemisten/3d-studio-viewer-ui'; - import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; +import { ViewerUI } from '@schablone/3d-studio-viewer-ui'; (function () { const container = document.getElementById('viewer-container'); @@ -171,7 +170,7 @@ import * as ReactDOM from 'react-dom/client'; }, i18n: { de: { - content: 'Schlimmer als ein SVU. Lässt keine Change zum Abrollen.', + content: 'Schlimmer als ein SVU. Lässt keine Chance zum Abrollen.', headline: 'Fahrzeugfront', }, en: { @@ -277,8 +276,12 @@ import * as ReactDOM from 'react-dom/client'; }, }; - const launcher = new ViewerLauncher(); - const viewer = launcher.createHTMLViewer(container, config); + const launcher = new ViewerLauncher({ + loggerOptions: { + environment: 'local', + }, + }); + const viewer = launcher.createCanvasViewer(config, container); const root = ReactDOM.createRoot(document.getElementById('studio-ui') as HTMLElement); root.render( diff --git a/apps/3d-studio-example/tsconfig.app.json b/apps/3d-studio-example/tsconfig.app.json index 306b516..768ee79 100644 --- a/apps/3d-studio-example/tsconfig.app.json +++ b/apps/3d-studio-example/tsconfig.app.json @@ -2,9 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["node"] + "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] }, - "exclude": [ - "jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"], "include": ["**/*.ts"] } diff --git a/apps/3d-studio-example/tsconfig.spec.json b/apps/3d-studio-example/tsconfig.spec.json index 6d3be74..b0ad5c1 100644 --- a/apps/3d-studio-example/tsconfig.spec.json +++ b/apps/3d-studio-example/tsconfig.spec.json @@ -2,7 +2,14 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] }, "include": [ "vite.config.ts", diff --git a/apps/3d-studio-example/vite.config.ts b/apps/3d-studio-example/vite.config.ts index 8ec4df9..4ce7db5 100644 --- a/apps/3d-studio-example/vite.config.ts +++ b/apps/3d-studio-example/vite.config.ts @@ -1,6 +1,6 @@ /// import { defineConfig } from 'vite'; -import viteTsConfigPaths from 'vite-tsconfig-paths'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; export default defineConfig({ cacheDir: '../../.vite/3d-studio-example', @@ -15,11 +15,7 @@ export default defineConfig({ host: 'localhost', }, - plugins: [ - viteTsConfigPaths({ - root: '../../', - }), - ], + plugins: [nxViteTsPaths()], // Uncomment this if you are using workers. // worker: { diff --git a/jest.config.ts b/jest.config.ts index 2a738f7..d0dbd1b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,4 @@ -import { getJestProjects } from '@nrwl/jest'; +import { getJestProjects } from '@nx/jest'; export default { projects: getJestProjects(), diff --git a/jest.preset.js b/jest.preset.js index e6c8ebe..f078ddc 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,3 +1,3 @@ -const nxPreset = require('@nrwl/jest/preset').default; +const nxPreset = require('@nx/jest/preset').default; module.exports = { ...nxPreset }; diff --git a/libs/gltf-extension-validator/project.json b/libs/gltf-extension-validator/project.json new file mode 100644 index 0000000..f5a8db9 --- /dev/null +++ b/libs/gltf-extension-validator/project.json @@ -0,0 +1,46 @@ +{ + "name": "gltf-extension-validator", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/gltf-extension-validator/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/gltf-extension-validator", + "main": "libs/gltf-extension-validator/src/index.ts", + "tsConfig": "libs/gltf-extension-validator/tsconfig.lib.json", + "assets": ["libs/gltf-extension-validator/*.md"] + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "ts-node tools/scripts/publish.ts gltf-extension-validator {args.ver} {args.tag}" + }, + "dependsOn": [ + { + "projects": "self", + "target": "build" + } + ] + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/gltf-extension-validator/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/gltf-extension-validator"], + "options": { + "jestConfig": "libs/gltf-extension-validator/jest.config.ts", + "passWithNoTests": true + } + } + }, + "tags": [] +} diff --git a/libs/gltf-extension/project.json b/libs/gltf-extension/project.json index b7145ea..e3fc635 100644 --- a/libs/gltf-extension/project.json +++ b/libs/gltf-extension/project.json @@ -4,15 +4,6 @@ "sourceRoot": "libs/gltf-extension/src", "projectType": "library", "targets": { - "generate": { - "executor": "nx:run-commands", - "options": { - "cwd": "libs/gltf-extension", - "commands": [ - "npx ts-node --project ./tsconfig.scripts.json ./scripts/generate-typescript-from-schema.ts" - ] - } - }, "build": { "executor": "@nx/js:tsc", "outputs": ["{options.outputPath}"], @@ -20,12 +11,23 @@ "outputPath": "dist/libs/gltf-extension", "main": "libs/gltf-extension/src/main.ts", "tsConfig": "libs/gltf-extension/tsconfig.lib.json", - "assets": ["libs/gltf-extension/*.md"], - "updateBuildableProjectDepsInPackageJson": true + "assets": ["libs/gltf-extension/*.md"] } }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "ts-node tools/scripts/publish.ts gltf-extension {args.ver} {args.tag}" + }, + "dependsOn": [ + { + "projects": "self", + "target": "build" + } + ] + }, "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { "lintFilePatterns": ["libs/gltf-extension/**/*.ts"] @@ -33,7 +35,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["coverage/libs/gltf-extension"], + "outputs": ["{workspaceRoot}/coverage/libs/gltf-extension"], "options": { "jestConfig": "libs/gltf-extension/jest.config.ts", "passWithNoTests": true diff --git a/libs/gltf-extension/samples/sample_utz.json b/libs/gltf-extension/samples/sample_utz.json deleted file mode 100644 index de4aa91..0000000 --- a/libs/gltf-extension/samples/sample_utz.json +++ /dev/null @@ -1,431 +0,0 @@ -{ - "camera": { - "VIEW_ANGLE": 37, - "NEAR": 0.1, - "FAR": 20000 - }, - "object": { - "name": "KLAPA--34-1210-5000", - "type": "gltf", - "parts": 1, - "scale": 2, - "slots": [], - "changeMaterials": [] - }, - "project": { - "id": "klapa", - "version": 1, - "changeDate": 1572194149, - "projectID": "klapa", - "name": "KLAPA", - "basedir": "https:\/\/3d.alchemisten.de\/static\/", - "folder": "projects\/utz\/klapa\/", - "introText": "usage_intro", - "languages": [ - "de", - "en" - ], - "orientation": "all" - }, - "surrounding": { - "environment": "blurry", - "background": null, - "fogColor": 0 - }, - "tech": { - "debug": false, - "showHighlight": false, - "useFloatingLight": false, - "usePostProcessing": false, - "useSky": false, - "useSkyBox": false, - "useVideo": false, - "useWireframe": false - }, - "features": { - "useSkyBox": { - "active": true, - "default": true - }, - "useAutoRotate": { - "active": true, - "default": true - }, - "showHighlight": { - "active": true, - "default": true - }, - "useFullscreen": { - "active": true, - "default": false - }, - "useSettings": { - "active": true, - "default": true - }, - "useAnimations": { - "active": true, - "default": false - }, - "useFloatingLight": { - "active": false, - "default": false - }, - "useWireframe": { - "active": false, - "default": false - }, - "useVideo": { - "active": false, - "default": false - }, - "usePostProcessing": { - "active": false, - "default": false - }, - "useTransparency": { - "active": false, - "default": false - } - }, - "materials": [], - "highlights": [ - { - "id": 2066, - "color": "#ffffff", - "scale": 0.25, - "fov": 40, - "headline": "hl_1_title", - "content": "hl_1_content", - "mount": null, - "speed": { - "in": 3, - "out": 6, - "fov": 6 - }, - "target": { - "x": -0.0690738, - "y": -2.04269, - "z": -5.87302 - }, - "pos": { - "x": 0, - "y": 0.208058, - "z": 0.592123 - }, - "cam": { - "x": 0.042004, - "y": 1.24217, - "z": 3.57141 - }, - "style": { - "anchor": "n" - }, - "nodes": [ - "" - ], - "delay": 4000, - "isTrigger": false - }, - { - "id": 2067, - "color": "#ffffff", - "scale": 0.25, - "fov": 40, - "headline": "hl_2_title", - "content": "hl_2_content", - "mount": null, - "speed": { - "in": 3, - "out": 6, - "fov": 6 - }, - "target": { - "x": -2.69564, - "y": 3.73807, - "z": -4.05676 - }, - "pos": { - "x": 0, - "y": -0.636742, - "z": 0.274419 - }, - "cam": { - "x": 1.77648, - "y": -2.89499, - "z": 1.94346 - }, - "style": { - "anchor": "n" - }, - "nodes": [ - "" - ], - "delay": 4000, - "isTrigger": false - }, - { - "id": 2068, - "color": "#ffffff", - "scale": 0.25, - "fov": 40, - "headline": "hl_3_title", - "content": "hl_3_content", - "mount": null, - "speed": { - "in": 3, - "out": 6, - "fov": 6 - }, - "target": { - "x": 5.76441, - "y": -2.27256, - "z": -1.648 - }, - "pos": { - "x": -0.759929, - "y": 0.337691, - "z": -0 - }, - "cam": { - "x": -3.20674, - "y": 1.49848, - "z": 0.653687 - }, - "style": { - "anchor": "n" - }, - "nodes": [ - "" - ], - "delay": 4000, - "isTrigger": false - }, - { - "id": 2069, - "color": "#ffffff", - "scale": 0.25, - "fov": 40, - "headline": "hl_4_title", - "content": "hl_4_content", - "mount": null, - "speed": { - "in": 3, - "out": 6, - "fov": 6 - }, - "target": { - "x": -3.95902, - "y": -1.88535, - "z": -4.69579 - }, - "pos": { - "x": 0.532814, - "y": 0.543921, - "z": 0.596605 - }, - "cam": { - "x": 2.10162, - "y": 1.4437, - "z": 2.52821 - }, - "style": { - "anchor": "n" - }, - "nodes": [ - "" - ], - "delay": 4000, - "isTrigger": false - }, - { - "id": 2070, - "color": "#ffffff", - "scale": 0.25, - "fov": 40, - "headline": "hl_5_title", - "content": "hl_5_content", - "mount": null, - "speed": { - "in": 3, - "out": 6, - "fov": 6 - }, - "target": { - "x": 2.17565, - "y": 1.04036, - "z": -6.97056 - }, - "pos": { - "x": -0.471042, - "y": -0.0899534, - "z": 0.572363 - }, - "cam": { - "x": -1.3235, - "y": -0.659105, - "z": 2.24181 - }, - "style": { - "anchor": "n" - }, - "nodes": [ - "" - ], - "delay": 4000, - "isTrigger": false - } - ], - "environments": [ - { - "id": 5, - "name": "Blurry Inddor", - "folder": "blurry" - } - ], - "lightSetups": [ - { - "id": 7, - "name": "High Performance Lights", - "lights": [ - { - "id": 38, - "type": "Point", - "color": "#ffffff", - "hasShadow": true, - "onlyShadow": false, - "castShadow": true, - "shadowMap": 2048, - "shadowNear": 1, - "shadowFar": 15, - "intensity": 1, - "pos": { - "x": 5, - "y": 5, - "z": 5 - }, - "dir": { - "x": 0, - "y": 0, - "z": 0 - } - }, - { - "id": 43, - "type": "Ambient", - "color": "#777777", - "hasShadow": false, - "onlyShadow": false, - "castShadow": false, - "shadowMap": 0, - "shadowNear": 0, - "shadowFar": 0, - "intensity": 0.3, - "pos": { - "x": 0, - "y": 0, - "z": 0 - }, - "dir": { - "x": 0, - "y": 0, - "z": 0 - } - } - ] - } - ], - "postprocess": [], - "i18n": { - "de": { - "hl_1_content": "

Die Klappm\u00f6glichkeit innerhalb der Seitenwand vereinfacht das Be- und Entladen<\/p>

(Alternativ: Seitenwand\u00f6ffnungen auf allen Seiten auf Anfrage verf\u00fcgbar)<\/em><\/p>", - "hl_1_title": "

Entnahme\u00f6ffnung<\/p>", - "hl_2_content": "

Erh\u00f6hen die Sicherheit beim Gabelstaplertransport<\/p>

(Alternativ: KLAPA mit Staplerschienen verf\u00fcgbar - verhindert die Besch\u00e4digung des Bodens beim Verschieben)<\/em><\/p>", - "hl_2_title": "

2 L\u00e4ngskufen und 3 F\u00fcsse<\/p>", - "hl_3_content": "

Durch Entriegelung des Verschlussmechanismus l\u00f6st sich die Verankerung links und rechts und die Seitenw\u00e4nde k\u00f6nnen \u00fcbereinandergelegt werden. Dadurch kann das Volumen bis auf Bodenh\u00f6he eingespart werden.<\/p>", - "hl_3_title": "

Verriegelung<\/p>", - "hl_4_content": "

abh\u00e4ngig von der Fuss- \/ Kufenform kann die KLAPA \u00fcber den Staubschutz- oder Stapeldeckel gestapelt werden.<\/p>", - "hl_4_title": "

Systemvariante stapelbar<\/p>", - "hl_5_content": "

<\/p>", - "hl_5_title": "

Beschriftungs- und Identifikationsfl\u00e4che<\/p>", - "KLAPA": "KLAPA", - "dastudio.animation.play": "Animation abspielen", - "dastudio.animation.stop": "Animation anhalten", - "dastudio.animations": "Animationen", - "dastudio.autopilot.start": "Start Autopilot", - "dastudio.autopilot.stop": "Stop Autopilot", - "dastudio.bloom": "Unreal Bloom", - "dastudio.changematerials": "Farbvarianten", - "dastudio.de": "Deutsch", - "dastudio.dof": "Depth of Field", - "dastudio.edgeglow": "Objekt Umrandung", - "dastudio.en": "Englisch", - "dastudio.floaty": "Licht Spot", - "dastudio.fullscreen": "Vollbild", - "dastudio.fxaa": "Effekt Antialiasing", - "dastudio.highlight.list": "Highlight Liste", - "dastudio.highlight.next": "N\u00e4chstes Highlight", - "dastudio.highlight.previous": "Vorheriges Highlight", - "dastudio.highlights": "Highlights", - "dastudio.info": "Info", - "dastudio.info.text": "Diese Echtzeit-3D Produktpr\u00e4sentation wird erm\u00f6glicht durch WebGL.", - "dastudio.languages": "Sprache umschalten", - "dastudio.lightsetups": "Beleuchtung", - "dastudio.loading": "Lade", - "dastudio.pand": "Programmierung & Design", - "dastudio.postprocessing": "Postprocessing", - "dastudio.rotate": "Auto Drehen", - "dastudio.settings": "Einstellungen", - "dastudio.ssao": "Ambient Occlusion", - "dastudio.taa": "Temporal Antialiasing", - "dastudio.wireframe": "Wireframe", - "introtext": " ", - "usage_intro": "

Halten Sie die linke Maustaste gedr\u00fcckt und bewegen Sie den Cursor, um das Modell im Raum zu drehen. Halten Sie die rechte Maustaste gedr\u00fcckt und bewegen Sie den Cursor, um das Modell im Raum zu verschieben. Mit Hilfe des Mausrads k\u00f6nnen Sie das Modell n\u00e4her heranholen oder aus weiterer Entfernung betrachten.<\/p>

Klicken Sie einmalig an beliebiger Stelle um dieses Infofenster zu schlie\u00dfen<\/strong><\/p>" - }, - "en": { - "hl_1_content": "

The possibility to fold the sidewall facilitates loading and unloading<\/p>

(Alternative: sidewall openings on all sides available on request)<\/em><\/p>", - "hl_1_title": "

Removal apertures<\/p>", - "hl_2_content": "

Increase safety when transporting by forklift<\/p>

(Alternative: KLAPA with forklift truck tracks available - prevents damage to the base when shifting)<\/em><\/p>", - "hl_2_title": "

2 runners lengthways and 3 feet<\/p>", - "hl_3_content": "

By unlocking the locking mechanism, the anchoring on the left and on the right releases and the side walls can be placed over each other. This is space saving down to the height of the base.<\/p>", - "hl_3_title": "

Locking<\/p>", - "hl_4_content": "

depending on foot and runner shape, the KLAPA can be stacked over dust covers or stacking lids.<\/p>", - "hl_4_title": "

System version stackable<\/p>", - "hl_5_content": "

<\/p>", - "hl_5_title": "

Marking- and Identification area<\/p>", - "KLAPA": "KLAPA", - "dastudio.animation.play": "Play animation", - "dastudio.animation.stop": "Stop animation", - "dastudio.animations": "Animations", - "dastudio.autopilot.start": "Start Autopilot", - "dastudio.autopilot.stop": "Stop Autopilot", - "dastudio.bloom": "Unreal Bloom", - "dastudio.changematerials": "Color variants", - "dastudio.de": "German", - "dastudio.dof": "Depth of Field", - "dastudio.edgeglow": "Object edge glow", - "dastudio.en": "English", - "dastudio.floaty": "Light spot", - "dastudio.fullscreen": "Fullscreen", - "dastudio.fxaa": "Effect antialiasing", - "dastudio.highlight.list": "Highlight list", - "dastudio.highlight.next": "Next highlight", - "dastudio.highlight.previous": "Previous highlight", - "dastudio.highlights": "Highlights", - "dastudio.info": "Info", - "dastudio.info.text": "This realtime 3D product presentation is powered by WebGL.", - "dastudio.languages": "Switch language", - "dastudio.lightsetups": "Lighting", - "dastudio.loading": "Loading", - "dastudio.pand": "Programming & Design", - "dastudio.postprocessing": "Postprocessing", - "dastudio.rotate": "Auto rotate", - "dastudio.settings": "Settings", - "dastudio.ssao": "Ambient Occlusion", - "dastudio.taa": "Temporal Antialiasing", - "dastudio.wireframe": "Wireframe", - "introtext": " ", - "usage_intro": "

Hold down the left mouse button and move the cursor to rotate the model in space. Hold down the right mouse button and move the cursor to move the model in space. Using the mouse wheel, you can zoom in and out. <\/p>

Click anywhere to close this information.<\/p>" - } - } -} \ No newline at end of file diff --git a/libs/react-website/.babelrc b/libs/react-website/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/libs/react-website/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/react-website/.eslintrc.json b/libs/react-website/.eslintrc.json new file mode 100644 index 0000000..7fe1196 --- /dev/null +++ b/libs/react-website/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["package.json", "project.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": ["error", { + "ignoredFiles": ["libs/react-website/vite.config.ts", "libs/react-website/jest.config.ts"] + }] + } + } + ] +} diff --git a/libs/react-website/README.md b/libs/react-website/README.md new file mode 100644 index 0000000..c4ee551 --- /dev/null +++ b/libs/react-website/README.md @@ -0,0 +1,24 @@ +# 3D Studio React Website + +This package contains the React website for 3D Studio. It is implemented as a +React application to be used with minimal configuration. + +## Usage + +Make sure to include the polyfills in your application exactly once as +demonstrated in the example below: + +```jsx +// Polyfills +import 'core-js/stable'; +import 'reflect-metadata'; +import 'regenerator-runtime/runtime'; + +import { Studio } from '@schablone/3d-studio-react-website'; + +const App = () => { + return ( + + ); +}; +``` diff --git a/libs/react-website/jest.config.ts b/libs/react-website/jest.config.ts new file mode 100644 index 0000000..afdbd10 --- /dev/null +++ b/libs/react-website/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'react-website', + preset: '../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/react-website', +}; diff --git a/libs/react-website/package.json b/libs/react-website/package.json new file mode 100644 index 0000000..d06d4a6 --- /dev/null +++ b/libs/react-website/package.json @@ -0,0 +1,31 @@ +{ + "name": "@schablone/3d-studio-react-website", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js" + } + }, + "peerDependencies": { + "@schablone/3d-studio-viewer-core": "__root_version__", + "@schablone/3d-studio-viewer-ui": "__root_version__", + "@schablone/logging": "^1.1.7", + "@schablone/logging-react": "^1.1.7", + "@tanstack/react-query": "^4.36.1", + "react": "18.2.0", + "react-intl": "^6.5.1", + "react-intl-provider": "^2.0.0", + "react-router-dom": "^6.14.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nx/devkit": "17.0.3", + "@vitejs/plugin-react": "4.0.0", + "vite": "4.3.9", + "vite-plugin-dts": "2.3.0", + "vite-tsconfig-paths": "^4.2.0" + } +} diff --git a/libs/react-website/project.json b/libs/react-website/project.json new file mode 100644 index 0000000..0f772d8 --- /dev/null +++ b/libs/react-website/project.json @@ -0,0 +1,58 @@ +{ + "name": "3d-studio-react-website", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/react-website/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/react-website/**/*.{ts,tsx,js,jsx}","libs/react-website/package.json","libs/react-website/project.json"] + } + }, + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/libs/react-website", + "generatePackageJson": true + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "ts-node tools/scripts/publish.ts 3d-studio-react-website {args.ver} {args.tag}" + }, + "dependsOn": [ + { + "target": "build" + } + ] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/react-website/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + } +} diff --git a/libs/react-website/public/assets/.gitkeep b/libs/react-website/public/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/libs/react-website/public/assets/alchemisten-logo.svg b/libs/react-website/public/assets/alchemisten-logo.svg new file mode 100644 index 0000000..301c3ab --- /dev/null +++ b/libs/react-website/public/assets/alchemisten-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/react-website/public/assets/bg_scene_default.jpg b/libs/react-website/public/assets/bg_scene_default.jpg new file mode 100644 index 0000000..5d4b52a Binary files /dev/null and b/libs/react-website/public/assets/bg_scene_default.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/neg-x.jpg b/libs/react-website/public/assets/textures/environments/blurry/neg-x.jpg new file mode 100644 index 0000000..ad7d615 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/neg-x.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/neg-y.jpg b/libs/react-website/public/assets/textures/environments/blurry/neg-y.jpg new file mode 100644 index 0000000..b08afba Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/neg-y.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/neg-z.jpg b/libs/react-website/public/assets/textures/environments/blurry/neg-z.jpg new file mode 100644 index 0000000..46ae734 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/neg-z.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/pos-x.jpg b/libs/react-website/public/assets/textures/environments/blurry/pos-x.jpg new file mode 100644 index 0000000..cb61197 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/pos-x.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/pos-y.jpg b/libs/react-website/public/assets/textures/environments/blurry/pos-y.jpg new file mode 100644 index 0000000..a6a6dd6 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/pos-y.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/blurry/pos-z.jpg b/libs/react-website/public/assets/textures/environments/blurry/pos-z.jpg new file mode 100644 index 0000000..147c248 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/blurry/pos-z.jpg differ diff --git a/libs/react-website/public/assets/textures/environments/studio_1.jpg b/libs/react-website/public/assets/textures/environments/studio_1.jpg new file mode 100644 index 0000000..00ae219 Binary files /dev/null and b/libs/react-website/public/assets/textures/environments/studio_1.jpg differ diff --git a/libs/react-website/public/assets/textures/highlights/action_trans.png b/libs/react-website/public/assets/textures/highlights/action_trans.png new file mode 100644 index 0000000..256b4f1 Binary files /dev/null and b/libs/react-website/public/assets/textures/highlights/action_trans.png differ diff --git a/libs/react-website/public/assets/textures/highlights/action_trans_hover.png b/libs/react-website/public/assets/textures/highlights/action_trans_hover.png new file mode 100644 index 0000000..c6d40a8 Binary files /dev/null and b/libs/react-website/public/assets/textures/highlights/action_trans_hover.png differ diff --git a/libs/react-website/public/assets/textures/highlights/simple_trans.png b/libs/react-website/public/assets/textures/highlights/simple_trans.png new file mode 100644 index 0000000..1aa71ed Binary files /dev/null and b/libs/react-website/public/assets/textures/highlights/simple_trans.png differ diff --git a/libs/react-website/public/assets/textures/highlights/simple_trans_hover.png b/libs/react-website/public/assets/textures/highlights/simple_trans_hover.png new file mode 100644 index 0000000..6d02189 Binary files /dev/null and b/libs/react-website/public/assets/textures/highlights/simple_trans_hover.png differ diff --git a/libs/react-website/public/favicon.ico b/libs/react-website/public/favicon.ico new file mode 100644 index 0000000..317ebcb Binary files /dev/null and b/libs/react-website/public/favicon.ico differ diff --git a/libs/react-website/src/assets/.gitkeep b/libs/react-website/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/libs/react-website/src/components/embed-builder/embed-builder.module.scss b/libs/react-website/src/components/embed-builder/embed-builder.module.scss new file mode 100644 index 0000000..e429229 --- /dev/null +++ b/libs/react-website/src/components/embed-builder/embed-builder.module.scss @@ -0,0 +1,75 @@ +@import '../../styles/scss-variables.scss'; + +.sidebar { + background-color: #fff; + bottom: 0; + box-shadow: 0 0 50px 0 rgba(0,0,0,.25); + box-sizing: border-box; + left: calc(-1 * var(--sidebar-width)); + overflow-y: auto; + padding: 25px; + position: fixed; + top: var(--header-height); + transition: left var(--animation-time); + width: var(--sidebar-width); + z-index: 5; + + &.open { + left: 0; + } +} + +.headline { + font-size: 24px; + margin: 20px 0; + text-transform: uppercase; +} + +.subheadline { + font-size: 19px; + margin: 20px 0; + text-transform: uppercase; +} + +.codeWindow { + background: var(--color-gray-light); + font-size: 14px; + margin: 15px 0; + min-height: 200px; + padding: 5px 5px 35px; + position: relative; +} + +.code { + margin: 0; + user-select: all; + white-space: normal; + word-wrap: break-word; +} + +.copyButton { + background: transparent; + bottom: 0; + height: 30px; + line-height: 30px; + padding: 0 10px; + position: absolute; + right: 0; + transition: background var(--animation-time); + + &:hover { + background: var(--color-interaction-active); + color: #fff; + } +} + + + +// ===== MEDIA QUERIES ===== +@media (max-width: $break-md) { + .sidebar { + bottom: 0; + left: -110vw; + width: 100vw; + } +} diff --git a/libs/react-website/src/components/embed-builder/embed-builder.tsx b/libs/react-website/src/components/embed-builder/embed-builder.tsx new file mode 100644 index 0000000..e938b3c --- /dev/null +++ b/libs/react-website/src/components/embed-builder/embed-builder.tsx @@ -0,0 +1,142 @@ +import { FC, useMemo, useState } from 'react'; +import type { CodeParams, LegacyProject } from '../../types'; +import styles from './embed-builder.module.scss'; +import { useConfigContext } from '../../provider'; +import { SettingsButton } from '../settings-button/settings-button'; + +export interface EmbedBuilderProps { + open: boolean; + selectedProject?: LegacyProject; +} + +const codeMap = { + logo: 'l', + allowZoom: 's', + language: 'lng', + togglePlay: 'e', + transparent: 't', +}; + +export const EmbedBuilder: FC = ({ open, selectedProject }) => { + const { baseUrl } = useConfigContext(); + const [codeParams, setCodeParams] = useState({ + height: '450px', + iframe: true, + language: 'none', + logo: false, + togglePlay: true, + width: '750px', + }); + + const copyCode = () => { + navigator.clipboard.writeText(code); + alert('Code wurde in die Zwischenablage kopiert.'); + }; + + const onCodeParamChanged = (key: keyof CodeParams, value?: string | boolean) => { + setCodeParams((prev) => ({ ...prev, [key]: value })); + }; + + const code = useMemo(() => { + if (!selectedProject) { + return ''; + } + const projectUrl = `${baseUrl}/view/${selectedProject.key}`; + const codes = Object.entries(codeMap).reduce((acc, [key, value]) => { + if (codeParams[key] !== undefined && codeParams[key] !== 'none') { + acc.push(`${value}=${codeParams[key]}`); + } + return acc; + }, [] as string[]); + const url = `${projectUrl}${codes.length > 0 ? `?${codes.join('&')}` : ''}`; + const styleSettings = + codeParams.height || codeParams.width + ? `style="${codeParams.height ? `height: ${codeParams.height};` : ''} ${ + codeParams.width ? `width: ${codeParams.width};` : '' + }"` + : ''; + + return codeParams.iframe ? `` : url; + }, [baseUrl, codeParams, selectedProject]); + + return ( +

+ ); +}; diff --git a/libs/react-website/src/components/index.ts b/libs/react-website/src/components/index.ts new file mode 100644 index 0000000..fb460b3 --- /dev/null +++ b/libs/react-website/src/components/index.ts @@ -0,0 +1,6 @@ +export * from './embed-builder/embed-builder'; +export * from './loading-screen/loading-screen'; +export * from './page-header/page-header'; +export * from './project-preview/project-preview'; +export * from './router-base/router-base'; +export * from './settings-button/settings-button'; diff --git a/libs/react-website/src/components/loading-screen/loading-screen.module.scss b/libs/react-website/src/components/loading-screen/loading-screen.module.scss new file mode 100644 index 0000000..3a2a150 --- /dev/null +++ b/libs/react-website/src/components/loading-screen/loading-screen.module.scss @@ -0,0 +1,59 @@ +.loadingScreen { + align-items: center; + display: flex; + flex-direction: column; + gap: 10vh; + height: 100%; + justify-content: center; + left: 0; + padding: var(--ui-size-box-padding); + position: fixed; + top: 0; + width: 100%; + z-index: 100; + + &.transparent { + background-image: none; + } +} + +.background { + height: 100%; + left: 0; + object-fit: cover; + position: absolute; + top: 0; + width: 100%; + z-index: 0; +} + +.button { + background: var(--color-preload-background); + border-radius: 50%; + color: var(--color-button); + height: 80px; + padding: 16px; + position: relative; + width: 80px; + z-index: 1; + + &[disabled] { + opacity: 0.3; + } + + &:hover { + background: var(--color-interaction-active); + } +} + +.box { + background: var(--color-preload-background); + border-radius: var(--preload-border-radius); + color: var(--color-button); + max-width: var(--preload-max-width); + padding: var(--preload-box-padding); + position: relative; + text-align: center; + width: 100%; + z-index: 1; +} diff --git a/libs/react-website/src/components/loading-screen/loading-screen.tsx b/libs/react-website/src/components/loading-screen/loading-screen.tsx new file mode 100644 index 0000000..98b5eee --- /dev/null +++ b/libs/react-website/src/components/loading-screen/loading-screen.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import styles from './loading-screen.module.scss'; + +export interface LoadingScreenProps { + isLoading: boolean; + onPlayClicked: () => void; + title?: string; + transparent?: boolean; +} + +export const LoadingScreen: FC = ({ isLoading, onPlayClicked, title, transparent = false }) => { + return ( +
+ {!transparent && Blurry background} + + +
{isLoading ? : title}
+
+ ); +}; diff --git a/libs/react-website/src/components/page-header/page-header.module.scss b/libs/react-website/src/components/page-header/page-header.module.scss new file mode 100644 index 0000000..e5ad748 --- /dev/null +++ b/libs/react-website/src/components/page-header/page-header.module.scss @@ -0,0 +1,53 @@ +@import '../../styles/scss-variables.scss'; + +.pageHeader { + align-items: center; + background: #fff; + box-shadow: 0 3px 15px rgba(0,0,0,.2); + box-sizing: border-box; + display: flex; + height: var(--header-height); + justify-content: space-between; + left: 0; + padding: 10px; + position: fixed; + right: 0; + text-align: center; + top: 0; + z-index: 20; +} + +.button { + flex: 0 0 auto; + height: var(--interaction-height); + padding: 0; + width: var(--interaction-height); + + svg { + height: 100%; + width: 100%; + } +} + +.logo { + height: 100%; + flex: 0 0 auto; + + img { + height: 100%; + width: auto; + } +} + +.headline { + font-size: 30px; +} + + + +// ===== MEDIA QUERIES ===== +@media (max-width: $break-md) { + .headline { + font-size: 24px; + } +} diff --git a/libs/react-website/src/components/page-header/page-header.tsx b/libs/react-website/src/components/page-header/page-header.tsx new file mode 100644 index 0000000..ca11df2 --- /dev/null +++ b/libs/react-website/src/components/page-header/page-header.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { useConfigContext } from '../../provider'; +import styles from './page-header.module.scss'; + +export interface PageHeaderProps { + alt?: string; + logo?: string; + onSidebarToggle: () => void; +} + +export const PageHeader: FC = ({ alt, logo, onSidebarToggle }) => { + const { baseUrl } = useConfigContext(); + + return ( +
+ +

3D Dashboard

+
{logo && {alt}}
+
+ ); +}; diff --git a/libs/react-website/src/components/project-preview/project-preview.module.scss b/libs/react-website/src/components/project-preview/project-preview.module.scss new file mode 100644 index 0000000..9c52ef3 --- /dev/null +++ b/libs/react-website/src/components/project-preview/project-preview.module.scss @@ -0,0 +1,79 @@ +.projectPreview { + --project-preview-title-color: #fff; + + background: #fff; + border-radius: 3px; + box-shadow: -1px 4px 50px 0 rgba(0,0,0,.5); + overflow: hidden; + position: relative; +} + +.image { + background-color: var(--color-gray); + overflow: hidden; + position: relative; + width: 100%; + + &:before { + content: ''; + display: block; + padding-top: 56.25%; + } + + img { + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + } +} + +.title { + bottom: 10px; + color: #fff; + font-size: 19px; + left: 20px; + pointer-events: none; + position: absolute; + right: 20px; + z-index: 2; +} + +.controls { + background-color: #fff; + display: flex; + height: 80px; + justify-content: space-between; + padding: 20px; +} + +.selectButton { + background: var(--color-gray-light); + border: 8px solid var(--color-gray); + border-radius: 25px; + height: var(--interaction-height); + outline: none; + padding: 0; + transition: background var(--animation-time) opacity var(--animation-time); + width: var(--interaction-height); + + &.selected { + background: var(--color-interaction-active); + } + + &:hover { + opacity: 0.9; + } +} + +.button { + border-radius: 2px; + color: var(--color-button); + + &:hover { + background-color: var(--color-interaction-active); + color: var(--color-button); + } +} diff --git a/libs/react-website/src/components/project-preview/project-preview.tsx b/libs/react-website/src/components/project-preview/project-preview.tsx new file mode 100644 index 0000000..acf792f --- /dev/null +++ b/libs/react-website/src/components/project-preview/project-preview.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; +import { NavLink } from 'react-router-dom'; + +import styles from './project-preview.module.scss'; +import { useConfigContext } from '../../provider'; + +export interface ProjectPreviewProps { + slug: string; + title: string; + image?: string; + onSelect: () => void; + selected?: boolean; +} + +export const ProjectPreview: FC = ({ image, onSelect, selected, slug, title }) => { + const { baseUrl } = useConfigContext(); + + return ( +
+
+
{title}
+ {image && ( + + {title} + + )} +
+
+
+
+ ); +}; diff --git a/libs/react-website/src/components/router-base/router-base.tsx b/libs/react-website/src/components/router-base/router-base.tsx new file mode 100644 index 0000000..e94f304 --- /dev/null +++ b/libs/react-website/src/components/router-base/router-base.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react'; +import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom'; +import { Overview, Project } from '../../views'; + +const MemoizedOutlet = React.memo(Outlet); + +export const RouterBase: FC = () => { + const router = createBrowserRouter([ + { + element: , + children: [ + { + element: , + path: '/', + }, + { + element: , + path: '/view/:id', + }, + ], + }, + ]); + + return ; +}; diff --git a/libs/react-website/src/components/settings-button/settings-button.module.scss b/libs/react-website/src/components/settings-button/settings-button.module.scss new file mode 100644 index 0000000..da37d85 --- /dev/null +++ b/libs/react-website/src/components/settings-button/settings-button.module.scss @@ -0,0 +1,31 @@ +.settingsButton { + width: 100%; +} + +.buttonLeft { + border-radius: calc(var(--interaction-height) / 2) 0 0 calc(var(--interaction-height) / 2); + min-width: 50px; +} + +.buttonCenter { + border-left: 1px solid var(--color-button); + min-width: 50px; +} + +.buttonRight { + border-left: 1px solid var(--color-button); + border-radius: 0 calc(var(--interaction-height) / 2) calc(var(--interaction-height) / 2) 0; + min-width: 50px; +} + +.buttonLeft, +.buttonCenter, +.buttonRight { + &:hover { + opacity: 0.9; + } + + &.active { + background-color: var(--color-interaction-active); + } +} diff --git a/libs/react-website/src/components/settings-button/settings-button.tsx b/libs/react-website/src/components/settings-button/settings-button.tsx new file mode 100644 index 0000000..21467a0 --- /dev/null +++ b/libs/react-website/src/components/settings-button/settings-button.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react'; +import styles from './settings-button.module.scss'; + +export interface SettingsButtonProps { + canBeUndefined?: boolean; + setting?: boolean; + setValue: (value?: boolean) => void; +} + +export const SettingsButton: FC = ({ canBeUndefined = false, setting, setValue }) => { + return ( +
+ + {canBeUndefined && ( + + )} + +
+ ); +}; diff --git a/libs/react-website/src/i18n/index.ts b/libs/react-website/src/i18n/index.ts new file mode 100644 index 0000000..a5911c6 --- /dev/null +++ b/libs/react-website/src/i18n/index.ts @@ -0,0 +1,7 @@ +import de from './lang/de.json'; +import en from './lang/en.json'; + +export const translations = { + de, + en, +}; diff --git a/libs/react-website/src/i18n/lang/de.json b/libs/react-website/src/i18n/lang/de.json new file mode 100644 index 0000000..654ed58 --- /dev/null +++ b/libs/react-website/src/i18n/lang/de.json @@ -0,0 +1,3 @@ +{ + "general.loading": "Lade..." +} diff --git a/libs/react-website/src/i18n/lang/en.json b/libs/react-website/src/i18n/lang/en.json new file mode 100644 index 0000000..48700e2 --- /dev/null +++ b/libs/react-website/src/i18n/lang/en.json @@ -0,0 +1,3 @@ +{ + "general.loading": "Loading..." +} diff --git a/libs/react-website/src/index.ts b/libs/react-website/src/index.ts new file mode 100644 index 0000000..6e51985 --- /dev/null +++ b/libs/react-website/src/index.ts @@ -0,0 +1,5 @@ +export * from './components'; +export * from './provider'; +export * from './studio'; +export * from './types'; +export * from './views'; diff --git a/libs/react-website/src/provider/config-provider.tsx b/libs/react-website/src/provider/config-provider.tsx new file mode 100644 index 0000000..a76fca1 --- /dev/null +++ b/libs/react-website/src/provider/config-provider.tsx @@ -0,0 +1,56 @@ +import { createContext, FC, PropsWithChildren, useContext } from 'react'; +import { ViewerConfigModel } from '@schablone/3d-studio-viewer-core'; + +export interface StudioConfig { + /** + * Base url for all API calls + */ + baseUrl: string; + /** + * Custom styles for the studio + */ + customStyles?: { + /** + * Custom styles for the viewer UI, the provided class will be added to the + * root element of the viewer UI. + */ + viewerUI?: string; + }; + /** + * Custom path for the API call to get all projects, default to `{baseUrl}/api/customer/projects/` + */ + pathAllProjects?: string; + /** + * Custom path for the API call to get a single project, default to `{baseUrl}/api/projects/` + */ + pathSingleProject?: string; + /** + * Optional parser for the project data. If no parser is provided, the data will be used as is. + * + * @param id The id of the project + * @param data The data of the project as returned by the API + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + projectParser?: (id: string, data: any) => ViewerConfigModel; +} + +export const defaultConfig: StudioConfig = { + baseUrl: 'http://localhost:3000', + pathAllProjects: '/api/customer/projects/', + pathSingleProject: '/api/projects/', + projectParser: (id, data) => data as ViewerConfigModel, +}; + +export const ConfigContext = createContext(defaultConfig); + +export const useConfigContext = () => { + return useContext(ConfigContext); +}; + +export interface ConfigProviderProps extends PropsWithChildren { + config: StudioConfig; +} + +export const ConfigProvider: FC = ({ children, config }) => { + return {children}; +}; diff --git a/libs/react-website/src/provider/index.ts b/libs/react-website/src/provider/index.ts new file mode 100644 index 0000000..2245ba1 --- /dev/null +++ b/libs/react-website/src/provider/index.ts @@ -0,0 +1 @@ +export * from './config-provider'; diff --git a/libs/react-website/src/studio.tsx b/libs/react-website/src/studio.tsx new file mode 100644 index 0000000..75465b4 --- /dev/null +++ b/libs/react-website/src/studio.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { ILogger, LoggerFactory } from '@schablone/logging'; +import { LoggingProvider } from '@schablone/logging-react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { ConfigProvider, StudioConfig } from './provider'; +import { RouterBase } from './components'; +import './styles/main.scss'; + +export interface StudioProps { + /** + * Configuration for the studio, including the base url for the API, custom + * styles and a parser for the project data. + */ + config: StudioConfig; + /** + * Logger that will be used throughout the application and passed to the + * viewer. If no logger is provided, a default logger will be created + * using the @schablone/logging package. + */ + logger?: ILogger; + /** + * QueryClient that will be used throughout the application to cache API + * calls. If no queryClient is provided, a default queryClient will be + * created using the @tanstack/react-query package. + */ + queryClient?: QueryClient; +} + +export const Studio: FC = ({ config, logger, queryClient }) => { + return ( + + + + + + + + ); +}; diff --git a/libs/react-website/src/styles/common.scss b/libs/react-website/src/styles/common.scss new file mode 100644 index 0000000..c21e84b --- /dev/null +++ b/libs/react-website/src/styles/common.scss @@ -0,0 +1,14 @@ +.button { + background-color: var(--color-gray); + color: var(--color-button); + display: inline-block; + font-family: var(--base-font-family); + font-size: var(--base-font-size); + height: var(--interaction-height); + line-height: var(--interaction-height); + padding: 0 10px; + text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: background-color var(--animation-time); +} diff --git a/libs/react-website/src/styles/forms.scss b/libs/react-website/src/styles/forms.scss new file mode 100644 index 0000000..893a9de --- /dev/null +++ b/libs/react-website/src/styles/forms.scss @@ -0,0 +1,18 @@ +.form-group { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin: 15px 0; +} + +input, +select { + border: 1px solid #000; + border-radius: 2px; + font-family: var(--base-font-family); + font-size: var(--base-font-size); + height: 30px; + padding: 2px; + width: 60%; +} diff --git a/libs/react-website/src/styles/main.scss b/libs/react-website/src/styles/main.scss new file mode 100644 index 0000000..d23fae5 --- /dev/null +++ b/libs/react-website/src/styles/main.scss @@ -0,0 +1,7 @@ +@import '@fontsource/open-sans/400.css'; +@import '@fontsource/open-sans/700.css'; + +@import './tags.scss'; +@import './variables.scss'; +@import './forms.scss'; +@import './common.scss'; diff --git a/libs/react-website/src/styles/scss-variables.scss b/libs/react-website/src/styles/scss-variables.scss new file mode 100644 index 0000000..d4787b9 --- /dev/null +++ b/libs/react-website/src/styles/scss-variables.scss @@ -0,0 +1,2 @@ +$break-lg: 1100px; +$break-md: 768px; diff --git a/libs/react-website/src/styles/tags.scss b/libs/react-website/src/styles/tags.scss new file mode 100644 index 0000000..f5f2dbb --- /dev/null +++ b/libs/react-website/src/styles/tags.scss @@ -0,0 +1,100 @@ +// ===== TAGS ===== +*, :after, :before { + border: 0 solid; + box-sizing: border-box; +} + +html { + font-size: 62.5% +} + +html, +body { + margin: 0; + padding: 0 +} + +body { + font-family: var(--base-font-family); + font-size: var(--base-font-size); + font-weight: 400; + line-height: var(--base-line-height); + min-height: 100vh; + min-width: 320px; +} + +img { + height: auto; + max-width: 100% +} + +video { + height: 100%; + object-fit: cover; + width: 100%; + will-change: transform; +} + +main, +section, +header, +footer { + display: block +} + +main { + padding-top: var(--header-height); +} + +address { + font-style: normal +} + +a { + color: inherit; + + &:hover { + color: var(--color-interaction-hover); + transition: color var(--animation-time); + } + + &.subtle { + text-decoration: none; + } +} + +button { + appearance: none; + background-color: transparent; + border: none; + color: inherit; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: inherit; +} + +blockquote, +figure { + margin: 0; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: inherit; + font-size: inherit; + margin: 0; +} + +p { + &:first-child { margin-top: 0; } + &:last-child { margin-bottom: 0; } +} + +sup { + line-height: 0 +} + +audio, canvas, embed, iframe, img, object, svg, video { + display: block; + vertical-align: middle; +} diff --git a/libs/react-website/src/styles/variables.scss b/libs/react-website/src/styles/variables.scss new file mode 100644 index 0000000..396b667 --- /dev/null +++ b/libs/react-website/src/styles/variables.scss @@ -0,0 +1,37 @@ +@import './scss-variables.scss'; + +:root { + --color-gray: #a9a9a9; + --color-gray-light: #d3d3d3; + --color-interaction-active: #003055; + --color-button: #fff; + --color-preload-background: rgba(0, 0, 0, 0.8); + + --base-font-family: 'Open Sans', Helvetica, Arial, sans-serif; + --base-font-size: 16px; + --base-line-height: 1.5; + + --header-height: 80px; + --sidebar-width: 300px; + --interaction-height: 40px; + --preload-border-radius: 10px; + --preload-box-padding: 22px; + --preload-max-width: 390px; + + --animation-time: 0.3s; +} + + + +// ===== MEDIA QUERIES ===== +@media (max-width: $break-lg) { + :root { + --sidebar-width: 250px; + } +} + +@media (max-width: $break-md) { + :root { + --header-height: 50px; + } +} diff --git a/libs/react-website/src/types.ts b/libs/react-website/src/types.ts new file mode 100644 index 0000000..3e19490 --- /dev/null +++ b/libs/react-website/src/types.ts @@ -0,0 +1,30 @@ +export interface CodeParams { + allowZoom?: boolean; + height: string; + iframe: boolean; + language: string; + logo?: boolean; + togglePlay?: boolean; + transparent?: boolean; + width: string; + + [key: string]: string | boolean | undefined; +} + +export interface LegacyConfig { + color: string; + key: string; + logo: string; + name: string; + projects: LegacyProject[]; +} + +export interface LegacyProject { + changeDate: number; + description: string; + image: string; + key: string; + languages: string[]; + name: string; + version: number; +} diff --git a/libs/react-website/src/views/index.ts b/libs/react-website/src/views/index.ts new file mode 100644 index 0000000..d4b7300 --- /dev/null +++ b/libs/react-website/src/views/index.ts @@ -0,0 +1,2 @@ +export * from './overview/overview'; +export * from './project/project'; diff --git a/libs/react-website/src/views/overview/overview.module.scss b/libs/react-website/src/views/overview/overview.module.scss new file mode 100644 index 0000000..e07251e --- /dev/null +++ b/libs/react-website/src/views/overview/overview.module.scss @@ -0,0 +1,36 @@ +@import '../../styles/scss-variables.scss'; + +.pageContent { + padding-left: 0; + padding-top: var(--header-height); + transition: padding-left var(--animation-time); + + .page.sidebarOpen & { + padding-left: var(--sidebar-width); + } +} + +.projects { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit,minmax(300px,1fr)); + margin: auto; + max-width: 1600px; + padding: 20px; +} + + + +// ===== MEDIA QUERIES ===== +@media (max-width: $break-md) { + .page { + &.sidebarOpen { + height: 100vh; + overflow: hidden; + + .pageContent { + padding-left: 0; + } + } + } +} diff --git a/libs/react-website/src/views/overview/overview.tsx b/libs/react-website/src/views/overview/overview.tsx new file mode 100644 index 0000000..727945e --- /dev/null +++ b/libs/react-website/src/views/overview/overview.tsx @@ -0,0 +1,69 @@ +import { FC, useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useLogger } from '@schablone/logging-react'; + +import type { LegacyConfig, LegacyProject } from '../../types'; +import { EmbedBuilder, PageHeader, ProjectPreview } from '../../components'; +import { useConfigContext } from '../../provider'; +import styles from './overview.module.scss'; + +export const Overview: FC = () => { + const { logger } = useLogger(); + const { baseUrl, pathAllProjects } = useConfigContext(); + const { data, error, isError, isLoading, isSuccess } = useQuery({ + queryKey: ['projects'], + queryFn: () => fetch(`${baseUrl}${pathAllProjects}`).then((res) => res.json()), + }); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [selectedProject, setSelectedProject] = useState(); + + const onSelectProject = (project: LegacyProject) => { + if (selectedProject?.key !== project.key) { + setSelectedProject(project); + setSidebarOpen(true); + } else { + setSelectedProject(undefined); + setSidebarOpen(false); + } + }; + + useEffect(() => { + if (isError) { + logger.error('Error loading list of all projects', { + objects: { baseUrl, pathAllProjects }, + error, + }); + } + }, [baseUrl, error, isError, logger, pathAllProjects]); + + return ( +
+ setSidebarOpen(!sidebarOpen)} /> + + + +
+ {isLoading &&
Loading
} + + {isError && ( +
Error while loading projects. Only an administrator can fix this.
+ )} + + {isSuccess && ( +
+ {(data as LegacyConfig)?.projects.map((project) => ( + onSelectProject(project)} + selected={selectedProject?.key === project.key} + slug={project.key} + title={project.name} + /> + ))} +
+ )} +
+
+ ); +}; diff --git a/libs/react-website/src/views/project/project.module.scss b/libs/react-website/src/views/project/project.module.scss new file mode 100644 index 0000000..5c971db --- /dev/null +++ b/libs/react-website/src/views/project/project.module.scss @@ -0,0 +1,8 @@ +.viewerCanvas { + height: 100vh; + max-width: 100%; + + &.hidden { + opacity: 0; + } +} diff --git a/libs/react-website/src/views/project/project.tsx b/libs/react-website/src/views/project/project.tsx new file mode 100644 index 0000000..2822730 --- /dev/null +++ b/libs/react-website/src/views/project/project.tsx @@ -0,0 +1,116 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { TranslationsProvider } from 'react-intl-provider'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { ViewerUI } from '@schablone/3d-studio-viewer-ui'; +import { ISkyboxFeature, IViewer, SkyboxFeatureToken, ViewerLauncher } from '@schablone/3d-studio-viewer-core'; +import { Subscription } from 'rxjs'; +import { useQuery } from '@tanstack/react-query'; +import { useLogger } from '@schablone/logging-react'; + +import { LoadingScreen } from '../../components'; +import { translations } from '../../i18n'; +import { useConfigContext } from '../../provider'; +import styles from './project.module.scss'; + +export const Project: FC = () => { + const { logger } = useLogger(); + const { baseUrl, customStyles, pathAllProjects, pathSingleProject, projectParser } = useConfigContext(); + const { id } = useParams(); + const { data, error, isError, isLoading, isSuccess } = useQuery({ + queryKey: ['project', id], + queryFn: () => fetch(`${baseUrl}${pathSingleProject}${id}`).then((res) => res.json()), + }); + const { data: customer } = useQuery({ + queryKey: ['projects'], + queryFn: () => fetch(`${baseUrl}${pathAllProjects}`).then((res) => res.json()), + }); + const viewerCanvas = useRef(null); + const [searchParams] = useSearchParams(); + const [logo] = useState(searchParams.get('l') === 'true'); + const [isLoadingAssets, setIsLoadingAssets] = useState(true); + const [playClicked, setPlayClicked] = useState(searchParams.get('e') !== 'true'); + const [title, setTitle] = useState(); + const [viewer, setViewer] = useState(); + const [launcher] = useState(new ViewerLauncher({ logger })); + + const transparent = searchParams.get('t') === 'true'; + const initialLanguage = searchParams.get('lng'); + const availableLanguages = Object.keys(translations); + const startLanguage = initialLanguage && availableLanguages.includes(initialLanguage) ? initialLanguage : 'de'; + + useEffect(() => { + if (isError) { + logger.error('Error loading project', { objects: { baseUrl, pathSingleProject }, error }); + } + }, [baseUrl, error, isError, logger, pathSingleProject]); + + useEffect(() => { + if (!viewerCanvas.current || !id) { + return; + } + + const allowZoom = searchParams.get('s') !== 'false'; + + if (isSuccess) { + const projectData = projectParser ? projectParser(id, data) : data; + setViewer(launcher.createCanvasViewer({ ...projectData, controls: { allowZoom } }, viewerCanvas.current)); + setIsLoadingAssets(true); + } + }, [data, id, isSuccess, launcher, projectParser, searchParams]); + + useEffect(() => { + if (!viewer) { + return; + } + + const subscription = new Subscription(); + + subscription.add( + viewer.assetService.getIsLoading().subscribe((loading) => { + setIsLoadingAssets(loading); + }) + ); + + subscription.add( + viewer.configService.getConfig().subscribe((config) => { + setTitle(config.project?.name); + }) + ); + + if (transparent) { + subscription.add( + viewer.featureService.getFeatures().subscribe((features) => { + const skyboxFeature = features.find((feature) => feature.id === SkyboxFeatureToken) as ISkyboxFeature; + skyboxFeature.setEnabled(false); + }) + ); + } + + return () => { + subscription.unsubscribe(); + }; + }, [transparent, viewer]); + + return ( + +
+ {!playClicked ? ( + setPlayClicked(true)} + title={title} + transparent={transparent} + /> + ) : ( + viewer && ( + : undefined} + viewer={viewer} + /> + ) + )} + + ); +}; diff --git a/libs/react-website/tsconfig.json b/libs/react-website/tsconfig.json new file mode 100644 index 0000000..bab74ff --- /dev/null +++ b/libs/react-website/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/libs/react-website/tsconfig.lib.json b/libs/react-website/tsconfig.lib.json new file mode 100644 index 0000000..938f016 --- /dev/null +++ b/libs/react-website/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node", "vite/client", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] + }, + "files": ["../../node_modules/@nx/react/typings/cssmodule.d.ts", "../../node_modules/@nx/react/typings/image.d.ts"], + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] +} diff --git a/libs/react-website/tsconfig.spec.json b/libs/react-website/tsconfig.spec.json new file mode 100644 index 0000000..7a624c5 --- /dev/null +++ b/libs/react-website/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/react-website/vite.config.ts b/libs/react-website/vite.config.ts new file mode 100644 index 0000000..e7bfab5 --- /dev/null +++ b/libs/react-website/vite.config.ts @@ -0,0 +1,63 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import viteTsConfigPaths from 'vite-tsconfig-paths'; +import dts from 'vite-plugin-dts'; +import { joinPathFragments } from '@nx/devkit'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/react-website', + + plugins: [ + dts({ + entryRoot: 'src', + tsConfigFilePath: joinPathFragments(__dirname, 'tsconfig.lib.json'), + skipDiagnostics: true, + }), + react(), + viteTsConfigPaths({ + root: '../../', + }), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ + // viteTsConfigPaths({ + // root: '../../', + // }), + // ], + // }, + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'react-website', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forgot to update your package.json as well. + formats: ['es', 'cjs'], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: [ + '@nx/devkit', + '@schablone/3d-studio-viewer-core', + '@schablone/3d-studio-viewer-ui', + '@schablone/logging', + '@schablone/logging-react', + '@tanstack/react-query', + 'react', + 'react-dom', + 'react-intl', + 'react-intl-provider', + 'react/jsx-runtime', + 'react-router-dom', + 'rxjs', + ], + }, + }, +}); diff --git a/libs/viewer-core/README.md b/libs/viewer-core/README.md index 98e0f52..552806d 100644 --- a/libs/viewer-core/README.md +++ b/libs/viewer-core/README.md @@ -1,4 +1,4 @@ -# Viewer Core +# 3D Studio Viewer Core The viewer core provides a 3D product visualization and render application that can be run standalone or integrated into other applications. The viewer can @@ -8,7 +8,7 @@ base64 encoded image source strings if usage without a browser is desired. ## Architecture ### Modules -* `core`: All services basic functionality, the viewer and the viewer launcher. +* `core`: All basic functionality services, the viewer and the viewer launcher. * `feature`: Core features as available in the legacy viewer. Also contains the feature service and the feature registry, which is responsible for registering additional features via the viewer launcher. @@ -24,23 +24,53 @@ access to the core services. ## Usage -Create an instance of the viewer launcher via the exported global +Create an instance of the viewer launcher (with optional config) after +importing the launcher into the desired application + ```javascript -const launcher = alcm.studio(); +import { ViewerLauncher } from '@schablone/3d-studio-viewer-core'; + +const launcher = new ViewerLauncher(); ``` -or after importing the launcher into the desired application and create one of -the available viewers via the launcher. A config object has to be provided, -containing at least the objects which should be loaded. Further configuration -of the viewer is optionally possible via the config. See the corresponding -[ViewerConfigModel interface](src/types.ts#L21) and examples for now. -Later on viewer configuration can be supplied entirely within the object file -if it is an *.alcm file. +and create one of the available viewers via the launcher. + +```javascript +// Get a reference to an HTML element, that will contain the viewer canvas +const container = document.getElementById('viewer-container'); -## Development +// Canvas viewer with contonuous rendering +const canvasViewer = launcher.createCanvasViewer({ + objects: [ + { + path: 'path/to/object.gtlf' + } + ], + render: { + continuousRendering: true, + } +}, container); -Running `yarn start` will serve a live reload development server on `localhost:4200`, -serving the files from the `3d-studio-example` app. The base files can be used for development, -further examples for specific functionality should receive their own subfolder in -`apps/3d-studio-example/src/examples`. To run any of these examples open the corresponding -index.html file in the browser, e.g. `localhost:4200/src/examples/multiple-viewers/index.html`. +// Image viewer +const images$ = launcher.createImageViewer({ + objects: [ + { + path: 'path/to/object.gtlf' + } + ], +}); +images$.subscribe((image) => { + // Do something with the image +}); +``` + +A config object has to be provided, containing at least the objects which +should be loaded. Currently, the viewer only support GTLF files. Further +configuration of the viewer is optionally possible via the config. See +the corresponding [ViewerConfigModel interface](src/types.ts#L24) and examples for now. + +For further usage, see the [examples in the repository](https://github.com/alchemisten/3d-studio/tree/develop/apps/3d-studio-example/src/examples). + +## Planed features +Later on viewer configuration can be supplied entirely within the object file +if it is a *.alcm file. diff --git a/libs/viewer-core/package.json b/libs/viewer-core/package.json index 124d032..7333ce6 100644 --- a/libs/viewer-core/package.json +++ b/libs/viewer-core/package.json @@ -1,5 +1,5 @@ { - "name": "@alchemisten/3d-studio-viewer-core", + "name": "@schablone/3d-studio-viewer-core", "description": "The core implementation for a 3d viewer based on threejs.", "keywords": [ "typescript", @@ -8,5 +8,6 @@ "viewer" ], "author": "Alchemisten AG", - "license": "ISC" + "license": "MIT", + "types": "./src/index.d.ts" } diff --git a/libs/viewer-core/project.json b/libs/viewer-core/project.json index b053256..0925acd 100644 --- a/libs/viewer-core/project.json +++ b/libs/viewer-core/project.json @@ -16,9 +16,9 @@ } }, "publish": { - "executor": "@nx/workspace:run-commands", + "executor": "nx:run-commands", "options": { - "command": "ts-node tools/scripts/publish.ts viewer-core {args.ver} {args.tag}" + "command": "ts-node tools/scripts/publish.ts 3d-studio-viewer-core {args.ver} {args.tag}" }, "dependsOn": [ { @@ -28,7 +28,7 @@ ] }, "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { "lintFilePatterns": ["libs/viewer-core/**/*.ts"] @@ -36,7 +36,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["coverage/libs/viewer-core"], + "outputs": ["{workspaceRoot}/coverage/libs/viewer-core"], "options": { "jestConfig": "libs/viewer-core/jest.config.ts", "passWithNoTests": true diff --git a/libs/viewer-core/src/core/services/animation.service.ts b/libs/viewer-core/src/core/services/animation.service.ts index 3d249bb..3a6cf5d 100644 --- a/libs/viewer-core/src/core/services/animation.service.ts +++ b/libs/viewer-core/src/core/services/animation.service.ts @@ -2,9 +2,9 @@ import { inject, injectable } from 'inversify'; import { AnimationAction, AnimationClip, AnimationMixer, Clock, Object3D } from 'three'; import { BehaviorSubject, Observable } from 'rxjs'; import type { ILogger } from '@schablone/logging'; -import type { AnimationIdModel, IAnimationService, ILoggerService, IRenderService } from '../../types'; +import type { AnimationIdModel, IAnimationService, ILoggerService, IRenderService, ISceneService } from '../../types'; import { MissingAnimationError, MissingMixerError, ObjectHasNoAnimationsError } from '../exceptions'; -import { LoggerServiceToken, RenderServiceToken } from '../../util'; +import { LoggerServiceToken, RenderServiceToken, SceneServiceToken } from '../../util'; import { AnimationTimeMap } from '../../types'; import { map } from 'rxjs/operators'; @@ -21,7 +21,8 @@ export class AnimationService implements IAnimationService { public constructor( @inject(LoggerServiceToken) logger: ILoggerService, - @inject(RenderServiceToken) private renderService: IRenderService + @inject(RenderServiceToken) private renderService: IRenderService, + @inject(SceneServiceToken) private sceneService: ISceneService ) { this.logger = logger.withOptions({ globalLogOptions: { tags: { Service: 'Animation' } } }); this.animations = {}; @@ -38,6 +39,9 @@ export class AnimationService implements IAnimationService { } } }); + this.sceneService.objectAddedToScene$.subscribe((object) => { + this.addMixerForObject(object); + }); } public addMixerForObject(object: Object3D): AnimationMixer | false { diff --git a/libs/viewer-core/src/core/services/asset.service.ts b/libs/viewer-core/src/core/services/asset.service.ts index 3965047..a067382 100644 --- a/libs/viewer-core/src/core/services/asset.service.ts +++ b/libs/viewer-core/src/core/services/asset.service.ts @@ -9,14 +9,14 @@ import { Texture, TextureLoader, WebGLCubeRenderTarget, + WebGLRenderer, } from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'; import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; import type { ILogger } from '@schablone/logging'; -import type { IAssetService, ILoggerService, IRenderService } from '../../types'; -import { ConfigServiceToken, Constants, LoggerServiceToken, RenderServiceToken } from '../../util'; -import type { ConfigService } from './config.service'; +import type { IAssetService, IConfigService, ILoggerService } from '../../types'; +import { ConfigServiceToken, Constants, LoggerServiceToken } from '../../util'; /** * The asset service handles all file loading for the 3D scene, like @@ -43,9 +43,8 @@ export class AssetService implements IAssetService { private readonly textureLoader: TextureLoader; public constructor( - @inject(ConfigServiceToken) private configService: ConfigService, - @inject(LoggerServiceToken) logger: ILoggerService, - @inject(RenderServiceToken) private renderService: IRenderService + @inject(ConfigServiceToken) private configService: IConfigService, + @inject(LoggerServiceToken) logger: ILoggerService ) { this.logger = logger.withOptions({ globalLogOptions: { tags: { Service: 'Asset' } } }); this.loadingManager = new LoadingManager( @@ -63,7 +62,7 @@ export class AssetService implements IAssetService { this.hookObjectLoaded$ = this.objectLoaded$.asObservable(); this.isLoading$ = new BehaviorSubject(false); this.configService.getConfig().subscribe((config) => { - this.basePath = config.project?.basedir ? `${config.project.basedir}/` : ''; + this.basePath = config.project?.basedir ? `${config.project.basedir.replace(/\/+$/, '')}/` : ''; }); } @@ -95,11 +94,11 @@ export class AssetService implements IAssetService { return this.assetMap[path] as Promise; } - public loadEnvironmentMap(path: string, resolution: number): Promise { + public loadEnvironmentMap(path: string, resolution: number, renderer: WebGLRenderer): Promise { return this.loadTexture(path).then((envTex: Texture) => { envTex.mapping = EquirectangularReflectionMapping; // SphericalReflectionMapping envTex.colorSpace = SRGBColorSpace; - return new WebGLCubeRenderTarget(resolution).fromEquirectangularTexture(this.renderService.renderer, envTex); + return new WebGLCubeRenderTarget(resolution).fromEquirectangularTexture(renderer, envTex); }); } @@ -120,9 +119,7 @@ export class AssetService implements IAssetService { }); break; default: - this.assetMap[path] = new Promise((resolve, reject) => { - reject(`Object type unknown: ${type}`); - }); + this.assetMap[path] = Promise.reject(`Asset service can't handle object type: ${type}`); break; } diff --git a/libs/viewer-core/src/core/services/control.service.ts b/libs/viewer-core/src/core/services/control.service.ts index 83ba823..aaac1b6 100644 --- a/libs/viewer-core/src/core/services/control.service.ts +++ b/libs/viewer-core/src/core/services/control.service.ts @@ -1,10 +1,12 @@ import { inject, injectable } from 'inversify'; import { PerspectiveCamera } from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; -import { fromEvent, Observable, Subject, Subscription } from 'rxjs'; +import { BehaviorSubject, fromEvent, Observable, Subscription } from 'rxjs'; import { take, withLatestFrom } from 'rxjs/operators'; -import type { IControlService, IRenderService, RenderConfigModel } from '../../types'; -import { RenderServiceToken } from '../../util'; +import type { IConfigService, IControlService, IRenderService, RenderConfigModel } from '../../types'; +import { ConfigServiceToken, LoggerServiceToken, RenderServiceToken } from '../../util'; +import { ILogger } from '@schablone/logging'; +import { ILoggerService } from '../../types'; /** * The control service provides access to orbit controls. @@ -16,11 +18,17 @@ import { RenderServiceToken } from '../../util'; @injectable() export class ControlService implements IControlService { private controls!: OrbitControls; - private controls$: Subject; + private controls$: BehaviorSubject; private changeSub!: Subscription; + private readonly logger: ILogger; - public constructor(@inject(RenderServiceToken) private renderService: IRenderService) { - this.controls$ = new Subject(); + public constructor( + @inject(ConfigServiceToken) private configService: IConfigService, + @inject(LoggerServiceToken) logger: ILoggerService, + @inject(RenderServiceToken) private renderService: IRenderService + ) { + this.logger = logger.withOptions({ globalLogOptions: { tags: { Service: 'Control' } } }); + this.controls$ = new BehaviorSubject(null); this.renderService.getCamera().pipe(take(1)).subscribe(this.createControls.bind(this)); this.renderService.hookAfterRender$ .pipe(withLatestFrom(this.renderService.getRenderConfig())) @@ -29,9 +37,15 @@ export class ControlService implements IControlService { this.controls.update(); } }); + this.configService.getConfig().subscribe((config) => { + if (config.controls && this.controls) { + this.controls.enableZoom = config.controls.allowZoom ?? true; + this.controls.update(); + } + }); } - public getControls(): Observable { + public getControls(): Observable { return this.controls$.asObservable(); } @@ -53,6 +67,7 @@ export class ControlService implements IControlService { } }); + this.logger.debug('Controls initialized', { objects: this.controls }); this.controls$.next(this.controls); } } diff --git a/libs/viewer-core/src/core/services/light.service.ts b/libs/viewer-core/src/core/services/light.service.ts index c7f7037..09e475c 100644 --- a/libs/viewer-core/src/core/services/light.service.ts +++ b/libs/viewer-core/src/core/services/light.service.ts @@ -82,7 +82,7 @@ export class LightService implements ILightService { } private addDefaultLights() { - const directionalLight = new DirectionalLight(0xffffff, 1.9); + const directionalLight = new DirectionalLight(0xffffff, 6); directionalLight.position.set(3, 10, -5); directionalLight.target = new Object3D(); directionalLight.castShadow = true; @@ -144,7 +144,7 @@ export class LightService implements ILightService { if (setup.position) { light.position.set(setup.position.x, setup.position.y, setup.position.z); } - light.castShadow = true; + light.castShadow = setup.castShadow ?? false; if (setup.shadow) { light.shadow.bias = setup.shadow.bias ?? 0; light.shadow.normalBias = setup.shadow.normalBias ?? 0; diff --git a/libs/viewer-core/src/core/services/logger.service.ts b/libs/viewer-core/src/core/services/logger.service.ts index e2b4270..8a1c531 100644 --- a/libs/viewer-core/src/core/services/logger.service.ts +++ b/libs/viewer-core/src/core/services/logger.service.ts @@ -1,16 +1,13 @@ import { injectable } from 'inversify'; -import { ILogger, Logger, LoggerOptions, LogOptions } from '@schablone/logging'; -import { ILoggerService } from '../../types'; +import { ILogger, LoggerFactory, LoggerOptions, LogOptions } from '@schablone/logging'; +import type { ILoggerService } from '../../types'; @injectable() export class LoggerService implements ILoggerService { - private logger: ILogger; + private logger!: ILogger; - public constructor() { - this.logger = new Logger({ - // TODO: Set environment via config - environment: 'local', - }); + public init(options?: LoggerOptions, logger?: ILogger): void { + this.logger = logger || LoggerFactory(options); } public debug(message: string, options?: LogOptions): void { diff --git a/libs/viewer-core/src/core/services/material.service.ts b/libs/viewer-core/src/core/services/material.service.ts index 7a32776..d1b5620 100644 --- a/libs/viewer-core/src/core/services/material.service.ts +++ b/libs/viewer-core/src/core/services/material.service.ts @@ -1,8 +1,9 @@ import { inject, injectable } from 'inversify'; import { Material, Mesh } from 'three'; import { BehaviorSubject, Observable } from 'rxjs'; -import type { IMaterialService, ISceneService } from '../../types'; -import { SceneServiceToken } from '../../util'; +import type { ILogger } from '@schablone/logging'; +import type { ILoggerService, IMaterialService, ISceneService } from '../../types'; +import { LoggerServiceToken, SceneServiceToken } from '../../util'; /** * The material service keeps a record of all materials available in the @@ -20,10 +21,15 @@ import { SceneServiceToken } from '../../util'; @injectable() export class MaterialService implements IMaterialService { private assignedMaterials$: BehaviorSubject>; + private readonly logger: ILogger; private materials: Material[]; private readonly materials$: BehaviorSubject; - public constructor(@inject(SceneServiceToken) private sceneService: ISceneService) { + public constructor( + @inject(LoggerServiceToken) logger: ILoggerService, + @inject(SceneServiceToken) private sceneService: ISceneService + ) { + this.logger = logger.withOptions({ globalLogOptions: { tags: { Service: 'Material' } } }); this.assignedMaterials$ = new BehaviorSubject>({}); this.materials = []; this.materials$ = new BehaviorSubject(this.materials); @@ -40,11 +46,14 @@ export class MaterialService implements IMaterialService { } else { (node as Mesh).material = existingMaterial; } + } else { + this.logger.debug('Material has no name', { objects: [node, material] }); } }); } }); }); + this.logger.debug('Updated materials', { objects: this.materials }); this.materials$.next(this.materials); }); } @@ -64,6 +73,7 @@ export class MaterialService implements IMaterialService { return this.materials$.asObservable(); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public setAssignedMaterial(materialSlot: string, material: Material): void { // TODO: Implement me } diff --git a/libs/viewer-core/src/core/services/render.service.ts b/libs/viewer-core/src/core/services/render.service.ts index 9ef4808..e17a73d 100644 --- a/libs/viewer-core/src/core/services/render.service.ts +++ b/libs/viewer-core/src/core/services/render.service.ts @@ -1,46 +1,98 @@ import { inject, injectable } from 'inversify'; import { PerspectiveCamera, WebGLRenderer } from 'three'; import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'; -import { Observable, Subject } from 'rxjs'; -import type { CameraConfigModel, IRenderService, ISceneService, RenderConfigModel } from '../../types'; +import { BehaviorSubject, fromEvent, Observable, Subject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import type { ILogger } from '@schablone/logging'; +import type { + CameraConfigModel, + IConfigService, + ILoggerService, + IRenderService, + ISceneService, + RenderConfigModel, + SizeModel, +} from '../../types'; import { defaultCameraConfig, defaultRenderConfig } from './config.service'; -import { SceneServiceToken } from '../../util'; +import { ConfigServiceToken, LoggerServiceToken, SceneServiceToken } from '../../util'; /** * The render service renders the current scene to its internal canvas. By - * default a single image will be rendered on demand, but the renderer can be + * default, a single image will be rendered on demand, but the renderer can be * configured to continuously render a new image every frame. */ @injectable() export class RenderService implements IRenderService { - public readonly composer: EffectComposer; + public composer!: EffectComposer; public readonly hookAfterRender$: Observable; public readonly hookBeforeRender$: Observable; - public readonly renderer: WebGLRenderer; - private readonly afterRender$: Subject; - private readonly beforeRender$: Subject; - private readonly camera: PerspectiveCamera; - private readonly camera$: Subject; - private continuousRenderEnabled: boolean; - private postProcessingEnabled: boolean; - private renderConfig: RenderConfigModel; - private renderConfig$: Subject; - - public constructor(@inject(SceneServiceToken) private sceneService: ISceneService) { + public renderer!: WebGLRenderer; + protected readonly afterRender$: Subject; + protected readonly beforeRender$: Subject; + protected readonly camera: PerspectiveCamera; + protected readonly camera$: BehaviorSubject; + protected context: HTMLElement | WebGL2RenderingContext | undefined; + protected continuousRenderEnabled: boolean; + protected logger: ILogger; + protected node!: HTMLElement; + protected postProcessingEnabled: boolean; + protected renderConfig!: RenderConfigModel; + protected renderConfig$: Subject; + + public constructor( + @inject(ConfigServiceToken) private configService: IConfigService, + @inject(LoggerServiceToken) logger: ILoggerService, + @inject(SceneServiceToken) private sceneService: ISceneService + ) { + this.logger = logger.withOptions({ globalLogOptions: { tags: { service: 'RenderService' } } }); this.afterRender$ = new Subject(); this.hookAfterRender$ = this.afterRender$.asObservable(); this.beforeRender$ = new Subject(); this.hookBeforeRender$ = this.beforeRender$.asObservable(); - this.renderer = new WebGLRenderer({ antialias: true, alpha: true }); this.postProcessingEnabled = false; - this.composer = new EffectComposer(this.renderer); this.camera = new PerspectiveCamera(); - this.camera$ = new Subject(); - this.setCameraConfig(defaultCameraConfig); + this.camera$ = new BehaviorSubject(this.camera); this.continuousRenderEnabled = false; - this.renderConfig = defaultRenderConfig; this.renderConfig$ = new Subject(); - this.setRenderConfig(this.renderConfig); + + this.renderer = new WebGLRenderer({ antialias: true, alpha: true }); + this.composer = new EffectComposer(this.renderer); + + this.configService.getConfig().subscribe((config) => { + if (this.context && !(this.context instanceof HTMLElement)) { + this.logger.fatal('This renderer needs an HTMLElement to render into.'); + return; + } + + const renderSize: SizeModel = this.context + ? (this.context.getBoundingClientRect() as SizeModel) + : config.render?.renderSize ?? defaultRenderConfig.renderSize; + + this.setCameraConfig( + Object.assign( + defaultCameraConfig, + { + aspect: renderSize.width / renderSize.height, + }, + config.camera + ) + ); + + this.setRenderConfig( + Object.assign( + defaultRenderConfig, + { + pixelRatio: window ? window.devicePixelRatio : 1, + renderSize, + }, + config.render + ) + ); + }); + + this.sceneService.objectAddedToScene$.subscribe(() => { + this.renderSingleFrame(); + }); } public getCamera(): Observable { @@ -51,6 +103,16 @@ export class RenderService implements IRenderService { return this.renderConfig$.asObservable(); } + public init(context?: HTMLElement | WebGL2RenderingContext): void { + this.context = context; + + if (this.context && this.context instanceof HTMLElement) { + this.context.appendChild(this.renderer.domElement); + + fromEvent(window, 'resize').pipe(debounceTime(300)).subscribe(this.onWindowResize.bind(this)); + } + } + public renderSingleFrame(): void { if (this.sceneService.scene && this.camera) { this.beforeRender$.next(true); @@ -141,8 +203,24 @@ export class RenderService implements IRenderService { this.renderConfig$.next(this.renderConfig); } - private setContinuousRenderingEnabled(enabled: boolean): void { + private onWindowResize() { + if (!this.context || !(this.context instanceof HTMLElement)) { + return; + } + + const screenSize = this.context.getBoundingClientRect() as SizeModel; + this.setRenderConfig({ + renderSize: screenSize, + }); + this.setCameraConfig({ + aspect: screenSize.width / screenSize.height, + }); + this.renderSingleFrame(); + } + + protected setContinuousRenderingEnabled(enabled: boolean): void { this.continuousRenderEnabled = enabled; + this.logger.debug('Continuous rendering enabled:', { objects: String(enabled) }); if (this.continuousRenderEnabled) { this.renderer.setAnimationLoop(this.renderSingleFrame.bind(this)); } else { diff --git a/libs/viewer-core/src/core/services/scene.service.ts b/libs/viewer-core/src/core/services/scene.service.ts index fba4d04..073dab8 100644 --- a/libs/viewer-core/src/core/services/scene.service.ts +++ b/libs/viewer-core/src/core/services/scene.service.ts @@ -2,8 +2,8 @@ import { inject, injectable } from 'inversify'; import { Group, Mesh, Object3D, Scene } from 'three'; import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import type { ILogger } from '@schablone/logging'; -import type { ILoggerService, ISceneService, ObjectSetupModel } from '../../types'; -import { LoggerServiceToken } from '../../util'; +import type { IAssetService, IConfigService, ILoggerService, ISceneService, ObjectSetupModel } from '../../types'; +import { AssetServiceToken, ConfigServiceToken, LoggerServiceToken } from '../../util'; /** * The scene service provides access to the rendered scene and keeps track of @@ -20,7 +20,11 @@ export class SceneService implements ISceneService { private objects$: BehaviorSubject; private lastObjectAdded$: ReplaySubject; - public constructor(@inject(LoggerServiceToken) logger: ILoggerService) { + public constructor( + @inject(AssetServiceToken) private assetService: IAssetService, + @inject(ConfigServiceToken) private configService: IConfigService, + @inject(LoggerServiceToken) logger: ILoggerService + ) { this.logger = logger.withOptions({ globalLogOptions: { tags: { Service: 'Scene' } } }); this.scene = new Scene(); this.group = new Group(); @@ -29,6 +33,19 @@ export class SceneService implements ISceneService { this.objects$ = new BehaviorSubject(this.group.children); this.lastObjectAdded$ = new ReplaySubject(1); this.objectAddedToScene$ = this.lastObjectAdded$.asObservable(); + + this.configService.getConfig().subscribe((config) => { + config.objects.forEach((objectSetup) => { + this.assetService + .loadObject(objectSetup.path) + .then((object) => { + this.addObjectToScene(object, objectSetup); + }) + .catch((error) => { + this.logger.error('Could not load object', { objects: objectSetup, error }); + }); + }); + }); } public addObjectToScene(object: Object3D, objectSetup?: ObjectSetupModel): void { diff --git a/libs/viewer-core/src/core/viewer-launcher.ts b/libs/viewer-core/src/core/viewer-launcher.ts index fe4b1a3..d77a7b1 100644 --- a/libs/viewer-core/src/core/viewer-launcher.ts +++ b/libs/viewer-core/src/core/viewer-launcher.ts @@ -15,8 +15,8 @@ import type { ISceneService, IViewer, IViewerLauncher, - SizeModel, ViewerConfigModel, + ViewerLauncherConfig, } from '../types'; import { Viewer } from './viewer'; import { @@ -55,44 +55,75 @@ export class ViewerLauncher implements IViewerLauncher { private readonly featureRegistry: IFeatureRegistryService; private readonly logger: ILoggerService; - public constructor() { + public constructor(config?: ViewerLauncherConfig) { + const customManager = config?.customManager || {}; this.containerDI = new Container(); - this.containerDI.bind(AnimationServiceToken).to(AnimationService).inSingletonScope(); - this.containerDI.bind(AssetServiceToken).to(AssetService).inSingletonScope(); - this.containerDI.bind(ConfigServiceToken).to(ConfigService).inSingletonScope(); - this.containerDI.bind(ControlServiceToken).to(ControlService).inSingletonScope(); - this.containerDI.bind(LightServiceToken).to(LightService).inSingletonScope(); - this.containerDI.bind(LoggerServiceToken).to(LoggerService).inSingletonScope(); - this.containerDI.bind(MaterialServiceToken).to(MaterialService).inSingletonScope(); - this.containerDI.bind(RenderServiceToken).to(RenderService).inSingletonScope(); - this.containerDI.bind(SceneServiceToken).to(SceneService).inSingletonScope(); - this.containerDI.bind(FeatureServiceToken).to(FeatureService).inSingletonScope(); + this.containerDI + .bind(LoggerServiceToken) + .to(customManager?.logger ?? LoggerService) + .inSingletonScope(); + this.logger = this.containerDI.get(LoggerServiceToken); + this.logger.init(config?.loggerOptions, config?.logger); + + this.containerDI + .bind(AnimationServiceToken) + .to(customManager?.animation ?? AnimationService) + .inSingletonScope(); + this.containerDI + .bind(AssetServiceToken) + .to(customManager?.asset ?? AssetService) + .inSingletonScope(); + this.containerDI + .bind(ConfigServiceToken) + .to(customManager?.config ?? ConfigService) + .inSingletonScope(); + this.containerDI + .bind(ControlServiceToken) + .to(customManager?.control ?? ControlService) + .inSingletonScope(); + this.containerDI + .bind(LightServiceToken) + .to(customManager?.light ?? LightService) + .inSingletonScope(); + this.containerDI + .bind(MaterialServiceToken) + .to(customManager?.material ?? MaterialService) + .inSingletonScope(); + this.containerDI + .bind(RenderServiceToken) + .to(customManager?.render ?? RenderService) + .inSingletonScope(); + this.containerDI + .bind(SceneServiceToken) + .to(customManager?.scene ?? SceneService) + .inSingletonScope(); + this.containerDI + .bind(FeatureServiceToken) + .to(customManager?.feature ?? FeatureService) + .inSingletonScope(); this.containerDI .bind(FeatureRegistryServiceToken) - .to(FeatureRegistryService) + .to(customManager?.featureRegistry ?? FeatureRegistryService) .inSingletonScope(); this.containerDI.bind(ViewerToken).to(Viewer); this.featureRegistry = this.containerDI.get(FeatureRegistryServiceToken); this.featureRegistry.setDIContainer(this.containerDI); - - this.logger = this.containerDI.get(LoggerServiceToken); } /** * Initializes a viewer that renders to a canvas element that is added to * the provided container. * - * @param container The renderer's canvas element will be appended as a - * child of this HTMLElement * @param config ViewerConfigModel containing at least one object that * should be loaded + * @param context The renderer's canvas element will be appended as a + * child of this HTMLElement * @returns The created viewer instance */ - public createHTMLViewer(container: HTMLElement, config: ViewerConfigModel): IViewer { + public createCanvasViewer(config: ViewerConfigModel, context: HTMLElement | WebGL2RenderingContext): IViewer { const viewer = this.containerDI.get(ViewerToken); - const screenSize = container.getBoundingClientRect() as SizeModel; - viewer.init(screenSize, config, container); + viewer.init(config, context); return viewer; } @@ -101,15 +132,13 @@ export class ViewerLauncher implements IViewerLauncher { * Initializes a viewer that renders images at the provided size and * returns them as an Observable. * - * @param renderSize SizeModel with the width and height of the desired - * rendering * @param config ViewerConfigModel containing at least one object that * should be loaded * @returns An Observable of base64 encoded image source strings */ - public createImageViewer(renderSize: SizeModel, config: ViewerConfigModel): Observable { + public createImageViewer(config: ViewerConfigModel): Observable { const viewer = this.containerDI.get(ViewerToken); - viewer.init(renderSize, config); + viewer.init(config); const renderService = this.containerDI.get(RenderServiceToken); return renderService.hookAfterRender$.pipe(map(() => renderService.renderer.domElement.toDataURL())); diff --git a/libs/viewer-core/src/core/viewer.ts b/libs/viewer-core/src/core/viewer.ts index 53ea794..c5f248b 100644 --- a/libs/viewer-core/src/core/viewer.ts +++ b/libs/viewer-core/src/core/viewer.ts @@ -1,17 +1,15 @@ import { inject, injectable } from 'inversify'; -import { fromEvent } from 'rxjs'; -import { debounceTime } from 'rxjs/operators'; import type { IAnimationService, IAssetService, IConfigService, IControlService, ILightService, + ILoggerService, IMaterialService, IRenderService, ISceneService, IViewer, - SizeModel, ViewerConfigModel, } from '../types'; import type { IFeatureService } from '../feature'; @@ -22,6 +20,7 @@ import { ControlServiceToken, FeatureServiceToken, LightServiceToken, + LoggerServiceToken, MaterialServiceToken, RenderServiceToken, SceneServiceToken, @@ -32,8 +31,6 @@ import { */ @injectable() export class Viewer implements IViewer { - private node!: HTMLElement; - public constructor( @inject(AnimationServiceToken) public animationService: IAnimationService, @inject(AssetServiceToken) public assetService: IAssetService, @@ -41,60 +38,14 @@ export class Viewer implements IViewer { @inject(ControlServiceToken) public controlService: IControlService, @inject(FeatureServiceToken) public featureService: IFeatureService, @inject(LightServiceToken) public lightService: ILightService, + @inject(LoggerServiceToken) public logger: ILoggerService, @inject(MaterialServiceToken) public materialService: IMaterialService, @inject(RenderServiceToken) public renderService: IRenderService, @inject(SceneServiceToken) public sceneService: ISceneService ) {} - public init(screenSize: SizeModel, config: ViewerConfigModel, node?: HTMLElement) { + public init(config: ViewerConfigModel, context?: HTMLElement | WebGL2RenderingContext) { + this.renderService.init(context); this.configService.loadConfig(config); - this.renderService.setCameraConfig( - Object.assign( - { - aspect: screenSize.width / screenSize.height, - }, - config.camera - ) - ); - this.renderService.setRenderConfig( - Object.assign( - { - pixelRatio: window ? window.devicePixelRatio : 1, - renderSize: screenSize, - }, - config.render - ) - ); - if (node && window) { - this.node = node; - this.node.appendChild(this.renderService.renderer.domElement); - - fromEvent(window, 'resize').pipe(debounceTime(300)).subscribe(this.onWindowResize.bind(this)); - } - - this.sceneService.objectAddedToScene$.subscribe((object) => { - this.animationService.addMixerForObject(object); - }); - config.objects.forEach((objectSetup) => { - this.assetService.loadObject(objectSetup.path).then((object) => { - this.sceneService.addObjectToScene(object, objectSetup); - this.renderService.renderSingleFrame(); - }); - }); - } - - private onWindowResize() { - if (!this.node) { - return; - } - - const screenSize = this.node.getBoundingClientRect() as SizeModel; - this.renderService.setRenderConfig({ - renderSize: screenSize, - }); - this.renderService.setCameraConfig({ - aspect: screenSize.width / screenSize.height, - }); - this.renderService.renderSingleFrame(); } } diff --git a/libs/viewer-core/src/enums.ts b/libs/viewer-core/src/enums.ts index d004c48..ef52f3f 100644 --- a/libs/viewer-core/src/enums.ts +++ b/libs/viewer-core/src/enums.ts @@ -4,11 +4,3 @@ export enum LightType { Point = 'point', Spot = 'spot', } - -export enum MaterialType { - Basic = 'basic', - Lambert = 'lambert', - Phong = 'phong', - Physical = 'physical', - Standard = 'standard', -} diff --git a/libs/viewer-core/src/feature/core-feature.map.ts b/libs/viewer-core/src/feature/core-feature.map.ts index 342b715..7968217 100644 --- a/libs/viewer-core/src/feature/core-feature.map.ts +++ b/libs/viewer-core/src/feature/core-feature.map.ts @@ -1,5 +1,5 @@ import type { interfaces } from 'inversify'; -import type { IFeature } from '../types'; +import type { IFeature } from './types'; import { CameraRotationFeature, HighlightFeature, diff --git a/libs/viewer-core/src/feature/features/camera-rotation/camera-rotation.feature.ts b/libs/viewer-core/src/feature/features/camera-rotation/camera-rotation.feature.ts index 164f2bb..542e0ea 100644 --- a/libs/viewer-core/src/feature/features/camera-rotation/camera-rotation.feature.ts +++ b/libs/viewer-core/src/feature/features/camera-rotation/camera-rotation.feature.ts @@ -1,9 +1,10 @@ import { inject, injectable } from 'inversify'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { BehaviorSubject, Observable } from 'rxjs'; +import type { ILogger } from '@schablone/logging'; -import type { IControlService } from '../../../types'; -import { CameraRotationFeatureToken, ControlServiceToken } from '../../../util'; +import type { IControlService, ILoggerService } from '../../../types'; +import { CameraRotationFeatureToken, ControlServiceToken, LoggerServiceToken } from '../../../util'; import type { CameraRotationFeatureConfig, ICameraRotationFeature } from './types'; /** @@ -15,8 +16,13 @@ export class CameraRotationFeature implements ICameraRotationFeature { private controls!: OrbitControls; private enabled!: boolean; private readonly enabled$: BehaviorSubject; + private readonly logger: ILogger; - public constructor(@inject(ControlServiceToken) private controlService: IControlService) { + public constructor( + @inject(ControlServiceToken) private controlService: IControlService, + @inject(LoggerServiceToken) logger: ILoggerService + ) { + this.logger = logger.withOptions({ globalLogOptions: { tags: { Feature: 'CameraRotation' } } }); this.enabled$ = new BehaviorSubject(false); } @@ -26,14 +32,24 @@ export class CameraRotationFeature implements ICameraRotationFeature { public init(config: CameraRotationFeatureConfig): void { this.enabled = config.enabled; + this.logger.debug('Initialized with config', { objects: config }); this.enabled$.next(this.enabled); this.controlService.getControls().subscribe((controls) => { - this.controls = controls; + if (controls) { + this.controls = controls; + this.setRotationEnabled(this.enabled); + if (config.rotationSpeed) { + this.setRotationSpeed(config.rotationSpeed); + } + } + }); + + if (this.controls) { this.setRotationEnabled(this.enabled); if (config.rotationSpeed) { this.setRotationSpeed(config.rotationSpeed); } - }); + } } public setEnabled(enabled: boolean): void { @@ -47,6 +63,8 @@ export class CameraRotationFeature implements ICameraRotationFeature { } private setRotationEnabled(enabled: boolean): void { - this.controls.autoRotate = enabled; + if (this.controls) { + this.controls.autoRotate = enabled; + } } } diff --git a/libs/viewer-core/src/feature/features/highlight/highlight.feature.ts b/libs/viewer-core/src/feature/features/highlight/highlight.feature.ts index b6916da..f88659b 100644 --- a/libs/viewer-core/src/feature/features/highlight/highlight.feature.ts +++ b/libs/viewer-core/src/feature/features/highlight/highlight.feature.ts @@ -27,6 +27,7 @@ import Highlight from './highlight'; @injectable() export class HighlightFeature implements IHighlightFeature { public readonly id = HighlightFeatureToken; + private assetPath!: string; private baseFov!: number; private camera!: PerspectiveCamera; private clickable: Sprite[] = []; @@ -77,7 +78,9 @@ export class HighlightFeature implements IHighlightFeature { this.focusedHighlight$ = new Subject(); this.highlights$ = new BehaviorSubject(this.highlights); this.controlService.getControls().subscribe((controls) => { - this.controls = controls; + if (controls) { + this.controls = controls; + } }); this.renderService.getCamera().subscribe((camera) => { this.camera = camera; @@ -89,6 +92,7 @@ export class HighlightFeature implements IHighlightFeature { public init(config: HighlightFeatureConfig) { this.logger.debug('Initializing with config', { objects: config }); this.enabled = config.enabled; + this.assetPath = config.assetPath ?? 'assets/textures/highlights/'; this.highlightsVisible = config.highlightsVisible ?? true; if (config.groupScale) { this.highlightGroup.scale.setScalar(config.groupScale); @@ -98,7 +102,7 @@ export class HighlightFeature implements IHighlightFeature { this.highlights$.next(this.highlights); }); this.renderService.hookAfterRender$.pipe(withLatestFrom(this.getEnabled())).subscribe(([, enabled]) => { - if (enabled) { + if ((enabled || this.state === HighlightMode.TO_ORBIT) && this.controls && this.camera) { this.update(); } }); @@ -123,6 +127,9 @@ export class HighlightFeature implements IHighlightFeature { this.sceneService.addObjectToScene(this.highlightGroup); document.addEventListener('mousemove', this.onDocumentMouseMove.bind(this), false); } else { + if (this.state === HighlightMode.HIGHLIGHT || this.state === HighlightMode.TO_HIGHLIGHT) { + this.leaveHighlight(); + } this.sceneService.removeObjectFromScene('highlights'); document.removeEventListener('mousemove', this.onDocumentMouseMove); } @@ -212,8 +219,12 @@ export class HighlightFeature implements IHighlightFeature { (event.clientX / window.innerWidth) * 2 - 1, -(event.clientY / window.innerHeight) * 2 + 1 ); - this.rayCaster.setFromCamera(vector, this.camera); + if (!this.camera) { + return []; + } + + this.rayCaster.setFromCamera(vector, this.camera); return this.rayCaster.intersectObjects(this.clickable); } @@ -244,6 +255,7 @@ export class HighlightFeature implements IHighlightFeature { this.setClickzonesVisible(this.highlightsVisible); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars private dispatchHighlightClick(hl: Highlight, immediate = false, fromStart = false) { // TODO: Highlight animations not yet implemented in AnimationsManager // check if highlight is trigger @@ -297,18 +309,7 @@ export class HighlightFeature implements IHighlightFeature { private handleMove = (newPos: Vector2): void => { if (this.startPos.distanceTo(newPos) > this.dragThreshold && this.state !== HighlightMode.TO_ORBIT) { - this.state = HighlightMode.TO_ORBIT; - this.focusedHighlight$.next(null); - // this.dispatcher.dispatch("onstate", this.state); - this.addListeners('wheel'); - this.controls.position0 = this.camera.position.clone(); - this.controls.target0 = this.viewCurrent.clone(); - this.controls.target = this.viewCurrent.clone(); - this.controls.reset(); - this.controls.enabled = true; - // if (this.animMan.animationPlaying) { - // this.mediator.dispatch("toggleState", { key: 'animatedHighlights', value: false }); - // } + this.leaveHighlight(); } }; @@ -323,6 +324,7 @@ export class HighlightFeature implements IHighlightFeature { this.mouseWheelFired = true; }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars private hoverHighlight(id: string, active: boolean): void { // TODO: Postprocessing not yet implemented // if (this._config.features.usePostProcessing.active @@ -353,6 +355,22 @@ export class HighlightFeature implements IHighlightFeature { return current + deltaMove; } + private leaveHighlight(): void { + this.logger.debug('Leaving highlight'); + this.state = HighlightMode.TO_ORBIT; + this.focusedHighlight$.next(null); + // this.dispatcher.dispatch("onstate", this.state); + this.addListeners('wheel'); + this.controls.position0 = this.camera.position.clone(); + this.controls.target0 = this.viewCurrent.clone(); + this.controls.target = this.viewCurrent.clone(); + this.controls.reset(); + this.controls.enabled = true; + // if (this.animMan.animationPlaying) { + // this.mediator.dispatch("toggleState", { key: 'animatedHighlights', value: false }); + // } + } + private loadHighlightTextures(): Promise { const textures: HighlightTextureMap = { actionTransTex: null, @@ -362,10 +380,10 @@ export class HighlightFeature implements IHighlightFeature { }; return Promise.all( Object.entries({ - actionTransTex: 'assets/textures/highlights/action_trans.png', - actionTransHoverTex: 'assets/textures/highlights/action_trans_hover.png', - simpleTransTex: 'assets/textures/highlights/simple_trans.png', - simpleTransHoverTex: 'assets/textures/highlights/simple_trans_hover.png', + actionTransTex: `${this.assetPath}action_trans.png`, + actionTransHoverTex: `${this.assetPath}action_trans_hover.png`, + simpleTransTex: `${this.assetPath}simple_trans.png`, + simpleTransHoverTex: `${this.assetPath}simple_trans_hover.png`, }).map(([key, value]) => this.assetService.loadTexture(value).then((texture) => { textures[key] = texture; @@ -564,19 +582,15 @@ export class HighlightFeature implements IHighlightFeature { this.controls.target.copy(orgTarget); } this.camera.lookAt(this.controls.target); - this.controls.update(); break; } case HighlightMode.HIGHLIGHT: + this.controls.autoRotate = false; this.controls.position0 = this.camera.position.clone(); this.controls.target0 = this.viewTarget.clone(); this.controls.target = this.viewTarget.clone(); break; - case HighlightMode.ORBIT: - this.controls.update(); - break; default: - this.controls.update(); break; } diff --git a/libs/viewer-core/src/feature/features/highlight/highlight.ts b/libs/viewer-core/src/feature/features/highlight/highlight.ts index 407bbf0..3800965 100644 --- a/libs/viewer-core/src/feature/features/highlight/highlight.ts +++ b/libs/viewer-core/src/feature/features/highlight/highlight.ts @@ -157,10 +157,11 @@ export default class Highlight implements HighlightModel { private initClickzone() { const wFrame = new MeshBasicMaterial({ color: 0xffffff, - wireframe: true, - transparent: true, + name: 'Studio_Clickzone', opacity: 0, + transparent: true, visible: false, + wireframe: true, }); const sp1g = new SphereGeometry(0.15, 20, 20); @@ -179,10 +180,11 @@ export default class Highlight implements HighlightModel { private initGlow(): Sprite { return new Sprite( new SpriteMaterial({ - map: this.isTrigger ? this.textures.actionTransTex : this.textures.simpleTransTex, + blending: NormalBlending, color: this.color, + map: this.isTrigger ? this.textures.actionTransTex : this.textures.simpleTransTex, + name: 'Studio_GlowSprite', transparent: true, - blending: NormalBlending, }) ); } diff --git a/libs/viewer-core/src/feature/features/highlight/types.ts b/libs/viewer-core/src/feature/features/highlight/types.ts index c08fe5e..e690253 100644 --- a/libs/viewer-core/src/feature/features/highlight/types.ts +++ b/libs/viewer-core/src/feature/features/highlight/types.ts @@ -91,6 +91,7 @@ export interface HighlightMount { } export interface HighlightFeatureConfig extends FeatureConfig { + assetPath?: string; groupScale?: number; highlightSetup: HighlightSetupModel[]; highlightsVisible?: boolean; diff --git a/libs/viewer-core/src/feature/features/light-scenario/light-scenario.feature.ts b/libs/viewer-core/src/feature/features/light-scenario/light-scenario.feature.ts index 29e920c..872633d 100644 --- a/libs/viewer-core/src/feature/features/light-scenario/light-scenario.feature.ts +++ b/libs/viewer-core/src/feature/features/light-scenario/light-scenario.feature.ts @@ -106,10 +106,10 @@ export class LightScenarioFeature implements ILightScenarioFeature { private transformLightSetups(scenarios: LightScenarioModel[]): LightScenarioModel[] { return scenarios.reduce((all, scenario) => { const newScenario = { ...scenario }; - if (scenario.lightSetups) { - scenario.lightSetups.forEach((setup) => { + if (newScenario.lightSetups) { + newScenario.lightSetups.forEach((setup) => { try { - scenario.lights[setup.name] = LightService.transformLightSetup(setup); + newScenario.lights[setup.name] = LightService.transformLightSetup(setup); } catch (error) { this.logger.warn(`Couldn't transform light setup`, { error: error }); this.logger.debug('Light setup', { objects: [setup] }); diff --git a/libs/viewer-core/src/feature/features/skybox/skybox.feature.ts b/libs/viewer-core/src/feature/features/skybox/skybox.feature.ts index 9072c99..3d3fa7a 100644 --- a/libs/viewer-core/src/feature/features/skybox/skybox.feature.ts +++ b/libs/viewer-core/src/feature/features/skybox/skybox.feature.ts @@ -1,14 +1,14 @@ import { inject, injectable } from 'inversify'; import { BehaviorSubject, Observable } from 'rxjs'; -import { withLatestFrom } from 'rxjs/operators'; -import type { Material, MeshStandardMaterial, Texture } from 'three'; +import type { ColorSpace, Texture } from 'three'; +import { SRGBColorSpace } from 'three'; import type { ILogger } from '@schablone/logging'; -import type { IAssetService, ILoggerService, IMaterialService, ISceneService } from '../../../types'; +import type { IAssetService, ILoggerService, IRenderService, ISceneService } from '../../../types'; import type { ISkyboxFeature, SkyboxFeatureConfig, SkyboxType } from './types'; import { AssetServiceToken, LoggerServiceToken, - MaterialServiceToken, + RenderServiceToken, SceneServiceToken, SkyboxFeatureToken, } from '../../../util'; @@ -20,14 +20,12 @@ export class SkyboxFeature implements ISkyboxFeature { private readonly enabled$: BehaviorSubject; private logger: ILogger; private skybox!: Texture; - private skyboxPath!: string; - private type!: SkyboxType; private useForMaterialEnv!: boolean; public constructor( @inject(AssetServiceToken) private assetService: IAssetService, @inject(LoggerServiceToken) loggerService: ILoggerService, - @inject(MaterialServiceToken) private materialService: IMaterialService, + @inject(RenderServiceToken) private renderService: IRenderService, @inject(SceneServiceToken) private sceneService: ISceneService ) { this.logger = loggerService.withOptions({ globalLogOptions: { tags: { Feature: 'Skybox' } } }); @@ -37,20 +35,33 @@ export class SkyboxFeature implements ISkyboxFeature { public init(config: SkyboxFeatureConfig): void { this.logger.debug('Initializing with config', { objects: config }); this.enabled = config.enabled; - this.skyboxPath = config.skyboxPath; - this.type = config.type || 'cube'; this.useForMaterialEnv = config.useForMaterialEnv ?? true; - this.materialService - .getMaterials() - .pipe(withLatestFrom(this.enabled$)) - .subscribe(([materials, enabled]) => { - this.setMaterialEnvironmentMap(enabled, materials); - }); - if (this.enabled) { - this.setSceneBackground(); - } + this.loadSkyBox(config.skyboxPath, config.type, config.colorSpace).then((success) => { + if (!success) { + this.logger.warn('Failed to load skybox', { objects: config }); + } else { + this.logger.debug('Skybox loaded', { objects: config }); + } + }); + this.enabled$.next(this.enabled); + + // Set scene background + this.enabled$.subscribe((enabled) => { + this.logger.debug('SkyboxFeature enabled:', { objects: String(enabled) }); + if (enabled) { + this.sceneService.scene.background = this.skybox; + if (this.useForMaterialEnv) { + this.sceneService.scene.environment = this.skybox; + } + } else { + this.sceneService.scene.background = null; + if (this.useForMaterialEnv) { + this.sceneService.scene.environment = null; + } + } + }); } public getEnabled(): Observable { @@ -59,56 +70,54 @@ export class SkyboxFeature implements ISkyboxFeature { public setEnabled(enabled: boolean): void { this.enabled = enabled; - if (this.enabled) { - this.setSceneBackground(); - } else { - this.sceneService.scene.background = null; - } this.enabled$.next(this.enabled); } - private setMaterialEnvironmentMap(enabled: boolean, materials: Material[]): void { - if (enabled && this.useForMaterialEnv) { - materials.forEach((material) => { - if (Object.prototype.hasOwnProperty.call(material, 'envMap')) { - (material as MeshStandardMaterial).envMap = this.skybox; - material.needsUpdate = true; - } - }); - } else { - materials.forEach((material) => { - if (Object.prototype.hasOwnProperty.call(material, 'envMap')) { - (material as MeshStandardMaterial).envMap = null; - material.needsUpdate = true; - } - }); - } - } - - private setSceneBackground(): void { - switch (this.type) { - case 'cube': - if (!this.skybox) { - this.assetService.loadCubeTexture(this.skyboxPath).then((texture) => { - this.skybox = texture; - this.sceneService.scene.background = this.skybox; - }); - } else { - this.sceneService.scene.background = this.skybox; - } - break; - case 'equirectangular': - if (!this.skybox) { - this.assetService.loadEnvironmentMap(this.skyboxPath, 1024).then((texture) => { - this.skybox = texture.texture; - this.sceneService.scene.background = this.skybox; - }); - } else { - this.sceneService.scene.background = this.skybox; - } - break; - default: - break; - } + private loadSkyBox( + skyboxPath: string, + type: SkyboxType = 'cube', + colorSpace: ColorSpace = SRGBColorSpace + ): Promise { + return new Promise((resolve) => { + switch (type) { + case 'cube': + this.assetService + .loadCubeTexture(skyboxPath) + .then((texture) => { + this.skybox = texture; + this.skybox.colorSpace = colorSpace; + if (this.enabled) { + this.sceneService.scene.background = this.skybox; + if (this.useForMaterialEnv) { + this.sceneService.scene.environment = this.skybox; + } + } + resolve(true); + }) + .catch(() => { + resolve(false); + }); + break; + case 'equirectangular': + this.assetService + .loadEnvironmentMap(skyboxPath, 1024, this.renderService.renderer) + .then((texture) => { + this.skybox = texture.texture; + if (this.enabled) { + this.sceneService.scene.background = this.skybox; + if (this.useForMaterialEnv) { + this.sceneService.scene.environment = this.skybox; + } + } + resolve(true); + }) + .catch(() => { + resolve(false); + }); + break; + default: + break; + } + }); } } diff --git a/libs/viewer-core/src/feature/features/skybox/types.ts b/libs/viewer-core/src/feature/features/skybox/types.ts index 235ab37..0b32ed7 100644 --- a/libs/viewer-core/src/feature/features/skybox/types.ts +++ b/libs/viewer-core/src/feature/features/skybox/types.ts @@ -1,10 +1,12 @@ import type { FeatureConfig, IFeature } from '../../types'; +import type { ColorSpace } from 'three'; export type SkyboxType = 'cube' | 'equirectangular'; export type ISkyboxFeature = IFeature; export interface SkyboxFeatureConfig extends FeatureConfig { + colorSpace?: ColorSpace; skyboxPath: string; type?: SkyboxType; useForMaterialEnv?: boolean; diff --git a/libs/viewer-core/src/feature/features/wireframe/wireframe.feature.ts b/libs/viewer-core/src/feature/features/wireframe/wireframe.feature.ts index 2f24ea9..3bfa385 100644 --- a/libs/viewer-core/src/feature/features/wireframe/wireframe.feature.ts +++ b/libs/viewer-core/src/feature/features/wireframe/wireframe.feature.ts @@ -2,10 +2,10 @@ import { inject, injectable } from 'inversify'; import { Material, MeshStandardMaterial } from 'three'; import { BehaviorSubject, Observable } from 'rxjs'; -import type { IMaterialService } from '../../../types'; +import type { IMaterialService, IRenderService } from '../../../types'; import type { FeatureConfig } from '../../types'; import type { IWireframeFeature } from './types'; -import { MaterialServiceToken, WireframeFeatureToken } from '../../../util'; +import { MaterialServiceToken, RenderServiceToken, WireframeFeatureToken } from '../../../util'; /** * When enabled all materials of all objects in the scene will be set to @@ -18,7 +18,10 @@ export class WireframeFeature implements IWireframeFeature { private readonly enabled$: BehaviorSubject; private materials!: Material[]; - public constructor(@inject(MaterialServiceToken) private materialService: IMaterialService) { + public constructor( + @inject(MaterialServiceToken) private materialService: IMaterialService, + @inject(RenderServiceToken) private renderService: IRenderService + ) { this.enabled$ = new BehaviorSubject(false); } @@ -48,5 +51,6 @@ export class WireframeFeature implements IWireframeFeature { material.needsUpdate = true; } }); + this.renderService.renderSingleFrame(); } } diff --git a/libs/viewer-core/src/types.ts b/libs/viewer-core/src/types.ts index 00dca41..e2987df 100644 --- a/libs/viewer-core/src/types.ts +++ b/libs/viewer-core/src/types.ts @@ -15,33 +15,111 @@ import type { WebGLRenderer, } from 'three'; import type { Observable } from 'rxjs'; -import type { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'; import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; -import type { ILogger } from '@schablone/logging'; -import type { FeatureSetup, IFeatureService } from './feature'; -import { LightType, MaterialType } from './enums'; +import type { ILogger, LoggerOptions } from '@schablone/logging'; +import type { FeatureSetup, IFeatureRegistryService, IFeatureService } from './feature'; +import { LightType } from './enums'; +import type { interfaces } from 'inversify'; export interface ViewerConfigModel { + /** + * The configuration for the camera. If not provided, the default camera + * configuration is used. See the config service source for the default + * configuration. + */ camera?: Partial; + /** + * The configuration for the controls. If not provided, the default controls + * configuration is used. + */ + controls?: Partial; + /** + * The configuration for the features determines which features are loaded + * and how they should be configured. By default, no features are loaded. + */ features?: FeatureSetup; + /** + * The configuration for the objects to load. At least one object has to be + * provided. + */ objects: ObjectSetupModel[]; + /** + * The configuration for the project. + */ project?: ProjectConfigModel; + /** + * The configuration for the renderer. If not provided, the default renderer + * configuration is used. See the config service source for the default + * configuration. + */ render?: Partial; } + +export interface ControlConfigModel { + /** + * Whether the user can zoom in and out of the scene with the mouse wheel. + * Enabled by default. + */ + allowZoom?: boolean; +} + export interface ObjectSetupModel { + /** + * Whether this object should cast shadows when hit by a shadow casting light + */ castShadow?: boolean; + /** + * The name of the object. If provided, this will overwrite the name in the + * GLTF file before adding the object to the scene. + */ name?: string; + /** + * The path to the object. Currently only GTLF files are supported. + * Note that relative paths are relative to the project basedir as + * specified in the project config of the viewer config. + */ path: string; + /** + * Optional scaling of the object if it is too big or too small in the scene + */ scale?: number; + /** + * Whether this object should be shadowed by other shadow casting objects (including itself) + */ receiveShadow?: boolean; } export interface ProjectConfigModel { + /** + * Base directory all assets are loaded from. This is used to resolve relative + * paths in the object setup and for other resources. + */ basedir?: string; + /** + * @deprecated The folder to load the object from. Legacy feature, currently not used and + * might be removed in the future. + */ folder?: string; + /** + * A language map containing the translations for the intro texts. The key is + * the language code and the value is the desired translation for that + * language. This is a legacy feature and might be replaced in the future. + */ introText?: I18nLanguageMap; + /** + * A list of languages supported by the project. Pass all languages you want + * to support here. The first language in the list is used as the default. + */ languages?: string[]; + /** + * The name of the project. This is used to identify the project in the + * project registry and for logging. + */ name?: string; + /** + * @deprecated The ID of the project. Legacy feature, currently not used anywhere and + * might be removed in the future. + */ projectID?: string; } @@ -53,62 +131,136 @@ export interface SizeModel { export type I18nTranslations = Record; export type I18nLanguageMap = Record; -export type UIControlId = string; -export interface UIControlModel { - controls?: UIControlModel[]; - i18n: I18nLanguageMap; - id: UIControlId; - type: unknown; - value: unknown; -} - export type ClearColor = { alpha?: number; color: string; }; export interface RenderConfigModel { + /** + * Whether the renderer should automatically clear the canvas before each + * render call. Defaults to true. + */ autoClear: boolean; + /** + * The color to clear the canvas with. Defaults to black with an alpha of 0. + */ clearColor: ClearColor; + /** + * Whether the renderer should continuously render the scene. Defaults to + * false, which only renders a single frame. If set to false, the controls + * will cause a rerender when clicking and dragging the mouse, thus only + * rendering when necessary. Animations require this to be true to work. + */ continuousRendering: boolean; + /** + * The color space to use for rendering. Defaults to SRGBColorSpace. + */ outputColorSpace: ColorSpace; + /** + * The pixel ratio to use for rendering. Defaults to 1. Might be interesting + * for high resolution displays. + */ pixelRatio: number; + /** + * The size to render the scene at. Defaults to 1024x768. If using the canvas + * viewer, this will be automatically set to the size of the canvas with a + * window resize listener. + */ renderSize: SizeModel; + /** + * Whether to enable shadow maps. Defaults to true. + */ shadowMapEnabled: boolean; + /** + * The type of shadow map to use. Defaults to PCFSoftShadowMap. + */ shadowMapType: ShadowMapType; } export interface CameraConfigModel { + /** + * Aspect ratio of the camera, should match the render size aspect ratio. + * Defaults to 1024 / 768. + */ aspect: number; + /** + * The far clipping plane of the camera. Defaults to 20000. + */ far: number; + /** + * The field of view of the camera in degrees. Defaults to 37. + */ fov: number; + /** + * The near clipping plane of the camera. Defaults to 0.1. + */ near: number; + /** + * The position of the camera. Defaults to (10, 10, 5). + */ position: Vector3; + /** + * The target of the camera. Defaults to (0, 0, 0). + */ target: Vector3; [key: string]: unknown; } -export interface IControllable { - getControls(): UIControlModel[]; -} - export interface IViewer { animationService: IAnimationService; assetService: IAssetService; configService: IConfigService; controlService: IControlService; featureService: IFeatureService; - init(screenSize: SizeModel, config: ViewerConfigModel, node?: HTMLElement): void; + init(config: ViewerConfigModel, context?: HTMLElement | WebGL2RenderingContext): void; lightService: ILightService; materialService: IMaterialService; renderService: IRenderService; sceneService: ISceneService; } +export interface ViewerLauncherConfig { + /** + * A map of custom services to use instead of the default ones. Services must + * fulfill the interface to the service they are replacing + */ + customManager?: CustomManagerMap; + /** + * Logger to use in the services and features. If not provided, a default + * logger is used that will behave as if it was in a production environment. + */ + logger?: ILogger; + /** + * Options to configure the internal logger in the viewer. Can be supplied + * instead of a logger instance if the logger needs to be configured, but not + * used outside the viewer. + */ + loggerOptions?: LoggerOptions; +} + export interface IViewerLauncher { - createHTMLViewer(node: HTMLElement, config: ViewerConfigModel): void; - createImageViewer(renderSize: SizeModel, config: ViewerConfigModel): Observable; + /** + * Creates a new canvas viewer instance. If the context is an HTML element, + * the viewer will append its canvas object to the provided context. If the + * context is a WebGL2RenderingContext, the viewer will use the provided + * context to render to. + * + * @param config Configuration object for the viewer + * @param context The context to render to + */ + createCanvasViewer(config: ViewerConfigModel, context: HTMLElement | WebGL2RenderingContext): void; + + /** + * Creates a new image viewer instance. Rendered images will be emitted as + * base64 encoded image source strings. + * + * @param config Configuration object for the viewer + * @returns An observable emitting each rendered image as a base64 encoded + * image source string + */ + createImageViewer(config: ViewerConfigModel): Observable; } /** @@ -206,51 +358,11 @@ export interface IAssetService { readonly hookObjectLoaded$: Observable; getIsLoading(): Observable; loadCubeTexture(envName: string, imageSuffix?: string): Promise; - loadEnvironmentMap(path: string, resolution: number): Promise; + loadEnvironmentMap(path: string, resolution: number, renderer: WebGLRenderer): Promise; loadObject(path: string): Promise; loadTexture(path: string): Promise; } -export interface MaterialSetupModel { - alphaMap?: string; - aoMap?: string; - aoMapIntensity?: number; - bumpMap?: string; - bumpScale?: number; - changeable: boolean; - clearCoat?: number; - clearCoatRoughness?: number; - color: string; - combine: number; - displacementMap: string; - displacementScale: number; - displacementBias: number; - emissive: string; - emissiveMap?: string; - emissiveIntensity?: number; - envID: number; - global: boolean; - id: number; - illum: number; - lights: boolean; - map?: string; - metalness?: number; - metalnessMap?: string; - name: string; - normalMap?: string; - normalMapScale?: number; - opacity: number; - reflectivity: number; - refractionRatio: number; - roughness?: number; - roughnessMap?: string; - side: number; - shininess: number; - specular: string; - specularMap?: string; - type: MaterialType; -} - // TODO: Rethink concept for material assignment, material slots and identifying materials export interface IMaterialService { addMaterial(material: Material): void; @@ -261,12 +373,12 @@ export interface IMaterialService { } export interface IRenderService { - readonly composer: EffectComposer; readonly hookAfterRender$: Observable; readonly hookBeforeRender$: Observable; readonly renderer: WebGLRenderer; getCamera(): Observable; getRenderConfig(): Observable; + init(context?: HTMLElement | WebGL2RenderingContext): void; renderSingleFrame(): void; setCameraConfig(config: Partial): void; setPostProcessingEnabled(enabled: boolean): void; @@ -319,7 +431,7 @@ export interface ILightService { } export interface IControlService { - getControls(): Observable; + getControls(): Observable; } export interface IConfigService { @@ -327,4 +439,20 @@ export interface IConfigService { loadConfig(config: ViewerConfigModel): void; } -export type ILoggerService = ILogger; +export type ILoggerService = { + init(loggerOptions?: LoggerOptions, logger?: ILogger): void; +} & ILogger; + +export interface CustomManagerMap { + animation?: interfaces.Newable; + asset?: interfaces.Newable; + config?: interfaces.Newable; + control?: interfaces.Newable; + feature?: interfaces.Newable; + featureRegistry?: interfaces.Newable; + light?: interfaces.Newable; + logger?: interfaces.Newable; + material?: interfaces.Newable; + render?: interfaces.Newable; + scene?: interfaces.Newable; +} diff --git a/libs/viewer-core/tsconfig.lib.json b/libs/viewer-core/tsconfig.lib.json index 2f3b122..0bfa820 100644 --- a/libs/viewer-core/tsconfig.lib.json +++ b/libs/viewer-core/tsconfig.lib.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "../../../dist/out-tsc", "declaration": true, - "types": [] + "types": [], + "importHelpers": false }, "include": ["**/*.ts"], "exclude": [ diff --git a/libs/viewer-ui/.babelrc b/libs/viewer-ui/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/libs/viewer-ui/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/viewer-ui/.eslintrc.json b/libs/viewer-ui/.eslintrc.json index a39ac5d..bebd4b1 100644 --- a/libs/viewer-ui/.eslintrc.json +++ b/libs/viewer-ui/.eslintrc.json @@ -13,6 +13,15 @@ { "files": ["*.js", "*.jsx"], "rules": {} + }, + { + "files": ["package.json", "project.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": ["error", { + "ignoredFiles": ["libs/viewer-ui/vite.config.ts", "libs/viewer-ui/jest.config.ts"] + }] + } } ] } diff --git a/libs/viewer-ui/README.md b/libs/viewer-ui/README.md index 52b5864..53bf901 100644 --- a/libs/viewer-ui/README.md +++ b/libs/viewer-ui/README.md @@ -1,7 +1,42 @@ -# viewer-ui +# 3D Studio Viewer UI -This library was generated with [Nx](https://nx.dev). +This package contains a UI for the 3D Studio Viewer Core. It is implemented as a +React component library and can be used in any React application. The UI provides +interface elements for the viewer and the core features. -## Running unit tests +## Usage -Run `nx test viewer-ui` to execute the unit tests via [Vitest](https://vitest.dev/). +```jsx +import { useEffect, useRef, useState } from 'react'; +import { IViewer, ViewerLauncher } from '@schablone/3d-studio-viewer-core'; +import { ViewerUI } from '@schablone/3d-studio-viewer-ui'; + +const App = () => { + const viewerCanvas = useRef(null); + const [viewer, setViewer] = useState(); + const [launcher] = useState(new ViewerLauncher()); + + useEffect(() => { + if (!viewerCanvas.current) { + return; + } + + const config = { + objects: [ + { + path: 'path/to/object.gtlf' + } + ] + }; + + setViewer(launcher.createCanvasViewer(config, viewerCanvas.current)); + }, [launcher]); + + return ( +
+
+ +
+ ); +}; +``` diff --git a/libs/viewer-ui/package.json b/libs/viewer-ui/package.json index 7ed0626..852f1da 100644 --- a/libs/viewer-ui/package.json +++ b/libs/viewer-ui/package.json @@ -1,5 +1,5 @@ { - "name": "@alchemisten/3d-studio-viewer-ui", + "name": "@schablone/3d-studio-viewer-ui", "version": "0.0.1", "main": "./index.js", "types": "./index.d.ts", @@ -8,5 +8,24 @@ "import": "./index.mjs", "require": "./index.js" } + }, + "peerDependencies": { + "@schablone/3d-studio-viewer-core": "__root_version__", + "@schablone/logging-react": "^1.1.7", + "dompurify": "^3.0.6", + "html-react-parser": "^4.2.2", + "react": "18.2.0", + "react-intl": "^6.5.1", + "react-intl-provider": "^2.0.0", + "react-range": "^1.8.14", + "rxjs": "^7.8.1", + "three": "^0.158.0" + }, + "devDependencies": { + "@nx/devkit": "17.0.3", + "@nx/vite": "17.0.3", + "@vitejs/plugin-react": "4.0.0", + "vite": "4.3.9", + "vite-plugin-dts": "2.3.0" } } diff --git a/libs/viewer-ui/project.json b/libs/viewer-ui/project.json index fbb4fd2..56fe83e 100644 --- a/libs/viewer-ui/project.json +++ b/libs/viewer-ui/project.json @@ -1,15 +1,15 @@ { - "name": "viewer-ui", + "name": "3d-studio-viewer-ui", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/viewer-ui/src", "projectType": "library", "tags": [], "targets": { "lint": { - "executor": "@nx/linter:eslint", + "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"], "options": { - "lintFilePatterns": ["libs/viewer-ui/**/*.{ts,tsx,js,jsx}"] + "lintFilePatterns": ["libs/viewer-ui/**/*.{ts,tsx,js,jsx}","libs/viewer-ui/package.json", "libs/viewer-ui/project.json"] } }, "build": { @@ -17,7 +17,8 @@ "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { - "outputPath": "dist/libs/viewer-ui" + "outputPath": "dist/libs/viewer-ui", + "generatePackageJson": true }, "configurations": { "development": { @@ -28,6 +29,17 @@ } } }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "ts-node tools/scripts/publish.ts 3d-studio-viewer-ui {args.ver} {args.tag}" + }, + "dependsOn": [ + { + "target": "build" + } + ] + }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], diff --git a/libs/viewer-ui/src/components/animation-bar/animation-bar.tsx b/libs/viewer-ui/src/components/animation-bar/animation-bar.tsx index 16dae79..1920e7a 100644 --- a/libs/viewer-ui/src/components/animation-bar/animation-bar.tsx +++ b/libs/viewer-ui/src/components/animation-bar/animation-bar.tsx @@ -116,7 +116,7 @@ export const AnimationBar: FC = () => { style={{ background: getTrackBackground({ values: [animationData?.time || 0], - colors: ['var(--ui-color-interaction)', '#fff'], + colors: ['var(--ui-color-interaction)', 'var(--ui-color-bar)'], min: 0, max: animation?.duration || 1, }), diff --git a/libs/viewer-ui/src/components/button/button.module.scss b/libs/viewer-ui/src/components/button/button.module.scss index 94c9a8f..67a0f94 100644 --- a/libs/viewer-ui/src/components/button/button.module.scss +++ b/libs/viewer-ui/src/components/button/button.module.scss @@ -1,5 +1,5 @@ .button { - border-radius: calc(var(--ui-size-button) / 2); + border-radius: var(--ui-size-button-radius); height: var(--ui-size-button); text-transform: uppercase; width: var(--ui-size-button); diff --git a/libs/viewer-ui/src/components/controls/controls.tsx b/libs/viewer-ui/src/components/controls/controls.tsx index 6b55cce..9b8f801 100644 --- a/libs/viewer-ui/src/components/controls/controls.tsx +++ b/libs/viewer-ui/src/components/controls/controls.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useMemo, useState } from 'react'; import { useTranslations } from 'react-intl-provider'; import type { FeatureMap } from '../../types'; @@ -27,6 +27,8 @@ export const Controls: FC = ({ features }) => { setLanguageMenuOpen(!languageMenuOpen); }; + const hasFeatures = useMemo(() => Object.values(features).length > 0, [features]); + return (
@@ -42,7 +44,7 @@ export const Controls: FC = ({ features }) => { )} - {features && ( + {hasFeatures && ( <> ))}
@@ -50,7 +51,7 @@ export const SelectBox: FC = ({ currentEntry, entries, onEntrySe