202 lines
6.5 KiB
JavaScript
202 lines
6.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildGcodePreviewMeshData,
|
|
GCODE_PREVIEW_DETAIL_MAX
|
|
} from "./buildPreviewMesh.js";
|
|
import { parseGcode } from "./parseGcode.js";
|
|
|
|
test("parseGcode extracts extrusion, travel, layers, and basic print stats", () => {
|
|
const data = parseGcode(`
|
|
; perimeter preview
|
|
G21
|
|
G90
|
|
M82
|
|
G92 E0
|
|
G0 X0 Y0 Z0.2 F6000
|
|
G1 X10 Y0 E0.4 F1200
|
|
G1 X10 Y10 E0.8
|
|
G0 X0 Y10 F6000
|
|
G92 E0
|
|
G1 X0 Y0 E0.4
|
|
G1 Z0.4 F600
|
|
G1 X5 Y0 E0.8
|
|
M104 S210
|
|
`, { sourceUrl: "/models/test.gcode", fileRef: "test.gcode" });
|
|
|
|
assert.equal(data.schemaVersion, 1);
|
|
assert.equal(data.sourceUrl, "/models/test.gcode");
|
|
assert.equal(data.fileRef, "test.gcode");
|
|
assert.equal(data.units, "mm");
|
|
assert.deepEqual(data.bounds, {
|
|
min: [0, 0, 0],
|
|
max: [10, 10, 0.4]
|
|
});
|
|
assert.equal(data.layers.length, 2);
|
|
assert.deepEqual(data.layers.map((layer) => layer.z), [0.2, 0.4]);
|
|
assert.equal(data.stats.movementCommands, 7);
|
|
assert.equal(data.stats.extrusionMoves, 4);
|
|
assert.equal(data.stats.travelMoves, 3);
|
|
assert.equal(data.stats.temperatureCommands, 1);
|
|
assert.equal(Math.round(data.stats.extrusionMm * 1000) / 1000, 1.6);
|
|
assert.equal(data.segments.filter((segment) => segment.kind === "extrusion").length, 4);
|
|
assert.equal(data.segments[1].layerIndex, 0);
|
|
assert.equal(data.segments.at(-1).layerIndex, 1);
|
|
});
|
|
|
|
test("parseGcode follows relative moves, samples IJ arcs, and reports ignored commands", () => {
|
|
const data = parseGcode(`
|
|
G91
|
|
M83
|
|
G1 X2 E0.05
|
|
G2 X2 Y0 E0.05 I1 J0
|
|
G4 P200
|
|
`);
|
|
|
|
assert.ok(data.stats.movementCommands > 2);
|
|
assert.equal(data.stats.extrusionMoves, data.stats.movementCommands);
|
|
assert.ok(data.segments.some((segment) => segment.arc));
|
|
assert.ok(data.warnings.some((warning) => warning.includes("Relative XYZ positioning")));
|
|
assert.ok(data.warnings.some((warning) => /G4 line \d+/.test(warning)));
|
|
});
|
|
|
|
test("parseGcode summarizes slicer feature annotations including supports", () => {
|
|
const data = parseGcode(`
|
|
G21
|
|
G90
|
|
M82
|
|
G0 X0 Y0 Z0.2
|
|
;TYPE:WALL-OUTER
|
|
G1 X10 Y0 E0.4
|
|
; FEATURE: Support interface
|
|
G1 X10 Y10 E0.8
|
|
;TYPE:SUPPORT
|
|
G1 X0 Y10 E1.2
|
|
; FEATURE: Sparse infill
|
|
G1 X0 Y0 E1.6
|
|
`);
|
|
const featuresById = new Map(data.features.map((feature) => [feature.id, feature]));
|
|
|
|
assert.equal(featuresById.get("outer-wall")?.label, "Outer wall");
|
|
assert.equal(featuresById.get("support-interface")?.category, "support");
|
|
assert.equal(featuresById.get("support-interface")?.extrusionMoves, 1);
|
|
assert.deepEqual(featuresById.get("support-interface")?.rawLabels, ["Support interface"]);
|
|
assert.equal(featuresById.get("support")?.extrusionMoves, 1);
|
|
assert.equal(featuresById.get("sparse-infill")?.category, "infill");
|
|
assert.deepEqual(
|
|
data.segments.filter((segment) => segment.kind === "extrusion").map((segment) => segment.featureId),
|
|
["outer-wall", "support-interface", "support", "sparse-infill"]
|
|
);
|
|
});
|
|
|
|
test("buildGcodePreviewMeshData turns visible G-code paths into layer-colored mesh data", () => {
|
|
const data = parseGcode(`
|
|
G21
|
|
G90
|
|
M82
|
|
G0 X0 Y0 Z0.2
|
|
;TYPE:WALL-OUTER
|
|
G1 X4 Y0 E0.2
|
|
G0 X4 Y4
|
|
G1 X0 Y4 E0.4
|
|
G1 Z0.4
|
|
G1 X0 Y0 E0.6
|
|
`);
|
|
|
|
const extrusionOnly = buildGcodePreviewMeshData(data, {
|
|
showTravel: false,
|
|
maxLayer: 0
|
|
});
|
|
|
|
assert.equal(extrusionOnly.sourceFormat, "gcode");
|
|
assert.deepEqual(extrusionOnly.metadata.options.layerRange, [0, 0]);
|
|
assert.equal(extrusionOnly.metadata.options.showTravel, false);
|
|
assert.equal(extrusionOnly.metadata.previewGeometry, "ribbon");
|
|
assert.equal(extrusionOnly.metadata.features.some((feature) => feature.id === "outer-wall"), true);
|
|
assert.equal(extrusionOnly.has_source_colors, true);
|
|
assert.ok(extrusionOnly.vertices.length > 0);
|
|
assert.ok(extrusionOnly.indices.length > 0);
|
|
assert.equal(extrusionOnly.colors.length, extrusionOnly.vertices.length);
|
|
assert.equal(extrusionOnly.normals.length, extrusionOnly.vertices.length);
|
|
assert.equal(extrusionOnly.bounds.min[2], 0.2);
|
|
assert.ok(extrusionOnly.vertices[2] > extrusionOnly.bounds.min[2]);
|
|
assert.deepEqual(extrusionOnly.parts.map((part) => part.label), ["Layer 1"]);
|
|
|
|
const withTravel = buildGcodePreviewMeshData(data, {
|
|
showTravel: true,
|
|
maxLayer: 1
|
|
});
|
|
assert.ok(withTravel.parts.some((part) => part.label === "Travel"));
|
|
assert.ok(withTravel.parts.some((part) => part.label === "Layer 2"));
|
|
assert.equal(withTravel.metadata.stats.extrusionMoves, 3);
|
|
});
|
|
|
|
test("buildGcodePreviewMeshData renders support paths with a neutral color and z bias", () => {
|
|
const data = parseGcode(`
|
|
G21
|
|
G90
|
|
M82
|
|
G0 X0 Y0 Z0.2
|
|
;TYPE:WALL-OUTER
|
|
G1 X4 Y0 E0.2
|
|
G1 Z0.4
|
|
G1 X4 Y4 E0.4
|
|
G1 Z0.6
|
|
;TYPE:SUPPORT
|
|
G1 X0 Y4 E0.6
|
|
`);
|
|
|
|
const preview = buildGcodePreviewMeshData(data, { detailMode: "full" });
|
|
const supportVertexOffset = 2 * 4 * 3;
|
|
const supportRgb = Array.from(preview.colors.slice(supportVertexOffset, supportVertexOffset + 3));
|
|
|
|
assert.ok(supportRgb[0] < 0.75);
|
|
assert.ok(supportRgb[1] > 0.45);
|
|
assert.ok(supportRgb[2] > 0.55);
|
|
assert.ok(preview.vertices[supportVertexOffset + 2] > 0.6);
|
|
assert.equal(preview.bounds.max[2], 0.6);
|
|
});
|
|
|
|
test("buildGcodePreviewMeshData adaptively samples whole layers unless full detail is requested", () => {
|
|
const moves = Array.from({ length: 8 }, (_, layerIndex) => `
|
|
G1 Z${((layerIndex + 1) * 0.2).toFixed(1)}
|
|
G1 X${layerIndex * 2 + 1} Y0 E${(layerIndex * 2 + 1) / 10}
|
|
G1 X${layerIndex * 2 + 2} Y0 E${(layerIndex * 2 + 2) / 10}
|
|
`).join("\n");
|
|
const data = parseGcode(`
|
|
G21
|
|
G90
|
|
M82
|
|
G0 X0 Y0 Z0.2
|
|
${moves}
|
|
`);
|
|
|
|
const adaptive = buildGcodePreviewMeshData(data, {
|
|
maxVisibleSegments: 6
|
|
});
|
|
assert.equal(adaptive.metadata.visibleSegments, 16);
|
|
assert.equal(adaptive.metadata.segmentStride, 1);
|
|
assert.equal(adaptive.metadata.layerStride, 4);
|
|
assert.equal(adaptive.metadata.renderedLayers, 3);
|
|
assert.equal(adaptive.metadata.renderedSegments, 6);
|
|
assert.equal(adaptive.metadata.decimationMode, "layers");
|
|
assert.equal(adaptive.metadata.decimated, true);
|
|
assert.deepEqual(adaptive.parts.map((part) => part.label), ["Layer 1", "Layer 5", "Layer 8"]);
|
|
|
|
const full = buildGcodePreviewMeshData(data, {
|
|
detailMode: "full",
|
|
maxVisibleSegments: 6
|
|
});
|
|
assert.equal(full.metadata.visibleSegments, 16);
|
|
assert.equal(full.metadata.segmentStride, 1);
|
|
assert.equal(full.metadata.layerStride, 1);
|
|
assert.equal(full.metadata.renderedSegments, 16);
|
|
assert.equal(full.metadata.decimated, false);
|
|
|
|
const maxAdaptive = buildGcodePreviewMeshData(data, {
|
|
detailLevel: 99
|
|
});
|
|
assert.equal(maxAdaptive.metadata.options.detailLevel, GCODE_PREVIEW_DETAIL_MAX);
|
|
});
|