506 lines
16 KiB
JavaScript
506 lines
16 KiB
JavaScript
const DEFAULT_EXTRUSION_WIDTH_MM = 0.42;
|
|
const DEFAULT_TRAVEL_WIDTH_MM = 0.08;
|
|
const DEFAULT_EXTRUSION_HEIGHT_MM = 0.18;
|
|
const DEFAULT_TRAVEL_HEIGHT_MM = 0.04;
|
|
export const DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET = 60000;
|
|
export const GCODE_PREVIEW_DETAIL_MIN = 1;
|
|
export const GCODE_PREVIEW_DETAIL_MAX = 7;
|
|
export const DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL = 3;
|
|
const GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS = Object.freeze({
|
|
1: 25000,
|
|
2: 40000,
|
|
3: DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET,
|
|
4: 90000,
|
|
5: 140000,
|
|
6: 220000,
|
|
7: 320000
|
|
});
|
|
const MIN_GCODE_PREVIEW_SEGMENT_BUDGET = 1;
|
|
const MIN_SEGMENT_LENGTH_MM = 1e-5;
|
|
const GCODE_PREVIEW_Z_LIFT_MM = 0.035;
|
|
const DEFAULT_LAYER_COLORS = Object.freeze([
|
|
"#2563eb",
|
|
"#16a34a",
|
|
"#0891b2",
|
|
"#ca8a04",
|
|
"#7c3aed",
|
|
"#0f766e",
|
|
"#4f46e5",
|
|
"#65a30d"
|
|
]);
|
|
const SUPPORT_COLOR = "#94a3b8";
|
|
const TRAVEL_COLOR = "#64748b";
|
|
const FEATURE_CATEGORY_Z_BIAS_MM = Object.freeze({
|
|
support: 0.001,
|
|
adhesion: 0.002,
|
|
infill: 0.003,
|
|
calibration: 0.004,
|
|
other: 0.004,
|
|
surface: 0.005,
|
|
wall: 0.006
|
|
});
|
|
const TRAVEL_Z_BIAS_MM = 0.008;
|
|
const RIBBON_VERTICES_PER_SEGMENT = 4;
|
|
const RIBBON_INDICES_PER_SEGMENT = 6;
|
|
const RIBBON_NORMAL = Object.freeze([0, 0, 1]);
|
|
|
|
function toFiniteNumber(value, fallback = 0) {
|
|
const numericValue = Number(value);
|
|
return Number.isFinite(numericValue) ? numericValue : fallback;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function normalizeBoolean(value, fallback = false) {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function normalizeDetailMode(value) {
|
|
return String(value || "").trim().toLowerCase() === "full" ? "full" : "adaptive";
|
|
}
|
|
|
|
function normalizePreviewDetailLevel(value) {
|
|
return clamp(
|
|
Math.round(toFiniteNumber(value, DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)),
|
|
GCODE_PREVIEW_DETAIL_MIN,
|
|
GCODE_PREVIEW_DETAIL_MAX
|
|
);
|
|
}
|
|
|
|
function segmentBudgetForPreviewDetailLevel(value) {
|
|
const detailLevel = normalizePreviewDetailLevel(value);
|
|
return GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS[detailLevel] || DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET;
|
|
}
|
|
|
|
function coordinate(value, index) {
|
|
return toFiniteNumber(value?.[index]);
|
|
}
|
|
|
|
function emptyBounds() {
|
|
return {
|
|
min: [Infinity, Infinity, Infinity],
|
|
max: [-Infinity, -Infinity, -Infinity]
|
|
};
|
|
}
|
|
|
|
function finalizeBounds(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,
|
|
max: bounds.max
|
|
};
|
|
}
|
|
|
|
function segmentLength(segment) {
|
|
const start = segment?.start;
|
|
const end = segment?.end;
|
|
return Math.hypot(
|
|
coordinate(end, 0) - coordinate(start, 0),
|
|
coordinate(end, 1) - coordinate(start, 1),
|
|
coordinate(end, 2) - coordinate(start, 2)
|
|
);
|
|
}
|
|
|
|
function normalizeLayerRange(value, layerCount) {
|
|
const maxLayer = Math.max(0, Math.trunc(toFiniteNumber(layerCount, 0)) - 1);
|
|
if (!Array.isArray(value) || value.length < 2) {
|
|
return [0, maxLayer];
|
|
}
|
|
const start = clamp(Math.trunc(toFiniteNumber(value[0], 0)), 0, maxLayer);
|
|
const end = clamp(Math.trunc(toFiniteNumber(value[1], maxLayer)), 0, maxLayer);
|
|
return start <= end ? [start, end] : [end, start];
|
|
}
|
|
|
|
export function normalizeGcodePreviewOptions(options = {}, layerCount = 0) {
|
|
const source = options && typeof options === "object" ? options : {};
|
|
const detailMode = normalizeDetailMode(source.detailMode);
|
|
const detailLevel = normalizePreviewDetailLevel(source.detailLevel);
|
|
const defaultSegmentBudget = segmentBudgetForPreviewDetailLevel(detailLevel);
|
|
const explicitRange = Array.isArray(source.layerRange)
|
|
? source.layerRange
|
|
: Number.isFinite(Number(source.maxLayer))
|
|
? [0, Number(source.maxLayer)]
|
|
: null;
|
|
return {
|
|
showTravel: normalizeBoolean(source.showTravel, false),
|
|
layerRange: normalizeLayerRange(explicitRange, layerCount),
|
|
detailMode,
|
|
detailLevel,
|
|
maxVisibleSegments: detailMode === "full"
|
|
? 0
|
|
: Math.max(
|
|
Math.trunc(toFiniteNumber(source.maxVisibleSegments, defaultSegmentBudget)),
|
|
MIN_GCODE_PREVIEW_SEGMENT_BUDGET
|
|
),
|
|
extrusionWidthMm: Math.max(toFiniteNumber(source.extrusionWidthMm, DEFAULT_EXTRUSION_WIDTH_MM), 0.01),
|
|
travelWidthMm: Math.max(toFiniteNumber(source.travelWidthMm, DEFAULT_TRAVEL_WIDTH_MM), 0.01),
|
|
extrusionHeightMm: Math.max(toFiniteNumber(source.extrusionHeightMm, DEFAULT_EXTRUSION_HEIGHT_MM), 0.01),
|
|
travelHeightMm: Math.max(toFiniteNumber(source.travelHeightMm, DEFAULT_TRAVEL_HEIGHT_MM), 0.01)
|
|
};
|
|
}
|
|
|
|
function layerColor(layerIndex) {
|
|
const index = Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0)));
|
|
return DEFAULT_LAYER_COLORS[index % DEFAULT_LAYER_COLORS.length];
|
|
}
|
|
|
|
function segmentGroupKey(segment) {
|
|
if (segment.kind === "travel") {
|
|
return "travel";
|
|
}
|
|
return `layer:${Math.max(0, Math.trunc(toFiniteNumber(segment.layerIndex, 0)))}`;
|
|
}
|
|
|
|
function groupLabel(key, layerIndex) {
|
|
if (key === "travel") {
|
|
return "Travel";
|
|
}
|
|
return `Layer ${Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0))) + 1}`;
|
|
}
|
|
|
|
function colorToRgb(color) {
|
|
const normalized = String(color || "").trim();
|
|
const expanded = /^#[0-9a-fA-F]{3}$/.test(normalized)
|
|
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
|
|
: normalized;
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(expanded)) {
|
|
return [1, 1, 1];
|
|
}
|
|
return [
|
|
parseInt(expanded.slice(1, 3), 16) / 255,
|
|
parseInt(expanded.slice(3, 5), 16) / 255,
|
|
parseInt(expanded.slice(5, 7), 16) / 255
|
|
];
|
|
}
|
|
|
|
function visibleSegmentMatches(segment, options) {
|
|
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
|
|
const [minLayer, maxLayer] = options.layerRange;
|
|
if (layerIndex < minLayer || layerIndex > maxLayer) {
|
|
return false;
|
|
}
|
|
if (segment?.kind === "travel") {
|
|
return options.showTravel;
|
|
}
|
|
return segment?.kind === "extrusion";
|
|
}
|
|
|
|
function analyzeVisibleSegments(gcodeData, options) {
|
|
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
|
|
const segmentsByLayer = new Map();
|
|
let visibleSegmentCount = 0;
|
|
for (const segment of segments) {
|
|
if (!visibleSegmentMatches(segment, options) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
|
|
continue;
|
|
}
|
|
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
|
|
segmentsByLayer.set(layerIndex, (segmentsByLayer.get(layerIndex) || 0) + 1);
|
|
visibleSegmentCount += 1;
|
|
}
|
|
return {
|
|
visibleSegmentCount,
|
|
segmentsByLayer
|
|
};
|
|
}
|
|
|
|
function includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride) {
|
|
if (layerStride <= 1) {
|
|
return true;
|
|
}
|
|
return (
|
|
layerIndex === minLayer ||
|
|
layerIndex === maxLayer ||
|
|
(layerIndex - minLayer) % layerStride === 0
|
|
);
|
|
}
|
|
|
|
function countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) {
|
|
let count = 0;
|
|
for (const [layerIndex, segmentCount] of segmentsByLayer.entries()) {
|
|
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
|
|
count += segmentCount;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function layerStrideForBudget(segmentsByLayer, visibleSegmentCount, options) {
|
|
const budget = Math.trunc(toFiniteNumber(options?.maxVisibleSegments, 0));
|
|
if (options?.detailMode === "full" || budget <= 0 || visibleSegmentCount <= budget) {
|
|
return 1;
|
|
}
|
|
const [minLayer, maxLayer] = options.layerRange;
|
|
const visibleLayerCount = Math.max(1, maxLayer - minLayer + 1);
|
|
for (let layerStride = 2; layerStride <= visibleLayerCount; layerStride += 1) {
|
|
if (countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) <= budget) {
|
|
return layerStride;
|
|
}
|
|
}
|
|
return visibleLayerCount;
|
|
}
|
|
|
|
function countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride) {
|
|
let count = 0;
|
|
for (const layerIndex of segmentsByLayer.keys()) {
|
|
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function updateBoundsFromCoordinates(bounds, x, y, z) {
|
|
bounds.min[0] = Math.min(bounds.min[0], x);
|
|
bounds.min[1] = Math.min(bounds.min[1], y);
|
|
bounds.min[2] = Math.min(bounds.min[2], z);
|
|
bounds.max[0] = Math.max(bounds.max[0], x);
|
|
bounds.max[1] = Math.max(bounds.max[1], y);
|
|
bounds.max[2] = Math.max(bounds.max[2], z);
|
|
}
|
|
|
|
function updateSegmentBounds(bounds, segment) {
|
|
updateBoundsFromCoordinates(
|
|
bounds,
|
|
coordinate(segment?.start, 0),
|
|
coordinate(segment?.start, 1),
|
|
coordinate(segment?.start, 2)
|
|
);
|
|
updateBoundsFromCoordinates(
|
|
bounds,
|
|
coordinate(segment?.end, 0),
|
|
coordinate(segment?.end, 1),
|
|
coordinate(segment?.end, 2)
|
|
);
|
|
}
|
|
|
|
function segmentColor(segment, key, layerIndex) {
|
|
if (key === "travel") {
|
|
return TRAVEL_COLOR;
|
|
}
|
|
if (segment?.featureCategory === "support") {
|
|
return SUPPORT_COLOR;
|
|
}
|
|
return layerColor(layerIndex);
|
|
}
|
|
|
|
function segmentZOffset(segment) {
|
|
if (segment?.kind === "travel") {
|
|
return GCODE_PREVIEW_Z_LIFT_MM + TRAVEL_Z_BIAS_MM;
|
|
}
|
|
const category = String(segment?.featureCategory || "other").trim().toLowerCase();
|
|
return GCODE_PREVIEW_Z_LIFT_MM + (FEATURE_CATEGORY_Z_BIAS_MM[category] ?? FEATURE_CATEGORY_Z_BIAS_MM.other);
|
|
}
|
|
|
|
function writeRibbonVertex({ vertices, colors, normals, bounds, offset, x, y, z, zOffset = 0, color }) {
|
|
vertices[offset] = x;
|
|
vertices[offset + 1] = y;
|
|
vertices[offset + 2] = z + zOffset;
|
|
colors[offset] = color[0];
|
|
colors[offset + 1] = color[1];
|
|
colors[offset + 2] = color[2];
|
|
normals[offset] = RIBBON_NORMAL[0];
|
|
normals[offset + 1] = RIBBON_NORMAL[1];
|
|
normals[offset + 2] = RIBBON_NORMAL[2];
|
|
updateBoundsFromCoordinates(bounds, x, y, z + zOffset);
|
|
}
|
|
|
|
function writeSegmentRibbon({ vertices, indices, colors, normals, bounds, segment, segmentIndex, width, color, zOffset }) {
|
|
const start = segment?.start;
|
|
const end = segment?.end;
|
|
const startX = coordinate(start, 0);
|
|
const startY = coordinate(start, 1);
|
|
const startZ = coordinate(start, 2);
|
|
const endX = coordinate(end, 0);
|
|
const endY = coordinate(end, 1);
|
|
const endZ = coordinate(end, 2);
|
|
const dx = endX - startX;
|
|
const dy = endY - startY;
|
|
const dz = endZ - startZ;
|
|
const length = Math.hypot(dx, dy, dz);
|
|
if (!(length > MIN_SEGMENT_LENGTH_MM)) {
|
|
return null;
|
|
}
|
|
let nx = -dy;
|
|
let ny = dx;
|
|
let normalLength = Math.hypot(nx, ny);
|
|
if (!(normalLength > MIN_SEGMENT_LENGTH_MM)) {
|
|
nx = 1;
|
|
ny = 0;
|
|
normalLength = 1;
|
|
}
|
|
const halfWidth = width / 2;
|
|
const offsetX = (nx / normalLength) * halfWidth;
|
|
const offsetY = (ny / normalLength) * halfWidth;
|
|
const vertexBase = segmentIndex * RIBBON_VERTICES_PER_SEGMENT;
|
|
const vertexOffset = vertexBase * 3;
|
|
|
|
writeRibbonVertex({
|
|
vertices,
|
|
colors,
|
|
normals,
|
|
bounds,
|
|
offset: vertexOffset,
|
|
x: startX + offsetX,
|
|
y: startY + offsetY,
|
|
z: startZ,
|
|
zOffset,
|
|
color
|
|
});
|
|
writeRibbonVertex({
|
|
vertices,
|
|
colors,
|
|
normals,
|
|
bounds,
|
|
offset: vertexOffset + 3,
|
|
x: startX - offsetX,
|
|
y: startY - offsetY,
|
|
z: startZ,
|
|
zOffset,
|
|
color
|
|
});
|
|
writeRibbonVertex({
|
|
vertices,
|
|
colors,
|
|
normals,
|
|
bounds,
|
|
offset: vertexOffset + 6,
|
|
x: endX - offsetX,
|
|
y: endY - offsetY,
|
|
z: endZ,
|
|
zOffset,
|
|
color
|
|
});
|
|
writeRibbonVertex({
|
|
vertices,
|
|
colors,
|
|
normals,
|
|
bounds,
|
|
offset: vertexOffset + 9,
|
|
x: endX + offsetX,
|
|
y: endY + offsetY,
|
|
z: endZ,
|
|
zOffset,
|
|
color
|
|
});
|
|
|
|
const indexOffset = segmentIndex * RIBBON_INDICES_PER_SEGMENT;
|
|
indices[indexOffset] = vertexBase;
|
|
indices[indexOffset + 1] = vertexBase + 1;
|
|
indices[indexOffset + 2] = vertexBase + 2;
|
|
indices[indexOffset + 3] = vertexBase;
|
|
indices[indexOffset + 4] = vertexBase + 2;
|
|
indices[indexOffset + 5] = vertexBase + 3;
|
|
return true;
|
|
}
|
|
|
|
export function buildGcodePreviewMeshData(gcodeData, options = {}) {
|
|
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
|
|
const normalized = normalizeGcodePreviewOptions(options, layers.length);
|
|
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
|
|
const { visibleSegmentCount, segmentsByLayer } = analyzeVisibleSegments(gcodeData, normalized);
|
|
const layerStride = layerStrideForBudget(segmentsByLayer, visibleSegmentCount, normalized);
|
|
const [minLayer, maxLayer] = normalized.layerRange;
|
|
const renderedSegmentCapacity = countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride);
|
|
const renderedLayerCount = countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride);
|
|
const vertices = new Float32Array(renderedSegmentCapacity * RIBBON_VERTICES_PER_SEGMENT * 3);
|
|
const indices = new Uint32Array(renderedSegmentCapacity * RIBBON_INDICES_PER_SEGMENT);
|
|
const colors = new Float32Array(vertices.length);
|
|
const normals = new Float32Array(vertices.length);
|
|
const parts = [];
|
|
const geometryBounds = emptyBounds();
|
|
const rawBounds = emptyBounds();
|
|
let currentGroup = null;
|
|
let writtenSegments = 0;
|
|
|
|
for (const segment of segments) {
|
|
if (!visibleSegmentMatches(segment, normalized) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
|
|
continue;
|
|
}
|
|
const key = segmentGroupKey(segment);
|
|
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
|
|
if (!includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
|
|
continue;
|
|
}
|
|
updateSegmentBounds(rawBounds, segment);
|
|
if (!currentGroup || currentGroup.key !== key) {
|
|
currentGroup = {
|
|
key,
|
|
layerIndex,
|
|
vertexOffset: writtenSegments * RIBBON_VERTICES_PER_SEGMENT,
|
|
triangleOffset: writtenSegments * 2,
|
|
segmentCount: 0
|
|
};
|
|
parts.push(currentGroup);
|
|
}
|
|
const added = writeSegmentRibbon({
|
|
vertices,
|
|
indices,
|
|
colors,
|
|
normals,
|
|
bounds: geometryBounds,
|
|
segment,
|
|
segmentIndex: writtenSegments,
|
|
width: segment.kind === "travel" ? normalized.travelWidthMm : normalized.extrusionWidthMm,
|
|
color: colorToRgb(segmentColor(segment, key, layerIndex)),
|
|
zOffset: segmentZOffset(segment)
|
|
});
|
|
if (added) {
|
|
currentGroup.segmentCount += 1;
|
|
writtenSegments += 1;
|
|
}
|
|
}
|
|
|
|
const finalizedParts = parts
|
|
.map((part, index) => {
|
|
const nextVertexOffset = parts[index + 1]?.vertexOffset ?? writtenSegments * RIBBON_VERTICES_PER_SEGMENT;
|
|
const nextTriangleOffset = parts[index + 1]?.triangleOffset ?? writtenSegments * 2;
|
|
return {
|
|
id: part.key === "travel" ? `gcode:travel:${index}` : `gcode:layer:${part.layerIndex}:${index}`,
|
|
label: groupLabel(part.key, part.layerIndex),
|
|
vertexOffset: part.vertexOffset,
|
|
vertexCount: Math.max(0, nextVertexOffset - part.vertexOffset),
|
|
triangleOffset: part.triangleOffset,
|
|
triangleCount: Math.max(0, nextTriangleOffset - part.triangleOffset),
|
|
bounds: finalizeBounds(rawBounds),
|
|
segmentCount: part.segmentCount
|
|
};
|
|
})
|
|
.filter((part) => part.vertexCount > 0 && part.triangleCount > 0);
|
|
|
|
const finalBounds = finalizeBounds(rawBounds);
|
|
return {
|
|
vertices,
|
|
indices,
|
|
normals,
|
|
colors,
|
|
bounds: finalBounds,
|
|
parts: finalizedParts,
|
|
has_source_colors: true,
|
|
sourceFormat: "gcode",
|
|
sourceColor: "#2563eb",
|
|
metadata: {
|
|
kind: "gcode",
|
|
previewGeometry: "ribbon",
|
|
options: normalized,
|
|
visibleSegments: visibleSegmentCount,
|
|
renderedSegments: writtenSegments,
|
|
visibleLayers: Math.max(0, maxLayer - minLayer + 1),
|
|
renderedLayers: renderedLayerCount,
|
|
layerStride,
|
|
segmentStride: 1,
|
|
decimationMode: layerStride > 1 ? "layers" : "none",
|
|
decimated: layerStride > 1,
|
|
stats: gcodeData?.stats || {},
|
|
features: Array.isArray(gcodeData?.features) ? gcodeData.features : [],
|
|
warnings: Array.isArray(gcodeData?.warnings) ? gcodeData.warnings : []
|
|
}
|
|
};
|
|
}
|