├── Dockerfile ├── .gitattributes ├── .prettierignore ├── .ncurc.json ├── env.d.ts ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── skybox │ ├── space_bk.png │ ├── space_dn.png │ ├── space_ft.png │ ├── space_lf.png │ ├── space_rt.png │ ├── space_up.png │ └── space.txt ├── extras │ ├── placeholder.png │ ├── explosion-large.gif │ └── explosion-small.gif ├── background │ ├── space-540w.jpg │ ├── space-960w.jpg │ ├── space-1920w.jpg │ └── space-2560w.jpg ├── lagrange_logo_raster.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png └── site.webmanifest ├── .vscode └── extensions.json ├── src ├── assets │ ├── sass │ │ ├── native │ │ │ ├── index.scss │ │ │ ├── _list.scss │ │ │ ├── _select.scss │ │ │ ├── _input.scss │ │ │ └── _text.scss │ │ ├── _fonts.scss │ │ ├── index.scss │ │ ├── _vacp.scss │ │ ├── _floating.scss │ │ ├── _effects.scss │ │ └── _main.scss │ └── fonts │ │ └── VCR_OSD_MONO_1.001.ttf ├── bail-types.d.ts ├── core │ ├── models │ │ ├── converters │ │ │ ├── model-converter.ts │ │ │ ├── ring-data.converter.ts │ │ │ ├── atmosphere-data.converter.ts │ │ │ ├── clouds-data.converter.ts │ │ │ └── planet-data.converter.ts │ │ ├── change-tracker.model.ts │ │ └── ring-parameters.model.ts │ ├── tsl │ │ ├── utils │ │ │ ├── vertex-utils.ts │ │ │ ├── math-utils.ts │ │ │ └── sobel-utils.ts │ │ ├── materials │ │ │ ├── tsl-material.ts │ │ │ └── ring.tslmat.ts │ │ ├── tsl-types.ts │ │ ├── noise │ │ │ ├── fbm1.ts │ │ │ └── fbm3.ts │ │ └── features │ │ │ ├── bump.ts │ │ │ ├── biomes.ts │ │ │ └── lwd.ts │ ├── capabilities │ │ ├── WebGPU.ts │ │ └── WebGL.ts │ ├── utils │ │ ├── utils.ts │ │ ├── svg-utils.ts │ │ ├── i18n-utils.ts │ │ ├── math │ │ │ └── rect.ts │ │ ├── render-utils.ts │ │ ├── texture │ │ │ └── layered-data-texture.ts │ │ └── math-utils.ts │ ├── helpers │ │ ├── compatibility.helper.ts │ │ ├── export.helper.ts │ │ ├── import.helper.ts │ │ └── preview.helper.ts │ ├── globals.ts │ ├── event-bus.ts │ ├── effects │ │ └── lens-flare.effect.ts │ └── types.ts ├── components │ ├── global │ │ ├── parameters │ │ │ ├── ParameterDivider.vue │ │ │ ├── ParameterGrid.vue │ │ │ ├── ParameterCategory.vue │ │ │ ├── ParameterCheckbox.vue │ │ │ ├── ParameterSelect.vue │ │ │ ├── ParameterRadio.vue │ │ │ ├── ParameterSlider.vue │ │ │ ├── ParameterRadioOption.vue │ │ │ ├── ParameterColor.vue │ │ │ ├── ParameterKeyBinding.vue │ │ │ ├── ParameterGroup.vue │ │ │ └── ParameterRing.vue │ │ ├── elements │ │ │ ├── GenericBoxElement.vue │ │ │ ├── OverlaySpinner.vue │ │ │ ├── MeasurementBoxElement.vue │ │ │ ├── AppLogo.vue │ │ │ ├── ToastElement.vue │ │ │ └── CollapsibleSection.vue │ │ ├── svg │ │ │ └── SVGProgressIcon.vue │ │ ├── decoration │ │ │ ├── SeparatorGreebleDeco.vue │ │ │ └── CornerDeco.vue │ │ ├── InlineFooter.vue │ │ ├── extras │ │ │ ├── ExtraSpecialDayElement.vue │ │ │ └── ExtraMetalSlugPlanetExplosion.vue │ │ ├── AppToastBar.vue │ │ ├── ViewHeader.vue │ │ ├── AppFooter.vue │ │ └── AppNavigation.vue │ ├── codex │ │ ├── elements │ │ │ ├── PlanetCardFeatureBoxElement.vue │ │ │ └── NewCardElement.vue │ │ ├── dialogs │ │ │ ├── ClearDataConfirmDialog.vue │ │ │ └── DeleteConfirmDialog.vue │ │ └── svg │ │ │ └── SVGRingsGraph.vue │ └── editor │ │ ├── dialogs │ │ ├── ResetConfirmDialog.vue │ │ ├── WarnSaveDialog.vue │ │ ├── ExportProgressDialog.vue │ │ └── EditorInitErrorDialog.vue │ │ └── controls │ │ ├── ControlsRings.vue │ │ ├── ControlsBasicData.vue │ │ ├── EditorSidebarControls.vue │ │ ├── ControlsLighting.vue │ │ ├── ControlsRendering.vue │ │ └── ControlsContainer.vue ├── _lib │ └── components │ │ ├── LgvChip.vue │ │ ├── LgvSelect.vue │ │ ├── LgvNotification.vue │ │ └── LgvButton.vue ├── router.config.ts ├── i18n.config.ts ├── dexie.config.ts ├── main.ts └── views │ └── PageNotFoundView.vue ├── .prettierrc.json ├── .gitignore ├── docs └── .$lagrange_diagrams.drawio.bkp ├── tsconfig.json ├── .github ├── workflows │ ├── release.yml │ └── main.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.yml ├── .devcontainer └── devcontainer.json ├── eslint.config.js ├── vite.config.ts ├── index.html ├── package.json ├── LICENSE └── CODE_OF_CONDUCT.md /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/core/helpers/* -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": [] 3 | } 4 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/skybox/space_bk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_bk.png -------------------------------------------------------------------------------- /public/skybox/space_dn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_dn.png -------------------------------------------------------------------------------- /public/skybox/space_ft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_ft.png -------------------------------------------------------------------------------- /public/skybox/space_lf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_lf.png -------------------------------------------------------------------------------- /public/skybox/space_rt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_rt.png -------------------------------------------------------------------------------- /public/skybox/space_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/skybox/space_up.png -------------------------------------------------------------------------------- /src/assets/sass/native/index.scss: -------------------------------------------------------------------------------- 1 | @use 'input'; 2 | @use 'select'; 3 | @use 'text'; 4 | @use 'list'; 5 | -------------------------------------------------------------------------------- /public/extras/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/extras/placeholder.png -------------------------------------------------------------------------------- /public/background/space-540w.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/background/space-540w.jpg -------------------------------------------------------------------------------- /public/background/space-960w.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/background/space-960w.jpg -------------------------------------------------------------------------------- /public/lagrange_logo_raster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/lagrange_logo_raster.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/background/space-1920w.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/background/space-1920w.jpg -------------------------------------------------------------------------------- /public/background/space-2560w.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/background/space-2560w.jpg -------------------------------------------------------------------------------- /public/extras/explosion-large.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/extras/explosion-large.gif -------------------------------------------------------------------------------- /public/extras/explosion-small.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/public/extras/explosion-small.gif -------------------------------------------------------------------------------- /src/assets/fonts/VCR_OSD_MONO_1.001.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EepyBerry/lagrange/HEAD/src/assets/fonts/VCR_OSD_MONO_1.001.ttf -------------------------------------------------------------------------------- /src/assets/sass/_fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: VCR OSD Mono; 3 | src: url('@assets/fonts/VCR_OSD_MONO_1.001.ttf'); 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "singleQuote": true, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /src/bail-types.d.ts: -------------------------------------------------------------------------------- 1 | import type PlanetData from './core/models/planet-data.model'; 2 | 3 | declare module '@vue/reactivity' { 4 | export interface RefUnwrapBailTypes { 5 | classes: PlanetData; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/sass/index.scss: -------------------------------------------------------------------------------- 1 | @use './main'; 2 | @use './themes'; 3 | @use './vacp'; 4 | @use './floating'; 5 | @use './native'; 6 | @use './fonts'; 7 | @use './effects'; 8 | @import url('vue-accessible-color-picker/styles'); 9 | -------------------------------------------------------------------------------- /src/core/models/converters/model-converter.ts: -------------------------------------------------------------------------------- 1 | export abstract class ModelConverter { 2 | protected _data: D; 3 | public abstract convert(): T; 4 | 5 | constructor(data: D) { 6 | this._data = data; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/sass/native/_list.scss: -------------------------------------------------------------------------------- 1 | li { 2 | list-style-type: '-'; 3 | padding: 0 0.25rem; 4 | } 5 | li[starred] { 6 | list-style-type: '⭐'; 7 | border-radius: 2px; 8 | font-weight: 500; 9 | color: var(--lg-text-starred); 10 | } 11 | -------------------------------------------------------------------------------- /src/core/tsl/utils/vertex-utils.ts: -------------------------------------------------------------------------------- 1 | import { vec4, type ShaderNodeObject } from 'three/tsl'; 2 | import type { Node } from 'three/webgpu'; 3 | 4 | export function flattenUV(uv: ShaderNodeObject) { 5 | return vec4(uv.mul(2.0).sub(1.0), 0.0, 1.0); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterDivider.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /src/core/tsl/materials/tsl-material.ts: -------------------------------------------------------------------------------- 1 | import type { NodeMaterial } from 'three/webgpu'; 2 | 3 | export interface TSLMaterial { 4 | uniforms: UniformType; 5 | buildMaterial(): MatType; 6 | } 7 | -------------------------------------------------------------------------------- /src/core/tsl/utils/math-utils.ts: -------------------------------------------------------------------------------- 1 | import { Fn, type ShaderNodeObject, int } from 'three/tsl'; 2 | import type { Node } from 'three/webgpu'; 3 | 4 | export function getMatrixElement(matrix: ShaderNodeObject, x: number, y: number): ShaderNodeObject { 5 | return matrix.element(int(x)).element(int(y)); 6 | } 7 | -------------------------------------------------------------------------------- /src/_lib/components/LgvChip.vue: -------------------------------------------------------------------------------- 1 | 6 | 19 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterGrid.vue: -------------------------------------------------------------------------------- 1 | 6 | 20 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lagrange - Procedural Planet Builder", 3 | "short_name": "Lagrange", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#06080f", 17 | "background_color": "#06080f", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | 32 | # rollup-plugin-visualizer output 33 | stats.html -------------------------------------------------------------------------------- /public/skybox/space.txt: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /src/assets/sass/native/_select.scss: -------------------------------------------------------------------------------- 1 | select { 2 | background: var(--lg-input); 3 | border: 1px solid var(--lg-accent); 4 | border-radius: 2px; 5 | padding: 0 0.75rem; 6 | color: var(--lg-text); 7 | min-height: 2rem; 8 | font-family: Poppins, Inter; 9 | text-overflow: ellipsis; 10 | cursor: pointer; 11 | 12 | & > option:hover { 13 | cursor: pointer; 14 | } 15 | } 16 | select[disabled] { 17 | background: var(--lg-input-disabled); 18 | border: 1px solid var(--lg-border-disabled); 19 | cursor: not-allowed; 20 | } 21 | -------------------------------------------------------------------------------- /docs/.$lagrange_diagrams.drawio.bkp: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterCategory.vue: -------------------------------------------------------------------------------- 1 | 8 | 27 | -------------------------------------------------------------------------------- /src/assets/sass/native/_input.scss: -------------------------------------------------------------------------------- 1 | input { 2 | background: transparent; 3 | background: var(--lg-input); 4 | border: 1px solid var(--lg-accent); 5 | border-radius: 2px; 6 | padding-left: 4px; 7 | padding-right: 4px; 8 | color: var(--lg-text); 9 | min-height: 1.5rem; 10 | -moz-appearance: textfield; 11 | } 12 | input::-webkit-outer-spin-button, 13 | input::-webkit-inner-spin-button { 14 | -webkit-appearance: none; 15 | margin: 0; 16 | } 17 | 18 | input[type='checkbox'] { 19 | accent-color: var(--lg-contrast); 20 | width: 1rem; 21 | } 22 | 23 | @media screen and (max-width: 767px) { 24 | input[type='checkbox'] { 25 | accent-color: var(--lg-contrast); 26 | width: 1.375rem; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/sass/native/_text.scss: -------------------------------------------------------------------------------- 1 | // headlines 2 | h1, 3 | h2, 4 | h3, 5 | h4, 6 | h5, 7 | h6 { 8 | line-height: 1; 9 | font-family: Poppins; 10 | font-weight: 600; 11 | display: flex; 12 | align-items: center; 13 | gap: 8px; 14 | } 15 | h1 { 16 | font-size: 1.75rem; 17 | } 18 | h2 { 19 | font-size: 1.375rem; 20 | } 21 | h3 { 22 | font-size: 1.125rem; 23 | } 24 | 25 | strong, 26 | b { 27 | font-weight: 600; 28 | } 29 | 30 | // sup 31 | sup { 32 | font-size: 0.75rem; 33 | } 34 | 35 | // lists 36 | ul { 37 | list-style-type: none; 38 | padding-left: 0; 39 | } 40 | 41 | // custom classes 42 | .highlight { 43 | font-weight: 600; 44 | } 45 | .nowrap { 46 | text-wrap: nowrap; 47 | } 48 | 49 | label { 50 | padding-right: 8px; 51 | } 52 | -------------------------------------------------------------------------------- /src/core/models/converters/ring-data.converter.ts: -------------------------------------------------------------------------------- 1 | import type { Texture } from 'three'; 2 | import type { RingUniformData } from '@core/tsl/materials/ring.tslmat'; 3 | import type { RingParameters } from '../ring-parameters.model'; 4 | import { ModelConverter } from './model-converter'; 5 | 6 | export class RingDataConverter extends ModelConverter { 7 | private _ringTexture: Texture; 8 | 9 | constructor(data: RingParameters, tex: Texture) { 10 | super(data); 11 | this._ringTexture = tex; 12 | } 13 | 14 | public convert(): RingUniformData { 15 | return { 16 | innerRadius: this._data.innerRadius, 17 | outerRadius: this._data.outerRadius, 18 | texture: this._ringTexture, 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterCheckbox.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 32 | -------------------------------------------------------------------------------- /src/components/global/elements/GenericBoxElement.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 30 | -------------------------------------------------------------------------------- /src/components/global/elements/OverlaySpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 3 | "exclude": ["src/**/__tests__/*"], 4 | "compilerOptions": { 5 | "strict": true, 6 | "noEmit": true, 7 | "target": "es2023", 8 | "module": "es2022", 9 | "moduleResolution": "bundler", 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "jsx": "preserve", 15 | "jsxImportSource": "vue", 16 | "noImplicitThis": true, 17 | "verbatimModuleSyntax": true, 18 | "useDefineForClassFields": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "removeComments": false, 21 | 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./src/*"], 25 | "@core/*": ["./src/core/*"], 26 | "@assets/*": ["./src/assets/*"], 27 | "@components/*": ["./src/components/*"] 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/codex/elements/PlanetCardFeatureBoxElement.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /src/_lib/components/LgvSelect.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 38 | -------------------------------------------------------------------------------- /src/components/global/svg/SVGProgressIcon.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'release/*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20.x 20 | cache: 'npm' 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Build application 24 | run: npm run build --if-present 25 | - name: Get current NPM package version 26 | id: package-version 27 | uses: martinbeentjes/npm-get-version-action@v1.3.1 28 | - name: Upload build artifact 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: lagrange-${{ steps.package-version.outputs.current-version}}-SNAPSHOT-${{ github.sha }} 32 | path: dist 33 | -------------------------------------------------------------------------------- /src/core/capabilities/WebGPU.ts: -------------------------------------------------------------------------------- 1 | import type { Composer } from 'vue-i18n'; 2 | 3 | /** 4 | * Custom class to check WebGPU capabilities, based on three.js code 5 | * @see https://github.com/mrdoob/three.js/blob/dev/examples/jsm/capabilities/WebGPU.js 6 | */ 7 | export default class WebGPU { 8 | private static _error?: Error | DOMException; 9 | 10 | static async isAvailable() { 11 | try { 12 | const isAvailable = typeof navigator !== 'undefined' && navigator.gpu !== undefined; 13 | if (typeof window !== 'undefined' && isAvailable) { 14 | return Boolean(await navigator.gpu.requestAdapter()); 15 | } 16 | } catch (e) { 17 | this._error = e as Error | DOMException; 18 | return false; 19 | } 20 | } 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | static getErrorMessage(i18n: Composer): string { 24 | return this._error ? this._error.message : i18n.t('main.error.default_webgpu_support'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/global/decoration/SeparatorGreebleDeco.vue: -------------------------------------------------------------------------------- 1 | 8 | 43 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterSelect.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | 21 | 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lagrange Deployment 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 20.x 20 | cache: 'npm' 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Build application 24 | run: npm run build --if-present 25 | - name: Create release 26 | uses: thedoctor0/zip-release@0.7.5 27 | with: 28 | type: 'zip' 29 | path: dist 30 | filename: 'lagrange-${{ github.ref_name }}.zip' 31 | - name: Upload release 32 | uses: ncipollo/release-action@v1.12.0 33 | with: 34 | artifacts: 'lagrange-${{ github.ref_name }}.zip' 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/router.config.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router'; 2 | import CodexView from './views/CodexView.vue'; 3 | import PlanetEditorView from './views/PlanetEditorView.vue'; 4 | import PageNotFoundView from './views/PageNotFoundView.vue'; 5 | 6 | const router = createRouter({ 7 | history: createWebHistory(), 8 | routes: [ 9 | { 10 | path: '/', 11 | redirect: '/codex', 12 | }, 13 | { 14 | path: '/codex', 15 | name: 'codex', 16 | component: CodexView, 17 | meta: { title: 'Codex' }, 18 | }, 19 | { 20 | path: '/planet-editor', 21 | redirect: '/planet-editor/new', 22 | }, 23 | { 24 | path: '/planet-editor/:id', 25 | name: 'planet-editor', 26 | component: PlanetEditorView, 27 | meta: { title: 'Planet Editor' }, 28 | }, 29 | { 30 | path: '/:pathMatch(.*)*', 31 | name: 'page-not-found', 32 | component: PageNotFoundView, 33 | meta: { title: 'Page Not Found' }, 34 | }, 35 | ], 36 | }); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterRadio.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 44 | -------------------------------------------------------------------------------- /src/components/global/elements/MeasurementBoxElement.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | 15 | 42 | -------------------------------------------------------------------------------- /src/core/tsl/tsl-types.ts: -------------------------------------------------------------------------------- 1 | import type { ShaderNodeObject } from 'three/tsl'; 2 | import type { Color, Matrix3, UniformNode, Vector2, Vector3, Vector4 } from 'three/webgpu'; 3 | 4 | // Convenience type declarations, to avoid repeating stuff 5 | export type UniformBooleanNode = ShaderNodeObject>; 6 | export type UniformNumberNode = ShaderNodeObject>; 7 | export type UniformVector2Node = ShaderNodeObject>; 8 | export type UniformVector3Node = ShaderNodeObject>; 9 | export type UniformVector4Node = ShaderNodeObject>; 10 | export type UniformMatrix3Node = ShaderNodeObject>; 11 | export type UniformColorNode = ShaderNodeObject>; 12 | 13 | // Ditto, for DataType declarations 14 | export type WarpingData = { 15 | layers: number; 16 | warpFactor: Vector3; 17 | }; 18 | export type DisplacementData = { 19 | factor: number; 20 | epsilon: number; 21 | multiplier: number; 22 | }; 23 | export type NoiseData = { 24 | frequency: number; 25 | amplitude: number; 26 | lacunarity: number; 27 | octaves: number; 28 | }; 29 | -------------------------------------------------------------------------------- /src/core/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { LOCALE_MAP } from '@core/globals'; 2 | 3 | // note: 'any' needed as we have a loose object, and never/unknown act weird with the reduce function below 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export function setObjectValue(obj: Record, path: string, value: unknown) { 6 | const pathArray = path.match(/([^[.\]])+/g); 7 | if (!pathArray) throw new Error('No property path found in object'); 8 | pathArray.reduce((acc, key, i) => { 9 | if (acc[key] === undefined) acc[key] = {}; 10 | if (i === pathArray.length - 1) acc[key] = value; 11 | return acc[key]; 12 | }, obj); 13 | } 14 | 15 | export function sleep(delay: number): Promise { 16 | return new Promise((resolve) => setTimeout(resolve, delay)); 17 | } 18 | 19 | export function mapLocale(locale: string): string { 20 | return locale.length > 2 ? locale : (LOCALE_MAP[locale] ?? 'en-US'); 21 | } 22 | 23 | export function prefersReducedMotion() { 24 | return ( 25 | window.matchMedia(`(prefers-reduced-motion: reduce)`).matches || 26 | window.matchMedia(`(prefers-reduced-motion: reduce)`).matches 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Existing Dockerfile", 5 | "build": { 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 9 | "dockerfile": "../Dockerfile" 10 | } 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Uncomment the next line to run commands after the container is created. 19 | // "postCreateCommand": "cat /etc/os-release", 20 | 21 | // Configure tool-specific properties. 22 | // "customizations": {}, 23 | 24 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 25 | // "remoteUser": "devcontainer" 26 | } 27 | -------------------------------------------------------------------------------- /src/i18n.config.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n'; 2 | 3 | import enUS from '@assets/i18n/en-US.json'; 4 | import enUwU from '@assets/i18n/en-UwU.json'; 5 | import frFR from '@assets/i18n/fr-FR.json'; 6 | import deDE from '@assets/i18n/de-DE.json'; 7 | 8 | const I18N_SUPPORTED_LANGS = ['en', 'en-US', 'en-UwU', 'fr', 'fr-FR', 'de', 'de-DE'] as const; 9 | 10 | /** 11 | * Base i18n schema template (derived from {@linkcode enUS en-US} file) 12 | */ 13 | type IntlSchema = typeof enUS; 14 | 15 | /** 16 | * String literal type derived from {@linkcode I18N_SUPPORTED_LANGS} 17 | * @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions 18 | */ 19 | type IntlSupportedLangs = (typeof I18N_SUPPORTED_LANGS)[number]; 20 | 21 | const i18n = createI18n<[IntlSchema], IntlSupportedLangs>({ 22 | legacy: false, 23 | fallbackLocale: 'en-US', 24 | warnHtmlMessage: false, 25 | messages: { 26 | en: enUS, 27 | 'en-US': enUS, 28 | 'en-UwU': enUwU, 29 | 30 | fr: frFR, 31 | 'fr-FR': frFR, 32 | 33 | de: deDE, 34 | 'de-DE': deDE, 35 | }, 36 | }); 37 | 38 | export { I18N_SUPPORTED_LANGS, type IntlSupportedLangs, i18n }; 39 | -------------------------------------------------------------------------------- /src/core/utils/svg-utils.ts: -------------------------------------------------------------------------------- 1 | export function makeSVGLinearPath(start: number[], end: number[], stops: number) { 2 | let path: string = `M${start[0]},${start[1]}`; 3 | const curValues: number[] = [start[0], start[1]]; 4 | for (let i = 0; i < stops; i++) { 5 | curValues[0] += (end[0] - start[0]) / (stops + 1.0); 6 | curValues[1] += (end[1] - start[1]) / (stops + 1.0); 7 | path += ` ${curValues[0]},${curValues[1]}`; 8 | } 9 | return path + ' ' + end; 10 | } 11 | 12 | function _polarToCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) { 13 | const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; 14 | return { 15 | x: centerX + radius * Math.cos(angleInRadians), 16 | y: centerY + radius * Math.sin(angleInRadians), 17 | }; 18 | } 19 | export function makeSVGCircleArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) { 20 | const start = _polarToCartesian(x, y, radius, endAngle); 21 | const end = _polarToCartesian(x, y, radius, startAngle); 22 | const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; 23 | return ['M', start.x, start.y, 'A', radius, radius, 0, largeArcFlag, 0, end.x, end.y].join(' '); 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[X.Y.Z] Feature Request' 5 | labels: 'enhancement' 6 | assignees: 'EepyBerry' 7 | --- 8 | 9 | ## **Feature description** 10 | ``` 11 | Put information about your proposed feature/enhancement here. 12 | Please be as detailed as possible in your description! 13 | 14 | **NOTE:** targeted version should be added to the ticket between brackets if known! 15 | Unversioned tickets will be rejected if not comprehensive enough. 16 | Example: "[0.4.2] Thing that doesn't work" 17 | ``` 18 | 19 | 20 | ## **Reasoning behind proposed enhancement(s)** 21 | - [ ] Does it solve a previously unknown/unnoticed issue? 22 | - [ ] Is it about a completely new parameter/property/element/...? 23 | - [ ] Is it an improvement over an existing feature? 24 | - [ ] Something else (please specify below) 25 | ``` 26 | Please add details here. Incomplete information might be rejected/ignored! 27 | ``` 28 | ## **Additional context (screenshots, etc)** 29 | ``` 30 | If you'd like to add more context, feel free to do so here. 31 | Any kind of supporting content is welcome: screenshots, drawings, anything that helps! 32 | ``` 33 | -------------------------------------------------------------------------------- /src/core/helpers/compatibility.helper.ts: -------------------------------------------------------------------------------- 1 | import type { ColorRamp } from '../models/color-ramp.model' 2 | import type PlanetData from '../models/planet-data.model' 3 | import { RingParameters } from '../models/ring-parameters.model' 4 | 5 | type LegacyRingPlanetData = PlanetData & { 6 | _id: string 7 | _ringEnabled: boolean 8 | _ringInnerRadius: number 9 | _ringOuterRadius: number 10 | _ringColorRamp: ColorRamp 11 | } 12 | 13 | /** 14 | * Converts legacy singular ring storage to multi-ring format 15 | * @since v0.4.3 16 | * @param self target PlanetData object 17 | * @param legacyData legacy data object 18 | */ 19 | export function convertLegacyRingStorage(self: PlanetData, legacyData: LegacyRingPlanetData): void { 20 | if (legacyData._ringInnerRadius) { 21 | self.ringsEnabled = legacyData._ringEnabled ?? false 22 | const convertedParams = new RingParameters( 23 | self.changedProps, 24 | '_ringsParams', 25 | legacyData._ringInnerRadius ?? 1.25, 26 | legacyData._ringOuterRadius ?? 1.5, 27 | legacyData._ringColorRamp?._steps, 28 | ) 29 | convertedParams.id = legacyData._id ? legacyData._id : convertedParams.id 30 | self.ringsParams.push(convertedParams) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/global/elements/AppLogo.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 43 | -------------------------------------------------------------------------------- /src/assets/sass/_vacp.scss: -------------------------------------------------------------------------------- 1 | .vacp-color-picker { 2 | --vacp-spacing: 6px; 3 | --vacp-font-family: inherit; 4 | --vacp-font-size: 1rem; 5 | --vacp-color-focus: var(--lg-input-contrast-focus); 6 | 7 | display: grid; 8 | grid-gap: 6px; 9 | grid-template-columns: 1fr min-content; 10 | font-size: 0.8rem; 11 | width: 100%; 12 | max-width: 100%; 13 | padding: 0.5rem; 14 | //font-family: var(--vacp-font-family, -apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif); 15 | color: var(--lg-text); 16 | background: transparent; 17 | border-radius: 2px; 18 | border: 1px solid var(--lg-input); 19 | 20 | label { 21 | padding-right: unset; 22 | } 23 | input { 24 | min-height: unset; 25 | } 26 | div.vacp-color-space { 27 | aspect-ratio: unset; 28 | height: 160px; 29 | } 30 | div.vacp-color-input-group { 31 | width: min-content; 32 | min-width: 18rem; 33 | } 34 | 35 | button.vacp-copy-button, 36 | button.vacp-format-switch-button { 37 | align-self: flex-end; 38 | background: transparent; 39 | cursor: pointer; 40 | } 41 | 42 | input[id^='color-picker-color-'] { 43 | background: var(--lg-input); 44 | border-radius: 2px; 45 | } 46 | } 47 | :where(.vacp-color-picker) button { 48 | border-radius: 2px; 49 | } 50 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterSlider.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 50 | -------------------------------------------------------------------------------- /src/core/capabilities/WebGL.ts: -------------------------------------------------------------------------------- 1 | import type { Composer } from 'vue-i18n'; 2 | 3 | /** 4 | * Custom class to check WebGL2 capabilities, based on three.js code 5 | * @see https://github.com/mrdoob/three.js/blob/dev/examples/jsm/capabilities/WebGL.js 6 | */ 7 | export default class WebGL { 8 | private static _error?: Error | DOMException; 9 | 10 | static isWebGL2Available() { 11 | try { 12 | const canvas = document.createElement('canvas'); 13 | return !!(window.WebGL2RenderingContext && canvas.getContext('webgl2')); 14 | } catch (e) { 15 | this._error = e as Error | DOMException; 16 | return false; 17 | } 18 | } 19 | 20 | static isColorSpaceAvailable(colorSpace: PredefinedColorSpace) { 21 | try { 22 | const canvas = document.createElement('canvas'); 23 | const ctx = window.WebGL2RenderingContext && canvas.getContext('webgl2')!; 24 | ctx.drawingBufferColorSpace = colorSpace; 25 | return ctx.drawingBufferColorSpace === colorSpace; 26 | } catch (_) { 27 | return false; 28 | } 29 | } 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | static getWebGL2ErrorMessage(i18n: Composer): string { 33 | return this._error ? this._error.message : i18n.t('main.error.default_webgl_support'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier'; 3 | import eslintPluginVue from 'eslint-plugin-vue'; 4 | import typescriptEslint from 'typescript-eslint'; 5 | import globals from 'globals'; 6 | 7 | export default typescriptEslint.config( 8 | { 9 | ignores: ['*.d.ts', 'coverage', 'dist', 'node_modules'], 10 | }, 11 | { 12 | extends: [ 13 | eslint.configs.recommended, 14 | ...typescriptEslint.configs.recommended, 15 | ...eslintPluginVue.configs['flat/recommended'], 16 | ], 17 | files: ['**/*.{ts,vue}'], 18 | languageOptions: { 19 | ecmaVersion: 'latest', 20 | sourceType: 'module', 21 | globals: globals.browser, 22 | parserOptions: { 23 | parser: typescriptEslint.parser, 24 | }, 25 | }, 26 | rules: { 27 | 'no-undef': 'off', 28 | 'no-unused-vars': 'off', 29 | '@typescript-eslint/no-unused-vars': [ 30 | 'warn', 31 | { 32 | args: 'all', 33 | argsIgnorePattern: '^_', 34 | caughtErrors: 'all', 35 | caughtErrorsIgnorePattern: '^_', 36 | destructuredArrayIgnorePattern: '^_', 37 | varsIgnorePattern: '^_', 38 | ignoreRestSiblings: true, 39 | }, 40 | ], 41 | }, 42 | }, 43 | eslintConfigPrettier, 44 | ); 45 | -------------------------------------------------------------------------------- /src/assets/sass/_floating.scss: -------------------------------------------------------------------------------- 1 | .floating { 2 | visibility: hidden; // will be overridden 3 | background: var(--lg-panel); 4 | border: 1px solid var(--lg-accent); 5 | border-radius: 2px; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | 10 | .floating-content { 11 | display: flex; 12 | align-items: center; 13 | gap: 1rem; 14 | 15 | label { 16 | font-size: 0.875rem; 17 | font-weight: 300; 18 | } 19 | input { 20 | flex-grow: 1; 21 | } 22 | } 23 | 24 | .floating-actions { 25 | display: flex; 26 | gap: 0.5rem; 27 | width: 100%; 28 | 29 | & > button { 30 | flex: 1 1 auto; 31 | text-align: center; 32 | } 33 | } 34 | 35 | button { 36 | border: none; 37 | padding: 0 0.5rem; 38 | } 39 | button.dark { 40 | min-width: 2.5rem; 41 | min-height: 2.5rem; 42 | border: none; 43 | border-radius: 0; 44 | padding: 0 0.5rem; 45 | justify-content: flex-start; 46 | } 47 | } 48 | 49 | @media screen and (max-width: 1023px) { 50 | .floating { 51 | .floating-content { 52 | input { 53 | height: 2rem; 54 | font-size: 1rem; 55 | } 56 | } 57 | 58 | button { 59 | height: 2.5rem; 60 | } 61 | button.dark { 62 | font-size: 1rem; 63 | height: 2.5rem; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/core/models/change-tracker.model.ts: -------------------------------------------------------------------------------- 1 | export abstract class ChangeTracker { 2 | protected _changedProps: ChangedProp[] = []; 3 | protected _changePrefix: string = ''; 4 | 5 | public get changedProps() { 6 | return this._changedProps; 7 | } 8 | public markForChange(prop: string, source?: ChangeSource, action: ChangeAction = ChangeAction.EDIT) { 9 | this._changedProps.push({ prop, source, action }); 10 | } 11 | public clearChangedProps() { 12 | this._changedProps.splice(0); 13 | } 14 | 15 | /** 16 | * Marks all properties of this object for change. Should be overriden in subclasses when necessary 17 | */ 18 | public markAllForChange(): void { 19 | this.changedProps.push(...Object.keys(this).map((k) => ({ prop: k, action: ChangeAction.EDIT }))); 20 | } 21 | 22 | constructor(changedPropsRef?: ChangedProp[], changePrefix?: string) { 23 | if (changedPropsRef && changePrefix) { 24 | this._changedProps = changedPropsRef; 25 | this._changePrefix = changePrefix; 26 | } 27 | } 28 | } 29 | 30 | export type ChangeSource = { arrayIndex?: number; data?: unknown }; 31 | export enum ChangeAction { 32 | ADD = 'ADD', 33 | EDIT = 'EDIT', 34 | DELETE = 'DELETE', 35 | SORT_UP = 'SORT_UP', 36 | SORT_DOWN = 'SORT_DOWN', 37 | } 38 | export type ChangedProp = { 39 | prop: string; 40 | source?: ChangeSource; 41 | action?: ChangeAction; 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/global/decoration/CornerDeco.vue: -------------------------------------------------------------------------------- 1 | 4 | 52 | -------------------------------------------------------------------------------- /src/components/codex/elements/NewCardElement.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | 17 | 55 | -------------------------------------------------------------------------------- /src/_lib/components/LgvNotification.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | 20 | 48 | -------------------------------------------------------------------------------- /src/core/helpers/export.helper.ts: -------------------------------------------------------------------------------- 1 | import saveAs from 'file-saver' 2 | import { type Mesh } from 'three' 3 | import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js' 4 | import { setObjectValue } from '../utils/utils' 5 | 6 | const GLTF_EXPORTER = new GLTFExporter() 7 | 8 | /** 9 | * Exports a list of meshes to a glTF file, which is then automatically downloaded to the user's device 10 | * @param meshes meshes to export, usually the full planet w/o atmosphere 11 | * @param filename name of the glTF file (without .gltf extension) 12 | */ 13 | export function exportMeshesToGLTF(meshes: Mesh[], filename: string): void { 14 | GLTF_EXPORTER.parse( 15 | meshes, 16 | (data) => { 17 | console.info(' Exported meshes to GLTF successfully!') 18 | const gltfString = JSON.stringify(patchGLTFProperties(data as Record)) 19 | saveAs(new Blob([gltfString]), `${filename}.gltf`) 20 | }, 21 | (err) => console.error(err), 22 | ) 23 | } 24 | 25 | /** 26 | * Patches certain values in the glTF file to work around three.js limitations 27 | * @param gltf the gltf object to patch 28 | * @returns the patched object 29 | */ 30 | function patchGLTFProperties(gltf: Record): Record { 31 | setObjectValue(gltf, 'materials[0].emissiveFactor', [1,1,1]) // emissiveFactor; isn't set when emissiveMap and/or emissiveNode are already set 32 | return gltf 33 | } -------------------------------------------------------------------------------- /src/core/models/converters/atmosphere-data.converter.ts: -------------------------------------------------------------------------------- 1 | import type { Vector3 } from 'three'; 2 | import type PlanetData from '../planet-data.model'; 3 | import { ModelConverter } from './model-converter'; 4 | import type { AtmosphereUniformsData } from '@/core/tsl/materials/atmosphere.tslmat'; 5 | 6 | export class AtmosphereDataConverter extends ModelConverter { 7 | private _sunPosition: Vector3; 8 | 9 | constructor(data: PlanetData, sunPosition: Vector3) { 10 | super(data); 11 | this._sunPosition = sunPosition; 12 | } 13 | 14 | public convert(): AtmosphereUniformsData { 15 | return { 16 | sunlight: { 17 | position: this._sunPosition, 18 | intensity: this._data.sunLightIntensity, 19 | }, 20 | transform: { 21 | radius: this._data.planetRadius + this._data.atmosphereHeight, 22 | surfaceRadius: this._data.planetRadius, 23 | }, 24 | render: { 25 | density: this._data.atmosphereDensityScale, 26 | intensity: this._data.atmosphereIntensity, 27 | colorMode: this._data.atmosphereColorMode, 28 | hue: this._data.atmosphereHue, 29 | tint: this._data.atmosphereTint, 30 | advanced: { 31 | mieScatteringConstant: this._data.atmosphereMieScatteringConstant, 32 | rayleighDensityRatio: this._data.atmosphereRayleighDensityRatio, 33 | mieDensityRatio: this._data.atmosphereMieDensityRatio, 34 | opticalDensityRatio: this._data.atmosphereOpticalDensityRatio, 35 | }, 36 | }, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/core/tsl/noise/fbm1.ts: -------------------------------------------------------------------------------- 1 | import { float, sin, fract, Fn, floor, mix, int, Loop } from 'three/tsl'; 2 | import type { UniformNumberNode, UniformVector3Node, UniformVector4Node } from '../tsl-types'; 3 | import type { VaryingNode } from 'three/webgpu'; 4 | 5 | export const rand = /*@__PURE__*/ Fn(([i_n]: [UniformNumberNode]) => { 6 | return fract(sin(i_n).mul(43758.5453123)); 7 | }).setLayout({ 8 | name: 'LG_NOISE_rand', 9 | type: 'float', 10 | inputs: [{ name: 'n', type: 'float' }], 11 | }); 12 | 13 | export const noise1 = /*@__PURE__*/ Fn(([i_p]: [UniformVector3Node]) => { 14 | const fl = float(floor(i_p)).toVar(); 15 | const fc = float(fract(i_p)).toVar(); 16 | return mix(rand(fl), rand(fl.add(1.0)), fc); 17 | }).setLayout({ 18 | name: 'LG_NOISE_noise1', 19 | type: 'float', 20 | inputs: [{ name: 'p', type: 'float' }], 21 | }); 22 | 23 | export const fbm1 = /*@__PURE__*/ Fn(([i_x, i_params]: [VaryingNode, UniformVector4Node]) => { 24 | const freq = float(i_params.x).toVar(); 25 | const amp = float(i_params.y).toVar(); 26 | const lac = float(i_params.z).toVar(); 27 | const octaves = int(i_params.w).toVar(); 28 | const x = float(i_x).toVar(); 29 | const val = float(0.0).toVar(); 30 | 31 | Loop({ start: int(0), end: octaves, condition: '<' }, () => { 32 | val.addAssign(amp.mul(noise1(x.mul(freq)))); 33 | freq.mulAssign(lac); 34 | amp.mulAssign(0.5); 35 | }); 36 | return val; 37 | }).setLayout({ 38 | name: 'LG_NOISE_fbm1', 39 | type: 'float', 40 | inputs: [ 41 | { name: 'i_x', type: 'float' }, 42 | { name: 'i_noise', type: 'vec4' }, 43 | ], 44 | }); 45 | -------------------------------------------------------------------------------- /src/core/globals.ts: -------------------------------------------------------------------------------- 1 | import { Vector3 } from 'three'; 2 | 3 | // Responsiveness width thresholds 4 | export const XS_WIDTH_THRESHOLD = 568; 5 | export const SM_WIDTH_THRESHOLD = 768; 6 | export const MD_WIDTH_THRESHOLD = 1200; 7 | 8 | // Internationalization 9 | export const LOCALE_MAP: { [k: string]: string } = { 10 | en: 'en-US', 11 | fr: 'fr-FR', 12 | de: 'de-DE', 13 | }; 14 | 15 | // Scene object names 16 | export const LG_MESH_NAME_PLANET = 'Planet'; 17 | export const LG_MESH_NAME_CLOUDS = 'Clouds'; 18 | export const LG_MESH_NAME_ATMOSPHERE = 'Atmosphere'; 19 | export const LG_MESH_NAME_RING_ANCHOR = 'RingSystem'; 20 | export const LG_MESH_NAME_SUN = 'Sun'; 21 | export const LG_MESH_NAME_SUNLIGHT = 'SunLight'; 22 | export const LG_MESH_NAME_AMBLIGHT = 'AmbientLight'; 23 | // Baking scene object names 24 | export const LG_MESH_NAME_METALLICROUGHNESSMAP = '_MetallicRoughnessMap'; 25 | export const LG_MESH_NAME_EMISSIVITYMAP = '_EmissivityMap'; 26 | export const LG_MESH_NAME_HEIGHTMAP = '_HeightMap'; 27 | export const LG_MESH_NAME_NORMALMAP = '_NormalMap'; 28 | 29 | // Global threejs axes 30 | export const AXIS_X = new Vector3(1, 0, 0); 31 | export const AXIS_Y = new Vector3(0, 1, 0); 32 | export const AXIS_Z = new Vector3(0, 0, 1); 33 | export const AXIS_NX = new Vector3(-1, 0, 0); 34 | export const AXIS_NY = new Vector3(0, -1, 0); 35 | export const AXIS_NZ = new Vector3(0, 0, -1); 36 | 37 | // Textures 38 | export const TEXTURE_SIZES = { 39 | SURFACE: 512, 40 | CLOUDS: 256, 41 | BIOME: 256, 42 | RING: 256, 43 | }; 44 | 45 | // Miscellaneous 46 | export const SUN_INIT_POS = new Vector3(0, 0, 4e3); 47 | -------------------------------------------------------------------------------- /src/core/models/converters/clouds-data.converter.ts: -------------------------------------------------------------------------------- 1 | import type { Texture } from 'three'; 2 | import type PlanetData from '../planet-data.model'; 3 | import type { CloudsUniformData } from '@core/tsl/materials/clouds.tslmat'; 4 | import { ModelConverter } from './model-converter'; 5 | 6 | export class CloudsDataConverter extends ModelConverter { 7 | private _opacityTexture: Texture; 8 | 9 | constructor(data: PlanetData, tex: Texture) { 10 | super(data); 11 | this._opacityTexture = tex; 12 | } 13 | 14 | public convert(): CloudsUniformData { 15 | return { 16 | flags: { 17 | showWarping: this._data.cloudsShowWarping, 18 | showDisplacement: this._data.cloudsShowDisplacement, 19 | }, 20 | color: this._data.cloudsColor, 21 | noise: this._data.cloudsNoise, 22 | warping: { 23 | layers: this._data.cloudsNoise.layers, 24 | warpFactor: this._data.cloudsNoise.warpFactor, 25 | }, 26 | displacement: { 27 | params: { 28 | factor: this._data.cloudsDisplacement.factor, 29 | epsilon: this._data.cloudsDisplacement.epsilon, 30 | multiplier: this._data.cloudsDisplacement.multiplier, 31 | }, 32 | noise: { 33 | frequency: this._data.cloudsDisplacement.frequency, 34 | amplitude: this._data.cloudsDisplacement.amplitude, 35 | lacunarity: this._data.cloudsDisplacement.lacunarity, 36 | octaves: this._data.cloudsDisplacement.octaves, 37 | }, 38 | }, 39 | texture: this._opacityTexture, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterRadioOption.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 47 | 48 | 69 | -------------------------------------------------------------------------------- /src/core/tsl/utils/sobel-utils.ts: -------------------------------------------------------------------------------- 1 | import { Fn, float, mat3, mul, normalize, vec3 } from 'three/tsl'; 2 | import type { UniformMatrix3Node, UniformNumberNode } from '../tsl-types'; 3 | import { getMatrixElement } from './math-utils'; 4 | 5 | export const sobel = Fn(([i_heights, i_strength]: [UniformMatrix3Node, UniformNumberNode]) => { 6 | const scale = float(i_strength).toVar('scale'); 7 | const heights = mat3(i_heights).toVar('heights'); 8 | const sobelX = float( 9 | scale.mul( 10 | float(1.0).mul( 11 | getMatrixElement(heights, 0, 0) 12 | .sub(getMatrixElement(heights, 0, 2)) 13 | .add(mul(2.0, getMatrixElement(heights, 1, 0))) 14 | .sub(mul(2.0, getMatrixElement(heights, 1, 2))) 15 | .add(getMatrixElement(heights, 2, 0)) 16 | .sub(getMatrixElement(heights, 2, 2)), 17 | ), 18 | ), 19 | ).toVar('sobelX'); 20 | const sobelY = float( 21 | scale.mul( 22 | float(-1.0).mul( 23 | getMatrixElement(heights, 0, 0) 24 | .add(mul(2.0, getMatrixElement(heights, 0, 1))) 25 | .add(getMatrixElement(heights, 0, 2)) 26 | .sub(getMatrixElement(heights, 2, 0)) 27 | .sub(mul(2.0, getMatrixElement(heights, 2, 1))) 28 | .sub(getMatrixElement(heights, 2, 2)), 29 | ), 30 | ), 31 | ).toVar('sobelY'); 32 | return vec3( 33 | normalize(vec3(sobelX, sobelY, 1.0)) 34 | .mul(0.5) 35 | .add(0.5), 36 | ); 37 | }).setLayout({ 38 | name: 'LG_SOBEL_sobel', 39 | type: 'vec3', 40 | inputs: [ 41 | { name: 'pHeights', type: 'mat3' }, 42 | { name: 'pStrength', type: 'float' }, 43 | ], 44 | }); 45 | -------------------------------------------------------------------------------- /src/dexie.config.ts: -------------------------------------------------------------------------------- 1 | // db.ts 2 | import Dexie, { type EntityTable } from 'dexie'; 3 | import type PlanetData from './core/models/planet-data.model'; 4 | 5 | enum KeyBindingAction { 6 | ToggleLensFlare = 'toggle-lens-flare', 7 | ToggleClouds = 'toggle-clouds', 8 | ToggleAtmosphere = 'toggle-atmosphere', 9 | ToggleBiomes = 'toggle-biomes', 10 | TakeScreenshot = 'take-screenshot', 11 | } 12 | 13 | interface IDBKeyBinding { 14 | id: number; 15 | action: KeyBindingAction; 16 | key: string; 17 | } 18 | 19 | interface IDBSettings { 20 | id: number; 21 | locale: string; 22 | theme: string; 23 | font: string; 24 | showInitDialog?: boolean; 25 | renderingBackend: 'webgl' | 'webgpu'; 26 | bakingResolution?: number; 27 | bakingPixelize?: boolean; 28 | enableEffects?: boolean; 29 | enableAnimations?: boolean; 30 | extrasCRTEffect?: boolean; 31 | extrasHologramEffect?: boolean; 32 | extrasMetalSlugMode?: boolean; 33 | extrasShowSpecialDays?: boolean; 34 | } 35 | 36 | interface IDBPlanet { 37 | id: string; 38 | timestamp?: number; 39 | version?: string; 40 | preview?: string; 41 | data: PlanetData; 42 | } 43 | 44 | const idb = new Dexie('LagrangeIDB', { autoOpen: true }) as Dexie & { 45 | keyBindings: EntityTable; 46 | settings: EntityTable; 47 | planets: EntityTable; 48 | }; 49 | 50 | // Schema declaration: 51 | idb.version(1).stores({ 52 | keyBindings: '++id, action', 53 | settings: '++id', 54 | planets: '++id, data._planetName', 55 | }); 56 | 57 | export type { IDBKeyBinding, IDBSettings, IDBPlanet }; 58 | export { idb, KeyBindingAction }; 59 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import vue, { Options } from '@vitejs/plugin-vue'; 5 | import { visualizer } from 'rollup-plugin-visualizer'; 6 | 7 | const vuePluginConfig: Options = { 8 | template: { 9 | compilerOptions: { 10 | isCustomElement: (tag) => tag.startsWith('iconify-'), 11 | }, 12 | }, 13 | }; 14 | 15 | // https://vitejs.dev/config/ 16 | export default defineConfig({ 17 | assetsInclude: ['**/*.glsl', '**/*.ico', '**/*.ttf'], 18 | plugins: [vue(vuePluginConfig), visualizer()], 19 | define: { 20 | 'import.meta.env.APP_VERSION': JSON.stringify(process.env.npm_package_version), 21 | }, 22 | resolve: { 23 | alias: { 24 | '@': fileURLToPath(new URL('./src', import.meta.url)), 25 | '@core': fileURLToPath(new URL('./src/core', import.meta.url)), 26 | '@assets': fileURLToPath(new URL('./src/assets', import.meta.url)), 27 | '@components': fileURLToPath(new URL('./src/components', import.meta.url)), 28 | }, 29 | }, 30 | optimizeDeps: { 31 | esbuildOptions: { 32 | target: 'es2022', 33 | }, 34 | }, 35 | build: { 36 | target: 'es2022', 37 | sourcemap: true, 38 | chunkSizeWarningLimit: 600, 39 | rollupOptions: { 40 | output: { 41 | manualChunks: { 42 | vue: ['vue'], 43 | vueplugins: ['vue-router', 'vue-accessible-color-picker', 'vue-i18n', '@unhead/vue'], 44 | three: ['three'], 45 | export: ['pako', 'jszip', 'file-saver'], 46 | }, 47 | }, 48 | }, 49 | }, 50 | esbuild: { 51 | supported: { 52 | 'top-level-await': true, 53 | }, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Lagrange - Procedural Planet Builder 30 | 40 | 41 | 42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/assets/sass/_effects.scss: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------------------------ 2 | // Effect: CRT (greatly simplified) 3 | 4 | .effect-crt { 5 | position: absolute; 6 | width: 100%; 7 | height: 100%; 8 | background: repeating-linear-gradient( 9 | 0deg, 10 | var(--lg-primary-static), 11 | var(--lg-primary-static) 2px, 12 | transparent 4px, 13 | transparent 8px 14 | ); 15 | background-size: 100%; 16 | opacity: 0.125; 17 | 18 | --duration: 30s; 19 | animation: crtscroll var(--duration) infinite linear forwards; 20 | -webkit-animation: crtscroll var(--duration) infinite linear forwards; 21 | -moz-animation: crtscroll var(--duration) infinite linear forwards; 22 | } 23 | .effect-hologram { 24 | img { 25 | filter: sepia(100%) saturate(250%) hue-rotate(180deg); 26 | } 27 | } 28 | 29 | // keyframe animations 30 | 31 | @-webkit-keyframes crtscroll { 32 | 0% { 33 | background-position: 0 0; 34 | } 35 | 100% { 36 | background-position: 0 256px; 37 | } 38 | } 39 | @-moz-keyframes crtscroll { 40 | 0% { 41 | background-position: 0% 0; 42 | } 43 | 100% { 44 | background-position: 0 256px; 45 | } 46 | } 47 | @keyframes crtscroll { 48 | 0% { 49 | background-position: 0% 0; 50 | } 51 | 100% { 52 | background-position: 0 256px; 53 | } 54 | } 55 | 56 | // ------------------------------------------------------------------------------------------------ 57 | // Accessibility changes (efefcts, animations, prefers-reduced-motion) 58 | 59 | :root[data-effects='off'] .effect-crt { 60 | display: none; 61 | } 62 | :root[data-animations='off'] .effect-crt { 63 | animation: none; 64 | } 65 | 66 | @media screen and (prefers-reduced-motion) { 67 | .effect-crt.animated { 68 | animation: none; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/utils/i18n-utils.ts: -------------------------------------------------------------------------------- 1 | import { PlanetType, PlanetClass } from '../types'; 2 | 3 | export function getI18nPlanetType(planetType?: PlanetType): string { 4 | switch (planetType) { 5 | case PlanetType.PLANET: 6 | return 'dialog.planetinfo.basic.type_planet'; 7 | case PlanetType.MOON: 8 | return 'dialog.planetinfo.basic.type_moon'; 9 | case PlanetType.GASGIANT: 10 | return 'dialog.planetinfo.basic.type_gasgiant'; 11 | default: 12 | return 'main.unknown_value'; 13 | } 14 | } 15 | export function getI18nPlanetClass(planetClass?: PlanetClass): string { 16 | switch (planetClass) { 17 | case PlanetClass.PLANET_TELLURIC: 18 | return 'main.planet_data.class_planet_telluric'; 19 | case PlanetClass.PLANET_ICE: 20 | return 'main.planet_data.class_planet_ice'; 21 | case PlanetClass.PLANET_OCEAN: 22 | return 'main.planet_data.class_planet_ocean'; 23 | case PlanetClass.PLANET_TROPICAL: 24 | return 'main.planet_data.class_planet_tropical'; 25 | case PlanetClass.PLANET_ARID: 26 | return 'main.planet_data.class_planet_arid'; 27 | case PlanetClass.PLANET_CHTHONIAN: 28 | return 'main.planet_data.class_planet_chthonian'; 29 | case PlanetClass.PLANET_MAGMATIC: 30 | return 'main.planet_data.class_planet_magmatic'; 31 | case PlanetClass.MOON_ROCKY: 32 | return 'main.planet_data.class_moon_rocky'; 33 | case PlanetClass.MOON_ICE: 34 | return 'main.planet_data.class_moon_ice'; 35 | case PlanetClass.MOON_CHTHONIAN: 36 | return 'main.planet_data.class_moon_chthonian'; 37 | case PlanetClass.GASGIANT_COLD: 38 | return 'main.planet_data.class_gasgiant_cold'; 39 | case PlanetClass.GASGIANT_HOT: 40 | return 'main.planet_data.class_gasgiant_hot'; 41 | default: 42 | return 'main.planet_data.class_indeterminate'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/global/InlineFooter.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 40 | 41 | 66 | -------------------------------------------------------------------------------- /src/assets/sass/_main.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | margin: 0; 6 | scrollbar-color: var(--lg-logo) var(--lg-input); 7 | } 8 | 9 | html { 10 | background: transparent; 11 | scroll-behavior: smooth; 12 | 13 | display: flex; 14 | flex-direction: column; 15 | min-height: 100%; 16 | } 17 | 18 | body { 19 | flex: 1; 20 | min-height: 100%; 21 | 22 | border-radius: 2px; 23 | background: black; 24 | color: var(--lg-text); 25 | 26 | font-family: 27 | Poppins, 28 | Inter, 29 | -apple-system, 30 | BlinkMacSystemFont, 31 | 'Segoe UI', 32 | Roboto, 33 | Oxygen, 34 | Ubuntu, 35 | Cantarell, 36 | 'Fira Sans', 37 | 'Droid Sans', 38 | 'Helvetica Neue', 39 | sans-serif; 40 | line-height: 1.5; 41 | 42 | text-rendering: optimizeLegibility; 43 | -webkit-font-smoothing: antialiased; 44 | -moz-osx-font-smoothing: grayscale; 45 | 46 | display: flex; 47 | flex-direction: column; 48 | 49 | #app { 50 | flex: 1; 51 | display: flex; 52 | flex-direction: column; 53 | } 54 | } 55 | body:has(dialog[open]) { 56 | touch-action: none; 57 | overflow: hidden; 58 | overscroll-behavior: none; 59 | -webkit-overflow-scrolling: none; 60 | } 61 | 62 | hr { 63 | border: 1px solid var(--lg-accent); 64 | } 65 | 66 | .code-block { 67 | padding: 0.75rem; 68 | background: var(--code-background); 69 | border-radius: 2px; 70 | font-family: monospace; 71 | overflow-x: auto; 72 | 73 | & > * { 74 | width: max-content; 75 | margin-bottom: 4px; 76 | } 77 | 78 | & > *:last-child { 79 | margin-bottom: 0; 80 | } 81 | } 82 | 83 | .a11y--visually-hidden { 84 | position: absolute; 85 | overflow: hidden; 86 | clip: rect(0 0 0 0); 87 | width: 1px; 88 | height: 1px; 89 | padding: 0; 90 | margin: -1px; 91 | border: 0; 92 | white-space: nowrap; 93 | } 94 | -------------------------------------------------------------------------------- /src/components/editor/dialogs/ResetConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | 32 | 46 | 47 | 62 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/sass/index.scss'; 2 | import 'iconify-icon'; 3 | 4 | import { createApp } from 'vue'; 5 | import App from './App.vue'; 6 | import router from './router.config'; 7 | import OverlaySpinner from '@components/global/elements/OverlaySpinner.vue'; 8 | import ParameterGrid from '@components/global/parameters/ParameterGrid.vue'; 9 | import ParameterGroup from '@components/global/parameters/ParameterGroup.vue'; 10 | import ParameterCategory from '@components/global/parameters/ParameterCategory.vue'; 11 | import ParameterSlider from '@components/global/parameters/ParameterSlider.vue'; 12 | import ParameterRadio from '@components/global/parameters/ParameterRadio.vue'; 13 | import ParameterRadioOption from '@components/global/parameters/ParameterRadioOption.vue'; 14 | import ParameterColorRamp from '@components/global/parameters/ParameterColorRamp.vue'; 15 | import ParameterColor from '@components/global/parameters/ParameterColor.vue'; 16 | import ParameterCheckbox from '@components/global/parameters/ParameterCheckbox.vue'; 17 | import ParameterDivider from '@components/global/parameters/ParameterDivider.vue'; 18 | import * as i18nConfig from './i18n.config'; 19 | import { createHead } from '@unhead/vue/client'; 20 | 21 | createApp(App) 22 | .use(router) 23 | .use(i18nConfig.i18n) 24 | .use(createHead()) 25 | .component('ParameterSlider', ParameterSlider) 26 | .component('ParameterRadio', ParameterRadio) 27 | .component('ParameterRadioOption', ParameterRadioOption) 28 | .component('ParameterColor', ParameterColor) 29 | .component('ParameterColorRamp', ParameterColorRamp) 30 | .component('ParameterDivider', ParameterDivider) 31 | .component('ParameterCategory', ParameterCategory) 32 | .component('ParameterCheckbox', ParameterCheckbox) 33 | .component('ParameterGroup', ParameterGroup) 34 | .component('ParameterGrid', ParameterGrid) 35 | .component('OverlaySpinner', OverlaySpinner) 36 | .mount('#app'); 37 | -------------------------------------------------------------------------------- /src/components/editor/controls/ControlsRings.vue: -------------------------------------------------------------------------------- 1 | 27 | 42 | 47 | -------------------------------------------------------------------------------- /src/components/global/extras/ExtraSpecialDayElement.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | 20 | 65 | -------------------------------------------------------------------------------- /src/components/global/AppToastBar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 44 | 45 | 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug/issue 3 | title: "🔴 [] " 4 | labels: ["bug", "triage"] 5 | assignees: ["EepyBerry"] 6 | body: 7 | - type: checkboxes 8 | id: checks 9 | attributes: 10 | label: Pre-submit checks 11 | description: Please check the points below before submitting the issue. 12 | options: 13 | - label: I have searched the existing issues to avoid duplicates 14 | required: true 15 | - label: This problem affects the latest version 16 | required: false 17 | - type: textarea 18 | id: desc 19 | attributes: 20 | label: Description 21 | description: | 22 | Put information about your problem/bug here. Please be as detailed as possible in your description to facilitate debugging. 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: repro 27 | attributes: 28 | label: Steps to reproduce 29 | description: | 30 | Add reproduction steps here: relevant planet data, editor settings, etc. You can attach a planet file if available! 31 | value: | 32 | 1. 33 | 2. 34 | 3. 35 | validations: 36 | required: true 37 | - type: dropdown 38 | id: device 39 | attributes: 40 | label: Device 41 | multiple: true 42 | options: 43 | - Desktop 44 | - Mobile 45 | - Headset 46 | - type: input 47 | id: os 48 | attributes: 49 | label: Operating system 50 | placeholder: ex. Windows 10 51 | validations: 52 | required: true 53 | - type: input 54 | id: platform 55 | attributes: 56 | label: Browser 57 | placeholder: ex. Firefox 58 | validations: 59 | required: true 60 | - type: textarea 61 | attributes: 62 | label: Additional information 63 | description: | 64 | If you'd like to add more context, feel free to do so here. Any kind of supporting content is welcome: screenshots, drawings, anything that helps! 65 | validations: 66 | required: false 67 | -------------------------------------------------------------------------------- /src/components/editor/controls/ControlsBasicData.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <ParameterGrid> 3 | <ParameterGroup toggleable> 4 | <template #title>{{ $t('editor.controls.basic_data.classification') }}</template> 5 | <template #content> 6 | <ParameterSelect id="b-type" v-model="LG_PLANET_DATA.planetType" @change="resetPlanetClass"> 7 | {{ $t('editor.controls.basic_data.classification_type') }}: 8 | <template #options> 9 | <option v-for="opt in listPlanetTypeValues()" :key="opt" :value="opt"> 10 | {{ $t(getI18nPlanetType(opt)) }} 11 | </option> 12 | </template> 13 | </ParameterSelect> 14 | <ParameterSelect id="b-type" v-model="LG_PLANET_DATA.planetClass"> 15 | {{ $t('editor.controls.basic_data.classification_class') }}: 16 | <template #options> 17 | <option v-for="opt in listPlanetClassValues()" :key="opt" :value="opt"> 18 | {{ $t(getI18nPlanetClass(opt)) }} 19 | </option> 20 | </template> 21 | </ParameterSelect> 22 | </template> 23 | </ParameterGroup> 24 | </ParameterGrid> 25 | </template> 26 | <script setup lang="ts"> 27 | import ParameterSelect from '@/components/global/parameters/ParameterSelect.vue'; 28 | import { PlanetClass, PlanetType } from '@/core/types'; 29 | import { getI18nPlanetClass, getI18nPlanetType } from '@/core/utils/i18n-utils'; 30 | import { LG_PLANET_DATA } from '@/core/services/editor.service'; 31 | 32 | function listPlanetTypeValues(): PlanetType[] { 33 | return Object.entries(PlanetType) 34 | .filter(([_, elem]) => Number.isInteger(elem)) 35 | .map(([_, v]) => v) as PlanetType[]; 36 | } 37 | function listPlanetClassValues(): PlanetClass[] { 38 | return LG_PLANET_DATA.value.getPlanetClassesFromType(LG_PLANET_DATA.value.planetType); 39 | } 40 | 41 | function resetPlanetClass() { 42 | LG_PLANET_DATA.value.planetClass = PlanetClass.INDETERMINATE; 43 | } 44 | </script> 45 | -------------------------------------------------------------------------------- /src/core/helpers/import.helper.ts: -------------------------------------------------------------------------------- 1 | import type { IDBKeyBinding, IDBPlanet, IDBSettings } from '@/dexie.config' 2 | import PlanetData from '../models/planet-data.model' 3 | import pako from 'pako' 4 | import { nanoid } from 'nanoid' 5 | 6 | export async function readFileSettings(json: File): Promise<{ settings: IDBSettings; keyBindings: IDBKeyBinding[] }> { 7 | return new Promise<{ settings: IDBSettings; keyBindings: IDBKeyBinding[] }>((resolve, reject) => { 8 | const reader = new FileReader() 9 | reader.onload = (e) => { 10 | if (!e.target || !e.target?.result) { 11 | reject() 12 | } 13 | 14 | const jsonData = JSON.parse(e.target!.result as string) 15 | const settings = jsonData.settings as IDBSettings 16 | const keyBindings = jsonData.keyBindings as IDBKeyBinding[] 17 | resolve({ settings, keyBindings }) 18 | } 19 | reader.readAsText(json) 20 | }) 21 | } 22 | 23 | export function readFileData(buf: ArrayBuffer): IDBPlanet | undefined { 24 | const rawData = JSON.parse(pako.inflate(buf, { to: 'string' })) 25 | if (rawData.version || rawData.data) { 26 | return readFileV2(rawData) 27 | } else { 28 | return readFileV1(rawData) // Only v1 files have no version attached 29 | } 30 | } 31 | 32 | function readFileV2(rawData: IDBPlanet): IDBPlanet | undefined { 33 | try { 34 | const newIdb: IDBPlanet = { 35 | id: rawData.id, 36 | data: PlanetData.createFrom(rawData.data), 37 | preview: rawData.preview, 38 | } 39 | console.debug('<Lagrange> Read file data as version 2') 40 | return newIdb 41 | } catch (err) { 42 | console.error(err) 43 | return undefined 44 | } 45 | } 46 | 47 | function readFileV1(rawData: PlanetData): IDBPlanet | undefined { 48 | try { 49 | const newIdb: IDBPlanet = { 50 | id: nanoid(), 51 | data: PlanetData.createFrom(rawData), 52 | } 53 | console.debug('<Lagrange> Read file data as version 1') 54 | return newIdb 55 | } catch (err) { 56 | console.error(err) 57 | return undefined 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/codex/dialogs/ClearDataConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <DialogElement 3 | id="dialog-clear-data-confirm" 4 | ref="dialogRef" 5 | :show-title="true" 6 | :show-actions="true" 7 | :closeable="true" 8 | :aria-label="$t('a11y.dialog_clear_data')" 9 | > 10 | <template #title> 11 | <iconify-icon icon="mingcute:warning-line" width="1.5rem" aria-hidden="true" /> 12 | {{ $t('dialog.clear_data.$title') }} 13 | </template> 14 | <template #content> 15 | <div class="clear-data-text"> 16 | <p>{{ $t('dialog.clear_data.message') }}</p> 17 | <p> 18 | <strong>{{ $t('dialog.clear_data.warning') }}</strong> 19 | </p> 20 | </div> 21 | </template> 22 | <template #actions> 23 | <LgvButton icon="mingcute:close-line" @click="cancelAndClose"> 24 | {{ $t('dialog.clear_data.$action_cancel') }} 25 | </LgvButton> 26 | <LgvButton class="warn" icon="mingcute:delete-2-line" @click="confirmAndClose"> 27 | {{ $t('dialog.clear_data.$action_confirm') }} 28 | </LgvButton> 29 | </template> 30 | </DialogElement> 31 | </template> 32 | <script setup lang="ts"> 33 | import LgvButton from '@/_lib/components/LgvButton.vue'; 34 | import DialogElement from '@components/global/elements/DialogElement.vue'; 35 | import { ref, type Ref } from 'vue'; 36 | 37 | const dialogRef: Ref<{ open: () => void; close: () => void } | null> = ref(null); 38 | 39 | const $emit = defineEmits(['confirm']); 40 | defineExpose({ 41 | open: () => { 42 | dialogRef.value?.open(); 43 | }, 44 | }); 45 | 46 | function cancelAndClose() { 47 | dialogRef.value?.close(); 48 | } 49 | 50 | function confirmAndClose() { 51 | $emit('confirm'); 52 | dialogRef.value?.close(); 53 | } 54 | </script> 55 | 56 | <style scoped lang="scss"> 57 | #dialog-clear-data-confirm { 58 | z-index: 20; 59 | min-width: 24rem; 60 | .clear-data-text { 61 | text-align: center; 62 | font-size: 1rem; 63 | } 64 | } 65 | @media screen and (max-width: 567px) { 66 | #dialog-clear-data-confirm { 67 | width: 100%; 68 | min-width: 0; 69 | } 70 | } 71 | </style> 72 | -------------------------------------------------------------------------------- /src/views/PageNotFoundView.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div id="pagenotfound-container"> 3 | <AppLogo /> 4 | <div class="text"> 5 | <h1 class="title" :class="{ ultra: msgVariant === 4 }">{{ loadMessage() }}</h1> 6 | <h2 class="subtitle">{{ $t('404.subtext') }}</h2> 7 | <LgvLink variant="button" icon="mingcute:book-2-line" href="/"> 8 | {{ $t('404.link') }} 9 | </LgvLink> 10 | </div> 11 | </div> 12 | </template> 13 | 14 | <script setup lang="ts"> 15 | import LgvLink from '@/_lib/components/LgvLink.vue'; 16 | import AppLogo from '@components/global/elements/AppLogo.vue'; 17 | import { useHead } from '@unhead/vue'; 18 | import { ref } from 'vue'; 19 | import { useI18n } from 'vue-i18n'; 20 | 21 | const i18n = useI18n(); 22 | useHead({ 23 | title: i18n.t('404.$title') + ' · ' + i18n.t('main.$title'), 24 | meta: [ 25 | { name: 'robots', content: 'noindex' }, 26 | { name: 'description', content: 'Page not found' }, 27 | ], 28 | }); 29 | 30 | const msgVariant = ref(0); 31 | function loadMessage(): string { 32 | msgVariant.value = Math.floor(Math.random() * 4) + 1; 33 | return i18n.t(`404.text_${msgVariant.value.toString().padStart(2, '0')}`); 34 | } 35 | </script> 36 | 37 | <style lang="scss"> 38 | #pagenotfound-container { 39 | flex: 1; 40 | background: var(--lg-panel); 41 | background-size: cover; 42 | 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | justify-content: center; 47 | gap: 2rem; 48 | padding: 1rem; 49 | 50 | #app-logo, 51 | #app-logo-uwu { 52 | max-width: unset; 53 | width: clamp(200px, 37.5vw, 300px); 54 | align-self: center; 55 | justify-self: center; 56 | } 57 | 58 | .text { 59 | display: flex; 60 | flex-direction: column; 61 | align-items: center; 62 | justify-content: center; 63 | gap: 1rem; 64 | 65 | text-align: center; 66 | padding-bottom: 2rem; 67 | } 68 | 69 | .title { 70 | font-size: clamp(2rem, 5vw, 2.5rem); 71 | } 72 | .subtitle { 73 | font-size: clamp(1rem, 2.5vw, 1.5rem); 74 | font-weight: 400; 75 | } 76 | .ultra { 77 | font-family: VCR OSD Mono; 78 | } 79 | } 80 | </style> 81 | -------------------------------------------------------------------------------- /src/components/editor/dialogs/WarnSaveDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <DialogElement 3 | id="dialog-warn-save" 4 | ref="dialogRef" 5 | :show-title="true" 6 | :show-actions="true" 7 | :closeable="true" 8 | :prevent-click-close="true" 9 | :aria-label="$t('a11y.dialog_warn_save')" 10 | > 11 | <template #title> 12 | <iconify-icon icon="mingcute:warning-line" width="1.5rem" aria-hidden="true" /> 13 | {{ $t('dialog.warnsave.$title') }} 14 | </template> 15 | <template #content> 16 | <div class="warn-text"> 17 | <p>{{ $t('dialog.warnsave.message') }}</p> 18 | <p> 19 | <b>{{ $t('dialog.warnsave.warning') }}</b> 20 | </p> 21 | </div> 22 | </template> 23 | <template #actions> 24 | <LgvButton icon="mingcute:close-line" @click="dialogRef?.close()"> 25 | {{ $t('dialog.warnsave.$action_cancel') }} 26 | </LgvButton> 27 | <LgvButton class="success" icon="mingcute:save-2-line" @click="saveConfirmClose"> 28 | {{ $t('dialog.warnsave.$action_saveconfirm') }} 29 | </LgvButton> 30 | <LgvButton class="warn" icon="mingcute:exit-line" @click="confirmAndClose"> 31 | {{ $t('dialog.warnsave.$action_confirm') }} 32 | </LgvButton> 33 | </template> 34 | </DialogElement> 35 | </template> 36 | <script setup lang="ts"> 37 | import LgvButton from '@/_lib/components/LgvButton.vue'; 38 | import DialogElement from '@components/global/elements/DialogElement.vue'; 39 | import { ref, type Ref } from 'vue'; 40 | 41 | const dialogRef: Ref<{ open: () => void; close: () => void } | null> = ref(null); 42 | const $emit = defineEmits(['save-confirm', 'confirm']); 43 | defineExpose({ open: () => dialogRef.value?.open() }); 44 | 45 | function saveConfirmClose() { 46 | $emit('save-confirm'); 47 | dialogRef.value?.close(); 48 | } 49 | 50 | function confirmAndClose() { 51 | $emit('confirm'); 52 | dialogRef.value?.close(); 53 | } 54 | </script> 55 | 56 | <style scoped lang="scss"> 57 | #dialog-warn-save { 58 | min-width: 24rem; 59 | .warn-text { 60 | text-align: center; 61 | font-size: 1rem; 62 | } 63 | } 64 | @media screen and (max-width: 567px) { 65 | #dialog-warn-save { 66 | width: 100%; 67 | min-width: 0; 68 | } 69 | } 70 | </style> 71 | -------------------------------------------------------------------------------- /src/components/editor/controls/EditorSidebarControls.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div id="controls" :class="{ compact: compactMode }"> 3 | <template v-if="compactMode"> 4 | <ControlsContainer :compact-mode="true" /> 5 | <InlineFooter /> 6 | </template> 7 | <aside v-else class="sidebar"> 8 | <ControlsContainer :compact-mode="false" /> 9 | </aside> 10 | </div> 11 | </template> 12 | 13 | <script setup lang="ts"> 14 | import InlineFooter from '@components/global/InlineFooter.vue'; 15 | import ControlsContainer from './ControlsContainer.vue'; 16 | defineProps<{ compactMode: boolean }>(); 17 | </script> 18 | 19 | <style scoped lang="scss"> 20 | #controls:not(.compact) { 21 | z-index: 10; 22 | position: absolute; 23 | inset: 0 auto 0; 24 | margin-top: 3.875rem; 25 | 26 | display: flex; 27 | flex-direction: column; 28 | justify-content: flex-start; 29 | overflow: hidden; 30 | 31 | .sidebar { 32 | width: 100%; 33 | padding: 0.5rem; 34 | display: flex; 35 | flex-direction: column; 36 | align-items: center; 37 | gap: 0.5rem; 38 | overflow-y: auto; 39 | 40 | user-select: none; 41 | direction: rtl; 42 | 43 | & > * { 44 | direction: ltr; 45 | align-self: flex-end; 46 | } 47 | } 48 | } 49 | #controls.compact { 50 | z-index: 5; 51 | position: absolute; 52 | inset: 50% 0 0; 53 | padding: 0.75rem 0.5rem 0.5rem; 54 | display: flex; 55 | flex-direction: column; 56 | justify-content: flex-start; 57 | gap: 0.5rem; 58 | overflow-y: auto; 59 | } 60 | 61 | @media screen and (max-width: 1199px) { 62 | #controls:not(.compact) { 63 | .sidebar { 64 | padding: 0 1.5rem 0 0.5rem; 65 | 66 | & > section { 67 | min-width: 0; 68 | } 69 | & > section.expanded { 70 | min-width: 26rem; 71 | } 72 | } 73 | } 74 | } 75 | @media screen and (max-width: 767px) { 76 | #controls:not(.compact) { 77 | min-width: 2rem; 78 | margin-bottom: 3.875rem; 79 | 80 | .sidebar { 81 | padding: 0.5rem; 82 | } 83 | } 84 | } 85 | @media screen and (max-width: 567px) { 86 | #controls:not(.compact) { 87 | .sidebar { 88 | padding: 0.5rem; 89 | min-width: 0; 90 | max-width: 100%; 91 | } 92 | } 93 | } 94 | </style> 95 | -------------------------------------------------------------------------------- /src/components/codex/dialogs/DeleteConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <DialogElement 3 | id="dialog-delete-confirm" 4 | ref="dialogRef" 5 | :show-title="true" 6 | :show-actions="true" 7 | :closeable="true" 8 | :aria-label="$t('a11y.dialog_delete')" 9 | > 10 | <template #title> 11 | <iconify-icon icon="mingcute:warning-line" width="1.5rem" aria-hidden="true" /> 12 | {{ $t('dialog.delete.$title', { planet: planet?.data.planetName ?? 'PLANET_NAME' }) }} 13 | </template> 14 | <template #content> 15 | <div class="delete-text"> 16 | <p>{{ $t('dialog.delete.message') }}</p> 17 | <p> 18 | <strong>{{ $t('dialog.delete.warning') }}</strong> 19 | </p> 20 | </div> 21 | </template> 22 | <template #actions> 23 | <LgvButton icon="mingcute:close-line" @click="cancelAndClose"> 24 | {{ $t('dialog.delete.$action_cancel') }} 25 | </LgvButton> 26 | <LgvButton class="warn" icon="mingcute:delete-2-line" @click="confirmAndClose"> 27 | {{ $t('dialog.delete.$action_confirm') }} 28 | </LgvButton> 29 | </template> 30 | </DialogElement> 31 | </template> 32 | <script setup lang="ts"> 33 | import LgvButton from '@/_lib/components/LgvButton.vue'; 34 | import type { IDBPlanet } from '@/dexie.config'; 35 | import DialogElement from '@components/global/elements/DialogElement.vue'; 36 | import { ref, type Ref } from 'vue'; 37 | 38 | const planet: Ref<IDBPlanet | null> = ref(null); 39 | const dialogRef: Ref<{ open: () => void; close: () => void } | null> = ref(null); 40 | 41 | const $emit = defineEmits(['confirm']); 42 | defineExpose({ 43 | open: (p: IDBPlanet) => { 44 | planet.value = p; 45 | dialogRef.value?.open(); 46 | }, 47 | }); 48 | 49 | function cancelAndClose() { 50 | planet.value = null; 51 | dialogRef.value?.close(); 52 | } 53 | 54 | function confirmAndClose() { 55 | $emit('confirm', planet.value!.id); 56 | planet.value = null; 57 | dialogRef.value?.close(); 58 | } 59 | </script> 60 | 61 | <style scoped lang="scss"> 62 | #dialog-delete-confirm { 63 | min-width: 24rem; 64 | .delete-text { 65 | text-align: center; 66 | font-size: 1rem; 67 | } 68 | } 69 | @media screen and (max-width: 567px) { 70 | #dialog-delete-confirm { 71 | width: 100%; 72 | min-width: 0; 73 | } 74 | } 75 | </style> 76 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterColor.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <p> 3 | <slot>ParameterName</slot> 4 | </p> 5 | <div class="color-wrapper"> 6 | <span class="current-color" :style="{ backgroundColor: `#${lgColor?.getHexString()}` }" @click="togglePanel"></span> 7 | <LgvButton 8 | class="sm" 9 | :class="{ success: pickerOpen }" 10 | :aria-label="$t('a11y.action_open_colorpanel')" 11 | :icon="pickerOpen ? 'mingcute:check-line' : 'mingcute:edit-2-line'" 12 | @click="togglePanel" 13 | /> 14 | </div> 15 | <ColorPicker 16 | v-show="pickerOpen" 17 | class="picker" 18 | alpha-channel="hide" 19 | default-format="hex" 20 | :visible-formats="['hex']" 21 | :color="pickerInitColor" 22 | @color-change="setColor($event.colors.hex)" 23 | > 24 | <template #hue-range-input-label> 25 | <span class="a11y--visually-hidden"></span> 26 | </template> 27 | <template #alpha-range-input-label> 28 | <span class="a11y--visually-hidden"></span> 29 | </template> 30 | </ColorPicker> 31 | </template> 32 | 33 | <script setup lang="ts"> 34 | import LgvButton from '@/_lib/components/LgvButton.vue'; 35 | import { Color } from 'three'; 36 | import { onMounted, ref } from 'vue'; 37 | import { ColorPicker } from 'vue-accessible-color-picker'; 38 | 39 | const lgColor = defineModel<Color>(); 40 | const pickerInitColor = ref(''); 41 | const pickerOpen = ref(false); 42 | 43 | onMounted(initPickerColor); 44 | 45 | function initPickerColor() { 46 | pickerInitColor.value = '#' + lgColor.value?.getHexString(); 47 | } 48 | 49 | function setColor(hex: string): void { 50 | lgColor.value = new Color(hex.substring(0, 7)); // Strip alpha 51 | pickerInitColor.value = '#' + lgColor.value?.getHexString(); 52 | } 53 | 54 | function togglePanel(): void { 55 | pickerOpen.value = !pickerOpen.value; 56 | } 57 | </script> 58 | 59 | <style scoped lang="scss"> 60 | .picker { 61 | grid-column: span 2; 62 | } 63 | .color-wrapper { 64 | display: flex; 65 | align-items: center; 66 | justify-content: flex-end; 67 | gap: 8px; 68 | height: 100%; 69 | } 70 | .current-color { 71 | display: inline-flex; 72 | align-self: center; 73 | width: 3rem; 74 | height: 2rem; 75 | border-radius: 2px; 76 | border: 1px solid var(--lg-accent); 77 | cursor: pointer; 78 | } 79 | .panel { 80 | display: none; 81 | } 82 | </style> 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lagrange", 3 | "version": "0.5.1", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "dev-host": "vite --host", 9 | "build": "run-p type-check prettify \"build-only {@}\" --", 10 | "preview": "vite preview", 11 | "preview-host": "vite preview --host", 12 | "build-only": "vite build", 13 | "type-check": "vue-tsc --build --force", 14 | "prettify": "prettier . --write", 15 | "lint": "eslint . --fix" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/EepyBerry/lagrange.git" 20 | }, 21 | "keywords": [ 22 | "vue", 23 | "vuejs", 24 | "typescript", 25 | "threejs" 26 | ], 27 | "author": "EepyBerry", 28 | "license": "ISTSL-NR 1.0", 29 | "dependencies": { 30 | "@floating-ui/vue": "^1.1.9", 31 | "@unhead/vue": "^2.0.14", 32 | "crypto-hash": "^3.1.0", 33 | "dexie": "^4.2.0", 34 | "file-saver": "^2.0.5", 35 | "iconify-icon": "^2.3.0", 36 | "jszip": "^3.10.1", 37 | "luxon": "^3.7.1", 38 | "nanoid": "^5.1.5", 39 | "pako": "^2.1.0", 40 | "seedrandom": "^3.0.5", 41 | "three": "^0.180.0", 42 | "vue": "^3.5.21", 43 | "vue-accessible-color-picker": "^5.3.1", 44 | "vue-i18n": "^11.1.11", 45 | "vue-router": "^4.5.1" 46 | }, 47 | "devDependencies": { 48 | "@rushstack/eslint-patch": "^1.10.5", 49 | "@types/file-saver": "^2.0.7", 50 | "@types/luxon": "^3.7.1", 51 | "@types/node": "^22.13.4", 52 | "@types/pako": "^2.0.3", 53 | "@types/seedrandom": "^3.0.8", 54 | "@types/three": "^0.180.0", 55 | "@typescript-eslint/eslint-plugin": "^8.24.1", 56 | "@typescript-eslint/parser": "^8.24.1", 57 | "@typescript-eslint/utils": "^8.24.1", 58 | "@vitejs/plugin-vue": "^6.0.1", 59 | "@vue/eslint-config-typescript": "^14.4.0", 60 | "@vue/tsconfig": "^0.7.0", 61 | "esbuild": ">=0.25.9", 62 | "eslint": "^9.20.1", 63 | "eslint-config-prettier": "^10.0.1", 64 | "eslint-plugin-prettier": "^5.2.3", 65 | "eslint-plugin-vue": "^9.32.0", 66 | "globals": "^16.3.0", 67 | "npm-run-all2": "^8.0.4", 68 | "prettier": "^3.6.2", 69 | "rollup-plugin-visualizer": "^5.14.0", 70 | "sass": "^1.92.0", 71 | "typescript": "~5.7.3", 72 | "typescript-eslint": "^8.24.1", 73 | "vite": "^7.1.4", 74 | "vue-tsc": "^3.0.6" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/global/extras/ExtraMetalSlugPlanetExplosion.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="explosion-container" role="presentation"> 3 | <img v-for="i in explosions" :key="i" ref="smallExplosionRef" src="/extras/placeholder.png" class="explosion sm" /> 4 | <img ref="largeExplosionRef" src="/extras/placeholder.png" class="explosion lg" /> 5 | </div> 6 | </template> 7 | 8 | <script setup lang="ts"> 9 | import { clampedPRNG, clampedPRNGSpaced } from '@/core/utils/math-utils'; 10 | import { sleep } from '@/core/utils/utils'; 11 | import { useTemplateRef } from 'vue'; 12 | 13 | const smallExplosionSrc = '/extras/explosion-small.gif'; 14 | const largeExplosionSrc = '/extras/explosion-large.gif'; 15 | 16 | const smallExplosionRefs = useTemplateRef('smallExplosionRef'); 17 | const largeExplosionRef = useTemplateRef('largeExplosionRef'); 18 | 19 | const $props = withDefaults(defineProps<{ smDelay?: number; lgDelay?: number; explosions?: number }>(), { 20 | smDelay: 100, 21 | lgDelay: 200, 22 | explosions: 5, 23 | }); 24 | const $emit = defineEmits(['obliteration']); 25 | 26 | defineExpose({ 27 | doEffect: async () => { 28 | const explosionTopLeft = [clampedPRNG(-4, 4), clampedPRNG(-1, 7)]; 29 | for (let i = 0; i < $props.explosions - 1; i++) { 30 | if (i > 0) { 31 | await sleep($props.smDelay); 32 | } 33 | explosionTopLeft[0] = clampedPRNGSpaced(explosionTopLeft[0], -4, 4, 3, 2); 34 | explosionTopLeft[1] = clampedPRNGSpaced(explosionTopLeft[1], -1, 7, 3, 2); 35 | 36 | const explosionElem = smallExplosionRefs.value!.at(i)!; 37 | explosionElem.style.top = explosionTopLeft[0] + 'rem'; 38 | explosionElem.style.left = explosionTopLeft[1] + 'rem'; 39 | explosionElem.src = smallExplosionSrc + '?t=' + Date.now(); 40 | } 41 | await sleep($props.lgDelay); 42 | largeExplosionRef.value!.src = largeExplosionSrc; 43 | $emit('obliteration'); 44 | await sleep(1500); 45 | }, 46 | }); 47 | </script> 48 | 49 | <style lang="scss"> 50 | .explosion-container { 51 | pointer-events: none; 52 | z-index: 5; 53 | position: absolute; 54 | transform: translateY(-1rem); 55 | width: 16rem; 56 | height: 16rem; 57 | 58 | .explosion { 59 | position: absolute; 60 | width: 10rem; 61 | transform-origin: 50% 50%; 62 | } 63 | .explosion.lg { 64 | width: 16rem; 65 | height: 16rem; 66 | transform: scale(1.375); 67 | } 68 | } 69 | </style> 70 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterKeyBinding.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="keybinds-label"> 3 | <iconify-icon :icon="icon" width="1.5rem" aria-hidden="true" /> 4 | <slot></slot> 5 | </div> 6 | <div class="keybinds-key-wrapper"> 7 | <div class="keybinds-key" :class="{ unset: keyBind?.key === '[unset]' }"> 8 | <iconify-icon 9 | v-if="tryGetKeyRepresentation(keyBind?.key)" 10 | :icon="tryGetKeyRepresentation(keyBind?.key)" 11 | width="1.25rem" 12 | /> 13 | <span v-else>{{ selected ? '.....' : keyBind?.key }}</span> 14 | </div> 15 | <LgvButton 16 | class="sm" 17 | :icon="selected ? 'mingcute:close-line' : 'mingcute:edit-2-line'" 18 | :a11y-label="$t('a11y.action_edit_keybind')" 19 | @click="$emit('toggle')" 20 | /> 21 | </div> 22 | </template> 23 | <script setup lang="ts"> 24 | import LgvButton from '@/_lib/components/LgvButton.vue'; 25 | import type { IDBKeyBinding } from '@/dexie.config'; 26 | 27 | const $emit = defineEmits(['toggle']); 28 | defineProps<{ keyBind?: IDBKeyBinding; selected: boolean; icon: string }>(); 29 | 30 | function tryGetKeyRepresentation(key?: string) { 31 | if (!key) { 32 | return undefined; 33 | } 34 | switch (key) { 35 | case 'ARROWUP': 36 | return 'mingcute:arrow-up-line'; 37 | case 'ARROWRIGHT': 38 | return 'mingcute:arrow-right-line'; 39 | case 'ARROWDOWN': 40 | return 'mingcute:arrow-down-line'; 41 | case 'ARROWLEFT': 42 | return 'mingcute:arrow-left-line'; 43 | default: 44 | return undefined; 45 | } 46 | } 47 | </script> 48 | 49 | <style scoped lang="scss"> 50 | .keybind { 51 | display: flex; 52 | align-items: center; 53 | justify-content: space-between; 54 | gap: 0.5rem; 55 | } 56 | .keybinds-label { 57 | grid-column: 1; 58 | display: flex; 59 | align-items: center; 60 | gap: 0.5rem; 61 | padding-right: 0.5rem; 62 | flex: 1; 63 | } 64 | 65 | .keybinds-key-wrapper { 66 | grid-column: 2; 67 | justify-self: end; 68 | display: flex; 69 | gap: 8px; 70 | } 71 | 72 | .keybinds-key { 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | width: 6rem; 77 | min-height: 2rem; 78 | background: var(--lg-panel); 79 | border: 1px solid var(--lg-accent); 80 | border-radius: 0.25rem; 81 | font-weight: 600; 82 | padding: 0 0.5rem; 83 | &.unset { 84 | border-color: var(--lg-warn-active); 85 | } 86 | } 87 | </style> 88 | -------------------------------------------------------------------------------- /src/core/helpers/preview.helper.ts: -------------------------------------------------------------------------------- 1 | import { CanvasTexture, LinearSRGBColorSpace, RenderTarget } from 'three'; 2 | import * as Globals from '@core/globals' 3 | import * as SceneHelper from './scene.helper' 4 | import type PlanetData from '../models/planet-data.model'; 5 | import { degToRad } from 'three/src/math/MathUtils.js'; 6 | import { EditorSceneCreationMode, type EditorSceneData } from '../types'; 7 | import { blobToDataURL, renderToCanvas } from '../utils/render-utils'; 8 | import { PostProcessing } from 'three/webgpu'; 9 | import { pass } from 'three/tsl'; 10 | 11 | export async function generatePlanetPreview(data: PlanetData): Promise<string> { 12 | try { 13 | const w = 384, h = 384 14 | const previewRenderTarget = new RenderTarget(w, h, { colorSpace: LinearSRGBColorSpace }) 15 | 16 | // TODO: Remove this once the Camera+RenderTarget system works again with WebGPU/TSL 17 | // ------------------------- Initialize scene & components -------------------------- 18 | const sceneData: EditorSceneData = await SceneHelper.buildEditorScene(data, w, h, w/h, EditorSceneCreationMode.PREVIEW) 19 | sceneData.camera.setRotationFromAxisAngle(Globals.AXIS_Y, degToRad(data.initCamAngle)) 20 | sceneData.camera.updateProjectionMatrix() 21 | sceneData.lensFlare!.mesh.visible = false 22 | 23 | // ---------------------------- Prepare Post-Processing ----------------------------- 24 | const postProcessing = new PostProcessing(sceneData.renderer) 25 | postProcessing.outputNode = pass(sceneData.scene, sceneData.camera) 26 | 27 | // ---------------------------- Setup renderer & render ----------------------------- 28 | const rawBuffer = new Uint8Array(w * h * 4) 29 | sceneData.renderer.setRenderTarget(previewRenderTarget) 30 | await postProcessing.renderAsync() 31 | rawBuffer.set(await sceneData.renderer.readRenderTargetPixelsAsync(previewRenderTarget, 0, 0, w, h)) 32 | sceneData.renderer.setRenderTarget(null) 33 | 34 | // ----------------- Create preview canvas & write data from buffer ----------------- 35 | const tex = new CanvasTexture(renderToCanvas(sceneData.renderer, rawBuffer, w, h)) 36 | const blob = await tex.image.convertToBlob() 37 | 38 | // ------------------------------- Clean-up resources ------------------------------- 39 | postProcessing.dispose() 40 | previewRenderTarget.dispose() 41 | SceneHelper.disposeScene(sceneData) 42 | return await blobToDataURL(blob) 43 | } catch (err) { 44 | console.error('<Lagrange> Could not save planet preview!', err) 45 | return '' 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | "I'm So Tired" Software License 1.0 (No-Resale ver.) 2 | 3 | Copyright (c) 2024-2025 EepyBerry 4 | 5 | This is anti-capitalist, anti-bigotry software, made by people who are tired of 6 | ill-intended organisations and individuals, and would rather not have those 7 | around their creations. 8 | 9 | Permission is granted, free of charge, to any user (be they a person or an 10 | organisation) obtaining a copy of this software, to use it for personal, 11 | commercial, or educational purposes, subject to the following conditions: 12 | 13 | 1. The above copyright notice and this permission notice shall be included in 14 | all copies or modified versions of this software. 15 | 16 | 2. The user is one of the following: 17 | a. an individual person, labouring for themselves 18 | b. a non-profit organisation 19 | c. an educational institution 20 | d. an organization that seeks shared profit for all of its members, and 21 | allows non-members to set the cost of their labor 22 | 23 | 3. If the user is an organization with owners, then all owners are workers and 24 | all workers are owners with equal equity and/or equal vote. 25 | 26 | 4. If the user is an organization, then the user is not law enforcement or 27 | military, or working for or under either. 28 | 29 | 5. The user does not use the software for ill-intentioned reasons, as 30 | determined by the authors of the software. said reasons include but are not 31 | limited to: 32 | a. bigotry, including but not limited to racism, xenophobia, homophobia, 33 | transphobia, ableism, sexism, antisemitism, religious intolerance 34 | b. pedophilia, zoophilia, and/or incest 35 | c. support for law enforcement and/or the military 36 | d. support for any blockchain-related technology, including but not limited to 37 | cryptocurrencies and non-fungible tokens 38 | 39 | 6. The user does not promote or engage with any of the activities listed in the 40 | previous item, and is not affiliated with any group that promotes or engages 41 | with any of such activities. 42 | 43 | 7. The user does not resell the software or any parts of it. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY 46 | KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 47 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 48 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 49 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 50 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 51 | -------------------------------------------------------------------------------- /src/components/global/elements/ToastElement.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="toast" :class="classObject"> 3 | <div class="toast-icon"> 4 | <iconify-icon v-if="type === 'success'" icon="mingcute:check-circle-line" width="1.5rem" aria-hidden="true" /> 5 | <iconify-icon v-if="type === 'info'" icon="mingcute:information-line" width="1.5rem" aria-hidden="true" /> 6 | <iconify-icon v-if="type === 'warn'" icon="mingcute:alert-line" width="1.5rem" aria-hidden="true" /> 7 | <iconify-icon v-if="type === 'wip'" icon="mingcute:traffic-cone-line" width="1.5rem" aria-hidden="true" /> 8 | </div> 9 | <div class="toast-message"> 10 | <slot></slot> 11 | </div> 12 | <LgvButton 13 | variant="icon" 14 | icon="mingcute:close-line" 15 | :a11y-label="$t('a11y.action_close_toast')" 16 | :tabindex="visible ? 'auto' : '-1'" 17 | @click="$emit('close')" 18 | /> 19 | </div> 20 | </template> 21 | 22 | <script setup lang="ts"> 23 | import LgvButton from '@/_lib/components/LgvButton.vue'; 24 | import type { EditorMessageLevel } from '@core/types'; 25 | import { computed, type ComputedRef } from 'vue'; 26 | 27 | const $props = defineProps<{ type: EditorMessageLevel; visible: boolean }>(); 28 | defineEmits(['close']); 29 | 30 | const classObject: ComputedRef<string[]> = computed(() => [$props.visible ? 'visible' : '', $props.type]); 31 | </script> 32 | 33 | <style scoped lang="scss"> 34 | .toast { 35 | width: fit-content; 36 | min-height: 2.75rem; 37 | background: var(--lg-panel); 38 | border: 1px solid var(--lg-accent); 39 | border-radius: 2px; 40 | transition: 41 | opacity 150ms ease-in-out, 42 | transform 150ms ease-in-out; 43 | opacity: 0; 44 | transform: scale(95%); 45 | 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: stretch; 49 | gap: 0.5rem; 50 | overflow: hidden; 51 | pointer-events: none; 52 | user-select: none; 53 | 54 | &.visible { 55 | pointer-events: all; 56 | user-select: all; 57 | opacity: 1; 58 | transform: scale(100%); 59 | } 60 | 61 | &.success .toast-icon { 62 | background: var(--lg-success); 63 | } 64 | &.info .toast-icon { 65 | background: var(--lg-info); 66 | } 67 | &.warn .toast-icon, 68 | &.wip .toast-icon { 69 | background: var(--lg-warn); 70 | } 71 | } 72 | .toast-icon { 73 | display: flex; 74 | align-items: center; 75 | padding: 0.5rem; 76 | } 77 | .toast-message { 78 | padding: 0.5rem; 79 | display: flex; 80 | flex-direction: column; 81 | justify-content: center; 82 | } 83 | 84 | button { 85 | padding: 0.5rem; 86 | } 87 | </style> 88 | -------------------------------------------------------------------------------- /src/core/tsl/materials/ring.tslmat.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DoubleSide, 3 | MeshBasicNodeMaterial, 4 | MeshStandardNodeMaterial, 5 | Node, 6 | Texture, 7 | type TextureNode, 8 | } from 'three/webgpu'; 9 | import type { UniformNumberNode } from '../tsl-types'; 10 | import type { TSLMaterial } from './tsl-material'; 11 | import { float, length, positionGeometry, texture, uniform, uv, vec2, type ShaderNodeObject } from 'three/tsl'; 12 | import { flattenUV } from '../utils/vertex-utils'; 13 | 14 | export type RingUniformData = { 15 | innerRadius: number; 16 | outerRadius: number; 17 | texture: Texture; 18 | }; 19 | export type RingUniforms = { 20 | innerRadius: UniformNumberNode; 21 | outerRadius: UniformNumberNode; 22 | texture: TextureNode; 23 | }; 24 | export class RingTSLMaterial implements TSLMaterial<MeshStandardNodeMaterial, RingUniformData, RingUniforms> { 25 | public readonly uniforms: RingUniforms; 26 | 27 | constructor(data: RingUniformData) { 28 | this.uniforms = { 29 | innerRadius: uniform(data.innerRadius, 'float').setName('uInnerRadius'), 30 | outerRadius: uniform(data.outerRadius, 'float').setName('uOuterRadius'), 31 | texture: texture(data.texture).setName('uTexture'), 32 | }; 33 | } 34 | 35 | buildMaterial(): MeshStandardNodeMaterial { 36 | const material = new MeshStandardNodeMaterial(); 37 | material.colorNode = this.sampleRampTexture(positionGeometry); 38 | material.transparent = true; 39 | material.side = DoubleSide; 40 | return material; 41 | } 42 | 43 | buildBakeMaterial(): MeshBasicNodeMaterial { 44 | const material = new MeshBasicNodeMaterial(); 45 | material.vertexNode = flattenUV(uv()); 46 | material.colorNode = this.sampleRampTexture(positionGeometry); 47 | material.transparent = true; 48 | material.side = DoubleSide; 49 | return material; 50 | } 51 | 52 | // -------------------------------------------------------------------------- 53 | 54 | private sampleRampTexture(pos: ShaderNodeObject<Node>): ShaderNodeObject<Node> { 55 | const distanceToCenter = length(pos.xy).toVar('distanceToCenter'); 56 | const rampFactor = float( 57 | this.clampToRange(distanceToCenter, this.uniforms.innerRadius, this.uniforms.outerRadius), 58 | ).toVar('rampFactor'); 59 | const texCoord = vec2(rampFactor, 0.5).toVar('texCoord'); 60 | return this.uniforms.texture.sample(texCoord); 61 | } 62 | 63 | private clampToRange( 64 | i_v: ShaderNodeObject<Node>, 65 | i_min: UniformNumberNode, 66 | i_max: UniformNumberNode, 67 | ): ShaderNodeObject<Node> { 68 | return i_v.sub(i_min).div(i_max.sub(i_min)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/components/editor/controls/ControlsLighting.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <ParameterGrid> 3 | <ParameterGroup v-model="LG_PLANET_DATA.lensFlareEnabled" :toggleable="LG_PLANET_DATA.lensFlareEnabled"> 4 | <template #title>{{ $t('editor.controls.lighting.lensflare') }}</template> 5 | <template #content> 6 | <template v-if="LG_PLANET_DATA.lensFlareEnabled"> 7 | <ParameterSlider 8 | id="f-pointsint" 9 | v-model="LG_PLANET_DATA.lensFlarePointsIntensity" 10 | :step="0.01" 11 | :min="0" 12 | :max="1" 13 | > 14 | {{ $t('editor.controls.lighting.lensflare_points_intensity') }} 15 | </ParameterSlider> 16 | <ParameterSlider 17 | id="f-glareint" 18 | v-model="LG_PLANET_DATA.lensFlareGlareIntensity" 19 | :step="0.01" 20 | :min="0" 21 | :max="1" 22 | > 23 | {{ $t('editor.controls.lighting.lensflare_glare_intensity') }} 24 | </ParameterSlider> 25 | </template> 26 | </template> 27 | </ParameterGroup> 28 | <ParameterGroup :toggleable="true"> 29 | <template #title>{{ $t('editor.controls.lighting.sunlight') }}</template> 30 | <template #content> 31 | <ParameterSlider id="l-angle" v-model="LG_PLANET_DATA.sunLightAngle" :step="0.1" :min="-90" :max="90"> 32 | {{ $t('editor.controls.lighting.sunlight_angle') }} <sup>(°)</sup> 33 | </ParameterSlider> 34 | <ParameterSlider id="l-int" v-model="LG_PLANET_DATA.sunLightIntensity" :step="0.1" :min="0" :max="50"> 35 | {{ $t('editor.controls.lighting.sunlight_intensity') }} 36 | </ParameterSlider> 37 | <ParameterColor v-model="LG_PLANET_DATA.sunLightColor"> 38 | {{ $t('editor.controls.lighting.sunlight_color') }} 39 | </ParameterColor> 40 | </template> 41 | </ParameterGroup> 42 | <ParameterGroup :toggleable="true"> 43 | <template #title>{{ $t('editor.controls.lighting.amblight') }}</template> 44 | <template #content> 45 | <ParameterSlider id="m-int" v-model="LG_PLANET_DATA.ambLightIntensity" :step="0.01" :min="0" :max="1"> 46 | {{ $t('editor.controls.lighting.amblight_intensity') }} 47 | </ParameterSlider> 48 | <ParameterColor v-model="LG_PLANET_DATA.ambLightColor"> 49 | {{ $t('editor.controls.lighting.amblight_color') }} 50 | </ParameterColor> 51 | </template> 52 | </ParameterGroup> 53 | </ParameterGrid> 54 | </template> 55 | <script setup lang="ts"> 56 | import { LG_PLANET_DATA } from '@/core/services/editor.service'; 57 | </script> 58 | -------------------------------------------------------------------------------- /src/core/tsl/features/bump.ts: -------------------------------------------------------------------------------- 1 | import { vec3, cross, normalize, mix, Fn } from 'three/tsl'; 2 | import type { UniformNumberNode, UniformVector3Node } from '../tsl-types'; 3 | 4 | // Transpiled (GLSL) from Daniel Greenheck: 5 | // https://github.com/dgreenheck/threejs-procedural-planets 6 | // --- 7 | // MIT License 8 | 9 | // Copyright (c) 2023 Daniel Greenheck 10 | 11 | // Permission is hereby granted, free of charge, to any person obtaining a copy 12 | // of this software and associated documentation files (the "Software"), to deal 13 | // in the Software without restriction, including without limitation the rights 14 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | // copies of the Software, and to permit persons to whom the Software is 16 | // furnished to do so, subject to the following conditions: 17 | 18 | // The above copyright notice and this permission notice shall be included in all 19 | // copies or substantial portions of the Software. 20 | 21 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | // SOFTWARE. 28 | export const applyBump = /*@__PURE__*/ Fn( 29 | ([i_normal, i_position, i_dx, i_dy, i_height, i_dxHeight, i_dyHeight, i_radius, i_strength]: [ 30 | UniformVector3Node, 31 | UniformVector3Node, 32 | UniformVector3Node, 33 | UniformVector3Node, 34 | UniformNumberNode, 35 | UniformNumberNode, 36 | UniformNumberNode, 37 | UniformNumberNode, 38 | UniformNumberNode, 39 | ]) => { 40 | const hPos = vec3(i_position.mul(i_radius.add(i_height))).toVar('hPos'); 41 | const dxPos = vec3(i_position.add(i_dx)).mul(i_radius.add(i_dxHeight)).toVar('dxPos'); 42 | const dyPos = vec3(i_position.add(i_dy)).mul(i_radius.add(i_dyHeight)).toVar('dyPos'); 43 | const bumpN = vec3(normalize(cross(dxPos.sub(hPos), dyPos.sub(hPos)))).toVar('bumpN'); 44 | return normalize(mix(i_normal, bumpN, i_strength)); 45 | }, 46 | ).setLayout({ 47 | name: 'LG_BUMP_applyBump', 48 | type: 'vec3', 49 | inputs: [ 50 | { name: 'normal', type: 'vec3' }, 51 | { name: 'pos', type: 'vec3' }, 52 | { name: 'dx', type: 'vec3' }, 53 | { name: 'dy', type: 'vec3' }, 54 | { name: 'height', type: 'float' }, 55 | { name: 'dxHeight', type: 'float' }, 56 | { name: 'dyHeight', type: 'float' }, 57 | { name: 'radius', type: 'float' }, 58 | { name: 'strength', type: 'float' }, 59 | ], 60 | }); 61 | -------------------------------------------------------------------------------- /src/core/tsl/features/biomes.ts: -------------------------------------------------------------------------------- 1 | import { fbm3 } from '../noise/fbm3'; 2 | import { float, step, abs, mix, smoothstep, Fn, vec2, vec4 } from 'three/tsl'; 3 | import type { TextureNode, UniformArrayNode } from 'three/webgpu'; 4 | import type { UniformNumberNode, UniformVector3Node, UniformVector4Node } from '../tsl-types'; 5 | 6 | export const computeTemperature = /*@__PURE__*/ Fn( 7 | ([i_position, i_noiseparams, i_mode]: [UniformVector3Node, UniformVector4Node, UniformNumberNode]) => { 8 | const FLAG_POLAR = float(step(0.5, i_mode)).toVar('FLAG_POLAR'); 9 | const FLAG_NOISE = float(step(1.5, i_mode)).toVar('FLAG_NOISE'); 10 | 11 | const ty = float(mix(abs(i_position.y), i_position.y, FLAG_POLAR)).toVar('ty'); 12 | const adjustedTy = float(smoothstep(1.0, FLAG_POLAR.negate(), ty)).toVar('adjustedTy'); 13 | const tHeight = float(mix(adjustedTy, 1.0, FLAG_NOISE)).toVar('tHeight'); 14 | return tHeight.mul(fbm3(i_position, i_noiseparams)); 15 | }, 16 | ).setLayout({ 17 | name: 'LG_BIOME_computeTemperature', 18 | type: 'float', 19 | inputs: [ 20 | { name: 'position', type: 'vec3' }, 21 | { name: 'noise', type: 'vec4' }, 22 | { name: 'mode', type: 'float' }, 23 | ], 24 | }); 25 | 26 | export const computeHumidity = /*@__PURE__*/ Fn( 27 | ([i_position, i_noiseparams, i_mode]: [UniformVector3Node, UniformArrayNode, UniformNumberNode]) => { 28 | const FLAG_POLAR = float(step(0.5, i_mode)).toVar('FLAG_POLAR'); 29 | const FLAG_NOISE = float(step(1.5, i_mode)).toVar('FLAG_NOISE'); 30 | 31 | const hy = float(mix(abs(i_position.y), i_position.y, FLAG_POLAR)).toVar('hy'); 32 | const adjustedHy = float(smoothstep(FLAG_POLAR.negate(), 1.0, hy)).toVar('adjustedHy'); 33 | const hHeight = float(mix(adjustedHy, 1.0, FLAG_NOISE)).toVar('hHeight'); 34 | return hHeight.mul(fbm3(i_position, i_noiseparams)); 35 | }, 36 | ).setLayout({ 37 | name: 'LG_BIOME_computeHumidity', 38 | type: 'float', 39 | inputs: [ 40 | { name: 'position', type: 'vec3' }, 41 | { name: 'noise', type: 'vec4' }, 42 | { name: 'mode', type: 'float' }, 43 | ], 44 | }); 45 | 46 | // TODO: add setLayout when feature is ready in TSL 47 | export const sampleBiomeTexture = /*@__PURE__*/ Fn( 48 | ([i_tex, i_temperature, i_humidity, i_color]: [ 49 | TextureNode, 50 | UniformNumberNode, 51 | UniformNumberNode, 52 | UniformVector3Node, 53 | ]) => { 54 | const texel = vec4(i_tex.sample(vec2(i_humidity, i_temperature))).toVar('texel'); 55 | return mix(i_color, texel.xyz, texel.w); 56 | }, 57 | ); /*.setLayout({ 58 | name: 'LG_BIOME_sampleBiomeTexture', 59 | type: 'float', 60 | inputs: [ 61 | { name: 'tex', type: 'sampler2D' }, 62 | { name: 'temperature', type: 'float' }, 63 | { name: 'humidity', type: 'float' }, 64 | { name: 'color', type: 'vec3' }, 65 | ] 66 | })*/ 67 | -------------------------------------------------------------------------------- /src/core/utils/math/rect.ts: -------------------------------------------------------------------------------- 1 | export default class Rect { 2 | private _x: number; 3 | private _y: number; 4 | private _w: number; 5 | private _h: number; 6 | 7 | public get x(): number { 8 | return this._x; 9 | } 10 | public set x(value: number) { 11 | this._x = value; 12 | } 13 | 14 | public get y(): number { 15 | return this._y; 16 | } 17 | public set y(value: number) { 18 | this._y = value; 19 | } 20 | 21 | public get w(): number { 22 | return this._w; 23 | } 24 | public set w(value: number) { 25 | this._w = value; 26 | } 27 | 28 | public get h(): number { 29 | return this._h; 30 | } 31 | public set h(value: number) { 32 | this._h = value; 33 | } 34 | 35 | constructor(x: number, y: number, w: number, h: number) { 36 | this._x = x; 37 | this._y = y; 38 | this._w = w; 39 | this._h = h; 40 | } 41 | 42 | /** 43 | * Clones this rect 44 | * @returns a new Rect instance with the same x,y,w,h values 45 | */ 46 | public clone() { 47 | return new Rect(Number(this.x), Number(this.y), Number(this.w), Number(this.h)); 48 | } 49 | 50 | /** 51 | * Finds overlaps on a given w*h plane's borders with this Rect 52 | * @param w total plane width 53 | * @param h total plane height 54 | * @returns an array containing overlaps for the top, right, bottom & left sides, in that order 55 | */ 56 | public findOverlaps(w: number, h: number): boolean[] { 57 | const borderOverlaps: boolean[] = [false, false, false, false]; 58 | borderOverlaps[0] = this.y <= 0; 59 | borderOverlaps[1] = this.x + this.w >= w; 60 | borderOverlaps[2] = this.y + this.h >= h; 61 | borderOverlaps[3] = this.x <= 0; 62 | return borderOverlaps; 63 | } 64 | 65 | /** 66 | * Finds the nearest point on this rect from the given (x,y) coordinates within that rect 67 | * @param px point x 68 | * @param py point y 69 | * @returns the coordinates of the nearest rect point from (x,y) 70 | */ 71 | public findMinDistanceWithin(px: number, py: number, overlaps: boolean[]): number { 72 | return Math.min( 73 | overlaps[3] ? 1e3 : px - this.x, 74 | overlaps[0] ? 1e3 : py - this.y, 75 | overlaps[1] ? 1e3 : this.x + this.w - px, 76 | overlaps[2] ? 1e3 : this.y + this.h - py, 77 | ); 78 | } 79 | 80 | public adjustToHTMLCanvas(): Rect { 81 | this.x += 0.5; 82 | this.y += 0.5; 83 | this.w--; 84 | this.h--; 85 | return this; 86 | } 87 | 88 | public shrink(borderOverlaps: boolean[]): Rect { 89 | this.x += borderOverlaps[3] ? 0 : 1; 90 | this.y += borderOverlaps[0] ? 0 : 1; 91 | this.w -= borderOverlaps[1] ? (borderOverlaps[3] ? 0 : 1) : borderOverlaps[3] ? 1 : 2; 92 | this.h -= borderOverlaps[2] ? (borderOverlaps[0] ? 0 : 1) : borderOverlaps[0] ? 1 : 2; 93 | return this; 94 | } 95 | 96 | public isValid(): boolean { 97 | return this.x >= 0 && this.w >= 0 && this.h >= 0; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/core/models/ring-parameters.model.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import { ChangeTracker, type ChangedProp } from './change-tracker.model'; 3 | import { ColorRamp, ColorRampStep } from './color-ramp.model'; 4 | import { clampedPRNG } from '@core/utils/math-utils'; 5 | 6 | export class RingParameters extends ChangeTracker { 7 | private _id: string; 8 | private _innerRadius: number; 9 | private _outerRadius: number; 10 | private _colorRamp: ColorRamp; 11 | 12 | constructor( 13 | changedPropsRef: ChangedProp[], 14 | changePrefix: string, 15 | innerRadius: number, 16 | outerRadius: number, 17 | colorRampSteps?: ColorRampStep[], 18 | oldId?: string, 19 | ) { 20 | super(changedPropsRef, changePrefix); 21 | this._id = oldId ?? nanoid(); 22 | this._innerRadius = innerRadius; 23 | this._outerRadius = outerRadius; 24 | this._colorRamp = new ColorRamp(this._changedProps, `${this._changePrefix}[element]._colorRamp`, [ 25 | new ColorRampStep(0x856f4e, 0.0, true), 26 | new ColorRampStep(0x000000, 0.5), 27 | new ColorRampStep(0xbf9a5e, 1.0, true), 28 | ]); 29 | if (colorRampSteps) { 30 | this._colorRamp.loadFromSteps(colorRampSteps); 31 | } 32 | } 33 | 34 | public get id(): string { 35 | return this._id; 36 | } 37 | public set id(value: string) { 38 | this._id = value; 39 | } 40 | 41 | public get innerRadius(): number { 42 | return this._innerRadius; 43 | } 44 | public set innerRadius(value: number) { 45 | this._innerRadius = value; 46 | if (this.outerRadius < this._innerRadius) { 47 | this.outerRadius = value; // Call setter to trigger change 48 | } 49 | this.markForChange(`${this._changePrefix}._innerRadius`, { data: this }); 50 | } 51 | 52 | public get outerRadius(): number { 53 | return this._outerRadius; 54 | } 55 | public set outerRadius(value: number) { 56 | this._outerRadius = value; 57 | if (this.innerRadius > this._outerRadius) { 58 | this.innerRadius = value; // Call setter to trigger change 59 | } 60 | this.markForChange(`${this._changePrefix}._outerRadius`, { data: this }); 61 | } 62 | 63 | public get colorRamp(): ColorRamp { 64 | return this._colorRamp; 65 | } 66 | 67 | public loadColorRampSteps(steps: ColorRampStep[]) { 68 | this._colorRamp.loadFromSteps(steps); 69 | } 70 | 71 | public static createRandom(changedProps: ChangedProp[], changePrefix: string) { 72 | const innerRadius = clampedPRNG(1.25, 4.75); 73 | const params = new RingParameters(changedProps, changePrefix, innerRadius, clampedPRNG(innerRadius, 5)); 74 | params._colorRamp.randomize(3); 75 | return params; 76 | } 77 | 78 | /** 79 | * Marks all properties of this class for change, using `this._changePrefix` 80 | */ 81 | public override markAllForChange(): void { 82 | this.markForChange(`${this._changePrefix}._colorRamp`, { data: this }); 83 | this.markForChange(`${this._changePrefix}._innerRadius`, { data: this }); 84 | this.markForChange(`${this._changePrefix}._outerRadius`, { data: this }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/core/event-bus.ts: -------------------------------------------------------------------------------- 1 | import { ref, type Ref } from 'vue'; 2 | import type { EditorMessageLevel } from './types'; 3 | 4 | /** 5 | * Defines options to pass when registering a window event-listener: 6 | * - `autoEnable`: if the listener should also be added to the window (default: `true`) 7 | */ 8 | type WindowEventRegistryOptions = { autoEnable: boolean }; 9 | type ToastMessageEvent = { type: EditorMessageLevel; translationKey: string; millis: number }; 10 | 11 | export class EventBus { 12 | public static clearEvent: Ref<string> = ref(''); 13 | public static toastEvent: Ref<ToastMessageEvent | null> = ref(null); 14 | public static clickEvent: Ref<MouseEvent | null> = ref(null); 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | private static windowEventRegistry: Map<keyof WindowEventMap, any> = new Map<keyof WindowEventMap, any>(); 17 | 18 | public static sendDataClearEvent() { 19 | EventBus.clearEvent.value = new Date().toISOString(); 20 | } 21 | 22 | public static sendToastEvent(type: EditorMessageLevel, translationKey: string, millis: number) { 23 | EventBus.toastEvent.value = { type, translationKey, millis }; 24 | } 25 | 26 | public static sendClickEvent(evt: MouseEvent) { 27 | EventBus.clickEvent.value = evt; 28 | } 29 | 30 | // ---------------------------------------------------------------------------------------------- 31 | 32 | /** 33 | * Registers a window event-listener 34 | * @param type event-listener type (e.g. `keydown`) 35 | * @param listener the listener to register 36 | * @param options registering options (see {@link WindowEventRegistryOptions}) 37 | */ 38 | public static registerWindowEventListener<K extends keyof WindowEventMap>( 39 | type: K, 40 | listener: (this: Window, ev: WindowEventMap[K]) => void, 41 | options?: WindowEventRegistryOptions, 42 | ) { 43 | EventBus.windowEventRegistry.set(type, listener); 44 | if (!options || options.autoEnable) { 45 | window.addEventListener(type, listener); 46 | } 47 | } 48 | 49 | public static deregisterWindowEventListener<K extends keyof WindowEventMap>( 50 | type: K, 51 | listener: (this: Window, ev: WindowEventMap[K]) => void, 52 | ) { 53 | EventBus.windowEventRegistry.delete(type); 54 | window.removeEventListener(type, listener); 55 | } 56 | 57 | /** 58 | * Adds the event-listener to the window context using the internal listener ref saved beforehand 59 | * @param type event-listener type (e.g. `keydown`) 60 | */ 61 | public static enableWindowEventListener<K extends keyof WindowEventMap>(type: K) { 62 | const event = EventBus.windowEventRegistry.get(type); 63 | window.addEventListener(type, event); 64 | } 65 | 66 | /** 67 | * Removes the event-listener from the window context, but keeps the listener reference 68 | * @param type event-listener type (e.g. `keydown`) 69 | */ 70 | public static disableWindowEventListener<K extends keyof WindowEventMap>(type: K) { 71 | const event = EventBus.windowEventRegistry.get(type); 72 | window.removeEventListener(type, event); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/global/elements/CollapsibleSection.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <section 3 | class="collapsible-section" 4 | :class="{ expanded: _expanded, compact: compactMode, 'allow-icon-mode': allowIconMode }" 5 | role="group" 6 | :aria-expanded="_expanded" 7 | > 8 | <button class="collapsible-section-title" @click="toggleExpand()" @keydown.enter="toggleExpand()"> 9 | <h3> 10 | <iconify-icon :icon="icon" width="1.25rem" aria-hidden="true" /> 11 | <span><slot name="title">SECTION_TITLE</slot></span> 12 | </h3> 13 | <iconify-icon class="indicator" icon="mingcute:right-fill" width="1.25rem" aria-hidden="true" /> 14 | </button> 15 | <div v-show="_expanded" class="collapsible-section-content"> 16 | <slot name="content"> 17 | <span class="default">Nothing here yet, sorry :c</span> 18 | </slot> 19 | </div> 20 | </section> 21 | </template> 22 | 23 | <script setup lang="ts"> 24 | import { type Ref, onMounted, ref } from 'vue'; 25 | const _expanded: Ref<boolean> = ref(true); 26 | 27 | const _props = defineProps<{ icon?: string; compactMode?: boolean; allowIconMode?: boolean; expand?: boolean }>(); 28 | onMounted(() => (_expanded.value = _props.expand ?? true)); 29 | 30 | function toggleExpand() { 31 | _expanded.value = !_expanded.value; 32 | } 33 | </script> 34 | 35 | <style scoped lang="scss"> 36 | .collapsible-section { 37 | pointer-events: all; 38 | background: var(--lg-primary); 39 | border: 1px solid var(--lg-accent); 40 | border-radius: 2px; 41 | min-width: 26rem; 42 | 43 | display: flex; 44 | flex-direction: column; 45 | align-items: space-between; 46 | gap: 4px; 47 | 48 | &.compact { 49 | min-width: 0; 50 | } 51 | &.expanded > .collapsible-section-title > .indicator { 52 | transform: rotateZ(90deg); 53 | } 54 | 55 | .collapsible-section-title { 56 | background: none; 57 | border: none; 58 | color: unset; 59 | font-size: 1rem; 60 | font-weight: 600; 61 | padding: 0.75rem; 62 | 63 | cursor: pointer; 64 | user-select: none; 65 | 66 | display: flex; 67 | justify-content: space-between; 68 | align-items: center; 69 | } 70 | .collapsible-section-content { 71 | font-size: 0.875rem; 72 | font-weight: 300; 73 | padding: 0 0.75rem 0.75rem; 74 | overflow-x: auto; 75 | } 76 | .collapsible-section-content .default { 77 | font-size: 0.75rem; 78 | } 79 | } 80 | .collapsible-section.warn { 81 | background: var(--lg-warn-panel); 82 | border-color: var(--lg-warn-active); 83 | } 84 | 85 | @media screen and (max-width: 1199px) { 86 | .collapsible-section:not(.expanded, .compact).allow-icon-mode { 87 | align-self: flex-start; 88 | width: fit-content; 89 | min-width: 0; 90 | 91 | .collapsible-section-title { 92 | min-width: 0; 93 | span, 94 | .indicator { 95 | display: none; 96 | } 97 | } 98 | } 99 | .collapsible-section:not(.compact) { 100 | min-width: 16rem; 101 | } 102 | } 103 | </style> 104 | -------------------------------------------------------------------------------- /src/core/tsl/noise/fbm3.ts: -------------------------------------------------------------------------------- 1 | import { float, floor, Fn, fract, int, Loop, mul, sub, vec2, vec3, vec4 } from 'three/tsl'; 2 | import type { VaryingNode } from 'three/webgpu'; 3 | import type { UniformNumberNode, UniformVector3Node, UniformVector4Node } from '../tsl-types'; 4 | 5 | // X mod 289 operation, float style 6 | export const mod289f = /*@__PURE__*/ Fn(([i_value]: [UniformNumberNode]) => { 7 | return i_value.sub(floor(i_value.mul(1.0 / 289.0)).mul(289.0)); 8 | }).setLayout({ 9 | name: 'LG_NOISE_mod289f', 10 | type: 'float', 11 | inputs: [{ name: 'i_value', type: 'float' }], 12 | }); 13 | 14 | // X mod 289 operation, vec4 style 15 | export const mod289v = /*@__PURE__*/ Fn(([i_vec]: [UniformVector3Node]) => { 16 | return i_vec.sub(floor(i_vec.mul(1.0 / 289.0)).mul(289.0)); 17 | }).setLayout({ 18 | name: 'LG_NOISE_mod289v', 19 | type: 'vec4', 20 | inputs: [{ name: 'i_vec', type: 'vec4' }], 21 | }); 22 | 23 | // Permutation function 24 | export const perm = /*@__PURE__*/ Fn(([i_vec]: [UniformVector3Node]) => { 25 | return mod289v(i_vec.mul(34.0).add(1.0).mul(i_vec)); 26 | }).setLayout({ 27 | name: 'LG_NOISE_perm', 28 | type: 'vec4', 29 | inputs: [{ name: 'i_vec', type: 'vec4' }], 30 | }); 31 | 32 | // 3D fractal Brownian motion - noise function 33 | export const noise3 = /*@__PURE__*/ Fn(([i_point]: [UniformVector3Node]) => { 34 | const p = vec3(i_point).toVar('p'); 35 | const a = vec3(floor(p)).toVar('a'); 36 | const d = vec3(p.sub(a)).toVar('d'); 37 | d.assign(d.mul(d).mul(sub(3.0, mul(2.0, d)))); 38 | 39 | const b = vec4(a.xxyy.add(vec4(0.0, 1.0, 0.0, 1.0))).toVar('b'); 40 | const k1 = vec4(perm(b.xyxy)).toVar('k1'); 41 | const k2 = vec4(perm(k1.xyxy.add(b.zzww))).toVar('k2'); 42 | 43 | const c = vec4(k2.add(a.zzzz)).toVar('c'); 44 | const k3 = vec4(perm(c)).toVar('k3'); 45 | const k4 = vec4(perm(c.add(1.0))).toVar('k4'); 46 | 47 | const o1 = vec4(fract(k3.mul(1.0 / 41.0))).toVar('o1'); 48 | const o2 = vec4(fract(k4.mul(1.0 / 41.0))).toVar('o2'); 49 | const o3 = vec4(o2.mul(d.z).add(o1.mul(sub(1.0, d.z)))).toVar('o3'); 50 | const o4 = vec2(o3.yw.mul(d.x).add(o3.xz.mul(sub(1.0, d.x)))).toVar('o4'); 51 | 52 | return o4.y.mul(d.y).add(o4.x.mul(sub(1.0, d.y))); 53 | }).setLayout({ 54 | name: 'LG_NOISE_noise3', 55 | type: 'float', 56 | inputs: [{ name: 'i_point', type: 'vec3' }], 57 | }); 58 | 59 | export const fbm3 = /*@__PURE__*/ Fn(([i_point, i_noise]: [VaryingNode, UniformVector4Node]) => { 60 | const freq = float(i_noise.x).toVar('freq'); 61 | const amp = float(i_noise.y).toVar('amp'); 62 | const lac = float(i_noise.z).toVar('lac'); 63 | const octaves = float(i_noise.w).toInt().toVar('octaves'); 64 | const point = vec3(i_point).toVar('x'); 65 | const val = float(0.0).toVar('val'); 66 | Loop({ start: int(0), end: octaves, condition: '<' }, () => { 67 | val.addAssign(amp.mul(noise3(point.mul(freq)))); 68 | freq.mulAssign(lac); 69 | amp.mulAssign(0.5); 70 | }); 71 | return val; 72 | }).setLayout({ 73 | name: 'LG_NOISE_fbm3', 74 | type: 'float', 75 | inputs: [ 76 | { name: 'i_point', type: 'vec3' }, 77 | { name: 'i_noise', type: 'vec4' }, 78 | ], 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/editor/dialogs/ExportProgressDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <DialogElement 3 | id="dialog-exportprogress" 4 | ref="dialogRef" 5 | :show-title="true" 6 | :closeable="!!_progressError" 7 | :prevent-click-close="true" 8 | :aria-label="$t('a11y.dialog_export_progress')" 9 | :class="{ failure: !!_progressError }" 10 | > 11 | <template #title> 12 | <iconify-icon icon="mingcute:sandglass-line" width="2rem" aria-hidden="true" /> 13 | <span>{{ $t('dialog.export_progress.$title') }}</span> 14 | </template> 15 | <template #content> 16 | <div class="progress-text"> 17 | <p>{{ $t('dialog.export_progress.step_' + _progressStep) }}</p> 18 | <p v-if="!!_progressError">{{ $t('dialog.export_progress.step_failed') }}</p> 19 | <p v-else>{{ _progressStep }}/{{ bakingSteps }}</p> 20 | </div> 21 | <div class="progress-bar"> 22 | <span class="progress" :style="{ width: `${(_progressStep * 100) / bakingSteps}%` }"></span> 23 | </div> 24 | <div v-if="_progressError" class="progress-error"> 25 | <p>{{ _progressError }}</p> 26 | </div> 27 | </template> 28 | </DialogElement> 29 | </template> 30 | <script setup lang="ts"> 31 | import { ref, type Ref } from 'vue'; 32 | import DialogElement from '@components/global/elements/DialogElement.vue'; 33 | const dialogRef: Ref<{ open: () => void; close: () => void } | null> = ref(null); 34 | 35 | const bakingSteps = 8; 36 | const _progressStep: Ref<number> = ref(1); 37 | const _progressError: Ref<unknown> = ref(undefined); 38 | 39 | function open() { 40 | dialogRef.value?.open(); 41 | _progressError.value = undefined; 42 | setProgress(1); 43 | } 44 | function setProgress(value: number) { 45 | if (_progressError.value) return; 46 | 47 | _progressStep.value = value; 48 | if (value === bakingSteps) { 49 | setTimeout(dialogRef.value!.close, 1000); 50 | } 51 | } 52 | function setError(value: unknown) { 53 | if (value instanceof Error) { 54 | _progressError.value = value + '\n'; 55 | } else { 56 | _progressError.value = value; 57 | } 58 | } 59 | 60 | defineEmits(['close']); 61 | defineExpose({ open, setProgress, setError }); 62 | </script> 63 | <style scoped lang="scss"> 64 | #dialog-exportprogress { 65 | z-index: 100; 66 | min-width: 24rem; 67 | 68 | .progress-text { 69 | display: flex; 70 | align-items: center; 71 | justify-content: space-between; 72 | } 73 | .progress-bar { 74 | position: relative; 75 | width: 100%; 76 | height: 1rem; 77 | border: 1px solid var(--lg-accent); 78 | border-radius: 2px; 79 | 80 | .progress { 81 | position: absolute; 82 | left: 0; 83 | height: 100%; 84 | background: var(--lg-input-contrast-focus); 85 | } 86 | } 87 | } 88 | #dialog-exportprogress.failure { 89 | border: 1px solid var(--lg-warn); 90 | background: var(--lg-warn-panel); 91 | .progress-bar { 92 | border: 1px solid var(--lg-warn-active); 93 | border-radius: 2px; 94 | .progress { 95 | background: var(--lg-warn); 96 | } 97 | } 98 | } 99 | </style> 100 | -------------------------------------------------------------------------------- /src/components/editor/dialogs/EditorInitErrorDialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <DialogElement 3 | id="dialog-editorerror" 4 | ref="dialogRef" 5 | class="warn" 6 | show-title 7 | :show-actions="allowRendererFallback" 8 | closeable 9 | prevent-click-close 10 | is-warn 11 | :aria-label="$t('a11y.dialog_editor_error')" 12 | @close="$emit('close', _wantsFallback)" 13 | > 14 | <template #title> 15 | <iconify-icon icon="mingcute:warning-line" width="2rem" aria-hidden="true" /> 16 | <span>{{ $t('dialog.editorerror.$title') }}</span> 17 | </template> 18 | <template #content> 19 | <div class="error-info"> 20 | <p>{{ $t('dialog.editorerror.brief') }}</p> 21 | <p> 22 | <b>{{ $t('dialog.editorerror.reporting') }}</b> 23 | </p> 24 | </div> 25 | <hr class="error-divider" /> 26 | <p class="error-container">{{ _error }}</p> 27 | <CollapsibleSection v-show="_stack.length > 0" class="warn code"> 28 | <template #title>{{ $t('main.error.stacktrace') }}</template> 29 | <template #content> 30 | <div class="code-block"> 31 | <pre v-for="(line, i) of _stack" :key="i">{{ line }}</pre> 32 | </div> 33 | </template> 34 | </CollapsibleSection> 35 | </template> 36 | <template #actions> 37 | <LgvButton v-if="!!allowRendererFallback" class="warn" icon="tabler:reload" @click="closeWithFallback"> 38 | {{ $t('dialog.editorerror.$action_reload_fallback_renderer') }} 39 | </LgvButton> 40 | </template> 41 | </DialogElement> 42 | </template> 43 | <script setup lang="ts"> 44 | import { ref, type Ref } from 'vue'; 45 | import DialogElement from '@components/global/elements/DialogElement.vue'; 46 | import CollapsibleSection from '@components/global/elements/CollapsibleSection.vue'; 47 | import LgvButton from '@/_lib/components/LgvButton.vue'; 48 | const dialogRef: Ref<{ open: () => void; close: () => void } | null> = ref(null); 49 | const allowRendererFallback: Ref<boolean> = ref(false); 50 | 51 | const _error: Ref<string> = ref(''); 52 | const _stack: Ref<string[]> = ref([]); 53 | const _wantsFallback: Ref<boolean> = ref(false); 54 | 55 | function openWithError(error: string, stack?: string, isWebGPUError: boolean = false) { 56 | _error.value = error; 57 | allowRendererFallback.value = isWebGPUError; 58 | if (stack) { 59 | _stack.value.push( 60 | ...stack 61 | .replaceAll('\n', '¤') 62 | .split('¤') 63 | .filter((d) => d.length > 0), 64 | ); 65 | } 66 | dialogRef.value?.open(); 67 | } 68 | 69 | async function closeWithFallback() { 70 | _wantsFallback.value = true; 71 | dialogRef.value!.close(); 72 | } 73 | 74 | defineEmits(['close']); 75 | defineExpose({ openWithError }); 76 | </script> 77 | <style scoped lang="scss"> 78 | #dialog-editorerror { 79 | z-index: 10; 80 | max-width: 48rem; 81 | 82 | hr.error-divider { 83 | border: 1px solid var(--lg-text); 84 | margin-top: 1rem; 85 | opacity: 0.5; 86 | } 87 | .error-info { 88 | text-align: center; 89 | } 90 | .error-container { 91 | min-height: 2rem; 92 | text-align: center; 93 | font-family: monospace; 94 | padding: 1rem; 95 | } 96 | } 97 | </style> 98 | -------------------------------------------------------------------------------- /src/components/global/ViewHeader.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <header class="view-header"> 3 | <AppNavigation /> 4 | <div class="view-header-controls"> 5 | <span class="view-header-corner lb" /> 6 | <span class="view-header-corner l" /> 7 | <span class="view-header-corner rb" /> 8 | <span class="view-header-corner r" /> 9 | <slot></slot> 10 | </div> 11 | <span class="filler" /> 12 | </header> 13 | </template> 14 | 15 | <script setup lang="ts"> 16 | import AppNavigation from './AppNavigation.vue'; 17 | </script> 18 | 19 | <style lang="scss"> 20 | .view-header { 21 | z-index: 15; 22 | position: absolute; 23 | inset: 0 0 auto 0; 24 | 25 | display: grid; 26 | grid-template-columns: 1fr auto 1fr; 27 | grid-template-rows: 1fr; 28 | align-items: center; 29 | justify-content: center; 30 | gap: 0.5rem; 31 | 32 | #nav-toggle { 33 | margin-top: 0.5rem; 34 | margin-left: 0.5rem; 35 | } 36 | .view-header-controls { 37 | position: relative; 38 | padding: 0.5rem; 39 | background: var(--lg-panel); 40 | border-bottom: 1px solid var(--lg-accent); 41 | pointer-events: all; 42 | 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | gap: 0.5rem; 47 | } 48 | } 49 | .view-header-corner { 50 | z-index: 1; 51 | position: absolute; 52 | top: 0; 53 | width: 0; 54 | height: 0; 55 | border-style: solid; 56 | 57 | $corner-width: 1.5rem; 58 | $corner-height: 3.5rem; 59 | $corner-border-width: calc(1.5rem + 1px); 60 | $corner-border-height: calc(3.5rem + 1px); 61 | 62 | &.lb { 63 | left: calc(0px - $corner-border-width); 64 | border-width: 0 $corner-width $corner-border-height 0; 65 | border-color: transparent var(--lg-accent) transparent transparent; 66 | } 67 | &.l { 68 | left: -$corner-width; 69 | bottom: 0; 70 | border-width: 0 $corner-width $corner-height 0; 71 | border-color: transparent var(--lg-panel) transparent transparent; 72 | } 73 | &.rb { 74 | right: calc(0px - $corner-border-width); 75 | border-width: $corner-border-height $corner-width 0 0; 76 | border-color: var(--lg-accent) transparent transparent transparent; 77 | } 78 | &.r { 79 | right: -$corner-width; 80 | bottom: 0; 81 | border-width: $corner-height $corner-width 0 0; 82 | border-color: var(--lg-panel) transparent transparent transparent; 83 | } 84 | } 85 | 86 | @media screen and (max-width: 1199px) { 87 | .view-header { 88 | grid-template-columns: 1fr auto; 89 | .filler { 90 | display: none; 91 | } 92 | } 93 | .view-header-corner.rb, 94 | .view-header-corner.r { 95 | display: none; 96 | } 97 | } 98 | @media screen and (max-width: 767px) { 99 | .view-header { 100 | gap: 0; 101 | #nav-toggle { 102 | margin-top: 0; 103 | margin-left: 0.25rem; 104 | } 105 | } 106 | .view-header.xs-fullwidth { 107 | grid-template-columns: unset; 108 | grid-template-rows: unset; 109 | display: flex; 110 | .view-header-controls { 111 | flex: 1; 112 | background: none; 113 | border: none; 114 | } 115 | .view-header-corner { 116 | display: none; 117 | } 118 | } 119 | } 120 | </style> 121 | -------------------------------------------------------------------------------- /src/core/utils/render-utils.ts: -------------------------------------------------------------------------------- 1 | import type { ColorRamp } from '@core/models/color-ramp.model'; 2 | import { EditorBackendType } from '@core/types'; 3 | import { type TypedArray } from 'three'; 4 | import type { WebGPURenderer } from 'three/webgpu'; 5 | 6 | /** 7 | * Renders a buffer onto an OffscreenCanvas 8 | * @param buf the buffer, represented as a `TypedArray` (usually `UInt8Array`) 9 | * @param w width of the output (in pixels) 10 | * @param h height of the output (in pixels) 11 | * @returns an `OffscreenCanvas` instance containing data from the buffer 12 | */ 13 | export function renderToCanvas(renderer: WebGPURenderer, buf: TypedArray, w: number, h: number): OffscreenCanvas { 14 | const backendType = Object.hasOwn(renderer.backend, 'gl') ? EditorBackendType.WEBGL : EditorBackendType.WEBGPU; 15 | 16 | const canvas = new OffscreenCanvas(w, h); 17 | const ctx = canvas.getContext('2d')!; 18 | const imageData = ctx.createImageData(w, h); 19 | imageData.data.set(backendType === EditorBackendType.WEBGL ? flipBufferY(buf as Uint8Array, w, h) : buf); 20 | ctx.putImageData(imageData, 0, 0); 21 | return canvas; 22 | } 23 | 24 | /** 25 | * Flips an UInt8Array's data vertically to have a normalized +X/+Y image 26 | * @param buffer the data buffer 27 | * @param w width of the resulting image 28 | * @param h height of the resulting image 29 | * @returns the flipped buffer 30 | */ 31 | export function flipBufferY(buffer: Uint8Array, w: number, h: number): Uint8Array { 32 | const length = w * h * 4; 33 | const row = w * 4; 34 | const end = (h - 1) * row; 35 | const result = new Uint8Array(length); 36 | 37 | for (let i = 0; i < length; i += row) { 38 | result.set(buffer.subarray(i, i + row), end - i); 39 | } 40 | return result; 41 | } 42 | 43 | /** 44 | * Converts a color ramp to a left-to-right CSS `linear-gradient`, according to its steps 45 | * @param ramp the color ramp to convert 46 | * @returns an object with `color` and `alpha` gradients 47 | */ 48 | export function colorRampToStyle(ramp: ColorRamp): { color: string; alpha: string } { 49 | const gradient: string[] = []; 50 | const alphaGradient: string[] = []; 51 | for (let i = 0; i < ramp.steps.length; i++) { 52 | const step = ramp.steps[i]; 53 | const rgb = step.color.getHexString(); 54 | const a = Math.ceil(step.alpha * 255).toString(16); 55 | gradient.push(`#${rgb} ${step.factor * 100.0}%`); 56 | alphaGradient.push(`#${a + a + a} ${step.factor * 100.0}%`); 57 | } 58 | return { 59 | color: `linear-gradient(90deg, ${gradient.join(', ')})`, 60 | alpha: `linear-gradient(90deg, ${alphaGradient.join(', ')})`, 61 | }; 62 | } 63 | 64 | /** 65 | * Converts an alpha value to a corresponding greyscale value 66 | * @param alpha the alpha value 67 | * @param full set to `true` if all components of the color should be greyscale 68 | * @returns either a single channel or the full RGB hex-string, after conversion 69 | */ 70 | export function alphaToGrayscale(alpha: number, full = false): string { 71 | const hex = Math.ceil(alpha * 255) 72 | .toString(16) 73 | .padStart(2, '0'); 74 | return full ? `#${hex + hex + hex}` : hex; 75 | } 76 | 77 | export async function blobToDataURL(blob: Blob): Promise<string> { 78 | return new Promise<string>((resolve, reject) => { 79 | const reader = new FileReader(); 80 | reader.onload = () => resolve(reader.result as string); 81 | reader.onerror = reject; 82 | reader.readAsDataURL(blob); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project owner at `eepyberry@proton.me`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /src/components/editor/controls/ControlsRendering.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <ParameterGrid> 3 | <ParameterGroup toggleable> 4 | <template #title>{{ $t('editor.controls.planet_rendering.transform') }}</template> 5 | <template #content> 6 | <ParameterSlider id="p-tilt" v-model="LG_PLANET_DATA.planetRadius" :step="0.01" :min="0.5" :max="1"> 7 | {{ $t('editor.controls.planet_rendering.transform_radius') }} 8 | </ParameterSlider> 9 | <ParameterSlider id="p-tilt" v-model="LG_PLANET_DATA.planetAxialTilt" :step="1" :min="-180" :max="180"> 10 | {{ $t('editor.controls.planet_rendering.transform_axialtilt') }} <sup>(°)</sup> 11 | </ParameterSlider> 12 | <ParameterSlider id="p-rot" v-model="LG_PLANET_DATA.planetRotation" :step="1" :min="0" :max="360"> 13 | {{ $t('editor.controls.planet_rendering.transform_rotation') }} <sup>(°)</sup> 14 | </ParameterSlider> 15 | </template> 16 | </ParameterGroup> 17 | <ParameterGroup toggleable> 18 | <template #title>{{ $t('editor.controls.planet_rendering.metallicroughness') }}</template> 19 | <template #content> 20 | <ParameterSlider id="p-wlevel" v-model="LG_PLANET_DATA.planetWaterLevel" :step="0.01" :min="0" :max="1"> 21 | {{ $t('editor.controls.planet_rendering.waterlevel') }} 22 | </ParameterSlider> 23 | <ParameterDivider /> 24 | <ParameterSlider id="p-wrough" v-model="LG_PLANET_DATA.planetWaterRoughness" :step="0.01" :min="0" :max="1"> 25 | {{ $t('editor.controls.planet_rendering.metallicroughness_waterroughness') }} 26 | </ParameterSlider> 27 | <ParameterSlider id="p-wmetal" v-model="LG_PLANET_DATA.planetWaterMetalness" :step="0.01" :min="0" :max="1"> 28 | {{ $t('editor.controls.planet_rendering.metallicroughness_watermetalness') }} 29 | </ParameterSlider> 30 | <ParameterDivider /> 31 | <ParameterSlider id="p-grough" v-model="LG_PLANET_DATA.planetGroundRoughness" :step="0.01" :min="0" :max="1"> 32 | {{ $t('editor.controls.planet_rendering.metallicroughness_groundroughness') }} 33 | </ParameterSlider> 34 | <ParameterSlider id="p-gmetal" v-model="LG_PLANET_DATA.planetGroundMetalness" :step="0.01" :min="0" :max="1"> 35 | {{ $t('editor.controls.planet_rendering.metallicroughness_groundmetalness') }} 36 | </ParameterSlider> 37 | </template> 38 | </ParameterGroup> 39 | <ParameterGroup v-model="LG_PLANET_DATA.planetShowEmissive" :toggleable="LG_PLANET_DATA.planetShowEmissive"> 40 | <template #title>{{ $t('editor.controls.planet_rendering.emissivity') }}</template> 41 | <template #content> 42 | <ParameterSlider 43 | id="e-wemissive" 44 | v-model="LG_PLANET_DATA.planetWaterEmissiveIntensity" 45 | :step="0.01" 46 | :min="0" 47 | :max="10" 48 | > 49 | {{ $t('editor.controls.planet_rendering.emissivity_waterintensity') }} 50 | </ParameterSlider> 51 | <ParameterSlider 52 | id="e-gemissive" 53 | v-model="LG_PLANET_DATA.planetGroundEmissiveIntensity" 54 | :step="0.01" 55 | :min="0" 56 | :max="10" 57 | > 58 | {{ $t('editor.controls.planet_rendering.emissivity_groundintensity') }} 59 | </ParameterSlider> 60 | </template> 61 | </ParameterGroup> 62 | </ParameterGrid> 63 | </template> 64 | <script setup lang="ts"> 65 | import { LG_PLANET_DATA } from '@/core/services/editor.service'; 66 | </script> 67 | -------------------------------------------------------------------------------- /src/core/tsl/features/lwd.ts: -------------------------------------------------------------------------------- 1 | import { vec3, float, mix, Fn, clamp, int } from 'three/tsl'; 2 | import { fbm3 } from '../noise/fbm3'; 3 | import type { VaryingNode } from 'three/webgpu'; 4 | import { fbm1 } from '../noise/fbm1'; 5 | import type { UniformNumberNode, UniformVector3Node, UniformVector4Node } from '../tsl-types'; 6 | 7 | export const doDisplace = /*@__PURE__*/ Fn( 8 | ([i_position, i_params, i_noise]: [VaryingNode, UniformVector3Node, UniformVector4Node]) => { 9 | const vPos = vec3(i_position).toVar('vPos'); 10 | const eps = float(i_params.y).toVar('eps'); 11 | const mul = float(i_params.z).toVar('mul'); 12 | 13 | const n1 = float(fbm3(vec3(vPos.x.add(eps), vPos.y, vPos.z), i_noise)).toVar('n1'); 14 | const n2 = float(fbm3(vec3(vPos.x.sub(eps), vPos.y, vPos.z), i_noise)).toVar('n2'); 15 | const dx = float(n1.sub(n2).div(mul.mul(eps))).toVar('dx'); 16 | 17 | n1.assign(fbm3(vec3(vPos.x, vPos.y.add(eps), vPos.z), i_noise)); 18 | n2.assign(fbm3(vec3(vPos.x, vPos.y.sub(eps), vPos.z), i_noise)); 19 | const dy = float(n1.sub(n2).div(mul.mul(eps))).toVar('dy'); 20 | 21 | n1.assign(fbm3(vec3(vPos.x, vPos.y, vPos.z.add(eps)), i_noise)); 22 | n2.assign(fbm3(vec3(vPos.x, vPos.y, vPos.z.sub(eps)), i_noise)); 23 | const dz = float(n1.sub(n2).div(mul.mul(eps))).toVar('dz'); 24 | 25 | return mix(vPos, vec3(dx, dy, dz), i_params.x); 26 | }, 27 | ).setLayout({ 28 | name: 'LG_LWD_doDisplace', 29 | type: 'vec3', 30 | inputs: [ 31 | { name: 'i_position', type: 'vec3' }, 32 | { name: 'i_params', type: 'vec3' }, 33 | { name: 'i_noise', type: 'vec4' }, 34 | ], 35 | }); 36 | 37 | // ---------------------------------------------------------------------------- 38 | 39 | export const layer = /*@__PURE__*/ Fn( 40 | ([i_position, i_noise, i_layers]: [VaryingNode, UniformVector4Node, UniformNumberNode]) => { 41 | const height = float(fbm3(i_position, i_noise)).toVar('height'); 42 | height.assign(mix(height, fbm1(height, i_noise), clamp(i_layers.sub(1.0), 0.0, 1.0))); 43 | height.assign(mix(height, fbm1(height, i_noise), clamp(i_layers.sub(2.0), 0.0, 1.0))); 44 | return height; 45 | }, 46 | ).setLayout({ 47 | name: 'LG_LWD_layer', 48 | type: 'float', 49 | inputs: [ 50 | { name: 'i_position', type: 'vec3' }, 51 | { name: 'i_noise', type: 'vec4' }, 52 | { name: 'i_layers', type: 'int' }, 53 | ], 54 | }); 55 | 56 | export const warp = /*@__PURE__*/ Fn( 57 | ([i_position, i_params, i_enable]: [VaryingNode, UniformVector4Node, UniformNumberNode]) => { 58 | const vPos = vec3(i_position).toVar('vPos'); 59 | vPos.x.mulAssign(mix(1.0, i_params.y, i_enable)); 60 | vPos.y.mulAssign(mix(1.0, i_params.z, i_enable)); 61 | vPos.z.mulAssign(mix(1.0, i_params.w, i_enable)); 62 | return vPos; 63 | }, 64 | ).setLayout({ 65 | name: 'LG_LWD_warp', 66 | type: 'vec3', 67 | inputs: [ 68 | { name: 'i_position', type: 'vec3' }, 69 | { name: 'i_params', type: 'vec4' }, 70 | { name: 'i_enable', type: 'float' }, 71 | ], 72 | }); 73 | 74 | export const displace = /*@__PURE__*/ Fn( 75 | ([i_position, i_params, i_noise, i_enable]: [ 76 | VaryingNode, 77 | UniformVector3Node, 78 | UniformVector4Node, 79 | UniformNumberNode, 80 | ]) => { 81 | const vPos = vec3(i_position).toVar('vPos'); 82 | const enabled = int(i_enable).toVar('enabled'); 83 | return mix(vPos, doDisplace(i_position, i_params, i_noise), enabled); 84 | }, 85 | ).setLayout({ 86 | name: 'LG_LWD_displace', 87 | type: 'vec3', 88 | inputs: [ 89 | { name: 'i_position', type: 'vec3' }, 90 | { name: 'i_params', type: 'vec3' }, 91 | { name: 'i_noise', type: 'vec4' }, 92 | { name: 'i_enable', type: 'int' }, 93 | ], 94 | }); 95 | -------------------------------------------------------------------------------- /src/core/utils/texture/layered-data-texture.ts: -------------------------------------------------------------------------------- 1 | import saveAs from 'file-saver'; 2 | import { nanoid } from 'nanoid'; 3 | import { DataTexture } from 'three'; 4 | 5 | /** 6 | * DataTexture wrapper to manage multiple layers more efficiently, such as for biomes. 7 | * 8 | * *Note: layers are stored in descending order.* 9 | */ 10 | export type LayerDrawOptions = { width?: number; height?: number }; 11 | export type Layer = { id: string; canvas: OffscreenCanvas }; 12 | export class LayeredDataTexture<DataObject> { 13 | private _layers: Layer[] = []; 14 | private _workCanvas: OffscreenCanvas; 15 | private _texture: DataTexture; 16 | private _width: number; 17 | private _height: number; 18 | private _layerDrawFunc: (dataObj: DataObject, canvas: OffscreenCanvas) => void; 19 | 20 | public get layers(): Layer[] { 21 | return this._layers; 22 | } 23 | public get texture(): DataTexture { 24 | return this._texture; 25 | } 26 | 27 | constructor( 28 | width: number, 29 | height: number, 30 | dataObjs: DataObject[], 31 | drawFunc: (dataObj: DataObject, canvas: OffscreenCanvas) => void, 32 | ) { 33 | this._width = width; 34 | this._height = height; 35 | this._layerDrawFunc = drawFunc; 36 | this._workCanvas = new OffscreenCanvas(width, height); 37 | this._texture = new DataTexture(null, width, height); 38 | 39 | dataObjs.forEach((d) => this.addLayer(d)); 40 | this.updateTexture(); 41 | } 42 | 43 | public dispose() { 44 | this._layers.splice(0); 45 | this._texture.dispose(); 46 | } 47 | 48 | public debugSaveTexture() { 49 | saveAs(new Blob([this._texture.image.data as BlobPart]), 'layeredtex.raw'); 50 | } 51 | 52 | public updateTexture() { 53 | const ctx = this._workCanvas.getContext('2d', { willReadFrequently: true })!; 54 | ctx.clearRect(0, 0, this._width, this._height); 55 | this._layers.toReversed().forEach((layer) => ctx.drawImage(layer.canvas, 0, 0)); 56 | this._texture.image = ctx.getImageData(0, 0, this._width, this._height); 57 | this._texture.needsUpdate = true; 58 | } 59 | 60 | public updateAllLayers(data: DataObject[]) { 61 | if (data.length !== this._layers.length) { 62 | console.warn( 63 | `<Lagrange> Layer count (${this._layers.length}) does not match data count (${data.length})! Please report this on GitHub if you see this message.`, 64 | ); 65 | } 66 | this._layers.forEach((layer, i) => this._layerDrawFunc(data[i], layer.canvas)); 67 | this.updateTexture(); 68 | } 69 | 70 | public updateLayer(index: number, data: DataObject) { 71 | const layer = this._layers.at(index); 72 | if (!layer) { 73 | console.warn(`<Lagrange> Cannot update layer: layer at index ${index} does not exist`); 74 | return; 75 | } 76 | if (!data) { 77 | return; 78 | } 79 | this._layerDrawFunc(data, layer.canvas); 80 | this.updateTexture(); 81 | } 82 | 83 | public addLayer(data: DataObject): Layer { 84 | const newLayer = { id: nanoid(), canvas: new OffscreenCanvas(this._width, this._height) }; 85 | this._layers.push(newLayer); 86 | this._layerDrawFunc(data, newLayer.canvas); 87 | this.updateTexture(); 88 | return newLayer; 89 | } 90 | 91 | public removeLayer(index: number) { 92 | this._layers.splice(index, 1); 93 | this.updateTexture(); 94 | } 95 | 96 | public moveLayer(index: number, diff: -1 | 1) { 97 | const element = this.layers[index]; 98 | this.layers.splice(index, 1); 99 | this.layers.splice(index + diff, 0, element); 100 | this.updateTexture(); 101 | } 102 | 103 | public reset(dataObjs?: DataObject[]) { 104 | this.layers.splice(0); 105 | dataObjs?.forEach((d) => this.addLayer(d)); 106 | this.updateTexture(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/components/global/AppFooter.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <footer v-show="$route.name !== 'page-not-found'" id="app-footer"> 3 | <span class="footer-corner lb" /> 4 | <span class="footer-corner l" /> 5 | <span class="footer-corner rb" /> 6 | <span class="footer-corner r" /> 7 | <div id="app-footer-nav"> 8 | <LgvButton 9 | variant="dark" 10 | icon="mingcute:information-line" 11 | :a11y-label="$t('a11y.footer_about')" 12 | @click="infoDialog!.open()" 13 | /> 14 | <LgvButton 15 | variant="dark" 16 | icon="mingcute:settings-6-line" 17 | :a11y-label="$t('a11y.footer_settings')" 18 | @click="settingsDialog!.open()" 19 | /> 20 | <LgvLink 21 | variant="dark" 22 | link-type="external" 23 | href="https://github.com/EepyBerry/lagrange" 24 | icon="mingcute:github-line" 25 | /> 26 | <ExtraSpecialDayElement /> 27 | </div> 28 | </footer> 29 | <AppAboutDialog ref="infoDialog" /> 30 | <AppSettingsDialog ref="settingsDialog" /> 31 | </template> 32 | 33 | <script setup lang="ts"> 34 | import { ref, type Ref } from 'vue'; 35 | import AppAboutDialog from '@components/global/dialogs/AboutDialog.vue'; 36 | import AppSettingsDialog from '@components/global/dialogs/SettingsDialog.vue'; 37 | import ExtraSpecialDayElement from '@components/global/extras/ExtraSpecialDayElement.vue'; 38 | import LgvButton from '@/_lib/components/LgvButton.vue'; 39 | import LgvLink from '@/_lib/components/LgvLink.vue'; 40 | const infoDialog: Ref<{ open: () => void; close: () => void } | null> = ref(null); 41 | const settingsDialog: Ref<{ open: () => void; close: () => void } | null> = ref(null); 42 | </script> 43 | 44 | <style lang="scss"> 45 | #app-footer { 46 | position: fixed; 47 | bottom: 0; 48 | padding: 0.5rem; 49 | display: flex; 50 | align-items: center; 51 | align-self: center; 52 | gap: 1rem; 53 | z-index: 5; 54 | 55 | background: var(--lg-panel); 56 | border: 1px solid var(--lg-accent); 57 | border-bottom: 0; 58 | 59 | .footer-corner { 60 | position: absolute; 61 | bottom: 0; 62 | width: 0; 63 | height: 0; 64 | border-style: solid; 65 | 66 | $corner-width: 1.5rem; 67 | $corner-height: 3.5rem; 68 | $corner-border-width: calc(1.5rem + 1px); 69 | $corner-border-height: calc(3.5rem + 1px); 70 | 71 | &.lb { 72 | left: calc(0px - $corner-border-width); 73 | border-width: 0 0 $corner-border-height $corner-width; 74 | border-color: transparent transparent var(--lg-accent) transparent; 75 | } 76 | &.l { 77 | left: -$corner-width; 78 | border-width: 0 0 $corner-height $corner-width; 79 | border-color: transparent transparent var(--lg-panel) transparent; 80 | } 81 | &.rb { 82 | right: calc(0px - $corner-border-width); 83 | border-width: $corner-border-height 0 0 $corner-width; 84 | border-color: transparent transparent transparent var(--lg-accent); 85 | } 86 | &.r { 87 | right: -$corner-width; 88 | border-width: $corner-height 0 0 $corner-width; 89 | border-color: transparent transparent transparent var(--lg-panel); 90 | } 91 | } 92 | #app-footer-nav { 93 | display: flex; 94 | flex-direction: row; 95 | align-items: center; 96 | justify-content: center; 97 | gap: 0.5rem; 98 | 99 | hr { 100 | height: 1.5rem; 101 | border-color: var(--lg-accent); 102 | } 103 | } 104 | } 105 | @media screen and (max-width: 1199px) { 106 | #app-footer { 107 | align-self: flex-end; 108 | border-top-right-radius: 0; 109 | border-right: 0; 110 | } 111 | } 112 | 113 | @media screen and (max-width: 767px) { 114 | #app-footer { 115 | display: none; 116 | } 117 | } 118 | </style> 119 | -------------------------------------------------------------------------------- /src/components/global/AppNavigation.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <LgvButton 3 | id="nav-toggle" 4 | ref="navMenuTrigger" 5 | variant="dark" 6 | class="contrast" 7 | :class="{ active: isNavMenuOpen }" 8 | :icon="isNavMenuOpen ? 'material-symbols:menu-open-rounded' : 'material-symbols:menu-rounded'" 9 | icon-width="1.75rem" 10 | :a11y-label="$t('a11y.action_nav_toggle')" 11 | /> 12 | <nav id="nav-menu" ref="navMenu" class="floating" :style="navFloatingStyles.floatingStyles.value"> 13 | <LgvLink 14 | variant="dark" 15 | link-type="internal" 16 | class="dark contrast" 17 | :active="!!$route.params.id" 18 | :href="uwuifyPath('/codex')" 19 | :a11y-label="$t('a11y.action_nav_codex')" 20 | icon="mingcute:book-2-line" 21 | > 22 | {{ $t('main.nav.codex') }} 23 | </LgvLink> 24 | <LgvLink 25 | variant="dark" 26 | link-type="internal" 27 | class="dark contrast" 28 | :class="{ 'router-link-active': !!$route.params.id }" 29 | :href="uwuifyPath('/planet-editor/new')" 30 | :a11y-label="$t('a11y.action_nav_editor')" 31 | icon="mingcute:planet-line" 32 | > 33 | {{ $t('main.nav.editor') }} 34 | </LgvLink> 35 | </nav> 36 | </template> 37 | 38 | <script setup lang="ts"> 39 | import { EventBus } from '@/core/event-bus'; 40 | import { uwuifyPath } from '@core/extras'; 41 | import { useFloating, autoUpdate, offset, type Placement } from '@floating-ui/vue'; 42 | import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'; 43 | import LgvButton from '@/_lib/components/LgvButton.vue'; 44 | import * as Globals from '@core/globals'; 45 | import LgvLink from '@/_lib/components/LgvLink.vue'; 46 | 47 | const navMenuTrigger = useTemplateRef('navMenuTrigger'); 48 | const navMenu = useTemplateRef('navMenu'); 49 | const navFloatingPlacement: Ref<Placement> = ref('right'); 50 | const navFloatingStyles = useFloating(navMenuTrigger, navMenu, { 51 | whileElementsMounted: autoUpdate, 52 | placement: navFloatingPlacement, 53 | middleware: [offset(8)], 54 | }); 55 | const isNavMenuOpen: Ref<boolean> = ref(false); 56 | 57 | onMounted(() => { 58 | EventBus.registerWindowEventListener('resize', updateNavFloatingLayout); 59 | updateNavFloatingLayout(); 60 | }); 61 | watch( 62 | () => EventBus.clickEvent.value, 63 | (evt) => { 64 | if (!evt) return; 65 | if ((evt.target as HTMLElement).id === navMenuTrigger.value!.$el.id) { 66 | toggleNavMenu(); 67 | } else if (!navMenu.value?.contains(evt.target as Node)) { 68 | toggleNavMenu(false); 69 | } 70 | }, 71 | ); 72 | 73 | function updateNavFloatingLayout() { 74 | if (window.innerWidth < Globals.SM_WIDTH_THRESHOLD) { 75 | navFloatingPlacement.value = 'bottom-start'; 76 | } else { 77 | navFloatingPlacement.value = 'right'; 78 | } 79 | } 80 | function toggleNavMenu(override?: boolean) { 81 | if (override !== undefined) { 82 | navMenu.value!.style.visibility = override ? 'visible' : 'hidden'; 83 | isNavMenuOpen.value = override; 84 | } else { 85 | navMenu.value!.style.visibility = navMenu.value!.style.visibility === 'visible' ? 'hidden' : 'visible'; 86 | isNavMenuOpen.value = navMenu.value!.style.visibility === 'visible'; 87 | } 88 | } 89 | </script> 90 | 91 | <style lang="scss"> 92 | #nav-toggle { 93 | width: 2.75rem; 94 | height: 2.75rem; 95 | } 96 | #nav-menu { 97 | z-index: 1; 98 | background: none; 99 | border: none; 100 | 101 | display: flex; 102 | flex-direction: row; 103 | align-items: center; 104 | gap: 0.5rem; 105 | 106 | & > a { 107 | flex: 1; 108 | height: 2.75rem; 109 | width: 100%; 110 | } 111 | } 112 | 113 | @media screen and (max-width: 767px) { 114 | #nav-menu { 115 | flex-direction: column; 116 | } 117 | } 118 | </style> 119 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterGroup.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <section class="parameter-group" :class="{ expanded: _expanded }" role="group" :aria-expanded="_expanded"> 3 | <button 4 | type="button" 5 | class="parameter-group-title" 6 | :class="{ locked: !toggleable }" 7 | @click="toggleExpand()" 8 | @keydown.enter="toggleExpand()" 9 | > 10 | <span> 11 | <input 12 | v-model="_toggleParam" 13 | type="checkbox" 14 | class="lg" 15 | :class="{ 'no-model': _toggleParam === undefined }" 16 | @click="handleCheckboxClick" 17 | /> 18 | <h4><slot name="title">GROUP_TITLE</slot></h4> 19 | </span> 20 | <iconify-icon v-if="toggleable" class="indicator" icon="mingcute:right-fill" width="1rem" aria-hidden="true" /> 21 | </button> 22 | <div v-show="_expanded" class="parameter-group-content"> 23 | <slot name="content"> 24 | <span class="default">Nothing here yet, sorry :c</span> 25 | </slot> 26 | </div> 27 | </section> 28 | </template> 29 | 30 | <script setup lang="ts"> 31 | import { type Ref, onMounted, ref, watch } from 'vue'; 32 | 33 | const _toggleParam = defineModel<boolean | undefined>({ default: undefined }); 34 | const _expanded: Ref<boolean> = ref(true); 35 | 36 | const _props = defineProps<{ expand?: boolean; toggleable?: boolean }>(); 37 | onMounted(() => (_expanded.value = _props.expand ?? true)); 38 | watch( 39 | () => _toggleParam.value, 40 | (v) => { 41 | if (!v) _expanded.value = false; 42 | }, 43 | ); 44 | 45 | function handleCheckboxClick(evt: Event) { 46 | evt.stopImmediatePropagation(); 47 | } 48 | function toggleExpand() { 49 | _expanded.value = !_expanded.value; 50 | } 51 | </script> 52 | 53 | <style scoped lang="scss"> 54 | .parameter-group { 55 | grid-column: span 2; 56 | 57 | background: var(--lg-panel); 58 | border: 1px solid var(--lg-accent); 59 | border-radius: 2px; 60 | 61 | display: flex; 62 | flex-direction: column; 63 | justify-content: center; 64 | gap: 4px; 65 | 66 | &.expanded > .parameter-group-title > .indicator { 67 | transform: rotateZ(90deg); 68 | } 69 | 70 | .parameter-group-title { 71 | min-height: 2.25rem; 72 | font-size: 1rem; 73 | font-weight: 600; 74 | padding: 0 0.5rem; 75 | 76 | background: none; 77 | border: none; 78 | color: unset; 79 | 80 | cursor: pointer; 81 | user-select: none; 82 | 83 | display: flex; 84 | justify-content: space-between; 85 | align-items: center; 86 | 87 | span { 88 | display: flex; 89 | align-items: center; 90 | gap: 8px; 91 | 92 | input[type='checkbox'] { 93 | pointer-events: all; 94 | cursor: pointer; 95 | } 96 | input[type='checkbox'].no-model { 97 | opacity: 0; 98 | visibility: hidden; 99 | display: none; 100 | pointer-events: none; 101 | cursor: default; 102 | } 103 | } 104 | 105 | &.locked { 106 | cursor: default; 107 | pointer-events: none; 108 | } 109 | } 110 | .parameter-group-content { 111 | font-size: 0.875rem; 112 | font-weight: 300; 113 | padding: 0 0.5rem 0.5rem; 114 | overflow-x: auto; 115 | 116 | display: grid; 117 | grid-template-columns: auto auto; 118 | align-items: center; 119 | gap: 8px 0; 120 | } 121 | .parameter-group-content { 122 | .default { 123 | grid-column: span 2; 124 | text-align: center; 125 | font-size: 0.75rem; 126 | } 127 | } 128 | } 129 | .parameter-group.warn { 130 | background: var(--lg-warn-panel); 131 | border: 1px solid var(--lg-warn); 132 | } 133 | 134 | @media screen and (max-width: 1199px) { 135 | .parameter-group { 136 | min-width: 16rem; 137 | .parameter-group-title { 138 | padding: 0 0.75rem; 139 | } 140 | } 141 | } 142 | @media screen and (max-width: 767px) { 143 | .parameter-group { 144 | .parameter-group-title { 145 | padding: 0 0.5rem; 146 | } 147 | } 148 | } 149 | </style> 150 | -------------------------------------------------------------------------------- /src/components/global/parameters/ParameterRing.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div :id="lgParam!.id" class="ring-grid" :class="{ expanded: _expanded }"> 3 | <div class="ring-header"> 4 | <div class="ring-info"> 5 | <LgvButton 6 | variant="icon" 7 | class="sm" 8 | :icon="_expanded ? 'mingcute:down-fill' : 'mingcute:right-fill'" 9 | @click="toggleExpand()" 10 | @keydown.enter="toggleExpand()" 11 | /> 12 | <span class="current-color" :style="{ background: colorRampToStyle(lgParam!.colorRamp).color }"> 13 | <div class="alpha-color" :style="{ background: colorRampToStyle(lgParam!.colorRamp).alpha }"></div> 14 | </span> 15 | <span class="ring-index">{{ getPartialId() }}</span> 16 | </div> 17 | <span class="ring-actions"> 18 | <LgvButton class="sm warn" icon="mingcute:delete-2-line" @click="$emit('delete', lgParam!)" /> 19 | </span> 20 | </div> 21 | <div v-show="_expanded" class="ring-content"> 22 | <hr class="info-divider" /> 23 | <ParameterSlider :id="lgParam!.id + '-r-inner'" v-model="lgParam!.innerRadius" :step="0.01" :min="1.25" :max="5"> 24 | {{ $t('editor.controls.ring.transform_radius_inner') }} 25 | </ParameterSlider> 26 | <ParameterSlider :id="lgParam!.id + '-r-outer'" v-model="lgParam!.outerRadius" :step="0.01" :min="1.25" :max="5"> 27 | {{ $t('editor.controls.ring.transform_radius_outer') }} 28 | </ParameterSlider> 29 | <ParameterColorRamp :key="lgParam!.id" v-model="lgParam!.colorRamp" mode="rgba"> 30 | {{ $t('editor.general.noise_rgbaramp') }} 31 | </ParameterColorRamp> 32 | </div> 33 | </div> 34 | </template> 35 | 36 | <script setup lang="ts"> 37 | import LgvButton from '@/_lib/components/LgvButton.vue'; 38 | import type { RingParameters } from '@core/models/ring-parameters.model'; 39 | import { colorRampToStyle } from '@core/utils/render-utils'; 40 | import { onMounted, ref, type Ref } from 'vue'; 41 | 42 | const lgParam = defineModel<RingParameters>(); 43 | 44 | const _expanded: Ref<boolean> = ref(true); 45 | const _props = defineProps<{ index: number; expand?: boolean }>(); 46 | 47 | defineEmits(['delete']); 48 | onMounted(() => (_expanded.value = _props.expand ?? true)); 49 | 50 | function toggleExpand() { 51 | _expanded.value = !_expanded.value; 52 | } 53 | 54 | function getPartialId() { 55 | return lgParam.value?.id.substring(0, 6); 56 | } 57 | </script> 58 | 59 | <style scoped lang="scss"> 60 | .ring-grid { 61 | grid-column: span 2; 62 | max-width: 100%; 63 | min-height: 2rem; 64 | background: var(--lg-panel); 65 | border: 1px solid var(--lg-accent); 66 | border-radius: 2px; 67 | 68 | display: flex; 69 | flex-direction: column; 70 | padding: 0.5rem; 71 | 72 | .ring-header { 73 | grid-column: span 2; 74 | display: flex; 75 | align-items: center; 76 | justify-content: space-between; 77 | gap: 0.5rem; 78 | .ring-index { 79 | font-weight: 400; 80 | } 81 | .ring-info, 82 | .ring-actions { 83 | display: flex; 84 | align-items: center; 85 | gap: 8px; 86 | } 87 | } 88 | .ring-content { 89 | display: grid; 90 | grid-template-columns: auto auto; 91 | align-items: center; 92 | gap: 0.5rem; 93 | } 94 | hr.info-divider { 95 | border-style: dotted; 96 | grid-column: span 2; 97 | margin: 0.5rem 0; 98 | } 99 | hr.action-divider { 100 | height: 1.25rem; 101 | } 102 | } 103 | .current-color { 104 | display: inline-flex; 105 | align-self: center; 106 | width: 3rem; 107 | height: 1.5rem; 108 | border-radius: 2px; 109 | border: 1px solid var(--lg-accent); 110 | } 111 | strong { 112 | font-weight: 550; 113 | } 114 | 115 | @media screen and (max-width: 1023px) { 116 | .biome-grid { 117 | gap: 0 8px; 118 | font-size: 1rem; 119 | } 120 | } 121 | @media screen and (max-width: 767px) { 122 | .biome-grid { 123 | .biome-content { 124 | .biome-type { 125 | font-size: 1rem; 126 | flex-wrap: wrap; 127 | } 128 | } 129 | } 130 | } 131 | </style> 132 | -------------------------------------------------------------------------------- /src/core/effects/lens-flare.effect.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { LG_MESH_NAME_PLANET, LG_MESH_NAME_RING_ANCHOR } from '../globals'; 3 | import { damp } from 'three/src/math/MathUtils.js'; 4 | import { type NodeMaterial, type WebGPURenderer } from 'three/webgpu'; 5 | import { 6 | LensFlareTSLMaterial, 7 | type LensFlareData, 8 | type LensFlareUniforms, 9 | } from '@core/tsl/materials/lens-flare.tslmat'; 10 | 11 | /** 12 | * Custom class that contains all the processing required to create lens flares. 13 | * 14 | * Based on Anderson Mancini's code: https://github.com/ektogamat/lensflare-threejs-vanilla 15 | */ 16 | export class LensFlareEffect { 17 | private _parameters: LensFlareData; 18 | private _tslMaterial: LensFlareTSLMaterial; 19 | private _mesh: THREE.Mesh; 20 | private _uniforms: LensFlareUniforms; 21 | 22 | private _internalOpacity: number; 23 | private _viewport: THREE.Vector4; 24 | private _flarePosition: THREE.Vector3; 25 | private _raycaster: THREE.Raycaster; 26 | 27 | constructor(data: LensFlareData) { 28 | this._internalOpacity = 1; 29 | this._viewport = new THREE.Vector4(); 30 | this._flarePosition = new THREE.Vector3(); 31 | this._raycaster = new THREE.Raycaster(); 32 | 33 | this._parameters = data; 34 | this._tslMaterial = new LensFlareTSLMaterial(data); 35 | this._uniforms = this._tslMaterial.uniforms; 36 | this._mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 1, 1), this._tslMaterial.buildMaterial()); 37 | this._mesh.frustumCulled = false; 38 | } 39 | 40 | private checkTransparency(intersects: THREE.Intersection[]) { 41 | if (intersects?.length === 0) { 42 | this._internalOpacity = 1; 43 | return; 44 | } 45 | 46 | const iObject = intersects[0].object as THREE.Mesh; 47 | const iMaterial = iObject.material as NodeMaterial; 48 | if (!iObject.visible) { 49 | this._internalOpacity = 1; 50 | } else { 51 | if (iMaterial.transparent && iMaterial.opacity < 0.98) { 52 | this._internalOpacity = 1 / (iMaterial.opacity * 10); 53 | } else { 54 | this._internalOpacity = iObject.userData.lens === 'no-occlusion' ? 1 : 0; 55 | } 56 | } 57 | } 58 | 59 | public update(renderer: WebGPURenderer, scene: THREE.Scene, camera: THREE.Camera, clock: THREE.Clock) { 60 | const dt = clock.getDelta(); 61 | 62 | renderer.getViewport(this._viewport); 63 | this._mesh.lookAt(camera.position); 64 | this._tslMaterial.uniforms.resolution.value.x = this._viewport.z; 65 | this._tslMaterial.uniforms.resolution.value.y = this._viewport.w; 66 | 67 | const projectedPosition = this._parameters.lensPosition.clone(); 68 | projectedPosition.project(camera); 69 | 70 | this._flarePosition.set(projectedPosition.x, projectedPosition.y, projectedPosition.z); 71 | if (this._flarePosition.z < 1) { 72 | this._tslMaterial.uniforms.lensPosition.value.x = this._flarePosition.x; 73 | this._tslMaterial.uniforms.lensPosition.value.y = this._flarePosition.y; 74 | } 75 | 76 | this._raycaster.setFromCamera(new THREE.Vector2(projectedPosition.x, projectedPosition.y), camera); 77 | 78 | const planet = scene.getObjectByName(LG_MESH_NAME_PLANET); 79 | const rings = scene.getObjectByName(LG_MESH_NAME_RING_ANCHOR)?.children; 80 | if (planet && rings) { 81 | const intersects = this._raycaster.intersectObjects([planet, ...rings], false); 82 | this.checkTransparency(intersects); 83 | } 84 | this._tslMaterial.uniforms.opacity.value = damp( 85 | this._tslMaterial.uniforms.opacity.value, 86 | this._internalOpacity, 87 | 10, 88 | dt, 89 | ); 90 | } 91 | 92 | public updatePosition(lensPosition: THREE.Vector3) { 93 | this._tslMaterial.uniforms.lensPosition.value.x = lensPosition.x; 94 | this._tslMaterial.uniforms.lensPosition.value.y = lensPosition.y; 95 | this._tslMaterial.uniforms.lensPosition.value.z = lensPosition.z; 96 | } 97 | 98 | public get mesh(): THREE.Mesh { 99 | return this._mesh; 100 | } 101 | 102 | public get uniforms(): LensFlareUniforms { 103 | return this._uniforms; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AmbientLight, 3 | Clock, 4 | DataTexture, 5 | DirectionalLight, 6 | Group, 7 | Mesh, 8 | PerspectiveCamera, 9 | Scene, 10 | Texture, 11 | } from 'three'; 12 | import type { LensFlareEffect } from './effects/lens-flare.effect'; 13 | import type { WebGPURenderer } from 'three/webgpu'; 14 | import type { PlanetUniforms } from '@core/tsl/materials/planet.tslmat'; 15 | import type { AtmosphereUniforms } from '@core/tsl/materials/atmosphere.tslmat'; 16 | import type { CloudsUniforms } from '@core/tsl/materials/clouds.tslmat'; 17 | import type { RingUniforms } from '@core/tsl/materials/ring.tslmat'; 18 | import type { LensFlareUniforms } from '@core/tsl/materials/lens-flare.tslmat'; 19 | import type { LayeredDataTexture } from './utils/texture/layered-data-texture'; 20 | import type { BiomeParameters } from './models/biome-parameters.model'; 21 | 22 | // ---------------------------------- Editor types ---------------------------------- 23 | export type EditorMessageLevel = 'success' | 'info' | 'warn' | 'wip'; 24 | export enum EditorSceneCreationMode { 25 | EDITOR, 26 | PREVIEW, 27 | } 28 | export enum EditorBackendType { 29 | WEBGL, 30 | WEBGPU, 31 | } 32 | export enum EditorState { 33 | INITIALIZATION = 'INITIALIZATION', 34 | EDITION = 'EDITION', 35 | RANDOMIZATION = 'RANDOMIZATION', 36 | RESET = 'RESET', 37 | PREVIEW_GENERATION = 'PREVIEW_GENERATION', 38 | SCENE_DISPOSAL = 'SCENE_DISPOSAL', 39 | EXPORT = 'EXPORT', 40 | ERROR = 'ERROR', 41 | } 42 | 43 | // ---------------------------------- Shader loader --------------------------------- 44 | export enum ShaderFileType { 45 | CORE, 46 | BAKING, 47 | FUNCTION, 48 | } 49 | 50 | // ----------------------------------- Editor types --------------------------------- 51 | export enum PlanetType { 52 | PLANET, 53 | MOON, 54 | GASGIANT, 55 | } 56 | export enum PlanetClass { 57 | PLANET_TELLURIC, 58 | PLANET_ICE, 59 | PLANET_OCEAN, 60 | PLANET_TROPICAL, 61 | PLANET_ARID, 62 | PLANET_CHTHONIAN, 63 | PLANET_MAGMATIC, 64 | MOON_ROCKY, 65 | MOON_ICE, 66 | MOON_CHTHONIAN, 67 | GASGIANT_COLD, 68 | GASGIANT_HOT, 69 | INDETERMINATE, 70 | } 71 | 72 | export enum ColorMode { 73 | REALISTIC, 74 | DIRECT, 75 | MIXED, 76 | } 77 | 78 | export enum GradientMode { 79 | REALISTIC = 0, 80 | POLE_TO_POLE = 1, 81 | FULLNOISE = 2, 82 | } 83 | 84 | // ------------------------------------ Main data ----------------------------------- 85 | export type EditorSceneData = { 86 | // Scene, renderer, camera 87 | scene: Scene; 88 | renderer: WebGPURenderer; 89 | camera: PerspectiveCamera; 90 | 91 | // Groups 92 | planetGroup: Group; 93 | ringAnchor: Group; 94 | 95 | // Main objects 96 | planet: PlanetMeshData; 97 | clouds: CloudsMeshData; 98 | atmosphere: AtmosphereMeshData; 99 | rings: RingMeshData[]; 100 | sunLight: DirectionalLight; 101 | ambLight: AmbientLight; 102 | lensFlare?: LensFlareEffect; 103 | 104 | // Misc 105 | clock?: Clock; 106 | }; 107 | 108 | // ------------------------------------ Mesh data ----------------------------------- 109 | export type PlanetMeshData = { 110 | mesh?: Mesh; 111 | uniforms?: PlanetUniforms; 112 | 113 | surfaceBuffer: Uint8Array; 114 | surfaceTexture?: DataTexture; 115 | 116 | biomeLayersTexture?: LayeredDataTexture<BiomeParameters>; 117 | biomeEmissiveLayersTexture?: LayeredDataTexture<BiomeParameters>; 118 | }; 119 | export type CloudsMeshData = { 120 | mesh?: Mesh; 121 | uniforms?: CloudsUniforms; 122 | 123 | buffer: Uint8Array; 124 | texture?: DataTexture; 125 | }; 126 | export type AtmosphereMeshData = { 127 | mesh?: Mesh; 128 | uniforms?: AtmosphereUniforms; 129 | }; 130 | export type RingMeshData = { 131 | mesh?: Mesh; 132 | uniforms?: RingUniforms; 133 | 134 | buffer: Uint8Array | null; 135 | texture?: DataTexture; 136 | }; 137 | export type LensFlareMeshdata = { 138 | mesh?: Mesh; 139 | uniforms?: LensFlareUniforms; 140 | }; 141 | 142 | // ----------------------------------- Baking types --------------------------------- 143 | export type BakingTarget = { 144 | mesh: Mesh; 145 | textures: Texture[]; 146 | }; 147 | -------------------------------------------------------------------------------- /src/components/editor/controls/ControlsContainer.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!-- Basic data --> 3 | <CollapsibleSection 4 | icon="mingcute:paper-2-line" 5 | :expand="false" 6 | :compact-mode="compactMode" 7 | :allow-icon-mode="true" 8 | :button-aria-label="$t('editor.controls.basic_data.$title')" 9 | > 10 | <template #title>{{ $t('editor.controls.basic_data.$title') }}</template> 11 | <template #content> 12 | <ControlsBasicData /> 13 | </template> 14 | </CollapsibleSection> 15 | 16 | <!-- Lighting --> 17 | <CollapsibleSection 18 | icon="mingcute:sun-line" 19 | :expand="false" 20 | :compact-mode="compactMode" 21 | :allow-icon-mode="true" 22 | :button-aria-label="$t('editor.controls.lighting.$title')" 23 | > 24 | <template #title>{{ $t('editor.controls.lighting.$title') }}</template> 25 | <template #content> 26 | <ControlsLighting /> 27 | </template> 28 | </CollapsibleSection> 29 | 30 | <!-- Planet & Rendering --> 31 | <CollapsibleSection 32 | icon="tabler:gizmo" 33 | :expand="false" 34 | :compact-mode="compactMode" 35 | :allow-icon-mode="true" 36 | :button-aria-label="$t('editor.controls.planet_rendering.$title')" 37 | > 38 | <template #title>{{ $t('editor.controls.planet_rendering.$title') }}</template> 39 | <template #content> 40 | <ControlsRendering /> 41 | </template> 42 | </CollapsibleSection> 43 | 44 | <!-- Surface --> 45 | <CollapsibleSection 46 | icon="mingcute:grass-line" 47 | :expand="false" 48 | :compact-mode="compactMode" 49 | :allow-icon-mode="true" 50 | :button-aria-label="$t('editor.controls.surface.$title')" 51 | > 52 | <template #title>{{ $t('editor.controls.surface.$title') }}</template> 53 | <template #content> 54 | <ControlsSurface /> 55 | </template> 56 | </CollapsibleSection> 57 | 58 | <!-- Biomes --> 59 | <CollapsibleSection 60 | icon="mingcute:mountain-2-line" 61 | :expand="false" 62 | :compact-mode="compactMode" 63 | :allow-icon-mode="true" 64 | :button-aria-label="$t('editor.controls.biomes.$title')" 65 | > 66 | <template #title>{{ $t('editor.controls.biomes.$title') }}</template> 67 | <template #content> 68 | <ControlsBiomes /> 69 | </template> 70 | </CollapsibleSection> 71 | 72 | <!-- Clouds --> 73 | <CollapsibleSection 74 | icon="mingcute:clouds-line" 75 | :expand="false" 76 | :compact-mode="compactMode" 77 | :allow-icon-mode="true" 78 | :button-aria-label="$t('editor.controls.clouds.$title')" 79 | > 80 | <template #title>{{ $t('editor.controls.clouds.$title') }}</template> 81 | <template #content> 82 | <ControlsClouds /> 83 | </template> 84 | </CollapsibleSection> 85 | 86 | <!-- Atmosphere --> 87 | <CollapsibleSection 88 | icon="material-symbols:line-curve-rounded" 89 | :expand="false" 90 | :compact-mode="compactMode" 91 | :allow-icon-mode="true" 92 | :button-aria-label="$t('editor.controls.atmosphere.$title')" 93 | > 94 | <template #title>{{ $t('editor.controls.atmosphere.$title') }}</template> 95 | <template #content> 96 | <ControlsAtmosphere /> 97 | </template> 98 | </CollapsibleSection> 99 | 100 | <!-- Ring --> 101 | <CollapsibleSection 102 | icon="mingcute:planet-line" 103 | :expand="false" 104 | :compact-mode="compactMode" 105 | :allow-icon-mode="true" 106 | :button-aria-label="$t('editor.controls.atmosphere.$title')" 107 | > 108 | <template #title>{{ $t('editor.controls.ring.$title') }}</template> 109 | <template #content> 110 | <ControlsRing /> 111 | </template> 112 | </CollapsibleSection> 113 | </template> 114 | <script setup lang="ts"> 115 | import CollapsibleSection from '@components/global/elements/CollapsibleSection.vue'; 116 | import ControlsLighting from './ControlsLighting.vue'; 117 | import ControlsRendering from './ControlsRendering.vue'; 118 | import ControlsSurface from './ControlsSurface.vue'; 119 | import ControlsBiomes from './ControlsBiomes.vue'; 120 | import ControlsClouds from './ControlsClouds.vue'; 121 | import ControlsAtmosphere from './ControlsAtmosphere.vue'; 122 | import ControlsRing from './ControlsRings.vue'; 123 | import ControlsBasicData from './ControlsBasicData.vue'; 124 | defineProps<{ compactMode: boolean }>(); 125 | </script> 126 | -------------------------------------------------------------------------------- /src/components/codex/svg/SVGRingsGraph.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <svg 3 | id="svggraph-rings" 4 | :viewBox="`${svgRect.x} ${svgRect.y} ${svgRect.w} ${svgRect.h}`" 5 | role="figure" 6 | :title="$t('dialog.planetinfo.rings')" 7 | > 8 | <defs> 9 | <marker 10 | id="svggraph-rings-grad" 11 | viewBox="0 0 10 10" 12 | refX="5" 13 | refY="5" 14 | markerWidth="10" 15 | markerHeight="10" 16 | orient="auto-start-reverse" 17 | stroke="white" 18 | stroke-width="1" 19 | > 20 | <path d="M4.5,0 4.5,10" /> 21 | </marker> 22 | </defs> 23 | <svg :x="graphRect.x" :y="graphRect.y" :width="graphRect.w" :height="graphRect.h"> 24 | <g id="svggraph-rings-planet"> 25 | <circle cx="0" cy="90" :r="(props.planetRadius * 460.0) / 5.0" fill="none" stroke="white" stroke-width="2" /> 26 | <path 27 | :d="makeSVGLinearPath([0, 90], [460, 90], 4)" 28 | stroke="white" 29 | opacity="37.5%" 30 | stroke-dasharray="2" 31 | marker-mid="url(#svggraph-rings-grad)" 32 | marker-end="url(#svggraph-rings-grad)" 33 | /> 34 | <line x1="1" y1="86" x2="1" y2="94" stroke="white" stroke-width="2" /> 35 | <line x1="0" y1="90" x2="5" y2="90" stroke="white" stroke-width="2" /> 36 | <line 37 | :x1="(props.planetRadius * 460.0) / 5.0 - 3" 38 | y1="90" 39 | :x2="(props.planetRadius * 460.0) / 5.0 + 3" 40 | y2="90" 41 | stroke="white" 42 | stroke-width="2" 43 | /> 44 | 45 | <text x="5" y="84" text-anchor="start" font-style="italic" font-size="10" fill="white">c</text> 46 | <text 47 | :x="(props.planetRadius * 460.0) / 5.0 + 5" 48 | y="84" 49 | text-anchor="start" 50 | font-style="italic" 51 | font-size="10" 52 | fill="white" 53 | > 54 | s 55 | </text> 56 | <text x="100%" y="8" text-anchor="end" font-size="10" fill="white"> 57 | c = {{ $t('dialog.planetinfo.rings_center') }} 58 | </text> 59 | <text x="100%" y="20" text-anchor="end" font-size="10" fill="white"> 60 | s = {{ $t('dialog.planetinfo.rings_surface') }} 61 | </text> 62 | </g> 63 | <g id="svggraph-biomes-data"> 64 | <g v-for="(r, i) of ringData" :key="r.id"> 65 | <path 66 | :d="makeSVGRingArc((r.center * 460.0) / 5.0)" 67 | fill="none" 68 | stroke="var(--lg-contrast)" 69 | :stroke-width="(r.width * 460.0) / 5.0" 70 | /> 71 | <line 72 | :x1="(r.center * 460.0) / 5.0" 73 | y1="90" 74 | :x2="(r.center * 460.0) / 5.0" 75 | y2="58" 76 | stroke="white" 77 | stroke-width="1" 78 | /> 79 | <text :x="(r.center * 460.0) / 5.0" y="50" text-anchor="middle" font-size="14" fill="white"> 80 | {{ String.fromCharCode(i + 65) }} 81 | </text> 82 | </g> 83 | </g> 84 | </svg> 85 | </svg> 86 | </template> 87 | <script setup lang="ts"> 88 | import type { RingParameters } from '@/core/models/ring-parameters.model'; 89 | import { makeSVGLinearPath } from '@/core/utils/svg-utils'; 90 | import Rect from '@core/utils/math/rect'; 91 | import { onMounted, ref, type Ref } from 'vue'; 92 | 93 | const width = 480, 94 | height = 200; 95 | const svgRect = new Rect(0, 0, width, height); 96 | const graphRect = new Rect(10, 10, width - 20, height - 20); 97 | 98 | const props = defineProps<{ planetRadius: number; rings: RingParameters[] }>(); 99 | const ringData: Ref<{ id: string; center: number; width: number }[]> = ref([]); 100 | 101 | onMounted(() => { 102 | props.rings 103 | .toSorted((a, b) => a.innerRadius - b.innerRadius) 104 | .forEach((r) => 105 | ringData.value.push({ 106 | id: r.id, 107 | center: (r.innerRadius + r.outerRadius) / 2.0, 108 | width: r.outerRadius - r.innerRadius, 109 | }), 110 | ); 111 | }); 112 | 113 | function makeSVGRingArc(radius: number) { 114 | return `M${radius.toFixed(2)},90 a${radius.toFixed(2)},${radius.toFixed(2)} 0 0 1 ${-radius.toFixed(2)},${radius.toFixed(2)}`; 115 | } 116 | </script> 117 | <style scoped lang="scss"> 118 | #svggraph-rings { 119 | margin-top: 0.5rem; 120 | max-width: 600px; 121 | } 122 | </style> 123 | -------------------------------------------------------------------------------- /src/core/utils/math-utils.ts: -------------------------------------------------------------------------------- 1 | import seedrandom from 'seedrandom'; 2 | import { Color } from 'three'; 3 | import { ref, type Ref } from 'vue'; 4 | 5 | /** 6 | * Okay, explanation required for this value: 7 | * 8 | * We know that `0x010101` is the darkest possible RGB 8-bit color. Thus, we use it 9 | * as a multiplier with a value from `0` to `255` to get a grayscale color 10 | * from `(0x)000000` to `(0x)ffffff` 11 | * @example 0x5c5c5c = (0x5c * 0x010101) = (92 * 0x010101) 12 | */ 13 | const GRAYSCALE_MULTIPLIER = 0x010101; 14 | 15 | let currentPRNGSeed = Math.random().toString().substring(2); 16 | 17 | // @ts-expect-error Type definitions missing in 'seedrandom' package 18 | export const PRNG: Ref<seedrandom.PRNG> = ref(new seedrandom.alea(currentPRNGSeed)); 19 | export const PRNG_SEED: Ref<string> = ref(currentPRNGSeed); 20 | 21 | export function regenerateSeed() { 22 | PRNG_SEED.value = Math.random().toString().substring(2); 23 | } 24 | export function regeneratePRNGIfNecessary(force?: boolean): void { 25 | if (!force && PRNG_SEED.value === currentPRNGSeed) { 26 | return; 27 | } 28 | const s: string = PRNG_SEED.value; 29 | PRNG.value = seedrandom.alea(s); 30 | PRNG_SEED.value = s; 31 | currentPRNGSeed = s; 32 | } 33 | export function clampedPRNG(min: number, max: number, digits: number = 3): number { 34 | return Number(((max - min) * PRNG.value() + min).toFixed(digits)); 35 | } 36 | export function clampedPRNGSpaced(prev: number, min: number, max: number, precision: number = 3, spacing: number = 1) { 37 | let result = clampedPRNG(min, max, precision); 38 | let loop = 0; 39 | while (diff(result, prev) <= spacing && loop < 100) { 40 | result = clampedPRNG(min, max, precision); 41 | loop++; 42 | } 43 | return result; 44 | } 45 | 46 | export function clampedPRNGHex(min: number, max: number, hexMask: number): number { 47 | return clampedPRNG(min, max) * hexMask; 48 | } 49 | 50 | /** 51 | * Generates a random boolean, self-explanatory 52 | * @returns the boolean 53 | */ 54 | export function randomBoolean(): boolean { 55 | return Boolean(Math.round(clampedPRNG(0, 1))); 56 | } 57 | 58 | /** 59 | * Generates a random `THREE.Color`, in grayscale or not 60 | * @param grayscale if the color generated should be grayscale 61 | * @returns a new color, derived from PRNG 62 | */ 63 | export function randomColor(grayscale: boolean): Color { 64 | return new Color( 65 | grayscale ? Math.round(clampedPRNG(0, 255)) * GRAYSCALE_MULTIPLIER : Math.round(clampedPRNG(0, 0xffffff)), 66 | ); 67 | } 68 | 69 | /** 70 | * Generates a sorted array of intervals (pairs of numbers) 71 | * @param min min PRNG value 72 | * @param max max PRNG value 73 | * @param intervals number of intervals to generate 74 | * @returns the requested array of intervals 75 | * @example randomIntervals(0, 1, 3) => [[0.125, 0.273], [0.543, 0.861], [0.886, 0.892]] 76 | */ 77 | export function randomIntervals(min: number, max: number, intervals: number) { 78 | const numbers = []; 79 | for (let i = 0; i < intervals; i++) { 80 | numbers.push(clampedPRNG(min, max)); 81 | } 82 | numbers.sort((a, b) => a - b); 83 | return numbers.flatMap((_, i, arr) => (i % 2 ? [] : [arr.slice(i, i + 2)])); 84 | } 85 | 86 | /** 87 | * Simple numeric checking function. 88 | * @param n the object to check 89 | * @returns `true` if `n` is a number or can be interpreted as a number (excluding empty values), `false` otherwise 90 | */ 91 | export function isNumeric(n: string | number | boolean): boolean { 92 | if (['number'].includes(typeof n)) { 93 | return true; 94 | } else if (!n) { 95 | return false; 96 | } 97 | return !isNaN(Number(n)); 98 | } 99 | 100 | /** 101 | * Simple average function 102 | * @param values the values to average 103 | * @returns the average of the given values 104 | */ 105 | export function avg(...values: number[]) { 106 | if (values.length === 0) { 107 | return 0; 108 | } 109 | return values.reduce((prev, cur) => prev + cur) / values.length; 110 | } 111 | 112 | export function diff(a: number, b: number) { 113 | return Math.abs(Math.max(a, b) - Math.min(a, b)); 114 | } 115 | 116 | /** 117 | * Simple float truncating function 118 | * @param a the number to truncate 119 | * @param multPrecision the precision as an integer (e.g. 10000 => .toFixed(4)) 120 | */ 121 | export function truncateTo(a: number, multPrecision: number): number { 122 | return Math.trunc(a * multPrecision) / multPrecision; 123 | } 124 | -------------------------------------------------------------------------------- /src/_lib/components/LgvButton.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <button ref="btnRef" type="button" class="lgv"> 3 | <span v-if="a11yLabel" class="a11y--visually-hidden">{{ a11yLabel }}</span> 4 | <iconify-icon v-if="icon" :icon="icon" :width="iconWidth ?? getDefaultIconWidth()" aria-hidden="true" /> 5 | <span class="lgv--text"><slot></slot></span> 6 | </button> 7 | </template> 8 | 9 | <script setup lang="ts"> 10 | import { onMounted, useTemplateRef } from 'vue'; 11 | 12 | const btnRef = useTemplateRef('btnRef'); 13 | defineProps<{ icon?: string; iconWidth?: string; a11yLabel?: string }>(); 14 | onMounted(adjustPadding); 15 | 16 | function adjustPadding() { 17 | const textLeaf = btnRef.value!.querySelector('.lgv--text') as HTMLSpanElement; 18 | if (!textLeaf || !textLeaf.textContent) { 19 | textLeaf.style.display = 'none'; 20 | return; 21 | } 22 | if (textLeaf.textContent.length > 0) { 23 | btnRef.value?.classList.add('outer-pad'); 24 | } else { 25 | textLeaf.style.display = 'none'; 26 | } 27 | } 28 | 29 | function getDefaultIconWidth() { 30 | return btnRef.value?.classList.contains('sm') ? '1.25rem' : '1.5rem'; 31 | } 32 | </script> 33 | 34 | <style lang="scss"> 35 | // standard button 36 | button.lgv { 37 | position: relative; 38 | padding: 0; 39 | min-width: 2.5rem; 40 | min-height: 2.5rem; 41 | 42 | background: var(--lg-button); 43 | border: none; 44 | border-radius: 2px; 45 | color: var(--lg-text); 46 | font-family: inherit; 47 | cursor: pointer; 48 | 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | gap: 0.25rem; 53 | 54 | &:hover { 55 | cursor: pointer; 56 | background: var(--lg-button-hover); 57 | } 58 | &:active { 59 | cursor: pointer; 60 | background: var(--lg-button-active); 61 | } 62 | &:disabled { 63 | cursor: not-allowed; 64 | background: var(--lg-button-disabled); 65 | color: var(--lg-text-disabled); 66 | } 67 | 68 | iconify-icon, 69 | iconify-icon * { 70 | pointer-events: none; 71 | } 72 | 73 | &.outer-pad { 74 | padding: 0 0.75rem; 75 | } 76 | &.sm { 77 | min-width: 2rem; 78 | min-height: 2rem; 79 | } 80 | 81 | &.contrast { 82 | background: var(--lg-contrast); 83 | } 84 | &.contrast:not(:disabled):hover { 85 | background: var(--lg-contrast-hover); 86 | } 87 | &.contrast:not(:disabled):active { 88 | background: var(--lg-contrast-active); 89 | } 90 | 91 | &.success { 92 | background: var(--lg-success); 93 | } 94 | &.success:not(:disabled):hover { 95 | background: var(--lg-success-hover); 96 | } 97 | &.success:not(:disabled):active { 98 | background: var(--lg-success-active); 99 | } 100 | 101 | &.info { 102 | background: var(--lg-info); 103 | } 104 | &.info:not(:disabled):hover { 105 | background: var(--lg-info-hover); 106 | } 107 | &.info:not(:disabled):active { 108 | background: var(--lg-info-active); 109 | } 110 | 111 | &.warn { 112 | background: var(--lg-warn); 113 | } 114 | &.warn:not(:disabled):hover { 115 | background: var(--lg-warn-hover); 116 | } 117 | &.warn:not(:disabled):active { 118 | background: var(--lg-warn-active); 119 | } 120 | 121 | .lgv--text { 122 | display: inline-flex; 123 | align-items: center; 124 | gap: 0.25rem; 125 | } 126 | } 127 | 128 | // dark button 129 | button.lgv[variant='dark'] { 130 | min-width: 2.5rem; 131 | min-height: 2.5rem; 132 | overflow: hidden; 133 | 134 | background: var(--lg-primary); 135 | border: 1px solid var(--lg-accent); 136 | 137 | &:hover { 138 | background: var(--lg-button-dark-hover); 139 | } 140 | &:active { 141 | background: var(--lg-button-dark-active); 142 | } 143 | 144 | &.flush { 145 | border-width: 0; 146 | border-radius: 0; 147 | } 148 | 149 | &.contrast { 150 | background: var(--lg-button-dark-contrast); 151 | border-color: var(--lg-contrast); 152 | } 153 | &.contrast:not(:disabled):hover { 154 | background: var(--lg-button-dark-contrast-hover); 155 | } 156 | &.contrast:not(:disabled):active { 157 | background: var(--lg-button-dark-contrast-active); 158 | } 159 | } 160 | 161 | // icon button 162 | button.lgv[variant='icon'] { 163 | border: none; 164 | background: transparent; 165 | 166 | &:hover { 167 | filter: brightness(80%); 168 | transform: scale(1.05); 169 | } 170 | &:active { 171 | filter: brightness(60%); 172 | transform: scale(0.95); 173 | } 174 | &:disabled { 175 | filter: brightness(40%) grayscale(100%); 176 | } 177 | } 178 | </style> 179 | -------------------------------------------------------------------------------- /src/core/models/converters/planet-data.converter.ts: -------------------------------------------------------------------------------- 1 | import type { Texture } from 'three'; 2 | import type PlanetData from '../planet-data.model'; 3 | import type { PlanetUniformData } from '@core/tsl/materials/planet.tslmat'; 4 | import { ModelConverter } from './model-converter'; 5 | 6 | export class PlanetDataConverter extends ModelConverter<PlanetData, PlanetUniformData> { 7 | private _surfaceTexture?: Texture; 8 | private _biomesTexture?: Texture; 9 | private _biomesEmissiveTexture?: Texture; 10 | 11 | private _bakingSurfaceHeightMapTexture?: Texture; 12 | private _bakingUnifiedSurfaceTexture?: Texture; 13 | 14 | constructor(data: PlanetData) { 15 | super(data); 16 | } 17 | 18 | public withSurfaceTexture(tex: Texture): PlanetDataConverter { 19 | this._surfaceTexture = tex; 20 | return this; 21 | } 22 | 23 | public withBiomesTexture(tex: Texture): PlanetDataConverter { 24 | this._biomesTexture = tex; 25 | return this; 26 | } 27 | 28 | public withBiomesEmissiveTexture(tex: Texture): PlanetDataConverter { 29 | this._biomesEmissiveTexture = tex; 30 | return this; 31 | } 32 | 33 | public withBakingUnifiedSurfaceTexture(tex: Texture): PlanetDataConverter { 34 | this._bakingUnifiedSurfaceTexture = tex; 35 | return this; 36 | } 37 | 38 | public withBakingSurfaceHeightMapTexture(tex: Texture): PlanetDataConverter { 39 | this._bakingSurfaceHeightMapTexture = tex; 40 | return this; 41 | } 42 | 43 | public convert(): PlanetUniformData { 44 | return { 45 | radius: this._data.planetRadius, 46 | bumpStrength: this._data.planetSurfaceBumpStrength, 47 | flags: { 48 | showWarping: this._data.planetSurfaceShowWarping, 49 | showDisplacement: this._data.planetSurfaceShowDisplacement, 50 | showBumps: this._data.planetSurfaceShowBumps, 51 | showBiomes: this._data.biomesEnabled, 52 | showEmissive: this._data.planetShowEmissive, 53 | }, 54 | pbr: { 55 | waterLevel: this._data.planetWaterLevel, 56 | metallicRoughness: { 57 | waterRoughness: this._data.planetWaterRoughness, 58 | waterMetalness: this._data.planetWaterMetalness, 59 | groundRoughness: this._data.planetGroundRoughness, 60 | groundMetalness: this._data.planetGroundMetalness, 61 | }, 62 | emissive: { 63 | waterEmissiveIntensity: this._data.planetWaterEmissiveIntensity, 64 | groundEmissiveIntensity: this._data.planetGroundEmissiveIntensity, 65 | }, 66 | }, 67 | surface: { 68 | baseTexture: this._surfaceTexture, 69 | noise: { 70 | frequency: this._data.planetSurfaceNoise.frequency, 71 | amplitude: this._data.planetSurfaceNoise.amplitude, 72 | lacunarity: this._data.planetSurfaceNoise.lacunarity, 73 | octaves: this._data.planetSurfaceNoise.octaves, 74 | }, 75 | warping: { 76 | layers: this._data.planetSurfaceNoise.layers, 77 | warpFactor: this._data.planetSurfaceNoise.warpFactor, 78 | }, 79 | displacement: { 80 | params: { 81 | factor: this._data.planetSurfaceDisplacement.factor, 82 | epsilon: this._data.planetSurfaceDisplacement.epsilon, 83 | multiplier: this._data.planetSurfaceDisplacement.multiplier, 84 | }, 85 | noise: { 86 | frequency: this._data.planetSurfaceDisplacement.frequency, 87 | amplitude: this._data.planetSurfaceDisplacement.amplitude, 88 | lacunarity: this._data.planetSurfaceDisplacement.lacunarity, 89 | octaves: this._data.planetSurfaceDisplacement.octaves, 90 | }, 91 | }, 92 | }, 93 | biomes: { 94 | baseTexture: this._biomesTexture, 95 | emissiveTexture: this._biomesEmissiveTexture, 96 | temperatureMode: this._data.biomesTemperatureMode, 97 | temperatureNoise: { 98 | frequency: this._data.biomesTemperatureNoise.frequency, 99 | amplitude: this._data.biomesTemperatureNoise.amplitude, 100 | lacunarity: this._data.biomesTemperatureNoise.lacunarity, 101 | octaves: this._data.biomesTemperatureNoise.octaves, 102 | }, 103 | humidityMode: this._data.biomesHumidityMode, 104 | humidityNoise: { 105 | frequency: this._data.biomesHumidityNoise.frequency, 106 | amplitude: this._data.biomesHumidityNoise.amplitude, 107 | lacunarity: this._data.biomesHumidityNoise.lacunarity, 108 | octaves: this._data.biomesHumidityNoise.octaves, 109 | }, 110 | }, 111 | baking: { 112 | unifiedSurfaceTexture: this._bakingUnifiedSurfaceTexture, 113 | heightMapTexture: this._bakingSurfaceHeightMapTexture, 114 | }, 115 | }; 116 | } 117 | } 118 | --------------------------------------------------------------------------------