Files
cad-Integration/text-to-cad-main/packages/cadjs/src/lib/gcode/buildPreviewMesh.js
T
2026-07-22 13:48:46 +08:00

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 : []
}
};
}