From b95fba988290fdbda156887361c50c929025e01a Mon Sep 17 00:00:00 2001 From: Simon Hagger Date: Mon, 24 Aug 2026 10:05:07 +0100 Subject: [PATCH] feat(renderer): add three.js Scene Lab with orbitable torus knot - Lazy scene-lab route rendering a wireframe torus knot inside a star field via three.js WebGLRenderer with OrbitControls - Spin speed / wireframe / pause driven by signals through effect(); rAF loop outside Angular change detection with FPS readout - Full teardown: geometry/material disposal, controls.dispose(), renderer.dispose(), ResizeObserver disconnect --- apps/renderer/src/app/app-route-registry.ts | 14 ++ .../app/features/scene-lab/scene-lab-page.css | 62 +++++ .../features/scene-lab/scene-lab-page.html | 68 +++++ .../features/scene-lab/scene-lab-page.spec.ts | 22 ++ .../app/features/scene-lab/scene-lab-page.ts | 232 ++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 55 ++++- 7 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 apps/renderer/src/app/features/scene-lab/scene-lab-page.css create mode 100644 apps/renderer/src/app/features/scene-lab/scene-lab-page.html create mode 100644 apps/renderer/src/app/features/scene-lab/scene-lab-page.spec.ts create mode 100644 apps/renderer/src/app/features/scene-lab/scene-lab-page.ts diff --git a/apps/renderer/src/app/app-route-registry.ts b/apps/renderer/src/app/app-route-registry.ts index 4965a29..9f99bf3 100644 --- a/apps/renderer/src/app/app-route-registry.ts +++ b/apps/renderer/src/app/app-route-registry.ts @@ -152,6 +152,20 @@ const routeRegistry: ReadonlyArray = [ ), }), }, + { + path: 'scene-lab', + label: 'Scene Lab', + icon: 'view_in_ar', + lab: true, + nav: true, + toRoute: () => ({ + path: 'scene-lab', + loadComponent: () => + import('./features/scene-lab/scene-lab-page').then( + (m) => m.SceneLabPage, + ), + }), + }, { path: 'canvas-lab', label: 'Canvas Lab', diff --git a/apps/renderer/src/app/features/scene-lab/scene-lab-page.css b/apps/renderer/src/app/features/scene-lab/scene-lab-page.css new file mode 100644 index 0000000..70e6327 --- /dev/null +++ b/apps/renderer/src/app/features/scene-lab/scene-lab-page.css @@ -0,0 +1,62 @@ +.scene-lab { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1080px; + margin: 0 auto; +} + +.intro-card p { + margin: 0; + color: var(--mat-sys-on-surface-variant, #9aa4b2); +} + +.scene-frame { + height: 420px; + border-radius: 8px; + overflow: hidden; + background: #080b12; +} + +.scene-canvas { + display: block; + width: 100%; + height: 100%; +} + +.control-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.control-row label { + min-width: 90px; + font-size: 13px; + color: var(--mat-sys-on-surface-variant, #9aa4b2); +} + +.control-row input[type='range'] { + flex: 1; + accent-color: var(--mat-sys-primary, #38bdf8); +} + +.diag-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 20px; + margin: 0; +} + +.diag-grid dt { + font-size: 13px; + font-weight: 600; + color: var(--mat-sys-on-surface-variant, #9aa4b2); +} + +.diag-grid dd { + margin: 0; + font-size: 13px; +} diff --git a/apps/renderer/src/app/features/scene-lab/scene-lab-page.html b/apps/renderer/src/app/features/scene-lab/scene-lab-page.html new file mode 100644 index 0000000..e234acb --- /dev/null +++ b/apps/renderer/src/app/features/scene-lab/scene-lab-page.html @@ -0,0 +1,68 @@ +
+ + + view_in_ar + Scene Lab + + three.js WebGL scene — drag to orbit, scroll to zoom + + + +

+ A wireframe torus knot inside a star field, rendered by a lazy-loaded + three.js chunk. The render loop runs outside Angular change detection; + the controls below update signals read every frame. +

+
+
+ + + +
+ +
+
+
+ + + + Controls + + +
+ + +
+ +
+ + +
+ +
+
FPS
+
{{ fps() }}
+
Status
+
{{ webglReady() ? "Rendering" : "Initializing…" }}
+
+
+
+
diff --git a/apps/renderer/src/app/features/scene-lab/scene-lab-page.spec.ts b/apps/renderer/src/app/features/scene-lab/scene-lab-page.spec.ts new file mode 100644 index 0000000..e7c8b6a --- /dev/null +++ b/apps/renderer/src/app/features/scene-lab/scene-lab-page.spec.ts @@ -0,0 +1,22 @@ +import { TestBed } from '@angular/core/testing'; +import { SceneLabPage } from './scene-lab-page'; + +describe('SceneLabPage', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SceneLabPage], + }).compileComponents(); + }); + + it('should create the page', () => { + const fixture = TestBed.createComponent(SceneLabPage); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should tear down without errors after render', async () => { + const fixture = TestBed.createComponent(SceneLabPage); + fixture.detectChanges(); + await fixture.whenStable(); + expect(() => fixture.destroy()).not.toThrow(); + }); +}); diff --git a/apps/renderer/src/app/features/scene-lab/scene-lab-page.ts b/apps/renderer/src/app/features/scene-lab/scene-lab-page.ts new file mode 100644 index 0000000..0bda6ef --- /dev/null +++ b/apps/renderer/src/app/features/scene-lab/scene-lab-page.ts @@ -0,0 +1,232 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + afterNextRender, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + +@Component({ + selector: 'app-scene-lab-page', + imports: [CommonModule, MatCardModule, MatButtonModule, MatIconModule], + templateUrl: './scene-lab-page.html', + styleUrl: './scene-lab-page.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SceneLabPage { + private readonly destroyRef = inject(DestroyRef); + + readonly sceneCanvas = + viewChild.required>('sceneCanvas'); + + readonly rotationSpeed = signal(10); + readonly wireframe = signal(true); + readonly accent = signal('#38bdf8'); + readonly running = signal(true); + readonly fps = signal(0); + readonly webglReady = signal(false); + + private renderer: THREE.WebGLRenderer | null = null; + private scene: THREE.Scene | null = null; + private camera: THREE.PerspectiveCamera | null = null; + private controls: OrbitControls | null = null; + private knot: THREE.Mesh< + THREE.TorusKnotGeometry, + THREE.MeshStandardMaterial + > | null = null; + private stars: THREE.Points< + THREE.BufferGeometry, + THREE.PointsMaterial + > | null = null; + private resizeObserver: ResizeObserver | null = null; + private rafHandle = 0; + private frameCount = 0; + private lastFpsTimestamp = performance.now(); + private lastFrameTimestamp = performance.now(); + + constructor() { + afterNextRender(() => this.initialize()); + this.destroyRef.onDestroy(() => this.teardown()); + + effect(() => { + const material = this.knot?.material; + if (material) { + material.color.set(this.accent()); + material.wireframe = this.wireframe(); + } + if (this.stars) { + this.stars.material.color.set(this.accent()); + } + }); + } + + setRotationSpeed(value: number): void { + this.rotationSpeed.set(value); + } + + toggleWireframe(): void { + this.wireframe.update((value) => !value); + } + + toggleRunning(): void { + const next = !this.running(); + this.running.set(next); + if (next) { + this.lastFrameTimestamp = performance.now(); + this.lastFpsTimestamp = performance.now(); + this.frameCount = 0; + this.scheduleFrame(); + } + } + + private initialize(): void { + const canvas = this.sceneCanvas().nativeElement; + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + alpha: false, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setClearColor(0x080b12); + + this.renderer = renderer; + this.scene = new THREE.Scene(); + + this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 100); + this.camera.position.set(0, 1.4, 6.5); + + this.controls = new OrbitControls(this.camera, canvas); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.08; + + const knotGeometry = new THREE.TorusKnotGeometry(1.5, 0.42, 220, 28); + const knotMaterial = new THREE.MeshStandardMaterial({ + color: new THREE.Color(this.accent()), + wireframe: this.wireframe(), + metalness: 0.35, + roughness: 0.45, + }); + this.knot = new THREE.Mesh(knotGeometry, knotMaterial); + this.scene.add(this.knot); + + const starCount = 900; + const positions = new Float32Array(starCount * 3); + for (let i = 0; i < starCount * 3; i++) { + positions[i] = (Math.random() - 0.5) * 40; + } + const starGeometry = new THREE.BufferGeometry(); + starGeometry.setAttribute( + 'position', + new THREE.BufferAttribute(positions, 3), + ); + const starMaterial = new THREE.PointsMaterial({ + color: new THREE.Color(this.accent()), + size: 0.05, + transparent: true, + opacity: 0.7, + }); + this.stars = new THREE.Points(starGeometry, starMaterial); + this.scene.add(this.stars); + + const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); + keyLight.position.set(4, 6, 5); + this.scene.add(keyLight); + const rimLight = new THREE.DirectionalLight(0x8899ff, 0.9); + rimLight.position.set(-5, -3, -4); + this.scene.add(rimLight); + + this.webglReady.set(true); + this.resizeToHost(); + this.observeResize(); + this.scheduleFrame(); + } + + private observeResize(): void { + if (typeof ResizeObserver === 'undefined') { + return; + } + this.resizeObserver = new ResizeObserver(() => this.resizeToHost()); + this.resizeObserver.observe(this.sceneCanvas().nativeElement); + } + + private resizeToHost(): void { + const canvas = this.sceneCanvas().nativeElement; + const renderer = this.renderer; + const camera = this.camera; + if (!renderer || !camera) { + return; + } + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + + private scheduleFrame(): void { + if (!this.running()) { + return; + } + this.rafHandle = requestAnimationFrame(() => this.renderFrame()); + } + + private renderFrame(): void { + const now = performance.now(); + const dt = Math.min((now - this.lastFrameTimestamp) / 1000, 0.1); + this.lastFrameTimestamp = now; + + if (this.knot) { + this.knot.rotation.y += dt * (this.rotationSpeed() / 10); + this.knot.rotation.x += dt * (this.rotationSpeed() / 10) * 0.35; + } + if (this.stars) { + this.stars.rotation.y -= dt * 0.02; + } + this.controls?.update(); + + if (this.renderer && this.scene && this.camera) { + this.renderer.render(this.scene, this.camera); + } + this.updateFps(now); + this.scheduleFrame(); + } + + private updateFps(now: number): void { + this.frameCount++; + const interval = now - this.lastFpsTimestamp; + if (interval >= 500) { + this.fps.set(Math.round((this.frameCount * 1000) / interval)); + this.frameCount = 0; + this.lastFpsTimestamp = now; + } + } + + private teardown(): void { + cancelAnimationFrame(this.rafHandle); + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.controls?.dispose(); + this.knot?.geometry.dispose(); + this.knot?.material.dispose(); + this.stars?.geometry.dispose(); + this.stars?.material.dispose(); + this.scene?.clear(); + this.renderer?.dispose(); + this.renderer = null; + this.scene = null; + this.camera = null; + this.controls = null; + this.knot = null; + this.stars = null; + } +} diff --git a/package.json b/package.json index 7808482..1e87bc2 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "@swc/helpers": "~0.5.21", "@tailwindcss/postcss": "^4.2.4", "@types/node": "25.6.0", + "@types/three": "^0.185.4", "@typescript-eslint/utils": "^8.59.0", "@vitest/coverage-v8": "^4.1.5", "angular-eslint": "^21.3.1", @@ -163,6 +164,7 @@ "electron-updater": "^6.8.3", "keytar": "^7.9.0", "rxjs": "~7.8.2", + "three": "^0.185.1", "zod": "^4.3.6" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9e41b5..20f6578 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: rxjs: specifier: ~7.8.2 version: 7.8.2 + three: + specifier: ^0.185.1 + version: 0.185.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -175,6 +178,9 @@ importers: '@types/node': specifier: 25.6.0 version: 25.6.0 + '@types/three': + specifier: ^0.185.4 + version: 0.185.4 '@typescript-eslint/utils': specifier: ^8.59.0 version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) @@ -1499,6 +1505,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -4340,6 +4349,9 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4490,6 +4502,15 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.4': + resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -6177,6 +6198,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -7456,6 +7480,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -9323,6 +9350,9 @@ packages: peerDependencies: tslib: ^2 + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} @@ -11581,6 +11611,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@electron-forge/cli@7.11.1(@swc/core@1.15.30(@swc/helpers@0.5.21))(encoding@0.1.13)(esbuild@0.28.1)': dependencies: '@electron-forge/core': 7.11.1(@swc/core@1.15.30(@swc/helpers@0.5.21))(encoding@0.1.13)(esbuild@0.28.1) @@ -14718,6 +14750,8 @@ snapshots: '@tootallnate/once@2.0.0': {} + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -14908,6 +14942,19 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.4': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + '@types/wrap-ansi@3.0.0': {} '@types/ws@8.18.1': @@ -16831,6 +16878,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -17811,7 +17860,7 @@ snapshots: jest-message-util: 30.3.0 jest-util: 30.3.0 pretty-format: 30.3.0 - semver: 7.7.4 + semver: 7.8.5 synckit: 0.11.12 transitivePeerDependencies: - supports-color @@ -18372,6 +18421,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + methods@1.1.2: {} micromatch@4.0.8: @@ -20401,6 +20452,8 @@ snapshots: dependencies: tslib: 2.8.1 + three@0.185.1: {} + thunky@1.1.0: {} tiny-typed-emitter@2.1.0: {}