diff --git a/examples/visualizer/modules/loaders.js b/examples/visualizer/modules/loaders.js index 524ece4b..5242e2cc 100644 --- a/examples/visualizer/modules/loaders.js +++ b/examples/visualizer/modules/loaders.js @@ -5,11 +5,82 @@ import * as THREE from 'three'; import { GCodeLoaderExtended } from '../libs/GCodeLoaderExtended.js'; +import { loadSlicingSettings } from './state.js'; // File extensions export const MODEL_EXTENSIONS = ['stl', 'obj', '3mf', 'amf', 'ply', 'gltf', 'glb', 'dae']; export const GCODE_EXTENSIONS = ['gcode', 'gco', 'nc']; +// Fallback build plate dimensions (mm) for known printers when Polyslice is unavailable. +const PRINTER_BUILD_PLATES = { + Ender3: { width: 220, length: 220, height: 250 }, + Ender3V2: { width: 220, length: 220, height: 250 }, + Ender3Pro: { width: 220, length: 220, height: 250 }, + PrusaI3MK3S: { width: 250, length: 210, height: 210 }, + AnycubicI3Mega: { width: 210, length: 210, height: 205 }, + UltimakerS5: { width: 330, length: 240, height: 300 }, + BambuLabP1P: { width: 256, length: 256, height: 256 } +}; + +const DEFAULT_BUILD_PLATE_WIDTH = 220; +const DEFAULT_BUILD_PLATE_LENGTH = 220; +const DEFAULT_BUILD_PLATE_HEIGHT = 250; + +/** + * Get the build plate dimensions for the currently selected printer. + * Tries to use window.Polyslice.Printer when available; falls back to a + * lookup table and then to the Ender3 defaults (220 × 220 × 250 mm). + * + * @returns {{ width: number, length: number, height: number }} + */ +export function getBuildPlateDimensions() { + try { + const savedSettings = loadSlicingSettings(); + const printerName = (savedSettings && savedSettings.printer) ? savedSettings.printer : 'Ender3'; + + if (window.Polyslice?.Printer) { + const printer = new window.Polyslice.Printer(printerName); + return { width: printer.getSizeX(), length: printer.getSizeY(), height: printer.getSizeZ() }; + } + + const preset = PRINTER_BUILD_PLATES[printerName]; + if (preset) { + return preset; + } + } catch (error) { + console.warn('Could not determine build plate dimensions:', error); + } + + return { width: DEFAULT_BUILD_PLATE_WIDTH, length: DEFAULT_BUILD_PLATE_LENGTH, height: DEFAULT_BUILD_PLATE_HEIGHT }; +} + +/** + * Position a loaded mesh on the build plate by: + * 1. Placing its bottom face at Z = 0 (lay flat on the build plate). + * 2. Centering it in XY at (buildPlateWidth / 2, buildPlateLength / 2). + * + * This matches the positioning applied by the Polyslice slicer so that the + * model's visual location in the viewport corresponds to its printed location + * in the generated G-code. + */ +export function positionMeshOnBuildPlate(object) { + const { width: buildPlateWidth, length: buildPlateLength } = getBuildPlateDimensions(); + + const box = new THREE.Box3().setFromObject(object); + if (box.isEmpty()) { + console.warn('positionMeshOnBuildPlate: bounding box is empty, skipping positioning'); + return; + } + + const centerX = (box.min.x + box.max.x) / 2; + const centerY = (box.min.y + box.max.y) / 2; + const minZ = box.min.z; + + object.position.x += (buildPlateWidth / 2) - centerX; + object.position.y += (buildPlateLength / 2) - centerY; + object.position.z += -minZ; +} + /** * Handle file upload event. */ @@ -136,6 +207,10 @@ export function loadModel(file, scene, callbacks) { export function displayMesh(object, filename, scene, callbacks) { const { centerCamera, hideForkMeBanner, hideGCodeLegends, createSlicingGUI, updateMeshInfo } = callbacks; + // Center the model on the build plate and lay it flat at Z = 0 so its + // position matches the location it will occupy in the sliced G-code. + positionMeshOnBuildPlate(object); + scene.add(object); // Update info panel diff --git a/examples/visualizer/modules/scene.js b/examples/visualizer/modules/scene.js index 3e28154d..19bd8e8e 100644 --- a/examples/visualizer/modules/scene.js +++ b/examples/visualizer/modules/scene.js @@ -6,7 +6,7 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; -// Constants +// Default build volume dimensions (mm) – matches the Ender3 preset. export const AXIS_LENGTH = 220; // Scene state @@ -69,16 +69,18 @@ export function initScene() { /** * Create custom axes with proper colors and thickness. + * @param {number} sizeX - Length of the X axis in mm. + * @param {number} sizeY - Length of the Y axis in mm. + * @param {number} sizeZ - Length of the Z axis in mm. */ -function createAxes() { +function createAxes(sizeX = AXIS_LENGTH, sizeY = AXIS_LENGTH, sizeZ = AXIS_LENGTH) { - const axisLength = AXIS_LENGTH; const axisThickness = 3; // Create X axis (red) const xGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), - new THREE.Vector3(axisLength, 0, 0), + new THREE.Vector3(sizeX, 0, 0), ]); const xMaterial = new THREE.LineBasicMaterial({ color: 0xff0000, @@ -90,7 +92,7 @@ function createAxes() { // Create Y axis (green) const yGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, axisLength, 0), + new THREE.Vector3(0, sizeY, 0), ]); const yMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00, @@ -102,7 +104,7 @@ function createAxes() { // Create Z axis (blue) const zGeometry = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0, 0, axisLength), + new THREE.Vector3(0, 0, sizeZ), ]); const zMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff, @@ -117,12 +119,11 @@ function createAxes() { /** * Create grid helper on the XY plane. + * @param {number} sizeX - Width of the grid in mm (X direction). + * @param {number} sizeY - Length of the grid in mm (Y direction). */ -function createGridHelper() { +function createGridHelper(sizeX = AXIS_LENGTH, sizeY = AXIS_LENGTH) { - // Draw grid in X+ / Y+ quadrant on the XY plane (Z=0) - const sizeX = AXIS_LENGTH; - const sizeY = AXIS_LENGTH; const divisions = 20; const colorCenterLine = 0x888888; const colorGrid = 0x444444; @@ -163,6 +164,72 @@ function createGridHelper() { } +/** + * Update the scene axes and grid to match new printer build volume dimensions. + * Updates the existing Three.js objects in-place so that all references held by + * event listeners (axis visibility toggles) continue to work correctly. + * + * @param {number} width - Build plate width in mm (X axis). + * @param {number} length - Build plate length in mm (Y axis). + * @param {number} height - Build volume height in mm (Z axis). + */ +export function updateBuildVolume(width, length, height) { + + if (axesLines) { + // Update each axis line's endpoint position in-place. + const xPos = axesLines[0].geometry.attributes.position; + xPos.setXYZ(1, width, 0, 0); + xPos.needsUpdate = true; + + const yPos = axesLines[1].geometry.attributes.position; + yPos.setXYZ(1, 0, length, 0); + yPos.needsUpdate = true; + + const zPos = axesLines[2].geometry.attributes.position; + zPos.setXYZ(1, 0, 0, height); + zPos.needsUpdate = true; + } + + if (gridHelper) { + // Dispose existing child geometries and remove them from the group. + while (gridHelper.children.length > 0) { + const child = gridHelper.children[0]; + child.geometry.dispose(); + gridHelper.remove(child); + } + + // Rebuild the grid for the new build plate dimensions. + const divisions = 20; + const colorCenterLine = 0x888888; + const colorGrid = 0x444444; + + const materialCenter = new THREE.LineBasicMaterial({ color: colorCenterLine }); + const materialGrid = new THREE.LineBasicMaterial({ color: colorGrid }); + + const stepX = width / divisions; + const stepY = length / divisions; + + for (let x = 0; x <= width + 0.001; x += stepX) { + const geom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(x, 0, 0), + new THREE.Vector3(x, length, 0), + ]); + const isCenter = Math.abs(x) < 1e-6; + gridHelper.add(new THREE.Line(geom, isCenter ? materialCenter : materialGrid)); + } + + for (let y = 0; y <= length + 0.001; y += stepY) { + const geom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, y, 0), + new THREE.Vector3(width, y, 0), + ]); + const isCenter = Math.abs(y) < 1e-6; + gridHelper.add(new THREE.Line(geom, isCenter ? materialCenter : materialGrid)); + } + } + +} + /** * Handle window resize. */ diff --git a/examples/visualizer/modules/ui.js b/examples/visualizer/modules/ui.js index de2f21a7..3ace9434 100644 --- a/examples/visualizer/modules/ui.js +++ b/examples/visualizer/modules/ui.js @@ -126,7 +126,7 @@ export function createMoveSlider() { /** * Create the slicing GUI for loaded 3D models. */ -export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallback = null) { +export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallback = null, repositionCallback = null) { let slicingGUI = window.slicingGUI; @@ -195,8 +195,17 @@ export function createSlicingGUI(sliceCallback, useDefaults = false, rotateCallb rotateCallback('z', params.rotationZ); } + // Re-center after all three initial rotations have been applied. + if (repositionCallback) { + repositionCallback(); + } + h = trackFolder('Printer & Filament', slicingGUI.addFolder('Printer & Filament'), true); - h.add(params, 'printer', PRINTER_OPTIONS).name('Printer').onChange(() => saveSlicingSettings(params)); + h.add(params, 'printer', PRINTER_OPTIONS).name('Printer').onChange(() => { + saveSlicingSettings(params); + // Re-center the mesh for the newly selected printer's build plate dimensions. + if (repositionCallback) repositionCallback(); + }); h.add(params, 'filament', FILAMENT_OPTIONS).name('Filament').onChange(() => saveSlicingSettings(params)); h.add(params, 'nozzleTemperature', 150, 300, 5).name('Nozzle Temp (°C)').onFinishChange(() => saveSlicingSettings(params)); h.add(params, 'bedTemperature', 0, 120, 5).name('Bed Temp (°C)').onFinishChange(() => saveSlicingSettings(params)); diff --git a/examples/visualizer/visualizer.js b/examples/visualizer/visualizer.js index 3d4bd573..96906fa7 100644 --- a/examples/visualizer/visualizer.js +++ b/examples/visualizer/visualizer.js @@ -15,7 +15,7 @@ import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js'; import { TransformControls } from 'three/addons/controls/TransformControls.js'; // Import modules -import { initScene, scene, camera, renderer, controls, axesLines, gridHelper, onWindowResize, animate } from './modules/scene.js'; +import { initScene, scene, camera, renderer, controls, axesLines, gridHelper, onWindowResize, animate, updateBuildVolume } from './modules/scene.js'; import { createLegend, createLayerSlider, @@ -51,7 +51,9 @@ import { loadModel, displayMesh as displayMeshHelper, loadGCode as loadGCodeHelper, - updateMeshInfo + updateMeshInfo, + positionMeshOnBuildPlate, + getBuildPlateDimensions } from './modules/loaders.js'; import { setupLayerSlider as setupLayerSliderHelper, @@ -178,6 +180,8 @@ function radiansToDegrees(radians) { /** * Sync the TransformControls current rotation back to the slicing GUI sliders. + * Called on every objectChange event during a gizmo drag; does NOT reposition + * the mesh (repositioning happens once at drag-end via dragging-changed). */ function syncTransformToSliders() { const mesh = transformControls?.object; @@ -214,9 +218,19 @@ function initTransformControls() { // Disable orbit controls while the user is dragging the rotation gizmo. // Track that a drag occurred so the pointerup handler can ignore it. + // When the drag ends (event.value === false), reposition the mesh on the + // build plate so the final G-code position and the viewport position match. transformControls.addEventListener('dragging-changed', (event) => { controls.enabled = !event.value; - if (event.value) wasDraggingGizmo = true; + if (event.value) { + wasDraggingGizmo = true; + } else { + // Drag just ended – reposition once so the viewport matches the slicer. + const mesh = transformControls.object; + if (mesh) { + positionMeshOnBuildPlate(mesh); + } + } }); // Sync drag interactions back to the GUI sliders. @@ -262,12 +276,27 @@ function initTransformControls() { } /** - * Apply a rotation in degrees to one axis of a mesh. + * Apply a rotation in degrees to one axis of a mesh and re-center the mesh on + * the build plate so the displayed position continues to match the sliced G-code. */ function applyMeshRotation(mesh, axis, degrees) { if (!mesh) return; mesh.rotation[axis] = degrees * Math.PI / 180; mesh.updateMatrixWorld(true); + positionMeshOnBuildPlate(mesh); +} + +/** + * Re-position the currently loaded mesh on the build plate and update the + * scene axes and grid to reflect the selected printer's build volume. + * Called when the printer selection changes. + */ +function repositionMesh() { + if (meshObject) { + positionMeshOnBuildPlate(meshObject); + } + const { width, length, height } = getBuildPlateDimensions(); + updateBuildVolume(width, length, height); } /** @@ -298,7 +327,8 @@ function loadModelWrapper(file) { sliceModel(loadedModelForSlicing, currentFilename, loadGCodeWrapper); }, false, - (axis, degrees) => applyMeshRotation(meshObject, axis, degrees) + (axis, degrees) => applyMeshRotation(meshObject, axis, degrees), + repositionMesh ); }, updateMeshInfo @@ -499,7 +529,8 @@ function resetView() { createSlicingGUI( () => sliceModel(loadedModelForSlicing, currentFilename, loadGCodeWrapper), true, - (axis, degrees) => applyMeshRotation(meshObject, axis, degrees) + (axis, degrees) => applyMeshRotation(meshObject, axis, degrees), + repositionMesh ); }