mcp-3d-printer-server 0.2.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +267 -29
  2. package/dist/3mf_parser.d.ts +3 -0
  3. package/dist/3mf_parser.js +216 -0
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.js +1018 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/printers/bambu.d.ts +42 -0
  8. package/dist/printers/bambu.js +278 -0
  9. package/dist/printers/creality.d.ts +10 -0
  10. package/dist/printers/creality.js +90 -0
  11. package/dist/printers/duet.d.ts +10 -0
  12. package/dist/printers/duet.js +66 -0
  13. package/dist/printers/klipper.d.ts +10 -0
  14. package/dist/printers/klipper.js +62 -0
  15. package/dist/printers/octoprint.d.ts +10 -0
  16. package/dist/printers/octoprint.js +97 -0
  17. package/dist/printers/printer-factory.d.ts +9 -0
  18. package/dist/printers/printer-factory.js +33 -0
  19. package/dist/printers/prusa.d.ts +10 -0
  20. package/dist/printers/prusa.js +93 -0
  21. package/dist/printers/repetier.d.ts +10 -0
  22. package/dist/printers/repetier.js +59 -0
  23. package/dist/stl/stl-manipulator.d.ts +140 -0
  24. package/dist/stl/stl-manipulator.js +1081 -0
  25. package/dist/test/stl-manipulator-test.d.ts +2 -0
  26. package/dist/test/stl-manipulator-test.js +91 -0
  27. package/dist/types.d.ts +75 -0
  28. package/dist/types.js +26 -0
  29. package/package.json +14 -4
  30. package/src/3mf_parser.ts +223 -0
  31. package/src/index.ts +239 -10
  32. package/src/printers/bambu.ts +305 -100
  33. package/src/stl/stl-manipulator.ts +350 -117
  34. package/src/test/3DBenchy.3mf +0 -0
  35. package/src/test/stl-manipulator-test.js +114 -0
  36. package/src/test/stl-manipulator-test.ts +124 -0
  37. package/src/test/tsconfig.json +13 -0
  38. package/src/types.ts +65 -0
  39. package/.env.example +0 -17
  40. package/scripts/mcp-inspector.js +0 -49
  41. package/tsconfig.json +0 -16
@@ -1,11 +1,13 @@
1
1
  import * as THREE from 'three';
2
2
  import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
3
3
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js';
4
+ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
4
5
  import fs from 'fs';
5
6
  import path from 'path';
6
7
  import { promisify } from 'util';
7
8
  import { EventEmitter } from 'events';
8
9
  import * as crypto from 'crypto';
10
+ import { execFile } from 'child_process';
9
11
 
10
12
  const readFileAsync = promisify(fs.readFile);
11
13
  const writeFileAsync = promisify(fs.writeFile);
@@ -837,148 +839,142 @@ export class STLManipulator extends EventEmitter {
837
839
  }
838
840
 
839
841
  /**
840
- * Enhanced version of sliceSTL with progress reporting and error handling
841
- * @param stlFilePath Path to the STL file
842
- * @param slicerType Type of slicer to use ('prusaslicer', 'cura', 'slic3r')
842
+ * Slice an STL file using the specified slicer
843
+ * @param stlFilePath Path to the input STL file
844
+ * @param slicerType Type of slicer (prusaslicer, cura, slic3r, orcaslicer)
843
845
  * @param slicerPath Path to the slicer executable
844
- * @param slicerProfile Profile to use for slicing
846
+ * @param slicerProfile Optional path to the slicer profile/config file
845
847
  * @param progressCallback Optional callback for progress updates
846
848
  * @returns Path to the generated G-code file
847
849
  */
848
850
  async sliceSTL(
849
851
  stlFilePath: string,
850
- slicerType: 'prusaslicer' | 'cura' | 'slic3r',
852
+ slicerType: 'prusaslicer' | 'cura' | 'slic3r' | 'orcaslicer',
851
853
  slicerPath: string,
852
854
  slicerProfile?: string,
853
855
  progressCallback?: ProgressCallback
854
856
  ): Promise<string> {
855
857
  const operationId = this.generateOperationId();
856
858
  this.activeOperations.set(operationId, true);
857
-
859
+
860
+ if (!fs.existsSync(slicerPath)) {
861
+ throw new Error(`Slicer executable not found at: ${slicerPath}`);
862
+ }
863
+ if (slicerProfile && !fs.existsSync(slicerProfile)) {
864
+ console.warn(`Slicer profile specified but not found: ${slicerProfile}. Slicer might use defaults.`);
865
+ // Allow proceeding without profile, slicer might handle it
866
+ }
867
+
868
+ const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
869
+ const outputFilePath = path.join(this.tempDir, outputFileName);
870
+
871
+ let args: string[] = [];
872
+
858
873
  try {
859
- if (progressCallback) progressCallback(0, "Starting slicing operation...");
860
-
861
- const { exec } = require('child_process');
862
- const execAsync = promisify(exec);
863
-
864
- // Verify the STL file exists
865
- if (!fs.existsSync(stlFilePath)) {
866
- throw new Error(`STL file not found: ${stlFilePath}`);
867
- }
868
-
869
- // Verify the slicer executable exists if provided
870
- if (slicerPath && !fs.existsSync(slicerPath)) {
871
- throw new Error(`Slicer executable not found: ${slicerPath}`);
872
- }
873
-
874
- if (progressCallback) progressCallback(10, "Preparing slicing command...");
875
-
876
- const outputFileName = path.basename(stlFilePath, '.stl') + '.gcode';
877
- const outputFilePath = path.join(this.tempDir, outputFileName);
878
-
879
- let command = '';
880
-
874
+ if (progressCallback) progressCallback(0, `Starting slicing with ${slicerType}...`);
875
+
881
876
  switch (slicerType) {
882
877
  case 'prusaslicer':
883
- command = `"${slicerPath}" --export-gcode --output "${outputFilePath}"`;
884
- if (slicerProfile) {
885
- if (!fs.existsSync(slicerProfile)) {
886
- console.warn(`Warning: Slicer profile not found: ${slicerProfile}`);
887
- }
888
- command += ` --load "${slicerProfile}"`;
889
- }
890
- command += ` "${stlFilePath}"`;
878
+ case 'slic3r': // PrusaSlicer and Slic3r share similar CLI args
879
+ args = [
880
+ '--slice',
881
+ '--output', outputFilePath,
882
+ '--load', slicerProfile || '', // Pass profile path
883
+ stlFilePath
884
+ ];
885
+ // Remove empty profile arg if not provided
886
+ args = args.filter(arg => arg !== '');
891
887
  break;
892
-
888
+
889
+ case 'orcaslicer': // Add OrcaSlicer case
890
+ args = [
891
+ // OrcaSlicer might use --load-settings like Bambu, or --load like Prusa
892
+ // Assuming --load-settings based on discussion, adjust if needed.
893
+ // Also assuming profile path points to the main .ini or .json config.
894
+ '--load-settings', slicerProfile || '',
895
+ '--output', outputFilePath, // Assuming --output works like PrusaSlicer
896
+ stlFilePath
897
+ ];
898
+ // Alternative if --output doesn't specify filename:
899
+ // args = [
900
+ // '--load-settings', slicerProfile || '',
901
+ // '--outputdir', this.tempDir,
902
+ // stlFilePath
903
+ // ];
904
+ // Remove empty profile arg if not provided
905
+ args = args.filter(arg => arg !== '');
906
+ break;
907
+
893
908
  case 'cura':
894
- command = `"${slicerPath}" -o "${outputFilePath}"`;
895
- if (slicerProfile) {
896
- if (!fs.existsSync(slicerProfile)) {
897
- console.warn(`Warning: Slicer profile not found: ${slicerProfile}`);
898
- }
899
- command += ` -l "${slicerProfile}"`;
900
- }
901
- command += ` "${stlFilePath}"`;
902
- break;
903
-
904
- case 'slic3r':
905
- command = `"${slicerPath}" --output "${outputFilePath}"`;
909
+ // CuraEngine CLI args are different, often requiring -s for settings
910
+ // Example: curaengine slice -v -j cura_settings.json -s layer_height=0.2 -o output.gcode -l input.stl
911
+ // This requires parsing the profile or passing individual settings.
912
+ // Keeping it simple for now, assuming profile contains necessary info.
913
+ args = [
914
+ 'slice',
915
+ '-l', stlFilePath,
916
+ '-o', outputFilePath
917
+ ];
906
918
  if (slicerProfile) {
907
- if (!fs.existsSync(slicerProfile)) {
908
- console.warn(`Warning: Slicer profile not found: ${slicerProfile}`);
909
- }
910
- command += ` --load "${slicerProfile}"`;
919
+ args.push('-j', slicerProfile); // Load settings from profile definition file
911
920
  }
912
- command += ` "${stlFilePath}"`;
913
921
  break;
914
-
922
+
915
923
  default:
916
924
  throw new Error(`Unsupported slicer type: ${slicerType}`);
917
925
  }
918
-
919
- if (!this.activeOperations.get(operationId)) {
920
- throw new Error("Operation cancelled");
921
- }
922
-
923
- if (progressCallback) progressCallback(20, "Running slicer...");
924
- console.log(`Slicing STL with command: ${command}`);
925
-
926
- // Execute the slicing command
926
+
927
+ if (progressCallback) progressCallback(20, `Executing slicer: ${slicerPath} ${args.join(' ')}`);
928
+ console.log(`Executing: ${slicerPath} ${args.join(' ')}`);
929
+
930
+ // Execute the slicer
927
931
  await new Promise<void>((resolve, reject) => {
928
- const slicerProcess = exec(command);
929
-
930
- // Set up a timeout for the slicing operation (15 minutes)
931
- const timeout = setTimeout(() => {
932
- if (slicerProcess.pid) {
933
- process.kill(slicerProcess.pid);
934
- }
935
- reject(new Error("Slicing operation timed out after 15 minutes"));
936
- }, 15 * 60 * 1000);
937
-
938
- // Handle stdout data
939
- slicerProcess.stdout?.on('data', (data: string) => {
940
- console.log(`Slicer stdout: ${data}`);
941
- // Attempt to extract progress information (varies by slicer)
942
- const progressMatch = data.match(/(\d+)%/);
943
- if (progressMatch && progressCallback) {
944
- const slicerProgress = parseInt(progressMatch[1], 10);
945
- // Map slicer's 0-100% to our 20-90% range
946
- const mappedProgress = 20 + (slicerProgress * 0.7);
947
- progressCallback(mappedProgress, `Slicing: ${slicerProgress}%`);
948
- }
949
- });
950
-
951
- // Handle stderr data
952
- slicerProcess.stderr?.on('data', (data: string) => {
953
- console.error(`Slicer stderr: ${data}`);
954
- });
955
-
956
- // Handle completion
957
- slicerProcess.on('close', (code: number) => {
958
- clearTimeout(timeout);
959
- if (code === 0) {
960
- resolve();
932
+ const process = execFile(slicerPath, args, (error, stdout, stderr) => {
933
+ if (error) {
934
+ console.error(`Slicer Error: ${error.message}`);
935
+ console.error(`Slicer Stderr: ${stderr}`);
936
+ reject(new Error(`Slicer failed: ${error.message}. Stderr: ${stderr}`));
961
937
  } else {
962
- reject(new Error(`Slicer process exited with code ${code}`));
938
+ console.log(`Slicer Stdout: ${stdout}`);
939
+ if (stderr) {
940
+ console.warn(`Slicer Stderr: ${stderr}`); // Log stderr even on success
941
+ }
942
+ resolve();
963
943
  }
964
944
  });
945
+
946
+ // Optional: Add listeners for stdout/stderr for real-time progress if slicer provides it
947
+ // process.stdout?.on('data', (data) => { console.log(`Slicer stdout: ${data}`); });
948
+ // process.stderr?.on('data', (data) => { console.log(`Slicer stderr: ${data}`); });
965
949
 
966
- // Handle errors
967
- slicerProcess.on('error', (err: Error) => {
968
- clearTimeout(timeout);
969
- reject(err);
970
- });
950
+ // Check for cancellation periodically
951
+ const checkCancel = setInterval(() => {
952
+ if (!this.activeOperations.get(operationId)) {
953
+ clearInterval(checkCancel);
954
+ try {
955
+ process.kill(); // Attempt to kill the slicer process
956
+ reject(new Error("Slicing operation cancelled"));
957
+ } catch (killError) {
958
+ console.error("Error attempting to kill slicer process:", killError);
959
+ reject(new Error("Slicing operation cancelled, but failed to kill process."));
960
+ }
961
+ }
962
+ }, 500);
963
+
964
+ process.on('exit', () => clearInterval(checkCancel));
965
+ process.on('error', () => clearInterval(checkCancel)); // Ensure interval cleared on process error too
966
+
971
967
  });
972
-
968
+
973
969
  if (!this.activeOperations.get(operationId)) {
974
- throw new Error("Operation cancelled");
970
+ // This check might be redundant if the promise rejected on kill, but good safety
971
+ throw new Error("Slicing operation cancelled after process finished");
975
972
  }
976
973
 
977
- // Verify the G-code file was created
978
974
  if (!fs.existsSync(outputFilePath)) {
979
- throw new Error("Slicing failed: G-code file was not created");
975
+ throw new Error(`Slicer finished but output file not found: ${outputFilePath}`);
980
976
  }
981
-
977
+
982
978
  if (progressCallback) progressCallback(100, "Slicing completed successfully");
983
979
 
984
980
  this.emit('operationComplete', {
@@ -987,18 +983,16 @@ export class STLManipulator extends EventEmitter {
987
983
  success: true,
988
984
  output: outputFilePath
989
985
  });
990
-
986
+
991
987
  return outputFilePath;
992
988
  } catch (error) {
993
- console.error("Error slicing STL:", error);
994
-
989
+ console.error(`Slicing failed for ${stlFilePath}:`, error);
995
990
  this.emit('operationError', {
996
991
  operationId,
997
992
  type: 'slice',
998
993
  error: error instanceof Error ? error.message : String(error)
999
994
  });
1000
-
1001
- throw new Error(`Failed to slice STL: ${error instanceof Error ? error.message : String(error)}`);
995
+ throw error;
1002
996
  } finally {
1003
997
  this.activeOperations.delete(operationId);
1004
998
  }
@@ -1112,8 +1106,247 @@ export class STLManipulator extends EventEmitter {
1112
1106
  this.activeOperations.delete(operationId);
1113
1107
  }
1114
1108
  }
1115
- }
1116
1109
 
1117
- // Import BufferGeometryUtils for merging geometries
1118
- // This is a utility module used by Three.js
1119
- import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
1110
+ /**
1111
+ * Merge vertices within a specified tolerance.
1112
+ */
1113
+ async mergeVertices(
1114
+ stlFilePath: string,
1115
+ tolerance: number = 0.01, // Default tolerance in mm
1116
+ progressCallback?: ProgressCallback
1117
+ ): Promise<string> {
1118
+ const operationId = this.generateOperationId();
1119
+ this.activeOperations.set(operationId, true);
1120
+
1121
+ try {
1122
+ if (progressCallback) progressCallback(0, "Starting vertex merging...");
1123
+
1124
+ const { geometry } = await this.loadSTL(stlFilePath, progressCallback); // 10-50% progress
1125
+
1126
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1127
+ if (progressCallback) progressCallback(60, "Merging vertices...");
1128
+
1129
+ const originalVertexCount = geometry.attributes.position.count;
1130
+ const mergedGeometry = BufferGeometryUtils.mergeVertices(geometry, tolerance);
1131
+ const newVertexCount = mergedGeometry.attributes.position.count;
1132
+
1133
+ console.log(`Merged vertices: ${originalVertexCount} -> ${newVertexCount} (Tolerance: ${tolerance}mm)`);
1134
+ if (progressCallback) progressCallback(70, `Vertices merged: ${originalVertexCount} -> ${newVertexCount}`);
1135
+
1136
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1137
+
1138
+ const outputFileName = path.basename(stlFilePath, '.stl') + '_merged.stl';
1139
+ const outputFilePath = path.join(this.tempDir, outputFileName);
1140
+
1141
+ await this.saveSTL(mergedGeometry, outputFilePath, progressCallback); // 80-100% progress
1142
+
1143
+ this.emit('operationComplete', { operationId, type: 'mergeVertices', success: true, output: outputFilePath, verticesRemoved: originalVertexCount - newVertexCount });
1144
+ return outputFilePath;
1145
+
1146
+ } catch (error) {
1147
+ this.emit('operationError', { operationId, type: 'mergeVertices', error: error instanceof Error ? error.message : String(error) });
1148
+ throw error;
1149
+ } finally {
1150
+ this.activeOperations.delete(operationId);
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * Center the model at the origin (0,0,0).
1156
+ */
1157
+ async centerModel(
1158
+ stlFilePath: string,
1159
+ progressCallback?: ProgressCallback
1160
+ ): Promise<string> {
1161
+ const operationId = this.generateOperationId();
1162
+ this.activeOperations.set(operationId, true);
1163
+
1164
+ try {
1165
+ if (progressCallback) progressCallback(0, "Starting centering operation...");
1166
+
1167
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback); // 10-50% progress
1168
+
1169
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1170
+ if (progressCallback) progressCallback(60, "Calculating center...");
1171
+
1172
+ const center = new THREE.Vector3();
1173
+ boundingBox.getCenter(center);
1174
+
1175
+ if (center.lengthSq() < 0.0001) { // Already centered (or very close)
1176
+ if (progressCallback) progressCallback(100, "Model is already centered.");
1177
+ console.log("Model already centered. No changes made.");
1178
+ this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: stlFilePath, message: "Model already centered." });
1179
+ return stlFilePath; // Return original path
1180
+ }
1181
+
1182
+ if (progressCallback) progressCallback(70, `Applying translation: (${-center.x.toFixed(2)}, ${-center.y.toFixed(2)}, ${-center.z.toFixed(2)})`);
1183
+
1184
+ const translationMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
1185
+ geometry.applyMatrix4(translationMatrix);
1186
+ geometry.computeBoundingBox(); // Recompute bounds after moving
1187
+
1188
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1189
+
1190
+ const outputFileName = path.basename(stlFilePath, '.stl') + '_centered.stl';
1191
+ const outputFilePath = path.join(this.tempDir, outputFileName);
1192
+
1193
+ await this.saveSTL(geometry, outputFilePath, progressCallback); // 80-100% progress
1194
+
1195
+ this.emit('operationComplete', { operationId, type: 'centerModel', success: true, output: outputFilePath });
1196
+ return outputFilePath;
1197
+
1198
+ } catch (error) {
1199
+ this.emit('operationError', { operationId, type: 'centerModel', error: error instanceof Error ? error.message : String(error) });
1200
+ throw error;
1201
+ } finally {
1202
+ this.activeOperations.delete(operationId);
1203
+ }
1204
+ }
1205
+
1206
+ /**
1207
+ * Rotate the model so its largest flat face lies on the XY plane (Z=0).
1208
+ */
1209
+ async layFlat(
1210
+ stlFilePath: string,
1211
+ progressCallback?: ProgressCallback
1212
+ ): Promise<string> {
1213
+ const operationId = this.generateOperationId();
1214
+ this.activeOperations.set(operationId, true);
1215
+
1216
+ try {
1217
+ if (progressCallback) progressCallback(0, "Starting lay flat operation...");
1218
+
1219
+ const { geometry, boundingBox } = await this.loadSTL(stlFilePath, progressCallback); // 10-50%
1220
+
1221
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1222
+ if (progressCallback) progressCallback(55, "Analyzing faces...");
1223
+
1224
+ const positionAttribute = geometry.getAttribute('position');
1225
+ if (!positionAttribute) throw new Error("Geometry has no position attribute.");
1226
+
1227
+ const faces: { normal: THREE.Vector3; area: number; vertices: THREE.Vector3[] }[] = [];
1228
+ const vA = new THREE.Vector3();
1229
+ const vB = new THREE.Vector3();
1230
+ const vC = new THREE.Vector3();
1231
+ const faceNormal = new THREE.Vector3();
1232
+ const cb = new THREE.Vector3(), ab = new THREE.Vector3();
1233
+
1234
+ // Iterate through faces (triangles)
1235
+ for (let i = 0; i < positionAttribute.count; i += 3) {
1236
+ vA.fromBufferAttribute(positionAttribute, i);
1237
+ vB.fromBufferAttribute(positionAttribute, i + 1);
1238
+ vC.fromBufferAttribute(positionAttribute, i + 2);
1239
+
1240
+ // Calculate face normal
1241
+ cb.subVectors(vC, vB);
1242
+ ab.subVectors(vA, vB);
1243
+ cb.cross(ab);
1244
+ faceNormal.copy(cb).normalize();
1245
+
1246
+ // Calculate face area (using cross product magnitude / 2)
1247
+ const area = cb.length() / 2;
1248
+
1249
+ faces.push({
1250
+ normal: faceNormal.clone(),
1251
+ area: area,
1252
+ vertices: [vA.clone(), vB.clone(), vC.clone()] // Store vertices if needed for center calculation
1253
+ });
1254
+ }
1255
+
1256
+ if (faces.length === 0) throw new Error("No faces found in geometry.");
1257
+
1258
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1259
+ if (progressCallback) progressCallback(65, "Grouping faces by normal...");
1260
+
1261
+ // Group faces by similar normal vectors (tolerance for floating point errors)
1262
+ const normalGroups: Map<string, { totalArea: number; representativeNormal: THREE.Vector3 }> = new Map();
1263
+ const tolerance = 1e-5;
1264
+
1265
+ for (const face of faces) {
1266
+ let foundGroup = false;
1267
+ for (const [key, group] of normalGroups.entries()) {
1268
+ if (face.normal.distanceToSquared(group.representativeNormal) < tolerance) {
1269
+ group.totalArea += face.area;
1270
+ // Optional: Average the normal? For now, just use the first one.
1271
+ foundGroup = true;
1272
+ break;
1273
+ }
1274
+ }
1275
+ if (!foundGroup) {
1276
+ const key = `${face.normal.x.toFixed(5)},${face.normal.y.toFixed(5)},${face.normal.z.toFixed(5)}`;
1277
+ if (!normalGroups.has(key)) { // Ensure unique key even if floats slightly differ after toFixed
1278
+ normalGroups.set(key, { totalArea: face.area, representativeNormal: face.normal });
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ if (normalGroups.size === 0) throw new Error("Could not group faces by normal.");
1284
+
1285
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1286
+ if (progressCallback) progressCallback(75, "Finding largest flat surface...");
1287
+
1288
+ // Find the group with the largest total area (excluding near-vertical faces)
1289
+ let largestArea = 0;
1290
+ let targetNormal: THREE.Vector3 | null = null;
1291
+ const upVector = new THREE.Vector3(0, 1, 0); // Assuming Y is up for most models initially?
1292
+ const downVector = new THREE.Vector3(0, 0, -1); // Target normal (pointing down)
1293
+
1294
+ for (const group of normalGroups.values()) {
1295
+ // Check if the normal is reasonably horizontal (not too close to vertical Z)
1296
+ // Angle threshold could be adjusted (e.g., > 5 degrees from Z axis)
1297
+ if (Math.abs(group.representativeNormal.z) < 0.99 && group.totalArea > largestArea) {
1298
+ largestArea = group.totalArea;
1299
+ targetNormal = group.representativeNormal;
1300
+ }
1301
+ }
1302
+
1303
+ if (!targetNormal) {
1304
+ // If no suitable horizontal face found, maybe pick the lowest Z face?
1305
+ // For now, throw an error or return unchanged.
1306
+ console.warn("Could not find a suitable large flat face to lay down. No changes made.");
1307
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: stlFilePath, message: "No suitable flat face found." });
1308
+ return stlFilePath;
1309
+ }
1310
+
1311
+ if (progressCallback) progressCallback(80, "Calculating rotation...");
1312
+
1313
+ // Calculate the rotation needed to align targetNormal with downVector
1314
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(targetNormal, downVector);
1315
+ const rotationMatrix = new THREE.Matrix4().makeRotationFromQuaternion(quaternion);
1316
+
1317
+ // Rotate around the model's center
1318
+ const center = new THREE.Vector3();
1319
+ boundingBox.getCenter(center);
1320
+ const toOriginMatrix = new THREE.Matrix4().makeTranslation(-center.x, -center.y, -center.z);
1321
+ const fromOriginMatrix = new THREE.Matrix4().makeTranslation(center.x, center.y, center.z);
1322
+
1323
+ // Apply rotation
1324
+ geometry.applyMatrix4(toOriginMatrix);
1325
+ geometry.applyMatrix4(rotationMatrix);
1326
+ geometry.applyMatrix4(fromOriginMatrix);
1327
+
1328
+ // Translate so the new bottom is at Z=0
1329
+ geometry.computeBoundingBox(); // Recompute bounds after rotation
1330
+ const newMinZ = geometry.boundingBox!.min.z;
1331
+ const translateToZeroMatrix = new THREE.Matrix4().makeTranslation(0, 0, -newMinZ);
1332
+ geometry.applyMatrix4(translateToZeroMatrix);
1333
+ geometry.computeBoundingBox(); // Final bounds
1334
+
1335
+ if (!this.activeOperations.get(operationId)) throw new Error("Operation cancelled");
1336
+
1337
+ const outputFileName = path.basename(stlFilePath, '.stl') + '_flat.stl';
1338
+ const outputFilePath = path.join(this.tempDir, outputFileName);
1339
+
1340
+ await this.saveSTL(geometry, outputFilePath, progressCallback); // 90-100% progress
1341
+
1342
+ this.emit('operationComplete', { operationId, type: 'layFlat', success: true, output: outputFilePath });
1343
+ return outputFilePath;
1344
+
1345
+ } catch (error) {
1346
+ this.emit('operationError', { operationId, type: 'layFlat', error: error instanceof Error ? error.message : String(error) });
1347
+ throw error;
1348
+ } finally {
1349
+ this.activeOperations.delete(operationId);
1350
+ }
1351
+ }
1352
+ }
Binary file
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { STLManipulator } from '../stl/stl-manipulator.ts';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import * as THREE from 'three';
7
+
8
+ // Create a test directory
9
+ const TEST_DIR = path.join(process.cwd(), 'test');
10
+ if (!fs.existsSync(TEST_DIR)) {
11
+ fs.mkdirSync(TEST_DIR, { recursive: true });
12
+ }
13
+
14
+ // Path for a sample STL file
15
+ const sampleStlPath = path.join(TEST_DIR, 'sample_cube.stl');
16
+
17
+ // Create a simple cube STL for testing if it doesn't exist
18
+ async function createSampleCube() {
19
+ if (fs.existsSync(sampleStlPath)) {
20
+ console.log('Sample STL already exists, skipping creation');
21
+ return sampleStlPath;
22
+ }
23
+
24
+ console.log('Creating sample cube STL...');
25
+
26
+ // Create a simple cube geometry
27
+ const geometry = new THREE.BoxGeometry(10, 10, 10);
28
+ const material = new THREE.MeshStandardMaterial();
29
+ const mesh = new THREE.Mesh(geometry, material);
30
+
31
+ // Import STLExporter
32
+ const { STLExporter } = await import('three/examples/jsm/exporters/STLExporter.js');
33
+ const exporter = new STLExporter();
34
+
35
+ // Export as STL
36
+ const stlString = exporter.parse(mesh);
37
+
38
+ // Write to file
39
+ fs.writeFileSync(sampleStlPath, stlString);
40
+ console.log(`Sample cube saved to ${sampleStlPath}`);
41
+
42
+ return sampleStlPath;
43
+ }
44
+
45
+ // Test the STL manipulator
46
+ async function testSTLManipulator() {
47
+ try {
48
+ // Create a sample STL for testing
49
+ const stlPath = await createSampleCube();
50
+
51
+ // Create an instance of the STL manipulator with the test directory
52
+ const manipulator = new STLManipulator(TEST_DIR);
53
+
54
+ // Listen for events from the manipulator
55
+ manipulator.on('operationComplete', (data) => {
56
+ console.log('Operation complete:', data);
57
+ });
58
+
59
+ manipulator.on('operationError', (data) => {
60
+ console.error('Operation error:', data);
61
+ });
62
+
63
+ // Test progress callback
64
+ const progressCallback = (progress, message) => {
65
+ console.log(`Progress: ${progress}% - ${message || ''}`);
66
+ };
67
+
68
+ // Test 1: Get information about the STL file
69
+ console.log('\n=== Test 1: Get STL Info ===');
70
+ const stlInfo = await manipulator.getSTLInfo(stlPath);
71
+ console.log('STL Info:', stlInfo);
72
+
73
+ // Test 2: Scale the STL file
74
+ console.log('\n=== Test 2: Scale STL ===');
75
+ const scaledStlPath = await manipulator.scaleSTL(stlPath, 2.0, progressCallback);
76
+ console.log(`Scaled STL saved to: ${scaledStlPath}`);
77
+
78
+ // Test 3: Rotate the STL file
79
+ console.log('\n=== Test 3: Rotate STL ===');
80
+ const rotatedStlPath = await manipulator.rotateSTL(stlPath, [45, 0, 0], progressCallback);
81
+ console.log(`Rotated STL saved to: ${rotatedStlPath}`);
82
+
83
+ // Test 4: Translate the STL file
84
+ console.log('\n=== Test 4: Translate STL ===');
85
+ const translatedStlPath = await manipulator.translateSTL(stlPath, [5, 10, 0], progressCallback);
86
+ console.log(`Translated STL saved to: ${translatedStlPath}`);
87
+
88
+ // Test 5: Extend the base of the STL file
89
+ console.log('\n=== Test 5: Extend Base ===');
90
+ const extendedStlPath = await manipulator.extendBase(stlPath, 0.5, progressCallback);
91
+ console.log(`Extended STL saved to: ${extendedStlPath}`);
92
+
93
+ // Test 6: Modify a section of the STL file
94
+ console.log('\n=== Test 6: Modify Section (Top) ===');
95
+ const modifiedStlPath = await manipulator.modifySection(
96
+ stlPath,
97
+ 'top',
98
+ {
99
+ type: 'scale',
100
+ value: [1.5, 1.5, 1.5]
101
+ },
102
+ progressCallback
103
+ );
104
+ console.log(`Modified STL saved to: ${modifiedStlPath}`);
105
+
106
+ console.log('\nAll tests completed successfully!');
107
+ } catch (error) {
108
+ console.error('An error occurred during testing:', error);
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ // Run the tests
114
+ testSTLManipulator().catch(console.error);