const GCODE_RENDER_SCHEMA_VERSION = 1; const POSITION_EPSILON = 1e-7; const EXTRUSION_EPSILON = 1e-7; const ARC_CHORD_MM = 0.8; const MIN_ARC_SEGMENTS = 6; const MAX_ARC_SEGMENTS = 160; const UNCLASSIFIED_FEATURE = Object.freeze({ id: "unclassified", label: "Unclassified", category: "other", rawLabel: "" }); const FEATURE_CATEGORY_ORDER = Object.freeze({ support: 0, wall: 1, surface: 2, infill: 3, adhesion: 4, calibration: 5, other: 6 }); const FEATURE_DEFINITIONS = Object.freeze([ { id: "support-interface", label: "Support interface", category: "support", patterns: [ /\bsupport\b.*\b(interface|roof|floor)\b/, /\binterface\b.*\bsupport\b/ ] }, { id: "support", label: "Support", category: "support", patterns: [/\bsupport(?:s| material)?\b/] }, { id: "overhang-wall", label: "Overhang wall", category: "wall", patterns: [/\boverhang\b.*\b(wall|perimeter)\b/] }, { id: "outer-wall", label: "Outer wall", category: "wall", patterns: [ /\bouter\b.*\b(wall|perimeter|shell)\b/, /\bexternal\b.*\b(perimeter|wall|shell)\b/, /\bwall outer\b/ ] }, { id: "inner-wall", label: "Inner wall", category: "wall", patterns: [ /\binner\b.*\b(wall|perimeter|shell)\b/, /\binternal\b.*\bperimeter\b/, /\bwall inner\b/ ] }, { id: "wall", label: "Wall", category: "wall", patterns: [/\b(wall|perimeter|shell)\b/] }, { id: "bridge", label: "Bridge", category: "surface", patterns: [/\bbridge\b/] }, { id: "top-surface", label: "Top surface", category: "surface", patterns: [/\btop\b.*\b(surface|skin|shell|solid infill|infill)\b/] }, { id: "bottom-surface", label: "Bottom surface", category: "surface", patterns: [/\bbottom\b.*\b(surface|skin|shell|solid infill|infill)\b/] }, { id: "surface", label: "Surface", category: "surface", patterns: [/\b(skin|surface)\b/] }, { id: "gap-fill", label: "Gap fill", category: "infill", patterns: [/\bgap\b.*\bfill\b/] }, { id: "internal-solid-infill", label: "Internal solid infill", category: "infill", patterns: [ /\binternal\b.*\bsolid\b.*\binfill\b/, /\bsolid\b.*\binfill\b/ ] }, { id: "sparse-infill", label: "Sparse infill", category: "infill", patterns: [ /\bsparse\b.*\binfill\b/, /\binternal\b.*\binfill\b/, /\bfill\b/ ] }, { id: "infill", label: "Infill", category: "infill", patterns: [/\binfill\b/] }, { id: "skirt", label: "Skirt", category: "adhesion", patterns: [/\bskirt\b/] }, { id: "brim", label: "Brim", category: "adhesion", patterns: [/\bbrim\b/] }, { id: "raft", label: "Raft", category: "adhesion", patterns: [/\braft\b/] }, { id: "prime-tower", label: "Prime tower", category: "calibration", patterns: [/\b(prime|wipe)\b.*\btower\b/] }, { id: "ironing", label: "Ironing", category: "surface", patterns: [/\bironing\b/] }, { id: "flush", label: "Flush", category: "calibration", patterns: [/\bflush\b/] }, { id: "wipe", label: "Wipe", category: "calibration", patterns: [/\bwipe\b/] }, { id: "custom", label: "Custom", category: "other", patterns: [/\bcustom\b/] } ]); const SUPPORTED_COMMANDS = new Set([ "G0", "G1", "G2", "G3", "G20", "G21", "G28", "G90", "G91", "G92", "M82", "M83", "M84", "M104", "M106", "M107", "M109", "M140", "M190" ]); function toFiniteNumber(value, fallback = 0) { const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } function clonePoint(point) { return [ toFiniteNumber(point?.[0]), toFiniteNumber(point?.[1]), toFiniteNumber(point?.[2]) ]; } function stripComment(line) { return String(line || "").split(";", 1)[0].trim(); } function parseCommand(line) { const stripped = stripComment(line).toUpperCase(); if (!stripped) { return ""; } const first = stripped.split(/\s+/, 1)[0]; if (/^[GMT]\d+(?:\.\d+)?$/.test(first)) { return first.split(".", 1)[0]; } return first; } function parseTokens(line) { const stripped = stripComment(line).toUpperCase(); const tokens = {}; for (const match of stripped.matchAll(/([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))/g)) { tokens[match[1]] = Number(match[2]); } return tokens; } function commentText(line) { const source = String(line || ""); const commentIndex = source.indexOf(";"); return commentIndex >= 0 ? source.slice(commentIndex + 1).trim() : ""; } function normalizeFeatureText(value) { return String(value || "") .replace(/^["']+|["']+$/g, "") .replace(/[_-]+/g, " ") .replace(/\s+/g, " ") .trim(); } function featureSlug(value) { return normalizeFeatureText(value) .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); } function titleCaseFeatureLabel(value) { const normalized = normalizeFeatureText(value); if (!normalized) { return "Other"; } return normalized .toLowerCase() .split(" ") .map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : "") .join(" "); } function featureFromLabel(rawLabel) { const normalized = normalizeFeatureText(rawLabel); const matchText = normalized.toLowerCase(); for (const definition of FEATURE_DEFINITIONS) { if (definition.patterns.some((pattern) => pattern.test(matchText))) { return { id: definition.id, label: definition.label, category: definition.category, rawLabel: normalized }; } } const slug = featureSlug(normalized); return { id: slug ? `other:${slug}` : UNCLASSIFIED_FEATURE.id, label: slug ? titleCaseFeatureLabel(normalized) : UNCLASSIFIED_FEATURE.label, category: "other", rawLabel: normalized }; } function parseFeatureMarker(line) { const comment = commentText(line); if (!comment) { return null; } const match = comment.match(/^(?:FEATURE|TYPE|PRINT_FEATURE|TOOLPATH(?:_TYPE)?|PATH_TYPE)\s*[:=]\s*(.+)$/i); if (!match?.[1]) { return null; } return featureFromLabel(match[1]); } function distance3(a, b) { const dx = toFiniteNumber(b?.[0]) - toFiniteNumber(a?.[0]); const dy = toFiniteNumber(b?.[1]) - toFiniteNumber(a?.[1]); const dz = toFiniteNumber(b?.[2]) - toFiniteNumber(a?.[2]); return Math.hypot(dx, dy, dz); } function roundedZ(value) { return Math.round(toFiniteNumber(value) * 1000000) / 1000000; } function updateBounds(bounds, point) { const next = clonePoint(point); for (let axis = 0; axis < 3; axis += 1) { bounds.min[axis] = Math.min(bounds.min[axis], next[axis]); bounds.max[axis] = Math.max(bounds.max[axis], next[axis]); } } function layerKey(z) { return roundedZ(z).toFixed(6); } function layerIndexForZ(layers, z) { if (!layers.length) { return 0; } const target = roundedZ(z); let bestIndex = 0; let bestDistance = Infinity; for (let index = 0; index < layers.length; index += 1) { const distance = Math.abs(toFiniteNumber(layers[index]?.z) - target); if (distance < bestDistance) { bestIndex = index; bestDistance = distance; } } return bestIndex; } function endpointForMove(position, tokens, absolutePositioning) { return [ Object.hasOwn(tokens, "X") ? (absolutePositioning ? tokens.X : position.x + tokens.X) : position.x, Object.hasOwn(tokens, "Y") ? (absolutePositioning ? tokens.Y : position.y + tokens.Y) : position.y, Object.hasOwn(tokens, "Z") ? (absolutePositioning ? tokens.Z : position.z + tokens.Z) : position.z ]; } function nextExtruder(position, tokens, absoluteExtrusion) { if (!Object.hasOwn(tokens, "E")) { return position.e; } return absoluteExtrusion ? tokens.E : position.e + tokens.E; } function addSegment(segments, bounds, stats, { lineNumber, command, start, end, extrusionDelta = 0, feedRate = null, arc = false, feature = null }) { const lengthMm = distance3(start, end); if (lengthMm <= POSITION_EPSILON) { if (extrusionDelta < -EXTRUSION_EPSILON) { stats.retractMoves += 1; } else if (extrusionDelta > EXTRUSION_EPSILON) { stats.primeMoves += 1; } return; } const kind = extrusionDelta > EXTRUSION_EPSILON ? "extrusion" : "travel"; if (kind === "extrusion") { stats.extrusionMoves += 1; stats.extrusionMm += extrusionDelta; } else { stats.travelMoves += 1; } stats.movementCommands += 1; stats.pathMm += lengthMm; updateBounds(bounds, start); updateBounds(bounds, end); segments.push({ lineNumber, command, kind, start: clonePoint(start), end: clonePoint(end), z: roundedZ(end[2]), extrusionDelta, feedRate: Number.isFinite(feedRate) ? feedRate : null, lengthMm, arc: Boolean(arc), featureId: feature?.id || UNCLASSIFIED_FEATURE.id, featureLabel: feature?.label || UNCLASSIFIED_FEATURE.label, featureCategory: feature?.category || UNCLASSIFIED_FEATURE.category, featureRaw: feature?.rawLabel || "" }); } function arcSegments({ start, end, tokens, clockwise }) { if (!Object.hasOwn(tokens, "I") || !Object.hasOwn(tokens, "J")) { return null; } const centerX = start[0] + tokens.I; const centerY = start[1] + tokens.J; const radius = Math.hypot(start[0] - centerX, start[1] - centerY); if (!(radius > POSITION_EPSILON)) { return null; } const startAngle = Math.atan2(start[1] - centerY, start[0] - centerX); const endAngle = Math.atan2(end[1] - centerY, end[0] - centerX); let sweep = endAngle - startAngle; if (clockwise && sweep >= 0) { sweep -= Math.PI * 2; } else if (!clockwise && sweep <= 0) { sweep += Math.PI * 2; } const chordSegments = Math.ceil(Math.abs(sweep * radius) / ARC_CHORD_MM); const segmentCount = Math.max(MIN_ARC_SEGMENTS, Math.min(MAX_ARC_SEGMENTS, chordSegments)); const points = [clonePoint(start)]; for (let index = 1; index <= segmentCount; index += 1) { const t = index / segmentCount; const angle = startAngle + sweep * t; points.push([ centerX + radius * Math.cos(angle), centerY + radius * Math.sin(angle), start[2] + (end[2] - start[2]) * t ]); } return points; } function compactBounds(bounds) { if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) { return { min: [0, 0, 0], max: [1, 1, 1] }; } return { min: bounds.min.map((value) => roundedZ(value)), max: bounds.max.map((value) => roundedZ(value)) }; } function finalizeLayers(segments) { const zValues = new Map(); for (const segment of segments) { if (segment.kind !== "extrusion") { continue; } const key = layerKey(segment.z); if (!zValues.has(key)) { zValues.set(key, roundedZ(segment.z)); } } if (!zValues.size) { for (const segment of segments) { const key = layerKey(segment.z); if (!zValues.has(key)) { zValues.set(key, roundedZ(segment.z)); } } } const layers = [...zValues.values()] .sort((left, right) => left - right) .map((z, index) => ({ index, z, segmentCount: 0, extrusionMoves: 0, travelMoves: 0 })); for (const segment of segments) { const index = layerIndexForZ(layers, segment.z); segment.layerIndex = index; if (layers[index]) { layers[index].segmentCount += 1; if (segment.kind === "extrusion") { layers[index].extrusionMoves += 1; } else { layers[index].travelMoves += 1; } } } return layers; } function ensureFeatureRecord(records, feature) { const resolvedFeature = feature?.id ? feature : UNCLASSIFIED_FEATURE; if (!records.has(resolvedFeature.id)) { records.set(resolvedFeature.id, { id: resolvedFeature.id, label: resolvedFeature.label || UNCLASSIFIED_FEATURE.label, category: resolvedFeature.category || UNCLASSIFIED_FEATURE.category, rawLabels: new Set(), markerCount: 0, firstLine: null, lastLine: null, firstLayerIndex: null, lastLayerIndex: null, segmentCount: 0, extrusionMoves: 0, travelMoves: 0, extrusionMm: 0, pathMm: 0 }); } const record = records.get(resolvedFeature.id); if (resolvedFeature.rawLabel) { record.rawLabels.add(resolvedFeature.rawLabel); } return record; } function layerRangeText(record) { if (!Number.isInteger(record.firstLayerIndex) || !Number.isInteger(record.lastLayerIndex)) { return ""; } return record.firstLayerIndex === record.lastLayerIndex ? `Layer ${record.firstLayerIndex + 1}` : `Layers ${record.firstLayerIndex + 1}-${record.lastLayerIndex + 1}`; } function finalizeFeatures(segments, markers) { const records = new Map(); for (const marker of markers) { const record = ensureFeatureRecord(records, marker.feature); record.markerCount += 1; record.firstLine = record.firstLine == null ? marker.lineNumber : Math.min(record.firstLine, marker.lineNumber); record.lastLine = record.lastLine == null ? marker.lineNumber : Math.max(record.lastLine, marker.lineNumber); } for (const segment of segments) { const record = ensureFeatureRecord(records, { id: segment.featureId, label: segment.featureLabel, category: segment.featureCategory, rawLabel: segment.featureRaw }); record.segmentCount += 1; record.pathMm += toFiniteNumber(segment.lengthMm); if (Number.isInteger(segment.layerIndex)) { record.firstLayerIndex = record.firstLayerIndex == null ? segment.layerIndex : Math.min(record.firstLayerIndex, segment.layerIndex); record.lastLayerIndex = record.lastLayerIndex == null ? segment.layerIndex : Math.max(record.lastLayerIndex, segment.layerIndex); } if (segment.kind === "extrusion") { record.extrusionMoves += 1; record.extrusionMm += toFiniteNumber(segment.extrusionDelta); } else if (segment.kind === "travel") { record.travelMoves += 1; } } return [...records.values()] .map((record) => ({ ...record, rawLabels: [...record.rawLabels].sort((left, right) => left.localeCompare(right)), layerRangeText: layerRangeText(record), pathMm: roundedZ(record.pathMm), extrusionMm: roundedZ(record.extrusionMm) })) .sort((left, right) => { const categoryDelta = (FEATURE_CATEGORY_ORDER[left.category] ?? 99) - (FEATURE_CATEGORY_ORDER[right.category] ?? 99); if (categoryDelta !== 0) { return categoryDelta; } const extrusionDelta = right.extrusionMoves - left.extrusionMoves; if (extrusionDelta !== 0) { return extrusionDelta; } return left.label.localeCompare(right.label); }); } function warningList(warnings, unsupportedCommands) { const result = [...warnings]; const unsupported = [...unsupportedCommands.entries()] .sort((left, right) => left[0].localeCompare(right[0])) .slice(0, 12) .map(([command, line]) => `${command} line ${line}`); if (unsupported.length) { result.push(`Unsupported or ignored commands were left out of the preview: ${unsupported.join(", ")}.`); } return result; } export function parseGcode(text, { sourceUrl = "", fileRef = "" } = {}) { const lines = String(text || "").split(/\r?\n/); const segments = []; const unsupportedCommands = new Map(); const warnings = []; const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }; const stats = { lines: lines.length, nonCommentLines: 0, movementCommands: 0, extrusionMoves: 0, travelMoves: 0, retractMoves: 0, primeMoves: 0, temperatureCommands: 0, extrusionMm: 0, pathMm: 0 }; const position = { x: 0, y: 0, z: 0, e: 0 }; let absolutePositioning = true; let absoluteExtrusion = true; let units = "mm"; let relativeWarningAdded = false; let inchWarningAdded = false; let currentFeature = null; const featureMarkers = []; for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const lineNumber = lineIndex + 1; const rawLine = lines[lineIndex]; const featureMarker = parseFeatureMarker(rawLine); if (featureMarker) { currentFeature = featureMarker; featureMarkers.push({ lineNumber, feature: featureMarker }); } const command = parseCommand(rawLine); if (!command) { continue; } stats.nonCommentLines += 1; const tokens = parseTokens(rawLine); if (!SUPPORTED_COMMANDS.has(command) && !/^T\d+$/.test(command)) { unsupportedCommands.set(command, unsupportedCommands.get(command) || lineNumber); } if (command === "G20") { units = "inch"; if (!inchWarningAdded) { warnings.push("G20 inch units found; preview geometry assumes millimeters."); inchWarningAdded = true; } continue; } if (command === "G21") { units = "mm"; continue; } if (command === "G90") { absolutePositioning = true; continue; } if (command === "G91") { absolutePositioning = false; if (!relativeWarningAdded) { warnings.push("Relative XYZ positioning found; preview follows relative moves after G91."); relativeWarningAdded = true; } continue; } if (command === "M82") { absoluteExtrusion = true; continue; } if (command === "M83") { absoluteExtrusion = false; continue; } if (command === "G92") { if (Object.hasOwn(tokens, "X")) { position.x = tokens.X; } if (Object.hasOwn(tokens, "Y")) { position.y = tokens.Y; } if (Object.hasOwn(tokens, "Z")) { position.z = tokens.Z; } if (Object.hasOwn(tokens, "E")) { position.e = tokens.E; } continue; } if (["M104", "M109", "M140", "M190"].includes(command)) { stats.temperatureCommands += 1; continue; } if (!["G0", "G1", "G2", "G3"].includes(command)) { continue; } const start = [position.x, position.y, position.z]; const end = endpointForMove(position, tokens, absolutePositioning); const nextE = nextExtruder(position, tokens, absoluteExtrusion); const extrusionDelta = nextE - position.e; const feedRate = Object.hasOwn(tokens, "F") ? tokens.F : null; if (command === "G2" || command === "G3") { const points = arcSegments({ start, end, tokens, clockwise: command === "G2" }); if (points) { for (let index = 0; index < points.length - 1; index += 1) { addSegment(segments, bounds, stats, { lineNumber, command, start: points[index], end: points[index + 1], extrusionDelta: extrusionDelta / Math.max(points.length - 1, 1), feedRate, arc: true, feature: currentFeature }); } } else { warnings.push(`Line ${lineNumber}: ${command} arc is missing I/J center offsets; previewed as a straight move.`); addSegment(segments, bounds, stats, { lineNumber, command, start, end, extrusionDelta, feedRate, arc: false, feature: currentFeature }); } } else { addSegment(segments, bounds, stats, { lineNumber, command, start, end, extrusionDelta, feedRate, feature: currentFeature }); } position.x = end[0]; position.y = end[1]; position.z = end[2]; position.e = nextE; } const layers = finalizeLayers(segments); const features = finalizeFeatures(segments, featureMarkers); return { schemaVersion: GCODE_RENDER_SCHEMA_VERSION, sourceUrl, fileRef, units, bounds: compactBounds(bounds), layers, features, segments, stats, warnings: warningList(warnings, unsupportedCommands) }; }