From d3cf46887823b85b043d27076a66c2b7b0b5fd37 Mon Sep 17 00:00:00 2001 From: Hyacinth Date: Mon, 17 Aug 2026 16:44:00 +0800 Subject: [PATCH] fix(crystal-toolkit): stabilize legacy Storybook scenes and hover cleanup --- .storybook/preview.js | 1 - package-lock.json | 1 + package.json | 1 + .../scene/RadiusTubeBufferGeometry.ts | 72 +++++++++++-------- src/components/crystal-toolkit/scene/Scene.ts | 45 +++++++++--- .../crystal-toolkit/scene/animation-helper.ts | 5 +- .../crystal-toolkit/scene/debug-helper.ts | 32 +++++---- .../crystal-toolkit/scene/inset-helper.ts | 40 +++++++---- .../scene/phonon-animation-helper.ts | 24 +++++-- .../crystal-toolkit/scene/three_builder.ts | 6 +- .../crystal-toolkit/scene/tooltip-helper.ts | 39 ++++++---- 11 files changed, 178 insertions(+), 88 deletions(-) diff --git a/.storybook/preview.js b/.storybook/preview.js index e301b05f..de3e43f7 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,5 @@ import '../node_modules/bulma/css/bulma.min.css'; import '../src/styles.less'; -import '../src/assets/fonts.css'; import '../src//stories/stories.css'; export const parameters = { diff --git a/package-lock.json b/package-lock.json index 3b8d764e..907f1130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@babel/core": "^7.12.10", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.18.0", + "@babel/preset-react": "^7.28.5", "@parcel/transformer-less": "^2.5.0", "@parcel/transformer-sass": "^2.5.0", "@rollup/plugin-image": "^2.0.6", diff --git a/package.json b/package.json index a0bbc23b..7625aaf9 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@babel/core": "^7.12.10", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.18.0", + "@babel/preset-react": "^7.28.5", "@parcel/transformer-less": "^2.5.0", "@parcel/transformer-sass": "^2.5.0", "@rollup/plugin-image": "^2.0.6", diff --git a/src/components/crystal-toolkit/scene/RadiusTubeBufferGeometry.ts b/src/components/crystal-toolkit/scene/RadiusTubeBufferGeometry.ts index 8f173e80..83a92295 100644 --- a/src/components/crystal-toolkit/scene/RadiusTubeBufferGeometry.ts +++ b/src/components/crystal-toolkit/scene/RadiusTubeBufferGeometry.ts @@ -1,15 +1,14 @@ // TubeBufferGeometry -import { - BufferGeometry, - Float32BufferAttribute, - TubeBufferGeometry, - Vector2, - Vector3, -} from 'three'; - -export function RadiusTubeBufferGeometry( - this: any, +import { BufferGeometry, Float32BufferAttribute, Vector2, Vector3 } from 'three'; + +type WritableBufferGeometry = { + setIndex(index: unknown): void; + setAttribute(name: string, attribute: unknown): void; +}; + +function populateRadiusTubeBufferGeometry( + geometry: RadiusTubeBufferGeometry, path, tubularSegments, radius, @@ -17,15 +16,12 @@ export function RadiusTubeBufferGeometry( closed, taper ) { - BufferGeometry.call(this); - - this.type = 'RadiusTubeBufferGeometry'; - this.parameters = { + (geometry as any).parameters = { path: path, tubularSegments: tubularSegments, radius: radius, radialSegments: radialSegments, - closed: closed, + closed: closed }; tubularSegments = tubularSegments || 64; @@ -37,9 +33,9 @@ export function RadiusTubeBufferGeometry( // expose internals - this.tangents = frames.tangents; - this.normals = frames.normals; - this.binormals = frames.binormals; + geometry.tangents = frames.tangents; + geometry.normals = frames.normals; + geometry.binormals = frames.binormals; // helper variables @@ -63,10 +59,11 @@ export function RadiusTubeBufferGeometry( // build geometry - this.setIndex(indices); - this.setAttribute('position', new Float32BufferAttribute(vertices, 3)); - this.setAttribute('normal', new Float32BufferAttribute(normals, 3)); - this.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); + const writableGeometry = geometry as unknown as WritableBufferGeometry; + writableGeometry.setIndex(indices); + writableGeometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); + writableGeometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)); + writableGeometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)); // functions @@ -157,11 +154,28 @@ export function RadiusTubeBufferGeometry( } } -RadiusTubeBufferGeometry.prototype = Object.create(BufferGeometry.prototype); -RadiusTubeBufferGeometry.prototype.constructor = TubeBufferGeometry; +export class RadiusTubeBufferGeometry extends BufferGeometry { + public readonly type = 'RadiusTubeBufferGeometry'; + public tangents!: Vector3[]; + public normals!: Vector3[]; + public binormals!: Vector3[]; + + constructor(path, tubularSegments, radius, radialSegments, closed, taper) { + super(); + populateRadiusTubeBufferGeometry( + this, + path, + tubularSegments, + radius, + radialSegments, + closed, + taper + ); + } -RadiusTubeBufferGeometry.prototype.toJSON = function () { - var data = BufferGeometry.prototype.toJSON.call(this); - data.path = this.parameters.path.toJSON(); - return data; -}; + toJSON() { + var data: any = super.toJSON(); + data.path = (this as any).parameters.path.toJSON(); + return data; + } +} diff --git a/src/components/crystal-toolkit/scene/Scene.ts b/src/components/crystal-toolkit/scene/Scene.ts index 3be02195..054a8eb3 100644 --- a/src/components/crystal-toolkit/scene/Scene.ts +++ b/src/components/crystal-toolkit/scene/Scene.ts @@ -43,6 +43,8 @@ export default class Scene { private camera!: THREE.OrthographicCamera; private cameraState?: CameraState; private frameId?: number; + private controlsInitializationTimeout?: number; + private isDestroyed = false; private clickableObjects: THREE.Object3D[] = []; private tooltipObjects: THREE.Object3D[] = []; private objectDictionnary: { [id: string]: any } = {}; @@ -55,6 +57,8 @@ export default class Scene { private objectBuilder: ThreeBuilder; private clickCallback: (objects: any[]) => void; private debugHelper!: DebugHelper; + private dispatch: (p: Vector3, r: Quaternion, zoom: number) => void; + private debugDOMElement?; private readonly raycaster = new THREE.Raycaster(); private outline!: OutlineEffect; @@ -169,7 +173,12 @@ export default class Scene { // if the scene is configured // we defer the initialization of the control to the next event loop to avoid // some control events that would trigger unnecessary rendering - setTimeout(() => this.configureControls(), 0); + this.controlsInitializationTimeout = window.setTimeout(() => { + this.controlsInitializationTimeout = undefined; + if (!this.isDestroyed) { + this.configureControls(); + } + }, 0); } private configureControls() { @@ -364,8 +373,8 @@ export default class Scene { size, padding, clickCallback, - private dispatch: (p: Vector3, r: Quaternion, zoom: number) => void, - private debugDOMElement?, + dispatch: (p: Vector3, r: Quaternion, zoom: number) => void, + debugDOMElement?, cameraState?: CameraState ) { this.settings = Object.assign(defaults, settings); @@ -377,6 +386,8 @@ export default class Scene { this.configureScene(); this.configurePostProcessing(); this.clickCallback = clickCallback; + this.dispatch = dispatch; + this.debugDOMElement = debugDOMElement; this.outlineScene.autoUpdate = false; const isPhonon = sceneJson?.app === 'phonon'; this.animationHelper = isPhonon @@ -796,20 +807,31 @@ export default class Scene { public removeListener() { window.removeEventListener('resize', this.windowListener, false); - this.renderer.domElement.removeEventListener('mousemove', this.mouseMoveListener); - this.renderer.domElement.removeEventListener('click', this.clickListener); + this.renderer?.domElement?.removeEventListener('mousemove', this.mouseMoveListener); + this.renderer?.domElement?.removeEventListener('click', this.clickListener); document.removeEventListener('mousemove', this.mouseTrackballUpdate, false); } // call this when the parent component is destroyed public onDestroy() { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + if (this.controlsInitializationTimeout !== undefined) { + window.clearTimeout(this.controlsInitializationTimeout); + this.controlsInitializationTimeout = undefined; + } + + this.stop(); this.computeIdToThree = {}; this.threeUUIDTojsonObject = {}; this.removeListener(); - this.debugHelper && this.debugHelper.onDestroy(); - this.inset.onDestroy(); - this.controls.dispose(); - disposeSceneHierarchy(this.scene); + this.debugHelper?.onDestroy(); + this.inset?.onDestroy(); + this.controls?.dispose(); + this.scene && disposeSceneHierarchy(this.scene); // this.scene.dispose(); if (this.renderer instanceof THREE.WebGLRenderer) { this.renderer.forceContextLoss(); @@ -825,9 +847,10 @@ export default class Scene { this.renderer.domElement.parentElement.removeChild(this.renderer.domElement); } // this.renderer.domElement!.parentElement!.removeChild(this.renderer.domElement); - this.renderer.domElement = undefined as any; + if (this.renderer) { + this.renderer.domElement = undefined as any; + } this.renderer = null as any; - this.stop(); } removeObjectByName(name: string) { diff --git a/src/components/crystal-toolkit/scene/animation-helper.ts b/src/components/crystal-toolkit/scene/animation-helper.ts index e1b1a605..ee771509 100644 --- a/src/components/crystal-toolkit/scene/animation-helper.ts +++ b/src/components/crystal-toolkit/scene/animation-helper.ts @@ -6,11 +6,14 @@ import { SceneJsonObject } from './simple-scene'; import { ThreeBuilder } from './three_builder'; export class AnimationHelper { + private objectBuilder: ThreeBuilder; private mixers: THREE.AnimationMixer[] = []; private clock = new THREE.Clock(); private lineGeometriesToUpdate: THREE.LineSegments[] = []; - constructor(private objectBuilder: ThreeBuilder) {} + constructor(objectBuilder: ThreeBuilder) { + this.objectBuilder = objectBuilder; + } public reset() { this.mixers.forEach((m) => m.stopAllAction()); diff --git a/src/components/crystal-toolkit/scene/debug-helper.ts b/src/components/crystal-toolkit/scene/debug-helper.ts index 3c6d7166..dddd35c7 100644 --- a/src/components/crystal-toolkit/scene/debug-helper.ts +++ b/src/components/crystal-toolkit/scene/debug-helper.ts @@ -19,6 +19,11 @@ const DEBUG_SIZE = 500; const background = new THREE.Color('#000000'); export class DebugHelper { + private mountNode; + private scene; + private cameraToTrack; + private settings; + private builder; private cameraHelper: THREE.CameraHelper; private debugCamera: THREE.Camera; private debugRenderer: THREE.WebGLRenderer; // no SVG @@ -33,20 +38,18 @@ export class DebugHelper { private lights!: THREE.Object3D; private insetHelper: THREE.Object3D; - constructor( - private mountNode, - private scene, - private cameraToTrack, - private settings, - private builder, - insetCameraHelper - ) { + constructor(mountNode, scene, cameraToTrack, settings, builder, insetCameraHelper) { + this.mountNode = mountNode; + this.scene = scene; + this.cameraToTrack = cameraToTrack; + this.settings = settings; + this.builder = builder; if (!mountNode) { console.error('No mount node passed for the debug view'); } this.debugRenderer = new THREE.WebGLRenderer({ antialias: true, - alpha: true, + alpha: true }); (this.debugRenderer as any).gammaFactor = 2.2; this.debugRenderer.setSize(DEBUG_SIZE, DEBUG_SIZE); @@ -113,7 +116,12 @@ export class DebugHelper { } private setHelperObjectVisibility(isVisible) { - this.cameraHelper.visible = this.axis.visible = this.grid.visible = this.lights.visible = this.insetHelper.visible = isVisible; + this.cameraHelper.visible = + this.axis.visible = + this.grid.visible = + this.lights.visible = + this.insetHelper.visible = + isVisible; } public onDestroy() { @@ -126,7 +134,7 @@ export class DebugHelper { this.debugRenderer.forceContextLoss(); this.debugRenderer.dispose(); this.debugRenderer.domElement!.parentElement!.removeChild(this.debugRenderer.domElement); - this.debugRenderer.domElement = (undefined as unknown) as any; - this.debugRenderer = (null as unknown) as any; + this.debugRenderer.domElement = undefined as unknown as any; + this.debugRenderer = null as unknown as any; } } diff --git a/src/components/crystal-toolkit/scene/inset-helper.ts b/src/components/crystal-toolkit/scene/inset-helper.ts index 99074c07..03a1b1ab 100644 --- a/src/components/crystal-toolkit/scene/inset-helper.ts +++ b/src/components/crystal-toolkit/scene/inset-helper.ts @@ -8,7 +8,7 @@ export enum ScenePosition { NE = 'NE', SE = 'SE', SW = 'SW', - HIDDEN = 'HIDDEN', + HIDDEN = 'HIDDEN' } const AXIS_RADIUS = 0.07; @@ -18,6 +18,14 @@ const MIN_SIZE = 50; const DEFAULT_SIZE = 130; export class InsetHelper { + private detailedObject: THREE.Object3D; + private axisJson: any; + private origin: ThreePosition; + private cameraToFollow: THREE.Camera; + private threebuilder: ThreeBuilder; + private insetWidth: number; + private insetHeight: number; + private insetPadding: number; private insetCamera: THREE.OrthographicCamera; private frontRotation; private axisPadding = 0; // the space between the edge of the inset and the axis bounding box @@ -26,16 +34,24 @@ export class InsetHelper { private axis; constructor( - private detailedObject: THREE.Object3D, - private axisJson: any, + detailedObject: THREE.Object3D, + axisJson: any, baseScene: THREE.Scene, - private origin: ThreePosition, - private cameraToFollow: THREE.Camera, - private threebuilder: ThreeBuilder, - private insetWidth = DEFAULT_SIZE, - private insetHeight = DEFAULT_SIZE, - private insetPadding = 0 + origin: ThreePosition, + cameraToFollow: THREE.Camera, + threebuilder: ThreeBuilder, + insetWidth = DEFAULT_SIZE, + insetHeight = DEFAULT_SIZE, + insetPadding = 0 ) { + this.detailedObject = detailedObject; + this.axisJson = axisJson; + this.origin = origin; + this.cameraToFollow = cameraToFollow; + this.threebuilder = threebuilder; + this.insetWidth = insetWidth; + this.insetHeight = insetHeight; + this.insetPadding = insetPadding; this.axis = this.detailedObject; this.insetCamera = new THREE.OrthographicCamera(-4, 4, 4, -4, -10, 10); this.frontRotation = this.cameraToFollow.rotation.clone(); @@ -178,9 +194,9 @@ export class InsetHelper { disposeSceneHierarchy(this.scene); // this.scene.dispose(); // Note ONLY USE THIS PATTERN IN DISPOSAL METHOD - this.cameraToFollow = (null as unknown) as THREE.Camera; - this.insetCamera = (null as unknown) as THREE.OrthographicCamera; - this.detailedObject = (null as unknown) as THREE.Object3D; + this.cameraToFollow = null as unknown as THREE.Camera; + this.insetCamera = null as unknown as THREE.OrthographicCamera; + this.detailedObject = null as unknown as THREE.Object3D; } // TODO(chab) let's do something simple like having a width of 5 px diff --git a/src/components/crystal-toolkit/scene/phonon-animation-helper.ts b/src/components/crystal-toolkit/scene/phonon-animation-helper.ts index b1944ebf..bc8dab5e 100644 --- a/src/components/crystal-toolkit/scene/phonon-animation-helper.ts +++ b/src/components/crystal-toolkit/scene/phonon-animation-helper.ts @@ -5,6 +5,12 @@ import { SceneJsonObject } from './simple-scene'; import { ThreeBuilder } from './three_builder'; export class PhononAnimationHelper { + private objectBuilder: ThreeBuilder; + private A: number; + private phases: number[]; + private omega: number; + private eigenVectors: number[]; + private velocity: number; private clock = new THREE.Clock(); // refs to already-built objects (provided by objectBuilder) @@ -23,13 +29,19 @@ export class PhononAnimationHelper { } constructor( - private objectBuilder: ThreeBuilder, - private A: number, - private phases: number[], - private omega: number, - private eigenVectors: number[], - private velocity: number + objectBuilder: ThreeBuilder, + A: number, + phases: number[], + omega: number, + eigenVectors: number[], + velocity: number ) { + this.objectBuilder = objectBuilder; + this.A = A; + this.phases = phases; + this.omega = omega; + this.eigenVectors = eigenVectors; + this.velocity = velocity; this.atomNumber = Array.isArray(phases) ? phases.length : 0; } diff --git a/src/components/crystal-toolkit/scene/three_builder.ts b/src/components/crystal-toolkit/scene/three_builder.ts index 15b794f9..5c8819e8 100644 --- a/src/components/crystal-toolkit/scene/three_builder.ts +++ b/src/components/crystal-toolkit/scene/three_builder.ts @@ -54,7 +54,11 @@ class QuadraticSteppedBezierCurver extends THREE.QuadraticBezierCurve3 { * */ export class ThreeBuilder { - constructor(private settings) {} + private settings; + + constructor(settings) { + this.settings = settings; + } private validateRadiusArrays({ radiusTop, radiusBottom, positionPairs }) { if (!Array.isArray(radiusBottom)) { diff --git a/src/components/crystal-toolkit/scene/tooltip-helper.ts b/src/components/crystal-toolkit/scene/tooltip-helper.ts index b453cd35..11293397 100644 --- a/src/components/crystal-toolkit/scene/tooltip-helper.ts +++ b/src/components/crystal-toolkit/scene/tooltip-helper.ts @@ -19,8 +19,12 @@ export class TooltipHelper { } public updateTooltip(point, jsonObject: any, sceneObject: THREE.Object3D) { - if (!(this.tooltipedJsonObject === jsonObject)) { - sceneObject.children.forEach(c => { + const isCurrentObject = + this.tooltipedJsonObject === jsonObject && this.tooltipedThreeObject === sceneObject; + + if (!isCurrentObject) { + this.clearActiveTooltip(); + sceneObject.children.forEach((c) => { if (c instanceof THREE.Mesh) { const color = rgb(jsonObject.color).brighter(1); (c.material as THREE.MeshStandardMaterial).color = new THREE.Color(color.formatHex()); @@ -41,20 +45,25 @@ export class TooltipHelper { * Return true if the tooltip was removed */ public hideTooltipIfNeeded(): boolean { - if (this.tooltipedThreeObject) { - this.tooltipedThreeObject.children.forEach(c => { - if (c instanceof THREE.Mesh) { - (c.material as THREE.MeshStandardMaterial).color = new THREE.Color( - this.tooltipedJsonObject!.color - ); - } - }); - this.tooltipedThreeObject = null; - this.tooltipedJsonObject = null; - this.moveOffscreen(); - return true; + return this.clearActiveTooltip(); + } + + private clearActiveTooltip(): boolean { + if (!this.tooltipedThreeObject || !this.tooltipedJsonObject) { + return false; } - return false; + + this.tooltipedThreeObject.children.forEach((c) => { + if (c instanceof THREE.Mesh) { + (c.material as THREE.MeshStandardMaterial).color = new THREE.Color( + this.tooltipedJsonObject!.color + ); + } + }); + this.tooltipedThreeObject = null; + this.tooltipedJsonObject = null; + this.moveOffscreen(); + return true; } private moveOffscreen() {