From de7cfa85eecdf60131ed8f9e2f3d87ed8d5e8d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Mon, 8 Sep 2025 01:49:18 -0400 Subject: [PATCH 1/2] POC: toggle extrusions and travel moves without re-rendering everyting --- src/objects-manager.ts | 254 ++++++++++++++++++++++++++++++++ src/scene-manager.ts | 320 ++++++++--------------------------------- 2 files changed, 315 insertions(+), 259 deletions(-) create mode 100644 src/objects-manager.ts diff --git a/src/objects-manager.ts b/src/objects-manager.ts new file mode 100644 index 00000000..6208d2b3 --- /dev/null +++ b/src/objects-manager.ts @@ -0,0 +1,254 @@ +import { LineBox } from './helpers/line-box'; +import { Path } from './path'; +import { type Disposable } from './helpers/three-utils'; + +import { + Group, + Scene, + Color, + Plane, + Vector3, + ShaderMaterial, + Euler, + BatchedMesh, + BufferGeometry, + Material +} from 'three'; + +import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; +import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; +import { createColorMaterial } from './helpers/colorMaterial'; + +export class ObjectsManager { + extrusionsGroup: Group; + travelMovesGroup: Group; + boundingBox: LineBox; + scene: Scene; + disposables: Disposable[] = []; + clippingPlanes: Plane[] = []; + + // shader material + materials: ShaderMaterial[] = []; + ambientLight = 0.4; + directionalLight = 1.3; + brightness = 1.3; + + lineWidth: number; + lineHeight: number; + + extrusionWidth = 0.6; + + private renderedPaths: Path[] = []; + + constructor(scene: Scene, lineWidth: number, lineHeight = 0.2, extrusionWidth = 0.6) { + this.scene = scene; + this.extrusionsGroup = this.createGroup('Extrusions'); + this.travelMovesGroup = this.createGroup('Travel Moves'); + this.scene.add(this.extrusionsGroup); + this.scene.add(this.travelMovesGroup); + + this.lineWidth = lineWidth; + this.lineHeight = lineHeight ?? 0.2; + this.extrusionWidth = extrusionWidth; + this.clippingPlanes = this.createClippingPlanes(lineWidth, lineHeight); + } + + hideTravels() { + this.travelMovesGroup.visible = false; + } + + showTravels() { + this.travelMovesGroup.visible = true; + } + + hideExtrusions() { + this.extrusionsGroup.visible = false; + } + + showExtrusions() { + this.extrusionsGroup.visible = true; + } + + renderTravelLines(paths: Path[], color: Color) { + const unrenderedPaths = paths.filter((p) => !this.renderedPaths.includes(p)); + const line = this.renderPathsAsLines(unrenderedPaths, color); + this.travelMovesGroup.add(line); + this.renderedPaths.push(...unrenderedPaths); + } + + renderExtrusionLines(paths: Path[], color: Color) { + const unrenderedPaths = paths.filter((p) => !this.renderedPaths.includes(p)); + const line = this.renderPathsAsLines(unrenderedPaths, color); + this.extrusionsGroup.add(line); + this.renderedPaths.push(...unrenderedPaths); + } + + renderExtrusionTubes(paths: Path[], color: Color) { + const unrenderedPaths = paths.filter((p) => !this.renderedPaths.includes(p)); + const tubes = this.renderPathsAsTubes(unrenderedPaths, color); + this.extrusionsGroup.add(tubes); + this.renderedPaths.push(...unrenderedPaths); + } + + dispose() { + this.extrusionsGroup.removeFromParent(); + this.travelMovesGroup.removeFromParent(); + this.disposables.forEach((d) => d.dispose()); + this.disposables = []; + } + + updateClippingPlanes(minZ: number, maxZ: number) { + this.updateClippingPlanesForShaderMaterials(minZ, maxZ); + this.updateLineClipping(minZ, maxZ); + } + + private renderPathsAsLines(paths: Path[], color: Color): LineSegments2 { + console.log('rendering lines', paths.length); + const material = new LineMaterial({ + color: Number(color.getHex()), + linewidth: this.lineWidth + }); + + // lines need to be offset. + // The gcode specifies the nozzle height which is the top of the extrusion. + // The line doesn't have a constant height in world coords so it should be rendered at horizontal midplane of the extrusion layer. + // Otherwise the line will be clipped by the clipping plane. + const offset = -this.lineHeight / 2; + const lineVertices: number[] = []; + paths.forEach((path) => { + for (let i = 0; i < path.vertices.length - 3; i += 3) { + lineVertices.push(path.vertices[i], path.vertices[i + 1] - 0.1, path.vertices[i + 2] + offset); + lineVertices.push(path.vertices[i + 3], path.vertices[i + 4] - 0.1, path.vertices[i + 5] + offset); + } + }); + + const geometry = new LineSegmentsGeometry().setPositions(lineVertices); + this.disposables.push(material); + this.disposables.push(geometry); + return new LineSegments2(geometry, material); + } + + /** + * Renders paths as 3D tubes + * @param paths - Array of paths to render + * @param color - Color to use for the tubes + */ + private renderPathsAsTubes(paths: Path[], color: Color): BatchedMesh { + console.log('rendering tubes', paths.length); + const colorNumber = Number(color.getHex()); + const geometries: BufferGeometry[] = []; + + const material = createColorMaterial(colorNumber, this.ambientLight, this.directionalLight, this.brightness); + + this.materials.push(material); + + paths.forEach((path) => { + const geometry = path.geometry({ + extrusionWidthOverride: this.extrusionWidth, + lineHeightOverride: this.lineHeight + }); + + if (!geometry) return; + + this.disposables.push(geometry); + geometries.push(geometry); + }); + + const batchedMesh = this.createBatchMesh(geometries, material); + this.disposables.push(material); + return batchedMesh; + } + + /** + * Creates a batched mesh from multiple geometries sharing the same material + * @param geometries - Array of geometries to batch + * @param material - Material to use for the batched mesh + * @returns Batched mesh instance + */ + private createBatchMesh(geometries: BufferGeometry[], material: Material): BatchedMesh { + const maxVertexCount = geometries.reduce((acc, geometry) => geometry.attributes.position.count * 3 + acc, 0); + + const batchedMesh = new BatchedMesh(geometries.length, maxVertexCount, undefined, material); + this.disposables.push(batchedMesh); + + geometries.forEach((geometry) => { + const geometryId = batchedMesh.addGeometry(geometry); + // NOTE: for older versions of three.js, addInstance is not available + // This allow webgl1 browsers to use the batched mesh + batchedMesh.addInstance?.(geometryId); + }); + + return batchedMesh; + } + + /** + * Applies clipping planes to the specified material based on the minimum and maximum Z values. + * + * This method creates clipping planes for the top and bottom of the specified Z range, + * then applies them to the material's clippingPlanes property. + * + * @param material - Shader material to apply clipping planes to + * @param minZ - The minimum Z value for the clipping plane. + * @param maxZ - The maximum Z value for the clipping plane. + */ + private createClippingPlanes(minZ?: number | undefined, maxZ?: number | undefined) { + const planes = []; + if (minZ !== undefined) { + planes.push(new Plane(new Vector3(0, 1, 0), -minZ)); + } + if (maxZ !== undefined) { + planes.push(new Plane(new Vector3(0, -1, 0), maxZ)); + } + return planes; + } + + /** + * Updates the clipping planes for all `LineSegments2` objects in the scene. + * This method filters the scene's children to find instances of `LineSegments2`, + * then applies the clipping planes to their materials. + * + * @param minZ - The minimum Z value for the clipping plane. + * @param maxZ - The maximum Z value for the clipping plane. + */ + private updateLineClipping(minZ: number | undefined, maxZ: number | undefined) { + // TODO: apply clipping selectively to travels lines and extrusion lines + // and/or use a clipping group + this.scene.traverse((obj) => { + if (obj instanceof LineSegments2) { + const material = obj.material as LineMaterial; + material.clippingPlanes = this.createClippingPlanes(minZ, maxZ); + } + }); + } + + /** + * Updates the clipping planes for all shader materials in the scene. + * This method sets the min and max Z values for the clipping planes in the shader materials. + * + * @param minZ - The minimum Z value for the clipping plane. + * @param maxZ - The maximum Z value for the clipping plane + */ + + private updateClippingPlanesForShaderMaterials(minZ: number, maxZ: number) { + this.materials.forEach((material) => { + material.uniforms.clipMinY.value = minZ; + material.uniforms.clipMaxY.value = maxZ; + }); + } + + /** + * Creates a new Three.js group for organizing rendered paths + * @param name - Name for the group + * @returns Configured Three.js group + * @remarks + * Sets up the group's orientation and position based on build volume dimensions. + * If no build volume is defined, uses a default position. + */ + private createGroup(name: string): Group { + const group = new Group(); + group.name = name; + group.quaternion.setFromEuler(new Euler(-Math.PI / 2, 0, 0)); + return group; + } +} diff --git a/src/scene-manager.ts b/src/scene-manager.ts index 7c8325bf..63b254ef 100644 --- a/src/scene-manager.ts +++ b/src/scene-manager.ts @@ -1,33 +1,25 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js'; -import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js'; -import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { BuildVolume } from './build-volume'; import { type Disposable } from './helpers/three-utils'; import { LineBox } from './helpers/line-box'; -import { Path } from './path'; import { Job } from './job'; +import { ObjectsManager } from './objects-manager'; import { createColorMaterial } from './helpers/colorMaterial'; import { - BatchedMesh, - BufferGeometry, Color, ColorRepresentation, Euler, Group, - Material, PerspectiveCamera, - Plane, REVISION, Scene, - ShaderMaterial, - Vector3, WebGLRenderer, MathUtils, - LineBasicMaterial + LineBasicMaterial, + Object3D } from 'three'; import { EventsDispatcher } from './events-dispatcher'; @@ -127,12 +119,6 @@ export class SceneManager { private _renderTravel = false; /** Whether to render paths as 3D tubes */ private _renderTubes = false; - /** Width of extruded material */ - private _extrusionWidth?: number; - /** Width of rendered lines */ - private _lineWidth?: number; - /** Height of extruded lines */ - private _lineHeight = 0.2; /** First layer to render (1-based index) */ private _startLayer?: number; /** Last layer to render (1-based index) */ @@ -154,8 +140,6 @@ export class SceneManager { private _boundingBoxColor?: Color; // rendering - /** Group containing all rendered paths */ - private group?: Group; /** Disposable resources */ private disposables: Disposable[] = []; /** Default extrusion color */ @@ -168,13 +152,6 @@ export class SceneManager { private renderPathIndex?: number; /** Previous start layer before single layer mode */ private prevStartLayer = 0; - - // shader material - private materials: ShaderMaterial[] = []; - private _ambientLight = 0.4; - private _directionalLight = 1.3; - private _brightness = 1.3; - // colors /** Background color */ private _backgroundColor = new Color(0xe0e0e0); @@ -192,8 +169,8 @@ export class SceneManager { private _wireframe = false; /** Whether to preserve drawing buffer */ private preserveDrawingBuffer = false; - private currentChunk: Group; private eventsDispatcher: EventsDispatcher = new EventsDispatcher(); + private objectsManager: ObjectsManager; private _renderTimeout: ReturnType; @@ -205,14 +182,15 @@ export class SceneManager { constructor(opts: SceneManagerOptions, job: Job, eventsDispatcher?: EventsDispatcher) { this.job = job; this.scene = new Scene(); + this.objectsManager = new ObjectsManager(this.scene, opts.lineWidth ?? 1, opts.lineHeight, opts.extrusionWidth); + this.disposables.push(this.objectsManager); this.scene.background = this._backgroundColor; if (opts.backgroundColor !== undefined) { this.backgroundColor = new Color(opts.backgroundColor); } this.endLayer = opts.endLayer; this.startLayer = opts.startLayer; - this.lineWidth = opts.lineWidth ?? 1; - this.lineHeight = opts.lineHeight ?? this.lineHeight; + if (opts.buildVolume) { this._buildVolume = new BuildVolume( opts.buildVolume.x, @@ -227,7 +205,6 @@ export class SceneManager { this.renderExtrusion = opts.renderExtrusion ?? this.renderExtrusion; this.renderTravel = opts.renderTravel ?? this.renderTravel; this.renderTubes = opts.renderTubes ?? this.renderTubes; - this.extrusionWidth = opts.extrusionWidth; this.eventsDispatcher = eventsDispatcher ?? this.eventsDispatcher; if (opts.boundingBoxColor !== undefined) { @@ -269,6 +246,7 @@ export class SceneManager { canvas: this.canvas, preserveDrawingBuffer: this.preserveDrawingBuffer }); + this.disposables.push(this.renderer); this.renderer.localClippingEnabled = true; this.camera = new PerspectiveCamera(25, this.canvas.offsetWidth / this.canvas.offsetHeight, 1, 5000); @@ -277,6 +255,7 @@ export class SceneManager { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.target.set(this._buildVolume.x / 2, 0, -this._buildVolume.y / 2); + this.disposables.push(this.controls); this.loadCamera(); this.initScene(); @@ -331,15 +310,15 @@ export class SceneManager { // loop over the object and convert all colors to Color for (const [index, color] of value.entries()) { this._extrusionColor[index] = new Color(color); - if (!this.materials[index]) { - this.materials[index] = createColorMaterial( + if (!this.objectsManager.materials[index]) { + this.objectsManager.materials[index] = createColorMaterial( this._extrusionColor[index].getHex(), - this.ambientLight, - this.directionalLight, - this.brightness + this.objectsManager.ambientLight, + this.objectsManager.directionalLight, + this.objectsManager.brightness ); } - const material = this.materials[index]; + const material = this.objectsManager.materials[index]; if (material && material.uniforms) { material.uniforms.uColor.value = this._extrusionColor[index]; } @@ -348,16 +327,16 @@ export class SceneManager { } this._extrusionColor = new Color(value); - if (!this.materials[0]) { - this.materials[0] = createColorMaterial( + if (!this.objectsManager.materials[0]) { + this.objectsManager.materials[0] = createColorMaterial( this._extrusionColor.getHex(), - this.ambientLight, - this.directionalLight, - this.brightness + this.objectsManager.ambientLight, + this.objectsManager.directionalLight, + this.objectsManager.brightness ); } - this.materials[0].uniforms.uColor.value = this._extrusionColor; + this.objectsManager.materials[0].uniforms.uColor.value = this._extrusionColor; this.eventsDispatcher.emit(SceneManagerEvent.EXTRUSION_COLOR_CHANGE, this._extrusionColor); } @@ -506,27 +485,27 @@ export class SceneManager { } get lineWidth(): number | undefined { - return this._lineWidth; + return this.objectsManager.lineWidth; } set lineWidth(value: number | undefined) { - this._lineWidth = value; - this.eventsDispatcher.emit(SceneManagerEvent.LINE_WIDTH_CHANGE, this._lineWidth); + this.objectsManager.lineWidth = value; + this.eventsDispatcher.emit(SceneManagerEvent.LINE_WIDTH_CHANGE, this.objectsManager.lineWidth); } get lineHeight(): number { - return this._lineHeight; + return this.objectsManager.lineHeight; } set lineHeight(value: number) { - this._lineHeight = value; - this.eventsDispatcher.emit(SceneManagerEvent.LINE_HEIGHT_CHANGE, [this._lineHeight]); + this.objectsManager.lineHeight = value; + this.eventsDispatcher.emit(SceneManagerEvent.LINE_HEIGHT_CHANGE, this.objectsManager.lineHeight); } get extrusionWidth(): number | undefined { - return this._extrusionWidth; + return this.objectsManager.extrusionWidth; } set extrusionWidth(value: number | undefined) { - this._extrusionWidth = value; - this.eventsDispatcher.emit(SceneManagerEvent.EXTRUSION_WIDTH_CHANGE, [this._extrusionWidth]); + this.objectsManager.extrusionWidth = value; + this.eventsDispatcher.emit(SceneManagerEvent.EXTRUSION_WIDTH_CHANGE, this.objectsManager.extrusionWidth); } get disableGradient(): boolean { @@ -534,7 +513,7 @@ export class SceneManager { } set disableGradient(value: boolean) { this._disableGradient = value; - this.eventsDispatcher.emit(SceneManagerEvent.DISABLE_GRADIENT_CHANGE, [this._disableGradient]); + this.eventsDispatcher.emit(SceneManagerEvent.DISABLE_GRADIENT_CHANGE, this._disableGradient); } /** @@ -546,71 +525,14 @@ export class SceneManager { * * It then updates the clipping planes for shader materials and line clipping using these Z values. * - * @private */ - private updateClippingPlanes() { + updateClippingPlanes() { const startLayer = this.job.layers[this._startLayer - 1]; const endLayer = this.job.layers[this._endLayer - 1]; const minZ = startLayer?.z - startLayer?.height; const maxZ = endLayer?.z; - this.updateClippingPlanesForShaderMaterials(minZ, maxZ); - this.updateLineClipping(minZ, maxZ); - } - - /** - * Updates the clipping planes for all shader materials in the scene. - * This method sets the min and max Z values for the clipping planes in the shader materials. - * - * @param minZ - The minimum Z value for the clipping plane. - * @param maxZ - The maximum Z value for the clipping plane - */ - - private updateClippingPlanesForShaderMaterials(minZ: number, maxZ: number) { - this.materials.forEach((material) => { - material.uniforms.clipMinY.value = minZ; - material.uniforms.clipMaxY.value = maxZ; - }); - } - - /** - * Applies clipping planes to the specified material based on the minimum and maximum Z values. - * - * This method creates clipping planes for the top and bottom of the specified Z range, - * then applies them to the material's clippingPlanes property. - * - * @param material - Shader material to apply clipping planes to - * @param minZ - The minimum Z value for the clipping plane. - * @param maxZ - The maximum Z value for the clipping plane. - */ - private createClippingPlanes(minZ: number | undefined, maxZ: number | undefined) { - const planes = []; - if (minZ !== undefined) { - planes.push(new Plane(new Vector3(0, 1, 0), -minZ)); - } - if (maxZ !== undefined) { - planes.push(new Plane(new Vector3(0, -1, 0), maxZ)); - } - return planes; - } - - /** - * Updates the clipping planes for all `LineSegments2` objects in the scene. - * This method filters the scene's children to find instances of `LineSegments2`, - * then applies the clipping planes to their materials. - * - * @param minZ - The minimum Z value for the clipping plane. - * @param maxZ - The maximum Z value for the clipping plane. - */ - private updateLineClipping(minZ: number | undefined, maxZ: number | undefined) { - // TODO: apply clipping selectively to travels lines and extrusion lines - // and/or use a clipping group - this.scene.traverse((obj) => { - if (obj instanceof LineSegments2) { - const material = obj.material as LineMaterial; - material.clippingPlanes = this.createClippingPlanes(minZ, maxZ); - } - }); + this.objectsManager.updateClippingPlanes(minZ, maxZ); } /** @@ -668,37 +590,37 @@ export class SceneManager { } get ambientLight(): number { - return this._ambientLight; + return this.objectsManager.ambientLight; } set ambientLight(value: number) { - this._ambientLight = value; + this.objectsManager.ambientLight = value; // update material uniforms - this.materials.forEach((material) => { + this.objectsManager.materials.forEach((material) => { material.uniforms.ambient.value = value; }); } get directionalLight(): number { - return this._directionalLight; + return this.objectsManager.directionalLight; } set directionalLight(value: number) { - this._directionalLight = value; + this.objectsManager.directionalLight = value; // update material uniforms - this.materials.forEach((material) => { + this.objectsManager.materials.forEach((material) => { material.uniforms.directional.value = value; }); } get brightness(): number { - return this._brightness; + return this.objectsManager.brightness; } set brightness(value: number) { - this._brightness = value; + this.objectsManager.brightness = value; // update material uniforms - this.materials.forEach((material) => { + this.objectsManager.materials.forEach((material) => { material.uniforms.brightness.value = value; }); } @@ -722,9 +644,6 @@ export class SceneManager { * and lighting if 3D tube rendering is enabled. */ private initScene(): void { - this.group = this.group ?? this.createGroup('allLayers'); - this.currentChunk = this.group; - this.renderPathIndex = 0; this.renderPaths(); @@ -735,7 +654,6 @@ export class SceneManager { this.disposables.push(this._buildVolume); this._buildVolume.update(); } - this.scene.add(this.group); } /** Resets the scene by clearing all existing objects and re-initializing @@ -744,10 +662,10 @@ export class SceneManager { * and calls initScene to set up a fresh scene. */ private resetScene() { - this.materials = []; + // this.objectsManager.materials = []; // Recursively remove all children from the main group and their descendants from the scene - const removeRecursively = (object: Group) => { + const removeRecursively = (object: Object3D) => { while (object.children.length > 0) { const child = object.children[0]; if ((child as Group).children && (child as Group).children.length > 0) { @@ -757,34 +675,11 @@ export class SceneManager { this.scene.remove(child); } }; - if (this.group) { - removeRecursively(this.group); - } + // removeRecursively(this.scene); this.initScene(); } - /** - * Creates a new Three.js group for organizing rendered paths - * @param name - Name for the group - * @returns Configured Three.js group - * @remarks - * Sets up the group's orientation and position based on build volume dimensions. - * If no build volume is defined, uses a default position. - */ - private createGroup(name: string): Group { - const group = new Group(); - group.name = name; - group.quaternion.setFromEuler(new Euler(-Math.PI / 2, 0, 0)); - if (this._buildVolume) { - // group.position.set(-this._buildVolume.x / 2, 0, this._buildVolume.y / 2); - } else { - // FIXME: this is just a very crude approximation for centering - group.position.set(-100, 0, 100); - } - return group; - } - /** * Renders all visible paths in the scene */ @@ -844,20 +739,12 @@ export class SceneManager { * Updates the renderPathIndex to track progress through the job's paths. */ private renderFrame(pathCount: number): void { - if (!this.group) { - this.group = this.createGroup('allLayers'); - this.scene.add(this.group); - } - const chunk = new Group(); - chunk.name = 'chunk' + this.renderPathIndex; - this.currentChunk = chunk; const endPathNumber = Math.min(this.renderPathIndex + pathCount, this.job.paths.length - 1); this.renderPaths(endPathNumber); this.renderBoundingBox(); this.renderPathIndex = endPathNumber; this.renderBoundingBox(); - this.group?.add(chunk); this.renderer.render(this.scene, this.camera); } @@ -896,6 +783,9 @@ export class SceneManager { this.job = undefined; this.scene.remove(this.boundingBoxMesh); this.boundingBoxMesh = undefined; + this.objectsManager.dispose(); + this.objectsManager = new ObjectsManager(this.scene, this.lineWidth, this.lineHeight, this.extrusionWidth); + this.disposables.push(this.objectsManager); } resize(): void { @@ -907,12 +797,9 @@ export class SceneManager { } dispose(): void { + this.cancelAnimation(); this.disposables.forEach((d) => d.dispose()); this.disposables = []; - this.controls.dispose(); - this.renderer.dispose(); - - this.cancelAnimation(); } /** @@ -932,115 +819,30 @@ export class SceneManager { */ private renderPaths(endPathNumber: number = Infinity): void { if (this.renderTravel) { - this.renderPathsAsLines(this.job.travels.slice(this.renderPathIndex, endPathNumber), this._travelColor); + this.objectsManager.renderTravelLines( + this.job.travels.slice(this.renderPathIndex, endPathNumber), + this._travelColor + ); + this.objectsManager.showTravels(); + } else { + this.objectsManager.hideTravels(); } if (this.renderExtrusion && this.job?.toolPaths.length > 0) { this.job.toolPaths.forEach((toolPaths, index) => { const color = Array.isArray(this.extrusionColor) ? this.extrusionColor[index] : this.extrusionColor; if (this.renderTubes) { - this.renderPathsAsTubes(toolPaths.slice(this.renderPathIndex, endPathNumber), color); + this.objectsManager.renderExtrusionTubes(toolPaths.slice(this.renderPathIndex, endPathNumber), color); } else { - this.renderPathsAsLines(toolPaths.slice(this.renderPathIndex, endPathNumber), color); + this.objectsManager.renderExtrusionLines(toolPaths.slice(this.renderPathIndex, endPathNumber), color); } }); + this.objectsManager.showExtrusions(); + } else { + this.objectsManager.hideExtrusions(); } } - /** - * Renders paths as 2D lines - * @param paths - Array of paths to render - * @param color - Color to use for the lines - */ - private renderPathsAsLines(paths: Path[], color: Color): void { - const minZ = this.job.layers[this._startLayer - 1]?.z; - const maxZ = this.job.layers[this._endLayer - 1]?.z; - - let clippingPlanes: Plane[] = []; - clippingPlanes = this.createClippingPlanes(minZ, maxZ); - - const material = new LineMaterial({ - color: Number(color.getHex()), - linewidth: this.lineWidth, - clippingPlanes - }); - - const lineVertices: number[] = []; - - // lines need to be offset. - // The gcode specifies the nozzle height which is the top of the extrusion. - // The line doesn't have a constant height in world coords so it should be rendered at horizontal midplane of the extrusion layer. - // Otherwise the line will be clipped by the clipping plane. - const offset = -this.lineHeight / 2; - - paths.forEach((path) => { - for (let i = 0; i < path.vertices.length - 3; i += 3) { - lineVertices.push(path.vertices[i], path.vertices[i + 1] - 0.1, path.vertices[i + 2] + offset); - lineVertices.push(path.vertices[i + 3], path.vertices[i + 4] - 0.1, path.vertices[i + 5] + offset); - } - }); - - const geometry = new LineSegmentsGeometry().setPositions(lineVertices); - const line = new LineSegments2(geometry, material); - - this.disposables.push(material); - this.disposables.push(geometry); - this.currentChunk?.add(line); - } - - /** - * Renders paths as 3D tubes - * @param paths - Array of paths to render - * @param color - Color to use for the tubes - */ - private renderPathsAsTubes(paths: Path[], color: Color): void { - const colorNumber = Number(color.getHex()); - const geometries: BufferGeometry[] = []; - - const material = createColorMaterial(colorNumber, this.ambientLight, this.directionalLight, this.brightness); - - this.materials.push(material); - - paths.forEach((path) => { - const geometry = path.geometry({ - extrusionWidthOverride: this.extrusionWidth, - lineHeightOverride: this.lineHeight - }); - - if (!geometry) return; - - this.disposables.push(geometry); - geometries.push(geometry); - }); - - const batchedMesh = this.createBatchMesh(geometries, material); - this.disposables.push(material); - - this.currentChunk?.add(batchedMesh); - } - - /** - * Creates a batched mesh from multiple geometries sharing the same material - * @param geometries - Array of geometries to batch - * @param material - Material to use for the batched mesh - * @returns Batched mesh instance - */ - private createBatchMesh(geometries: BufferGeometry[], material: Material): BatchedMesh { - const maxVertexCount = geometries.reduce((acc, geometry) => geometry.attributes.position.count * 3 + acc, 0); - - const batchedMesh = new BatchedMesh(geometries.length, maxVertexCount, undefined, material); - this.disposables.push(batchedMesh); - - geometries.forEach((geometry) => { - const geometryId = batchedMesh.addGeometry(geometry); - // NOTE: for older versions of three.js, addInstance is not available - // This allow webgl1 browsers to use the batched mesh - batchedMesh.addInstance?.(geometryId); - }); - - return batchedMesh; - } - private setRerenderListeners() { const eventsRequiringRerender = [ SceneManagerEvent.BACKGROUND_COLOR_CHANGE, From 01db33c06bc8cff95e03b7c6649dfc66611052cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophie=20D=C3=A9ziel?= Date: Tue, 3 Feb 2026 23:41:33 -0500 Subject: [PATCH 2/2] Add unit tests for EventsDispatcher and ObjectsManager classes Tests cover event registration/emission, visibility toggles, rendering methods, disposal, and clipping plane updates. --- src/__tests__/events-dispatcher.ts | 137 +++++++++++++++++ src/__tests__/objects-manager.ts | 235 +++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 src/__tests__/events-dispatcher.ts create mode 100644 src/__tests__/objects-manager.ts diff --git a/src/__tests__/events-dispatcher.ts b/src/__tests__/events-dispatcher.ts new file mode 100644 index 00000000..8aa2f187 --- /dev/null +++ b/src/__tests__/events-dispatcher.ts @@ -0,0 +1,137 @@ +import { test, expect, describe, vi } from 'vitest'; +import { EventsDispatcher, CallbackFunction } from '../events-dispatcher'; + +describe('EventsDispatcher', () => { + describe('addEventListener', () => { + test('registers a callback for a single event', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('testEvent', callback); + dispatcher.emit('testEvent', 'arg1'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('arg1'); + }); + + test('registers a callback for multiple events', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener(['event1', 'event2'], callback); + dispatcher.emit('event1', 'arg1'); + dispatcher.emit('event2', 'arg2'); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'arg1'); + expect(callback).toHaveBeenNthCalledWith(2, 'arg2'); + }); + + test('allows multiple callbacks for the same event', () => { + const dispatcher = new EventsDispatcher(); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('testEvent', callback1); + dispatcher.addEventListener('testEvent', callback2); + dispatcher.emit('testEvent', 'arg'); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + + describe('emit', () => { + test('calls all registered callbacks for an event', () => { + const dispatcher = new EventsDispatcher(); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('testEvent', callback1); + dispatcher.addEventListener('testEvent', callback2); + dispatcher.emit('testEvent', 'data'); + + expect(callback1).toHaveBeenCalledWith('data'); + expect(callback2).toHaveBeenCalledWith('data'); + }); + + test('does nothing when emitting an unregistered event', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('registeredEvent', callback); + dispatcher.emit('unregisteredEvent', 'data'); + + expect(callback).not.toHaveBeenCalled(); + }); + + test('passes array arguments as spread parameters', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('testEvent', callback); + dispatcher.emit('testEvent', ['arg1', 'arg2', 'arg3']); + + expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 'arg3'); + }); + + test('wraps non-array arguments in array', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('testEvent', callback); + dispatcher.emit('testEvent', { key: 'value' }); + + expect(callback).toHaveBeenCalledWith({ key: 'value' }); + }); + + test('handles undefined arguments', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('testEvent', callback); + dispatcher.emit('testEvent', undefined); + + expect(callback).toHaveBeenCalledWith(undefined); + }); + + test('handles null arguments', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('testEvent', callback); + dispatcher.emit('testEvent', null); + + expect(callback).toHaveBeenCalledWith(null); + }); + }); + + describe('integration', () => { + test('callbacks are independent per event type', () => { + const dispatcher = new EventsDispatcher(); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + dispatcher.addEventListener('event1', callback1); + dispatcher.addEventListener('event2', callback2); + + dispatcher.emit('event1', 'data1'); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + }); + + test('same callback can be registered for different events', () => { + const dispatcher = new EventsDispatcher(); + const callback = vi.fn(); + + dispatcher.addEventListener('event1', callback); + dispatcher.addEventListener('event2', callback); + + dispatcher.emit('event1', 'data1'); + dispatcher.emit('event2', 'data2'); + + expect(callback).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/__tests__/objects-manager.ts b/src/__tests__/objects-manager.ts new file mode 100644 index 00000000..9296b703 --- /dev/null +++ b/src/__tests__/objects-manager.ts @@ -0,0 +1,235 @@ +import { test, expect, describe, vi, beforeEach } from 'vitest'; +import { ObjectsManager } from '../objects-manager'; +import { Path, PathType } from '../path'; +import { Scene, Color, Group } from 'three'; + +describe('ObjectsManager', () => { + let scene: Scene; + let objectsManager: ObjectsManager; + + beforeEach(() => { + scene = new Scene(); + objectsManager = new ObjectsManager(scene, 0.4, 0.2, 0.6); + }); + + describe('constructor', () => { + test('creates extrusions and travel moves groups', () => { + expect(objectsManager.extrusionsGroup).toBeInstanceOf(Group); + expect(objectsManager.travelMovesGroup).toBeInstanceOf(Group); + }); + + test('adds groups to scene', () => { + expect(scene.children).toContain(objectsManager.extrusionsGroup); + expect(scene.children).toContain(objectsManager.travelMovesGroup); + }); + + test('names the groups correctly', () => { + expect(objectsManager.extrusionsGroup.name).toBe('Extrusions'); + expect(objectsManager.travelMovesGroup.name).toBe('Travel Moves'); + }); + + test('sets line width and height', () => { + expect(objectsManager.lineWidth).toBe(0.4); + expect(objectsManager.lineHeight).toBe(0.2); + }); + + test('sets extrusion width', () => { + expect(objectsManager.extrusionWidth).toBe(0.6); + }); + + test('creates clipping planes', () => { + expect(objectsManager.clippingPlanes.length).toBe(2); + }); + }); + + describe('visibility controls', () => { + describe('hideTravels', () => { + test('sets travelMovesGroup visibility to false', () => { + objectsManager.showTravels(); + objectsManager.hideTravels(); + + expect(objectsManager.travelMovesGroup.visible).toBe(false); + }); + }); + + describe('showTravels', () => { + test('sets travelMovesGroup visibility to true', () => { + objectsManager.hideTravels(); + objectsManager.showTravels(); + + expect(objectsManager.travelMovesGroup.visible).toBe(true); + }); + }); + + describe('hideExtrusions', () => { + test('sets extrusionsGroup visibility to false', () => { + objectsManager.showExtrusions(); + objectsManager.hideExtrusions(); + + expect(objectsManager.extrusionsGroup.visible).toBe(false); + }); + }); + + describe('showExtrusions', () => { + test('sets extrusionsGroup visibility to true', () => { + objectsManager.hideExtrusions(); + objectsManager.showExtrusions(); + + expect(objectsManager.extrusionsGroup.visible).toBe(true); + }); + }); + }); + + describe('renderTravelLines', () => { + test('adds rendered paths to travelMovesGroup', () => { + const path = createTestPath(); + const color = new Color(0xff0000); + + objectsManager.renderTravelLines([path], color); + + expect(objectsManager.travelMovesGroup.children.length).toBe(1); + }); + + test('does not re-render already rendered paths', () => { + const path = createTestPath(); + const color = new Color(0xff0000); + + objectsManager.renderTravelLines([path], color); + objectsManager.renderTravelLines([path], color); + + expect(objectsManager.travelMovesGroup.children.length).toBe(2); + }); + + test('renders only unrendered paths from a mixed array', () => { + const path1 = createTestPath(); + const path2 = createTestPath(); + const color = new Color(0xff0000); + + objectsManager.renderTravelLines([path1], color); + const initialChildren = objectsManager.travelMovesGroup.children.length; + + objectsManager.renderTravelLines([path1, path2], color); + + expect(objectsManager.travelMovesGroup.children.length).toBe(initialChildren + 1); + }); + }); + + describe('renderExtrusionLines', () => { + test('adds rendered paths to extrusionsGroup', () => { + const path = createTestPath(); + const color = new Color(0x00ff00); + + objectsManager.renderExtrusionLines([path], color); + + expect(objectsManager.extrusionsGroup.children.length).toBe(1); + }); + + test('does not re-render already rendered paths', () => { + const path = createTestPath(); + const color = new Color(0x00ff00); + + objectsManager.renderExtrusionLines([path], color); + objectsManager.renderExtrusionLines([path], color); + + expect(objectsManager.extrusionsGroup.children.length).toBe(2); + }); + }); + + describe('renderExtrusionTubes', () => { + test('adds rendered paths to extrusionsGroup', () => { + const path = createTestPath(); + const color = new Color(0x0000ff); + + objectsManager.renderExtrusionTubes([path], color); + + expect(objectsManager.extrusionsGroup.children.length).toBe(1); + }); + + test('does not re-render already rendered paths', () => { + const path = createTestPath(); + const color = new Color(0x0000ff); + + objectsManager.renderExtrusionTubes([path], color); + objectsManager.renderExtrusionTubes([path], color); + + expect(objectsManager.extrusionsGroup.children.length).toBe(2); + }); + + test('adds material to materials array', () => { + const path = createTestPath(); + const color = new Color(0x0000ff); + + const initialMaterialCount = objectsManager.materials.length; + objectsManager.renderExtrusionTubes([path], color); + + expect(objectsManager.materials.length).toBe(initialMaterialCount + 1); + }); + }); + + describe('dispose', () => { + test('removes extrusionsGroup from parent', () => { + objectsManager.dispose(); + + expect(scene.children).not.toContain(objectsManager.extrusionsGroup); + }); + + test('removes travelMovesGroup from parent', () => { + objectsManager.dispose(); + + expect(scene.children).not.toContain(objectsManager.travelMovesGroup); + }); + + test('disposes all disposables', () => { + const mockDisposable = { dispose: vi.fn() }; + objectsManager.disposables.push(mockDisposable); + + objectsManager.dispose(); + + expect(mockDisposable.dispose).toHaveBeenCalledTimes(1); + }); + + test('clears disposables array after dispose', () => { + const mockDisposable = { dispose: vi.fn() }; + objectsManager.disposables.push(mockDisposable); + + objectsManager.dispose(); + + expect(objectsManager.disposables.length).toBe(0); + }); + }); + + describe('updateClippingPlanes', () => { + test('updates shader materials with min and max Z values', () => { + const path = createTestPath(); + const color = new Color(0x0000ff); + objectsManager.renderExtrusionTubes([path], color); + + objectsManager.updateClippingPlanes(1.0, 10.0); + + objectsManager.materials.forEach((material) => { + expect(material.uniforms.clipMinY.value).toBe(1.0); + expect(material.uniforms.clipMaxY.value).toBe(10.0); + }); + }); + }); + + describe('group orientation', () => { + test('groups are rotated to match coordinate system', () => { + const expectedRotation = -Math.PI / 2; + + const extrusionEuler = objectsManager.extrusionsGroup.rotation; + const travelEuler = objectsManager.travelMovesGroup.rotation; + + expect(extrusionEuler.x).toBeCloseTo(expectedRotation, 5); + expect(travelEuler.x).toBeCloseTo(expectedRotation, 5); + }); + }); +}); + +function createTestPath(): Path { + const path = new Path(PathType.Extrusion, 0.6, 0.2, 0); + path.addPoint(0, 0, 0); + path.addPoint(10, 0, 0); + path.addPoint(10, 10, 0); + return path; +}