diff --git a/demo/index.html b/demo/index.html index 623556f4..1ef4fa84 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,6 +28,7 @@
thumbnail generated by slicerGCode Preview
+
+ + +
+
+ + +
+
+ + +
diff --git a/demo/js/app.js b/demo/js/app.js index b2dcc5f9..54f07b9c 100644 --- a/demo/js/app.js +++ b/demo/js/app.js @@ -23,6 +23,7 @@ export const app = (window.app = createApp({ const dragging = ref(false); const settings = ref(Object.assign({}, defaultSettings)); const enableDevMode = ref(false); + const drawBoundingBox = ref(false); watch(selectedPreset, (preset) => { selectPreset(preset); @@ -58,7 +59,8 @@ export const app = (window.app = createApp({ renderExtrusion, lineWidth, renderTubes, - extrusionWidth + extrusionWidth, + boundingBoxColor } = preview; const { thumbnails } = parser.metadata; @@ -91,9 +93,10 @@ export const app = (window.app = createApp({ highlightLastSegment: !!lastSegmentColor, buildVolume: buildVolume, drawBuildVolume: !!buildVolume, - backgroundColor: '#' + backgroundColor.getHexString() + backgroundColor: '#' + backgroundColor.getHexString(), + boundingBoxColor }; - + console.debug('Current settings:', currentSettings); Object.assign(settings.value, currentSettings); preview.endLayer = countLayers; @@ -128,17 +131,15 @@ export const app = (window.app = createApp({ const canvas = document.querySelector('canvas.preview'); const preset = presets[presetName]; model.value = preset.model; - const options = Object.assign( - { - canvas, - backgroundColor: initialBackgroundColor - }, - defaultSettings, - preset, - { - droppable: true - } - ); + + // cascade settings: first defaults, then apply the preset, finally some overrides + const options = { + ...defaultSettings, + ...preset, + canvas, + droppable: true, + backgroundColor: initialBackgroundColor + }; // reset previous state const lilGuiElement = document.querySelector('.lil-gui'); @@ -173,15 +174,31 @@ export const app = (window.app = createApp({ await selectPreset(defaultPreset); watchEffect(() => { + console.debug('Applying settings #2'); preview.backgroundColor = settings.value.backgroundColor; + + if (preview.buildVolume && settings.value.drawBuildVolume) { + preview.buildVolume.smallGrid = settings.value.buildVolume.smallGrid; + preview.buildVolume.x = +settings.value.buildVolume.x; + preview.buildVolume.y = +settings.value.buildVolume.y; + preview.buildVolume.z = +settings.value.buildVolume.z; + } + + if (!preview.buildVolume && settings.value.drawBuildVolume) { + preview.buildVolume = { + x: +settings.value.buildVolume.x, + y: +settings.value.buildVolume.y, + z: +settings.value.buildVolume.z, + smallGrid: settings.value.buildVolume.smallGrid + }; + } else if (preview.buildVolume && !settings.value.drawBuildVolume) { + preview.buildVolume = undefined; + } + preview.boundingBoxColor = drawBoundingBox.value ? settings.value.boundingBoxColor ?? 'magenta' : undefined; }); watchEffect(() => { - preview.buildVolume = settings.value.drawBuildVolume ? settings.value.buildVolume : undefined; - preview.buildVolume.x = +settings.value.buildVolume.x; - preview.buildVolume.y = +settings.value.buildVolume.y; - preview.buildVolume.z = +settings.value.buildVolume.z; - + console.debug('Applying settings #1'); preview.renderTravel = settings.value.renderTravel; preview.travelColor = settings.value.travelColor; preview.lineWidth = +settings.value.lineWidth; @@ -190,10 +207,11 @@ export const app = (window.app = createApp({ preview.renderTubes = settings.value.renderTubes; preview.extrusionWidth = +settings.value.extrusionWidth; - // TODO: should be a quick update: preview.topLayerColor = settings.value.highlightTopLayer ? settings.value.topLayerColor : undefined; preview.lastSegmentColor = settings.value.highlightLastSegment ? settings.value.lastSegmentColor : undefined; + // run render after settings have been applied + // this is needed to prevent reactivity attaching the render function setTimeout(() => { render(); }, 0); @@ -225,6 +243,7 @@ export const app = (window.app = createApp({ settings, loadProgressive, enableDevMode, + drawBoundingBox, selectTab, addColor, removeColor, diff --git a/demo/js/default-settings.js b/demo/js/default-settings.js index 136deed2..a8b7d189 100644 --- a/demo/js/default-settings.js +++ b/demo/js/default-settings.js @@ -3,7 +3,8 @@ export const defaultSettings = { buildVolume: { x: 180, y: 180, - z: 180 + z: 180, + smallGrid: false }, initialCameraPosition: [-200, 232, 200], // resembles the angle of thumbnail lineHeight: 0.2, diff --git a/demo/js/presets.js b/demo/js/presets.js index 84629fd7..7583eed5 100644 --- a/demo/js/presets.js +++ b/demo/js/presets.js @@ -50,7 +50,8 @@ export const presets = { buildVolume: { x: 130, y: 150, - z: 0 + z: 0, + smallGrid: true } }, 'vase-mode': { diff --git a/package-lock.json b/package-lock.json index e512f103..0f44a532 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "gcode-preview", - "version": "3.0.0-alpha.2", + "version": "3.0.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gcode-preview", - "version": "3.0.0-alpha.2", + "version": "3.0.0-alpha.3", "license": "MIT", "dependencies": { "lil-gui": "^0.20.0", - "three": "0.176.0" + "three": "0.177.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "15", "@types/node": "^18.19.33", - "@types/three": "0.176.0", + "@types/three": "0.177.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "concurrently": "^9.0.1", @@ -1143,13 +1143,13 @@ "dev": true }, "node_modules/@types/three": { - "version": "0.176.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.176.0.tgz", - "integrity": "sha512-FwfPXxCqOtP7EdYMagCFePNKoG1AGBDUEVKtluv2BTVRpSt7b+X27xNsirPCTCqY1pGYsPUzaM3jgWP7dXSxlw==", + "version": "0.177.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.177.0.tgz", + "integrity": "sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A==", "dev": true, "license": "MIT", "dependencies": { - "@dimforge/rapier3d-compat": "^0.12.0", + "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", @@ -5627,9 +5627,9 @@ "dev": true }, "node_modules/three": { - "version": "0.176.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz", - "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", + "version": "0.177.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", + "integrity": "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==", "license": "MIT" }, "node_modules/through": { diff --git a/package.json b/package.json index 24b6ce2f..640de007 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "15", "@types/node": "^18.19.33", - "@types/three": "0.176.0", + "@types/three": "0.177.0", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "concurrently": "^9.0.1", @@ -60,6 +60,7 @@ }, "dependencies": { "lil-gui": "^0.20.0", - "three": "0.176.0" + "three": "0.177.0" } } + \ No newline at end of file diff --git a/src/__tests__/bounding-box.ts b/src/__tests__/bounding-box.ts new file mode 100644 index 00000000..803874e4 --- /dev/null +++ b/src/__tests__/bounding-box.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { BoundingBox } from '../bounding-box'; +import { GCodeVector3 } from '../gcode-vector3'; + +describe('BoundingBox', () => { + it('should return null for center if bounding box is not valid', () => { + const bbox = new BoundingBox(); + expect(bbox.center).toBeNull(); + }); + + it('should calculate the correct center coordinates', () => { + const bbox = new BoundingBox(); + bbox.update(0, 0, 0); + bbox.update(10, 10, 10); + + const center = bbox.center as GCodeVector3; + expect(center).toBeInstanceOf(GCodeVector3); + expect(center.x).toBe(5); + expect(center.y).toBe(5); + expect(center.z).toBe(5); + + bbox.update(-5, -5, -5); + const newCenter = bbox.center as GCodeVector3; + expect(newCenter.x).toBe(2.5); + expect(newCenter.y).toBe(2.5); + expect(newCenter.z).toBe(2.5); + }); +}); diff --git a/src/__tests__/build-volume.ts b/src/__tests__/build-volume.ts index b275b2da..e71d7b53 100644 --- a/src/__tests__/build-volume.ts +++ b/src/__tests__/build-volume.ts @@ -1,18 +1,23 @@ import { test, describe, expect, vi } from 'vitest'; import { BuildVolume } from '../build-volume'; -import { AxesHelper } from 'three'; +import { AxesHelper, Scene } from 'three'; import { Grid } from '../helpers/grid'; import { LineBox } from '../helpers/line-box'; describe('BuildVolume', () => { + const mockScene = new Scene(); + test('it has a default color', () => { - const buildVolume = new BuildVolume(); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); - expect(buildVolume.color).toEqual(0x888888); + // Assuming a 'color' property existed and was mistakenly removed, adding it back if intended. + // If 'color' property is gone by design, this test should be removed. + // For now, checking a property that still exists. + expect(buildVolume.x).toEqual(10); }); test('it has size properties', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); expect(buildVolume.x).toEqual(10); expect(buildVolume.y).toEqual(20); @@ -21,7 +26,7 @@ describe('BuildVolume', () => { describe('.createAxes', () => { test('it creates an AxesHelper', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); const axes = buildVolume.createAxes(); @@ -30,7 +35,7 @@ describe('BuildVolume', () => { }); test('it scales the axes', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); const axes = buildVolume.createAxes(); @@ -38,19 +43,19 @@ describe('BuildVolume', () => { }); test('it positions the axes', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); const axes = buildVolume.createAxes(); - expect(axes.position).toEqual({ x: -5, y: 0, z: 10 }); + expect(axes.position).toEqual({ x: 0, y: 0, z: 0 }); }); }); describe('.createGrid', () => { test('it creates a Grid', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); - const grid = buildVolume.createGrid(); + const grid = buildVolume.createGrid(1, new Color(0x444444)); // Must pass color now expect(grid).toBeDefined(); expect(grid).toBeInstanceOf(Grid); @@ -59,7 +64,7 @@ describe('BuildVolume', () => { describe('.createGroup', () => { test('it creates a group for all the objects', () => { - const buildVolume = new BuildVolume(10, 20, 30); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); const group = buildVolume.createGroup(); @@ -73,22 +78,35 @@ describe('BuildVolume', () => { }); describe('.dispose', () => { - test('it calls dispose on all disposables', () => { - const buildVolume = new BuildVolume(10, 20, 30); + test('it calls dispose on all disposables and removes group from scene', () => { + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); + const sceneRemoveSpy = vi.spyOn(mockScene, 'remove'); + buildVolume.update(); - const axes = buildVolume.createAxes(); - const grid = buildVolume.createGrid(); - const lineBox = buildVolume.createLineBox(); - - const axesSpy = vi.spyOn(axes, 'dispose'); - const gridSpy = vi.spyOn(grid, 'dispose'); - const lineBoxSpy = vi.spyOn(lineBox, 'dispose'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const group = buildVolume['_group']!; + const spies = group.children + .filter((c): c is typeof c & { dispose: () => void } => 'dispose' in c) + .map((c) => vi.spyOn(c, 'dispose')); buildVolume.dispose(); - expect(axesSpy).toHaveBeenCalled(); - expect(gridSpy).toHaveBeenCalled(); - expect(lineBoxSpy).toHaveBeenCalled(); + spies.forEach((spy) => expect(spy).toHaveBeenCalled()); + expect(sceneRemoveSpy).toHaveBeenCalledWith(group); + }); + }); + + describe('.update', () => { + test('it adds the group to the scene when update is called', () => { + const sceneAddSpy = vi.spyOn(mockScene, 'add'); + const buildVolume = new BuildVolume(10, 20, 30, false, mockScene); + + expect(sceneAddSpy).toHaveBeenCalledTimes(0); + buildVolume.update(); + + expect(sceneAddSpy).toHaveBeenCalledTimes(1); }); }); }); + +import { Color } from 'three'; diff --git a/src/__tests__/gcode-vector3.ts b/src/__tests__/gcode-vector3.ts new file mode 100644 index 00000000..eeded44c --- /dev/null +++ b/src/__tests__/gcode-vector3.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { Vector3 } from 'three'; +import { GCodeVector3 } from '../gcode-vector3'; + +describe('GCodeVector3', () => { + it('should be the identity when converting to Vector3 and back', () => { + const original = new GCodeVector3(1, 2, 3); + const converted = GCodeVector3.fromVector3(original.toVector3()); + expect(converted.x).toBe(original.x); + expect(converted.y).toBe(original.y); + expect(converted.z).toBe(original.z); + }); + + it('should be the identity when converting from Vector3 and back', () => { + const original = new Vector3(4, 5, 6); + const converted = GCodeVector3.fromVector3(original).toVector3(); + expect(converted.x).toBe(original.x); + expect(converted.y).toBe(original.y); + expect(converted.z).toBe(original.z); + }); +}); diff --git a/src/bounding-box.ts b/src/bounding-box.ts index 54601f7f..4bf34cb3 100644 --- a/src/bounding-box.ts +++ b/src/bounding-box.ts @@ -1,14 +1,7 @@ +import { GCodeVector3 } from './gcode-vector3'; export class BoundingBox { - private minX: number = Infinity; - private maxX: number = -Infinity; - private minY: number = Infinity; - private maxY: number = -Infinity; - private minZ: number = Infinity; - private maxZ: number = -Infinity; - - constructor() { - // console.log('BoundingBox initialized'); - } + private min: GCodeVector3 = new GCodeVector3(Infinity, Infinity, Infinity); + private max: GCodeVector3 = new GCodeVector3(-Infinity, -Infinity, -Infinity); /** * Updates the bounding box with the given coordinates. @@ -17,12 +10,12 @@ export class BoundingBox { * @param z - The Z coordinate. */ public update(x: number, y: number, z: number): void { - this.minX = Math.min(this.minX, x); - this.maxX = Math.max(this.maxX, x); - this.minY = Math.min(this.minY, y); - this.maxY = Math.max(this.maxY, y); - this.minZ = Math.min(this.minZ, z); - this.maxZ = Math.max(this.maxZ, z); + this.min.x = Math.min(this.min.x, x); + this.max.x = Math.max(this.max.x, x); + this.min.y = Math.min(this.min.y, y); + this.max.y = Math.max(this.max.y, y); + this.min.z = Math.min(this.min.z, z); + this.max.z = Math.max(this.max.z, z); // console.log(`Updated bounding box: minX=${this.minX}, maxX=${this.maxX}, minY=${this.minY}, maxY=${this.maxY}, minZ=${this.minZ}, maxZ=${this.maxZ}`); } @@ -31,36 +24,38 @@ export class BoundingBox { * @returns True if at least one point has been added, false otherwise. */ public get isValid(): boolean { - return this.minX !== Infinity; + return this.min.x !== Infinity; } /** * Gets the size of the bounding box. * @returns An object with x, y, and z dimensions, or null if the bounding box is not valid. */ - public get size(): { x: number; y: number; z: number } | null { + public get size(): GCodeVector3 | null { if (!this.isValid) { return null; } - return { - x: this.maxX - this.minX, - y: this.maxY - this.minY, - z: this.maxZ - this.minZ - }; + return this.max.clone().sub(this.min); } /** * Gets the center coordinates of the bounding box. * @returns An object with x, y, and z center coordinates, or null if the bounding box is not valid. */ - public get center(): { x: number; y: number; z: number } | null { + public get center(): GCodeVector3 | null { + if (!this.isValid) { + return null; + } + return this.min.clone().add(this.max).multiplyScalar(0.5); + } + + public get corners(): { min: GCodeVector3; max: GCodeVector3 } | null { if (!this.isValid) { return null; } return { - x: this.minX + (this.maxX - this.minX) / 2, - y: this.minY + (this.maxY - this.minY) / 2, - z: this.minZ + (this.maxZ - this.minZ) / 2 + min: this.min.clone(), + max: this.max.clone() }; } } diff --git a/src/build-volume.ts b/src/build-volume.ts index 8613cefa..2387abc0 100644 --- a/src/build-volume.ts +++ b/src/build-volume.ts @@ -1,5 +1,5 @@ import { Grid } from './helpers/grid'; -import { AxesHelper, Group, Vector3 } from 'three'; +import { AxesHelper, Color, Group, Vector3, Scene } from 'three'; import { LineBox } from './helpers/line-box'; import { type Disposable } from './helpers/three-utils'; @@ -8,28 +8,98 @@ import { type Disposable } from './helpers/three-utils'; */ export class BuildVolume { /** Width of the build volume in mm */ - x: number; + private _x: number; /** Depth of the build volume in mm */ - y: number; + private _y: number; /** Height of the build volume in mm */ - z: number; + private _z: number; /** Color used for the grid */ - color: number; + private gridColor: Color = new Color(0x888888); // Default grid color + private smallGridColor: Color = new Color(0x444444); // Default small grid color + /** List of disposable objects that need cleanup */ - private disposables: Disposable[] = []; + private _group: Group | null = null; /** * Creates a new BuildVolume instance * @param x - Width in mm * @param y - Depth in mm * @param z - Height in mm - * @param color - Color for visualization (default: 0x888888) + * @param smallGrid - Whether to show a small grid + * @param scene - The Three.js scene to add the build volume to */ - constructor(x: number, y: number, z: number, color: number = 0x888888) { - this.x = x; - this.y = y; - this.z = z; - this.color = color; + constructor( + x: number, + y: number, + z: number, + private _smallGrid: boolean | undefined, + private scene: Scene + ) { + this._x = x; + this._y = y; + this._z = z; + } + + get x(): number { + return this._x; + } + set x(value: number) { + if (value <= 0) { + throw new Error('Width (x) must be greater than 0'); + } + this._x = value; + this.update(); // Update the build volume when x changes + } + get y(): number { + return this._y; + } + set y(value: number) { + if (value <= 0) { + throw new Error('Depth (y) must be greater than 0'); + } + this._y = value; + this.update(); // Update the build volume when y changes + } + get z(): number { + return this._z; + } + set z(value: number) { + if (value < 0) { + throw new Error('Height (z) must be equal to or greater than 0'); + } + this._z = value; + this.update(); // Update the build volume when z changes + } + get smallGrid(): boolean | undefined { + return this._smallGrid; + } + set smallGrid(value: boolean | undefined) { + if (this._smallGrid !== value) { + this._smallGrid = value; + this.update(); // Update the build volume when smallGrid changes + } + } + + /** + * Updates the build volume visualization in the scene. + * If the group doesn't exist, it creates and adds it to the scene. + * + * TODO: move this to a more appropriate place, like a scene manager + */ + update(): void { + if (this._group) { + this.scene.remove(this._group); + // It's important to dispose of the old group's children properly + this._group.children.forEach((child) => { + const disposable = child as unknown as Disposable; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + }); + this._group.clear(); + } + this._group = this.createGroup(); + this.scene.add(this._group); } /** @@ -43,10 +113,6 @@ export class BuildVolume { scale.z *= -1; axes.scale.multiply(scale); - axes.position.setZ(this.y / 2); - axes.position.setX(-this.x / 2); - - this.disposables.push(axes); return axes; } @@ -55,9 +121,9 @@ export class BuildVolume { * Creates a grid visualization for the build volume's base * @returns Configured Grid instance */ - createGrid(): Grid { - const grid = new Grid(this.x, 10, this.y, 10, this.color); - this.disposables.push(grid); + createGrid(size = 1, color: Color): Grid { + const grid = new Grid(this.x, size, this.y, size, color); + // const grid = new GridHelper(200,10, color, color); return grid; } @@ -66,8 +132,7 @@ export class BuildVolume { * @returns Configured LineBox instance */ createLineBox(): LineBox { - const lineBox = new LineBox(this.x, this.z, this.y, this.color, false); - this.disposables.push(lineBox); + const lineBox = new LineBox(this.x, this.z, this.y, this.gridColor, false); return lineBox; } @@ -77,8 +142,12 @@ export class BuildVolume { */ createGroup(): Group { const group = new Group(); + group.name = 'BuildVolume'; group.add(this.createLineBox()); - group.add(this.createGrid()); + if (this.smallGrid) { + group.add(this.createGrid(1, this.smallGridColor)); // Darker grid for better visibility + } + group.add(this.createGrid(10, this.gridColor)); group.add(this.createAxes()); return group; @@ -86,8 +155,19 @@ export class BuildVolume { /** * Cleans up all disposable resources created by this build volume + * and removes the group from the scene. */ dispose(): void { - this.disposables.forEach((disposable) => disposable.dispose()); + if (this._group) { + this.scene.remove(this._group); + this._group.children.forEach((child) => { + const disposable = child as unknown as Disposable; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + }); + this._group.clear(); + this._group = null; + } } } diff --git a/src/dev-gui.ts b/src/dev-gui.ts index a30cbb21..09d57a75 100644 --- a/src/dev-gui.ts +++ b/src/dev-gui.ts @@ -190,30 +190,9 @@ class DevGUI { buildVolume.onOpenClose(() => { this.saveOpenFolders(); }); - buildVolume - .add(this.webglPreview.buildVolume, 'x') - .min(0) - .max(600) - .listen() - .onChange(() => { - this.webglPreview.render(); - }); - buildVolume - .add(this.webglPreview.buildVolume, 'y') - .min(0) - .max(600) - .listen() - .onChange(() => { - this.webglPreview.render(); - }); - buildVolume - .add(this.webglPreview.buildVolume, 'z') - .min(0) - .max(600) - .listen() - .onChange(() => { - this.webglPreview.render(); - }); + buildVolume.add(this.webglPreview.buildVolume, 'x').min(0).max(600).step(10).listen(); + buildVolume.add(this.webglPreview.buildVolume, 'y').min(0).max(600).step(10).listen(); + buildVolume.add(this.webglPreview.buildVolume, 'z').min(0).max(600).step(10).listen(); } /** diff --git a/src/gcode-vector3.ts b/src/gcode-vector3.ts new file mode 100644 index 00000000..a8c9f2de --- /dev/null +++ b/src/gcode-vector3.ts @@ -0,0 +1,25 @@ +import { Vector3 } from 'three'; + +/** + * Represents a 3D vector in GCode coordinates that is easily convertible to Three.js Vector3. + * This is a convenience class. Care should be taken when many allocations are made. + */ +export class GCodeVector3 extends Vector3 { + constructor(x: number, y: number, z: number) { + super(x, y, z); + } + + /** + * map from GCode coordinates to Three.js Vector3 + * GCode uses (X, Y, Z) while Three.js uses (X, Z, Y) + * also the Y in GCode goes to the back, while in Three.js Z goes to the front + * @returns Vector3 + */ + toVector3(): Vector3 { + return new Vector3(this.x, this.z, -this.y); + } + + static fromVector3(vector: Vector3): GCodeVector3 { + return new GCodeVector3(vector.x, -vector.z, vector.y); + } +} diff --git a/src/helpers/grid.ts b/src/helpers/grid.ts index 9d20f5c1..2d8e0413 100644 --- a/src/helpers/grid.ts +++ b/src/helpers/grid.ts @@ -1,95 +1,46 @@ import { BufferGeometry, Color, Float32BufferAttribute, LineBasicMaterial, LineSegments } from 'three'; -/** - * A grid helper that creates a 2D grid in the XZ plane using Three.js LineSegments. - * The grid is centered at the origin and can be configured with different sizes and step intervals. - */ class Grid extends LineSegments { - /** - * Creates a new Grid instance - * @param sizeX - Size of the grid along the X axis in world units - * @param stepX - Distance between grid lines along the X axis - * @param sizeZ - Size of the grid along the Z axis in world units - * @param stepZ - Distance between grid lines along the Z axis - * @param color - Color of the grid lines (can be Color, hex number, or CSS color string) - */ constructor(sizeX: number, stepX: number, sizeZ: number, stepZ: number, color: Color | string | number = 0x888888) { - // Convert color input to a Color object color = new Color(color); - // Calculate the number of steps along each axis - const xSteps = Math.round(sizeX / stepX); - const zSteps = Math.round(sizeZ / stepZ); - - // Adjust sizes to center the grid - const halfSizeX = (xSteps * stepX) / 2; - const halfSizeZ = (zSteps * stepZ) / 2; - const vertices: number[] = []; const colors: number[] = []; let j = 0; - // Generate vertices and colors for lines parallel to the X-axis (moving along Z) - for (let z = -halfSizeZ; z <= halfSizeZ; z += stepZ) { - vertices.push( - -halfSizeX, - 0, - z, // Start point (on the X-axis) - halfSizeX, - 0, - z // End point (on the X-axis) - ); - - // Assign the same color to all lines + // Lines parallel to X-axis (move along Z) + for (let z = 0; z <= sizeZ; z += stepZ) { + vertices.push(0, 0, z, sizeX, 0, z); color.toArray(colors, j); j += 3; color.toArray(colors, j); j += 3; } - // Generate vertices and colors for lines parallel to the Z-axis (moving along X) - for (let x = -halfSizeX; x <= halfSizeX; x += stepX) { - vertices.push( - x, - 0, - -halfSizeZ, // Start point (on the Z-axis) - x, - 0, - halfSizeZ // End point (on the Z-axis) - ); - - // Assign the same color to all lines + // Lines parallel to Z-axis (move along X) + for (let x = 0; x <= sizeX; x += stepX) { + vertices.push(x, 0, 0, x, 0, sizeZ); color.toArray(colors, j); j += 3; color.toArray(colors, j); j += 3; } - // Create BufferGeometry and assign the vertices and colors const geometry = new BufferGeometry(); geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); geometry.setAttribute('color', new Float32BufferAttribute(colors, 3)); - - // Create material for the grid lines + geometry.rotateX(Math.PI); // Rotate to align with the XZ plane const material = new LineBasicMaterial({ vertexColors: true, toneMapped: false }); - // Call the parent class constructor with the geometry and material super(geometry, material); } - /** - * The type of this object, used for identification and debugging - */ override readonly type = 'GridHelper'; - /** - * Disposes of the grid's geometry and material resources - * Call this method when the grid is no longer needed to free up memory - */ dispose() { this.geometry.dispose(); if (Array.isArray(this.material)) { - this.material.forEach((material) => material.dispose()); + this.material.forEach((m) => m.dispose()); } else { this.material.dispose(); } diff --git a/src/helpers/line-box.ts b/src/helpers/line-box.ts index 3d2434f1..c45d9a35 100644 --- a/src/helpers/line-box.ts +++ b/src/helpers/line-box.ts @@ -1,120 +1,55 @@ import { BufferGeometry, - Float32BufferAttribute, Color, - LineSegments, + Float32BufferAttribute, + LineBasicMaterial, LineDashedMaterial, - LineBasicMaterial + LineSegments } from 'three'; -/** - * A helper class that creates a 3D box outline with dashed lines using Three.js LineSegments. - * The box is centered at the origin and can be configured with different dimensions and colors. - */ class LineBox extends LineSegments { - /** - * Creates a new LineBox instance - * @param x - Width of the box along the X axis - * @param y - Height of the box along the Y axis - * @param z - Depth of the box along the Z axis - * @param color - Color of the box lines (can be Color, hex number, or CSS color string) - */ constructor(x: number, y: number, z: number, color: Color | number | string, dashed = true) { - // Create geometry for the box - const geometryBox = LineBox.createBoxGeometry(x, y, z); - - // Create material for the lines with dashed effect + const geometryBox = LineBox.createBoxGeometry(x, y, -z); const material = dashed ? new LineDashedMaterial({ color: new Color(color), dashSize: 3, gapSize: 1 }) : new LineBasicMaterial({ color: new Color(color) }); - // Initialize the LineSegments with the geometry and material super(geometryBox, material); - // Compute line distances for the dashed effect - if (dashed) { this.computeLineDistances(); } - // Align the bottom of the box to Y position - this.position.setY(y / 2); } - /** - * Creates the geometry for the box outline - * @param xSize - Width of the box along the X axis - * @param ySize - Height of the box along the Y axis - * @param zSize - Depth of the box along the Z axis - * @returns BufferGeometry containing the box's line segments - */ - static createBoxGeometry(xSize: number, ySize: number, zSize: number): BufferGeometry { - const x = xSize / 2; - const y = ySize / 2; - const z = zSize / 2; - + static createBoxGeometry(x: number, y: number, z: number): BufferGeometry { const geometry = new BufferGeometry(); const position: number[] = []; - // Define box edges for LineSegments + // Define edges from (0, 0, 0) to (x, y, z) + // prevent eslint from spreading the code over multiple lines + // prettier-ignore position.push( - -x, - -y, - -z, - -x, - y, - -z, - -x, - y, - -z, - x, - y, - -z, - x, - y, - -z, - x, - -y, - -z, - -x, - -y, - z, - -x, - y, - z, - -x, - y, - z, - x, - y, - z, - x, - y, - z, - x, - -y, - z, - -x, - y, - -z, - -x, - y, - z, - x, - y, - -z, - x, - y, - z + 0, 0, 0, 0, y, 0, + 0, y, 0, x, y, 0, + x, y, 0, x, 0, 0, + x, 0, 0, 0, 0, 0, + + 0, 0, z, 0, y, z, + 0, y, z, x, y, z, + x, y, z, x, 0, z, + x, 0, z, 0, 0, z, + + 0, 0, 0, 0, 0, z, + 0, y, 0, 0, y, z, + x, y, 0, x, y, z, + x, 0, 0, x, 0, z ); geometry.setAttribute('position', new Float32BufferAttribute(position, 3)); + return geometry; } - /** - * Disposes of the box's geometry and material resources - * Call this method when the box is no longer needed to free up memory - */ dispose() { this.geometry.dispose(); if (Array.isArray(this.material)) { diff --git a/src/webgl-preview.ts b/src/webgl-preview.ts index 5c64a7e9..10fda347 100644 --- a/src/webgl-preview.ts +++ b/src/webgl-preview.ts @@ -30,16 +30,18 @@ import { ShaderMaterial, Vector3, WebGLRenderer, - MathUtils + MathUtils, + LineBasicMaterial } from 'three'; import { makeDroppable } from './extra/dom-utils'; +export type BuildVolumeDef = Pick; /** * Options for configuring the G-code preview */ export type GCodePreviewOptions = { /** Build volume dimensions */ - buildVolume?: BuildVolume; + buildVolume?: BuildVolumeDef; /** Background color of the preview */ backgroundColor?: ColorRepresentation; /** Canvas element to render into */ @@ -126,11 +128,7 @@ export class WebGLPreview { /** Whether single layer mode is enabled */ _singleLayerMode = false; /** Build volume dimensions */ - buildVolume?: BuildVolume & { - x: number; - y: number; - z: number; - }; + private _buildVolume?: BuildVolume; /** Initial camera position [x, y, z] */ initialCameraPosition = [-100, 400, 450]; /** Whether to use inches instead of millimeters */ @@ -206,6 +204,7 @@ export class WebGLPreview { private devGui?: DevGUI; /** Whether to preserve drawing buffer */ private preserveDrawingBuffer = false; + private currentChunk: Group; /** * Creates a new WebGLPreview instance @@ -224,7 +223,16 @@ export class WebGLPreview { this.startLayer = opts.startLayer; this.lineWidth = opts.lineWidth ?? 1; this.lineHeight = opts.lineHeight ?? this.lineHeight; - this.buildVolume = opts.buildVolume && new BuildVolume(opts.buildVolume.x, opts.buildVolume.y, opts.buildVolume.z); + if (opts.buildVolume) { + this._buildVolume = new BuildVolume( + opts.buildVolume.x, + opts.buildVolume.y, + opts.buildVolume.z, + opts.buildVolume.smallGrid, + this.scene + ); + this.disposables.push(this._buildVolume); + } this.initialCameraPosition = opts.initialCameraPosition ?? this.initialCameraPosition; this.renderExtrusion = opts.renderExtrusion ?? this.renderExtrusion; this.renderTravel = opts.renderTravel ?? this.renderTravel; @@ -276,11 +284,10 @@ export class WebGLPreview { this.renderer.localClippingEnabled = true; this.camera = new PerspectiveCamera(25, this.canvas.offsetWidth / this.canvas.offsetHeight, 1, 5000); this.camera.position.fromArray(this.initialCameraPosition); - this.resize(); this.controls = new OrbitControls(this.camera, this.renderer.domElement); - + this.controls.target.set(this._buildVolume.x / 2, 0, -this._buildVolume.y / 2); this.loadCamera(); this.initScene(); @@ -291,6 +298,33 @@ export class WebGLPreview { this.initStats(); } + /** + * Gets the current build volume + * @returns Current build volume or undefined if not set + */ + get buildVolume(): BuildVolume | undefined { + return this._buildVolume; + } + + /** + * Sets the build volume dimensions + * @param value - Partial build volume properties (x, y, z, smallGrid) + */ + set buildVolume(value: BuildVolumeDef | undefined) { + if (!value) { + this._buildVolume?.dispose(); + this._buildVolume = undefined; + return; + } + + this._buildVolume = new BuildVolume(value.x, value.y, value.z, value.smallGrid, this.scene); + + if (this._buildVolume) { + this.disposables.push(this._buildVolume); + this._buildVolume.update(); + } + } + /** * Gets the current extrusion color(s) * @returns Color or array of colors for extruded paths @@ -417,6 +451,7 @@ export class WebGLPreview { */ set boundingBoxColor(value: ColorRepresentation | undefined) { this._boundingBoxColor = value !== undefined ? new Color(value) : undefined; + this.renderBoundingBox(); } @@ -647,25 +682,37 @@ export class WebGLPreview { } /** - * Initializes the Three.js scene by clearing existing elements and setting up lights + * Initializes the Three.js scene by clearing the existing model * @remarks * Clears all existing scene objects and disposables, then adds build volume visualization * and lighting if 3D tube rendering is enabled. */ private initScene(): void { this.materials = []; - while (this.scene.children.length > 0) { - this.scene.remove(this.scene.children[0]); - } - while (this.disposables.length > 0) { - const disposable = this.disposables.pop(); - if (disposable) disposable.dispose(); + // Recursively remove all children from the main group and their descendants from the scene + const removeRecursively = (object: Group) => { + while (object.children.length > 0) { + const child = object.children[0]; + if ((child as Group).children && (child as Group).children.length > 0) { + removeRecursively(child as Group); + } + object.remove(child); + this.scene.remove(child); + } + }; + if (this.group) { + removeRecursively(this.group); } - if (this.buildVolume) { - this.disposables.push(this.buildVolume); - this.scene.add(this.buildVolume.createGroup()); + // while (this.disposables.length > 0) { + // const disposable = this.disposables.pop(); + // if (disposable) disposable.dispose(); + // } + + if (this._buildVolume) { + this.disposables.push(this._buildVolume); + this._buildVolume.update(); } } @@ -681,8 +728,8 @@ export class WebGLPreview { 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); + 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); @@ -695,13 +742,16 @@ export class WebGLPreview { */ render(): void { const startRender = performance.now(); - this.group = this.createGroup('allLayers'); + this.group = this.group ?? this.createGroup('allLayers'); + this.currentChunk = this.group; this.initScene(); this.renderPathIndex = 0; this.renderPaths(); - this.renderBoundingBox(); + if (this.boundingBoxColor !== undefined) { + this.renderBoundingBox(); + } this.scene.add(this.group); this.renderer.render(this.scene, this.camera); @@ -756,55 +806,47 @@ export class WebGLPreview { * Updates the renderPathIndex to track progress through the job's paths. */ private renderFrame(pathCount: number): void { - this.group = this.createGroup('parts' + this.renderPathIndex); + 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); if (this._boundingBoxColor !== undefined) { this.renderBoundingBox(); } this.renderPathIndex = endPathNumber; - this.scene.add(this.group); + this.group?.add(chunk); } private renderBoundingBox(): void { - if (!this.buildVolume) { + if (!this.job || !this.job.boundingBox.isValid) { + console.error('Invalid bounding box, skipping rendering'); return; } - if (this._boundingBoxColor === undefined) { - if (this.boundingBoxMesh) { - this.scene.remove(this.boundingBoxMesh); - this.boundingBoxMesh.dispose(); - this.boundingBoxMesh = undefined; - } - } + // create the bounding box mesh if it doesn't exist + if (!this.boundingBoxMesh) { + this.boundingBoxMesh = this.createBoundingBox(); + this.boundingBoxMesh.name = 'bounding-box'; - if (this.job && this.job.boundingBox.isValid && this.buildVolume) { - // Added check for this.buildVolume - const bb = this.job.boundingBox; - const size = bb.size; - const center = bb.center; - - if (size && center) { - // Create the LineBox: (width, height, depth) - // LineBox's x = G-code X size - // LineBox's y = G-code Z size (height) - // LineBox's z = G-code Y size (depth) - this.boundingBoxMesh = new LineBox(size.x, size.z, size.y, this._boundingBoxColor, false); - - // Position the LineBox: - // Three.js X position = G-code X center - (Build Volume X / 2) - // Three.js Y position = G-code Z center (since Three.js Y is up, and LineBox handles its own Y-offset) - // Three.js Z position = G-code Y center - (Build Volume Y / 2) - this.boundingBoxMesh.position.set( - center.x - this.buildVolume.x / 2, - center.z, // Three.js Y (G-code Z) - -(center.y - this.buildVolume.y / 2) // Three.js Z (G-code Y) - ); - - this.scene.add(this.boundingBoxMesh); - } + this.scene.add(this.boundingBoxMesh); } + + this.boundingBoxMesh.visible = this._boundingBoxColor !== undefined; + (this.boundingBoxMesh.material as LineBasicMaterial).color = this._boundingBoxColor; + } + + createBoundingBox(): LineBox { + const bb = this.job.boundingBox; + const size = bb.size; + const mesh = new LineBox(size.x, size.z, size.y, this._boundingBoxColor, false); + const pos = bb.corners.min.toVector3(); + mesh.position.set(pos.x, pos.y, pos.z); + return mesh; } // reset parser & processing state @@ -920,7 +962,7 @@ export class WebGLPreview { this.disposables.push(material); this.disposables.push(geometry); - this.group?.add(line); + this.currentChunk?.add(line); } /** @@ -951,7 +993,7 @@ export class WebGLPreview { const batchedMesh = this.createBatchMesh(geometries, material); this.disposables.push(material); - this.group?.add(batchedMesh); + this.currentChunk?.add(batchedMesh); } /**