mcp-3d-printer-server 0.2.3 → 1.0.0
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.
- package/.dockerignore +52 -0
- package/.env.example +1 -1
- package/Dockerfile +31 -0
- package/README.md +267 -29
- package/docker-compose.yml +31 -0
- package/package.json +6 -2
- package/scripts/docker-run.sh +25 -0
- package/src/3mf_parser.ts +223 -0
- package/src/index.ts +239 -10
- package/src/printers/bambu.ts +305 -100
- package/src/stl/stl-manipulator.ts +350 -117
- package/src/test/3DBenchy.3mf +0 -0
- package/src/test/stl-manipulator-test.js +114 -0
- package/src/test/stl-manipulator-test.ts +124 -0
- package/src/test/tsconfig.json +13 -0
- package/src/types.ts +65 -0
- package/test/sample_cube.stl +86 -0
- package/test/sample_cube_extended.stl +172 -0
- package/test/sample_cube_modified.stl +86 -0
- package/test/sample_cube_rotated.stl +86 -0
- package/test/sample_cube_scaled.stl +86 -0
- package/test/sample_cube_translated.stl +86 -0
|
@@ -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
|
-
*
|
|
841
|
-
* @param stlFilePath Path to the STL file
|
|
842
|
-
* @param slicerType Type of slicer
|
|
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
|
|
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,
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
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 (
|
|
920
|
-
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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);
|