mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Minor GCode viewer improvements
This commit is contained in:
@@ -1520,38 +1520,122 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
|
||||
|
||||
has_model = False
|
||||
has_gcode = False
|
||||
has_source = False
|
||||
build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
|
||||
filament_colors: list[str] = []
|
||||
|
||||
# Check if source 3MF exists - this is where actual mesh data typically lives
|
||||
source_path = None
|
||||
if archive.source_3mf_path:
|
||||
source_path = settings.base_dir / archive.source_3mf_path
|
||||
if source_path.exists():
|
||||
has_source = True
|
||||
|
||||
# Helper function to check for mesh data and extract colors from a 3MF file
|
||||
def extract_3mf_info(zf_path: Path) -> tuple[bool, list[str], dict]:
|
||||
"""Extract mesh presence, colors, and build volume from a 3MF file."""
|
||||
found_mesh = False
|
||||
colors: list[str] = []
|
||||
volume = {"x": 256, "y": 256, "z": 256}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zf_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
# Check for 3D model - look for actual mesh data
|
||||
for name in names:
|
||||
if name.endswith(".model"):
|
||||
try:
|
||||
content = zf.read(name).decode("utf-8")
|
||||
if "<vertex" in content or "<mesh" in content:
|
||||
found_mesh = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract filament colors from project_settings.config
|
||||
if "Metadata/project_settings.config" in names:
|
||||
try:
|
||||
config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
|
||||
config_data = json.loads(config_content)
|
||||
|
||||
# Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
|
||||
printable_area = config_data.get("printable_area", [])
|
||||
if printable_area and len(printable_area) >= 3:
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for coord in printable_area:
|
||||
if "x" in coord:
|
||||
parts = coord.split("x")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
x, y = int(parts[0]), int(parts[1])
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
except ValueError:
|
||||
pass
|
||||
if max_x > 0 and max_y > 0:
|
||||
volume["x"] = max_x
|
||||
volume["y"] = max_y
|
||||
|
||||
# Parse printable_height
|
||||
printable_height = config_data.get("printable_height")
|
||||
if printable_height:
|
||||
try:
|
||||
volume["z"] = int(printable_height)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Extract filament colors
|
||||
raw_colors = config_data.get("filament_colour", [])
|
||||
if raw_colors:
|
||||
for color in raw_colors:
|
||||
if color and isinstance(color, str):
|
||||
colors.append(color)
|
||||
except Exception:
|
||||
pass
|
||||
except zipfile.BadZipFile:
|
||||
pass
|
||||
|
||||
return found_mesh, colors, volume
|
||||
|
||||
# First check source 3MF for mesh data and colors (preferred for 3D model viewing)
|
||||
if has_source and source_path:
|
||||
source_has_mesh, source_colors, source_volume = extract_3mf_info(source_path)
|
||||
if source_has_mesh:
|
||||
has_model = True
|
||||
if source_colors:
|
||||
filament_colors = source_colors
|
||||
if source_volume["x"] != 256 or source_volume["y"] != 256 or source_volume["z"] != 256:
|
||||
build_volume = source_volume
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
# Check for G-code
|
||||
# Check for G-code in the sliced file
|
||||
has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
|
||||
|
||||
# Check for 3D model - need to look for actual mesh data
|
||||
for name in names:
|
||||
if name.endswith(".model"):
|
||||
try:
|
||||
content = zf.read(name).decode("utf-8")
|
||||
# Check if this model file contains actual mesh vertices
|
||||
if "<vertex" in content or "<mesh" in content:
|
||||
has_model = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
# Check for 3D model in sliced file (fallback if no source)
|
||||
if not has_model:
|
||||
for name in names:
|
||||
if name.endswith(".model"):
|
||||
try:
|
||||
content = zf.read(name).decode("utf-8")
|
||||
if "<vertex" in content or "<mesh" in content:
|
||||
has_model = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract filament colors from slice_info.config
|
||||
# Extract filament colors from slice_info.config (for gcode preview)
|
||||
# These are the actual filaments used in the print, indexed by tool/extruder
|
||||
slice_colors: list[str] = []
|
||||
if "Metadata/slice_info.config" in names:
|
||||
try:
|
||||
slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
|
||||
root = ET.fromstring(slice_content)
|
||||
|
||||
# Get all filaments with their IDs and colors
|
||||
# <filament id="1" type="PLA" color="#FFFFFF" used_g="100" />
|
||||
# ID corresponds to the tool number in G-code (T0, T1, etc.)
|
||||
filaments = root.findall(".//filament")
|
||||
filament_map: dict[int, str] = {}
|
||||
for f in filaments:
|
||||
@@ -1563,59 +1647,66 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
|
||||
except (ValueError, TypeError):
|
||||
used_amount = 0
|
||||
|
||||
# Include all filaments, but mark unused ones
|
||||
if fid is not None and fcolor:
|
||||
try:
|
||||
# IDs are 1-based in slice_info, tools are 0-based
|
||||
tool_id = int(fid) - 1
|
||||
if tool_id >= 0 and used_amount > 0:
|
||||
filament_map[tool_id] = fcolor
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Convert to ordered list (tool 0, tool 1, etc.)
|
||||
if filament_map:
|
||||
max_tool = max(filament_map.keys())
|
||||
for i in range(max_tool + 1):
|
||||
filament_colors.append(filament_map.get(i, "#00AE42"))
|
||||
slice_colors.append(filament_map.get(i, "#00AE42"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract build volume from project settings
|
||||
if "Metadata/project_settings.config" in names:
|
||||
try:
|
||||
config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
|
||||
config_data = json.loads(config_content)
|
||||
# Use slice_info colors if we don't have colors from source yet
|
||||
if not filament_colors and slice_colors:
|
||||
filament_colors = slice_colors
|
||||
|
||||
# Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
|
||||
printable_area = config_data.get("printable_area", [])
|
||||
if printable_area and len(printable_area) >= 3:
|
||||
# Get max X and Y from the corner coordinates
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for coord in printable_area:
|
||||
if "x" in coord:
|
||||
parts = coord.split("x")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
x, y = int(parts[0]), int(parts[1])
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
except ValueError:
|
||||
pass
|
||||
if max_x > 0 and max_y > 0:
|
||||
build_volume["x"] = max_x
|
||||
build_volume["y"] = max_y
|
||||
# Extract build volume from sliced file if not already set from source
|
||||
if build_volume["x"] == 256 and build_volume["y"] == 256:
|
||||
if "Metadata/project_settings.config" in names:
|
||||
try:
|
||||
config_content = zf.read("Metadata/project_settings.config").decode("utf-8")
|
||||
config_data = json.loads(config_content)
|
||||
|
||||
# Parse printable_height
|
||||
printable_height = config_data.get("printable_height")
|
||||
if printable_height:
|
||||
try:
|
||||
build_volume["z"] = int(printable_height)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
printable_area = config_data.get("printable_area", [])
|
||||
if printable_area and len(printable_area) >= 3:
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
for coord in printable_area:
|
||||
if "x" in coord:
|
||||
parts = coord.split("x")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
x, y = int(parts[0]), int(parts[1])
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
except ValueError:
|
||||
pass
|
||||
if max_x > 0 and max_y > 0:
|
||||
build_volume["x"] = max_x
|
||||
build_volume["y"] = max_y
|
||||
|
||||
printable_height = config_data.get("printable_height")
|
||||
if printable_height:
|
||||
try:
|
||||
build_volume["z"] = int(printable_height)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Fallback colors from project_settings if still empty
|
||||
if not filament_colors:
|
||||
raw_colors = config_data.get("filament_colour", [])
|
||||
if raw_colors:
|
||||
for color in raw_colors:
|
||||
if color and isinstance(color, str):
|
||||
filament_colors.append(color)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid 3MF file")
|
||||
@@ -1623,6 +1714,7 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
|
||||
return {
|
||||
"has_model": has_model,
|
||||
"has_gcode": has_gcode,
|
||||
"has_source": has_source,
|
||||
"build_volume": build_volume,
|
||||
"filament_colors": filament_colors,
|
||||
}
|
||||
@@ -1661,6 +1753,69 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(500, f"Error extracting G-code: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{archive_id}/plate-preview")
|
||||
async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the plate preview image from the 3MF file.
|
||||
|
||||
Returns the slicer-generated plate thumbnail which shows the model
|
||||
with correct colors and positioning.
|
||||
"""
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(archive_id)
|
||||
if not archive:
|
||||
raise HTTPException(404, "Archive not found")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
# Try to find plate preview images in order of preference
|
||||
# First look for the specific plate being printed (check slice_info for plate index)
|
||||
plate_num = 1
|
||||
if "Metadata/slice_info.config" in names:
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
slice_content = zf.read("Metadata/slice_info.config").decode("utf-8")
|
||||
root = ET.fromstring(slice_content)
|
||||
plate_elem = root.find(".//plate/metadata[@key='index']")
|
||||
if plate_elem is not None:
|
||||
plate_num = int(plate_elem.get("value", "1"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try plate-specific image first, then fall back to plate_1
|
||||
preview_paths = [
|
||||
f"Metadata/plate_{plate_num}.png",
|
||||
"Metadata/plate_1.png",
|
||||
"Metadata/thumbnail.png",
|
||||
]
|
||||
|
||||
for preview_path in preview_paths:
|
||||
if preview_path in names:
|
||||
image_data = zf.read(preview_path)
|
||||
return Response(content=image_data, media_type="image/png")
|
||||
|
||||
# If no plate image, try any PNG in Metadata
|
||||
for name in names:
|
||||
if name.startswith("Metadata/plate_") and name.endswith(".png") and "_small" not in name:
|
||||
image_data = zf.read(name)
|
||||
return Response(content=image_data, media_type="image/png")
|
||||
|
||||
raise HTTPException(404, "No plate preview found in 3MF file")
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(400, "Invalid 3MF file")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Error extracting plate preview: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_archive(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
Generated
-1
@@ -4678,7 +4678,6 @@
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz",
|
||||
"integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lil-gui": "^0.19.2",
|
||||
"three": "^0.159.0"
|
||||
|
||||
@@ -1512,6 +1512,7 @@ export const api = {
|
||||
getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`,
|
||||
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
|
||||
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
|
||||
getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
|
||||
getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
|
||||
scanArchiveTimelapse: (id: number) =>
|
||||
request<{
|
||||
@@ -1640,6 +1641,7 @@ export const api = {
|
||||
request<{
|
||||
has_model: boolean;
|
||||
has_gcode: boolean;
|
||||
has_source: boolean;
|
||||
build_volume: { x: number; y: number; z: number };
|
||||
filament_colors: string[];
|
||||
}>(`/archives/${id}/capabilities`),
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { WebGLPreview, init } from 'gcode-preview';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { WebGLPreview } from 'gcode-preview';
|
||||
import { Loader2, Layers, ChevronLeft, ChevronRight, FileWarning } from 'lucide-react';
|
||||
|
||||
interface BuildVolume {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface GcodeViewerProps {
|
||||
gcodeUrl: string;
|
||||
buildVolume?: BuildVolume;
|
||||
buildVolume?: { x: number; y: number; z: number };
|
||||
filamentColors?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: GcodeViewerProps) {
|
||||
export function GcodeViewer({
|
||||
gcodeUrl,
|
||||
buildVolume = { x: 256, y: 256, z: 256 },
|
||||
filamentColors,
|
||||
className = ''
|
||||
}: GcodeViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewRef = useRef<WebGLPreview | null>(null);
|
||||
const renderTimeoutRef = useRef<number | null>(null);
|
||||
const initRef = useRef(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notSliced, setNotSliced] = useState(false);
|
||||
const [currentLayer, setCurrentLayer] = useState(0);
|
||||
const [totalLayers, setTotalLayers] = useState(0);
|
||||
|
||||
// Memoize colors to prevent re-renders
|
||||
const colorsKey = useMemo(() => JSON.stringify(filamentColors), [filamentColors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
if (!canvasRef.current || initRef.current) return;
|
||||
initRef.current = true;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
const hasColors = filamentColors && filamentColors.length > 0;
|
||||
const hasMultipleColors = filamentColors && filamentColors.length > 1;
|
||||
// Set canvas size before creating preview
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
}
|
||||
|
||||
// First color or default bambu green
|
||||
const primaryColor = hasColors ? filamentColors[0] : '#00ae42';
|
||||
// Use extrusionColor as array for multi-tool support
|
||||
// Index in array = tool number
|
||||
const hasMultiColor = filamentColors && filamentColors.length > 1;
|
||||
const primaryColor = filamentColors?.[0] || '#00ae42';
|
||||
|
||||
// Initialize the preview
|
||||
// For multi-color: pass array of CSS color strings to extrusionColor
|
||||
// The library uses index to match tool number (T0, T1, T2...)
|
||||
const preview = init({
|
||||
// Create preview
|
||||
const preview = new WebGLPreview({
|
||||
canvas,
|
||||
buildVolume: buildVolume,
|
||||
buildVolume,
|
||||
backgroundColor: 0x1a1a1a,
|
||||
travelColor: 0x444444,
|
||||
// Pass array for multi-color, single value for single color
|
||||
extrusionColor: hasMultipleColors ? filamentColors : primaryColor,
|
||||
// Disable topLayerColor for multi-color (it overrides per-tool colors)
|
||||
...(hasMultipleColors ? {} : { topLayerColor: primaryColor }),
|
||||
// Disable gradient for multi-color to preserve actual filament colors
|
||||
...(hasMultipleColors ? { disableGradient: true } : {}),
|
||||
lastSegmentColor: 0xffffff,
|
||||
// Pass full color array - library uses index as tool number
|
||||
extrusionColor: hasMultiColor ? filamentColors : primaryColor,
|
||||
disableGradient: true,
|
||||
lineHeight: 0.2,
|
||||
lineWidth: 2,
|
||||
renderTravel: false,
|
||||
renderExtrusion: true,
|
||||
@@ -58,11 +62,7 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
|
||||
previewRef.current = preview;
|
||||
|
||||
// Fetch and parse G-code
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNotSliced(false);
|
||||
|
||||
// Fetch and process gcode
|
||||
fetch(gcodeUrl)
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
@@ -78,59 +78,83 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
return response.text();
|
||||
})
|
||||
.then(gcode => {
|
||||
let processedGcode = gcode;
|
||||
|
||||
if (hasMultipleColors) {
|
||||
// Bambu G-code uses special T commands that confuse the parser:
|
||||
// T255, T1000, T1001, T65535, T65279 etc. are not real tool changes
|
||||
// Filter these out and keep only valid tool numbers (T0-T15)
|
||||
processedGcode = gcode
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
|
||||
if (match) {
|
||||
const toolNum = parseInt(match[2], 10);
|
||||
// Keep only valid tool numbers (0-15), comment out others
|
||||
if (toolNum > 15) {
|
||||
return `${match[1]}; FILTERED: T${toolNum}${match[3] || ''}`;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Prepend T0 to ensure initial tool is set
|
||||
processedGcode = `T0\n${processedGcode}`;
|
||||
// The gcode-preview library only supports T0-T7
|
||||
// We need to remap higher tool numbers to fit within this range
|
||||
// First, find all unique tool numbers used
|
||||
const toolNumbers = new Set<number>();
|
||||
const toolRegex = /^(\s*)T(\d+)(\s*;.*)?$/gim;
|
||||
let match;
|
||||
while ((match = toolRegex.exec(gcode)) !== null) {
|
||||
const toolNum = parseInt(match[2], 10);
|
||||
if (toolNum <= 15) { // Valid tool, not a special command
|
||||
toolNumbers.add(toolNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse G-code
|
||||
preview.processGCode(processedGcode);
|
||||
// Create a mapping from original tool numbers to 0-7 range
|
||||
const toolMapping = new Map<number, number>();
|
||||
const sortedTools = Array.from(toolNumbers).sort((a, b) => a - b);
|
||||
sortedTools.forEach((tool, index) => {
|
||||
toolMapping.set(tool, index % 8); // Map to 0-7
|
||||
});
|
||||
|
||||
// Build remapped color array based on the mapping
|
||||
const remappedColors: string[] = [];
|
||||
sortedTools.forEach((originalTool, index) => {
|
||||
const color = filamentColors?.[originalTool] || '#00ae42';
|
||||
remappedColors[index % 8] = color;
|
||||
});
|
||||
|
||||
// Process gcode: filter special commands and remap tool numbers
|
||||
const cleanedGcode = gcode
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
const match = line.match(/^(\s*)T(\d+)(\s*;.*)?$/i);
|
||||
if (match) {
|
||||
const toolNum = parseInt(match[2], 10);
|
||||
if (toolNum > 15) {
|
||||
// Filter out Bambu special commands (T255, T1000, T65535, etc.)
|
||||
return `; FILTERED: ${line.trim()}`;
|
||||
}
|
||||
// Remap tool number to 0-7 range
|
||||
const mappedTool = toolMapping.get(toolNum) ?? 0;
|
||||
return `${match[1]}T${mappedTool}${match[3] || ''}`;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Update colors for the preview using the remapped array
|
||||
if (remappedColors.length > 0) {
|
||||
(preview as unknown as { extrusionColor: string[] }).extrusionColor = remappedColors;
|
||||
}
|
||||
|
||||
preview.processGCode(cleanedGcode);
|
||||
|
||||
// Get layer count
|
||||
const layers = preview.layers?.length || 0;
|
||||
setTotalLayers(layers);
|
||||
setCurrentLayer(layers);
|
||||
|
||||
// Render all layers initially
|
||||
preview.render();
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message);
|
||||
if (err.message !== 'not_sliced') {
|
||||
setError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
const handleResize = () => {
|
||||
if (canvas.parentElement) {
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
preview.resize();
|
||||
if (canvas.parentElement && previewRef.current) {
|
||||
const newRect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = newRect.width;
|
||||
canvas.height = newRect.height;
|
||||
previewRef.current.resize();
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
@@ -138,17 +162,19 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
if (renderTimeoutRef.current) {
|
||||
cancelAnimationFrame(renderTimeoutRef.current);
|
||||
}
|
||||
preview.dispose();
|
||||
if (previewRef.current) {
|
||||
previewRef.current.dispose();
|
||||
previewRef.current = null;
|
||||
}
|
||||
initRef.current = false;
|
||||
};
|
||||
}, [gcodeUrl, buildVolume, filamentColors]);
|
||||
}, [gcodeUrl, colorsKey]); // Use colorsKey instead of filamentColors
|
||||
|
||||
// Debounce render to prevent freezing when dragging slider
|
||||
const handleLayerChange = useCallback((layer: number) => {
|
||||
if (!previewRef.current) return;
|
||||
const newLayer = Math.max(1, Math.min(layer, totalLayers));
|
||||
setCurrentLayer(newLayer);
|
||||
|
||||
// Debounce the actual render to avoid freezing
|
||||
if (renderTimeoutRef.current) {
|
||||
cancelAnimationFrame(renderTimeoutRef.current);
|
||||
}
|
||||
@@ -167,12 +193,8 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
|
||||
return (
|
||||
<div className={`relative flex flex-col h-full ${className}`}>
|
||||
{/* Canvas container */}
|
||||
<div className="flex-1 relative bg-bambu-dark rounded-lg overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<canvas ref={canvasRef} className="w-full h-full" />
|
||||
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
|
||||
@@ -190,7 +212,7 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
<p className="text-white font-medium mb-2">G-code not available</p>
|
||||
<p className="text-bambu-gray text-sm">
|
||||
This file hasn't been sliced yet. G-code preview is only available
|
||||
after slicing the model in Bambu Studio or Orca Slicer.
|
||||
after slicing in Bambu Studio or Orca Slicer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +227,6 @@ export function GcodeViewer({ gcodeUrl, buildVolume = { x: 256, y: 256, z: 256 }
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer controls */}
|
||||
{!loading && !error && !notSliced && totalLayers > 0 && (
|
||||
<div className="mt-4 px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -15,22 +15,26 @@ interface BuildVolume {
|
||||
interface ModelViewerProps {
|
||||
url: string;
|
||||
buildVolume?: BuildVolume;
|
||||
filamentColors?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface MeshData {
|
||||
vertices: number[];
|
||||
triangles: number[];
|
||||
extruder: number; // Per-mesh extruder index for coloring
|
||||
}
|
||||
|
||||
interface ObjectData {
|
||||
id: string;
|
||||
meshes: MeshData[];
|
||||
defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one)
|
||||
}
|
||||
|
||||
interface BuildItem {
|
||||
objectId: string;
|
||||
transform: THREE.Matrix4;
|
||||
extruder?: number; // Can override object's extruder
|
||||
}
|
||||
|
||||
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
|
||||
@@ -61,7 +65,7 @@ function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
|
||||
// Alias for backwards compatibility
|
||||
const parseTransform = parseTransform3MF;
|
||||
|
||||
async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
|
||||
async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise<MeshData[]> {
|
||||
const meshes: MeshData[] = [];
|
||||
const meshElements = doc.getElementsByTagName('mesh');
|
||||
|
||||
@@ -91,7 +95,7 @@ async function parseMeshFromDoc(doc: Document): Promise<MeshData[]> {
|
||||
}
|
||||
|
||||
if (vertices.length > 0 && triangles.length > 0) {
|
||||
meshes.push({ vertices, triangles });
|
||||
meshes.push({ vertices, triangles, extruder: defaultExtruder });
|
||||
}
|
||||
}
|
||||
return meshes;
|
||||
@@ -113,6 +117,56 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
return parser.parseFromString(content, 'application/xml');
|
||||
}
|
||||
|
||||
// Parse model_settings.config to get extruder assignments
|
||||
// Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder
|
||||
const extruderMapById = new Map<string, number>();
|
||||
const partExtruderMap = new Map<string, number>(); // Key: "objectId:partId"
|
||||
const modelSettingsFile = zip.files['Metadata/model_settings.config'];
|
||||
if (modelSettingsFile) {
|
||||
try {
|
||||
const content = await modelSettingsFile.async('string');
|
||||
const doc = parser.parseFromString(content, 'application/xml');
|
||||
const objectElements = doc.getElementsByTagName('object');
|
||||
for (let i = 0; i < objectElements.length; i++) {
|
||||
const objEl = objectElements[i];
|
||||
const objectId = objEl.getAttribute('id');
|
||||
if (!objectId) continue;
|
||||
|
||||
// Find object-level extruder
|
||||
const directMetadata = Array.from(objEl.children).filter(
|
||||
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
|
||||
);
|
||||
if (directMetadata.length > 0) {
|
||||
const extruderVal = directMetadata[0].getAttribute('value');
|
||||
if (extruderVal) {
|
||||
extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Find part-level extruders
|
||||
const partElements = objEl.getElementsByTagName('part');
|
||||
for (let j = 0; j < partElements.length; j++) {
|
||||
const partEl = partElements[j];
|
||||
const partId = partEl.getAttribute('id');
|
||||
if (!partId) continue;
|
||||
|
||||
// Look for extruder in part's direct children
|
||||
const partMetadata = Array.from(partEl.children).filter(
|
||||
(el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder'
|
||||
);
|
||||
if (partMetadata.length > 0) {
|
||||
const extruderVal = partMetadata[0].getAttribute('value');
|
||||
if (extruderVal) {
|
||||
partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore model_settings.config parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Find the main 3D model file
|
||||
const mainModelPath = Object.keys(zip.files).find(
|
||||
(name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model')
|
||||
@@ -124,9 +178,9 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
if (anyModelPath) {
|
||||
const doc = await loadModelFile(anyModelPath);
|
||||
if (doc) {
|
||||
const meshes = await parseMeshFromDoc(doc);
|
||||
const meshes = await parseMeshFromDoc(doc, 0);
|
||||
if (meshes.length > 0) {
|
||||
objects.set('1', { id: '1', meshes });
|
||||
objects.set('1', { id: '1', meshes, defaultExtruder: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +197,13 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
const objectId = objEl.getAttribute('id');
|
||||
if (!objectId) continue;
|
||||
|
||||
// Get default extruder from model_settings.config map, falling back to attribute or default
|
||||
let defaultExtruder = extruderMapById.get(objectId) ?? -1;
|
||||
if (defaultExtruder < 0) {
|
||||
const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1';
|
||||
defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
|
||||
}
|
||||
|
||||
const meshes: MeshData[] = [];
|
||||
|
||||
// Check for direct mesh in this object
|
||||
@@ -173,7 +234,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
|
||||
if (vertices.length > 0 && triangles.length > 0) {
|
||||
meshes.push({ vertices, triangles });
|
||||
meshes.push({ vertices, triangles, extruder: defaultExtruder });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +244,17 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
const compEl = componentElements[j];
|
||||
// p:path attribute contains the external file reference
|
||||
const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
|
||||
// objectid in component corresponds to part id in model_settings
|
||||
const compObjectId = compEl.getAttribute('objectid');
|
||||
|
||||
if (extPath) {
|
||||
const extDoc = await loadModelFile(extPath);
|
||||
if (extDoc) {
|
||||
const extMeshes = await parseMeshFromDoc(extDoc);
|
||||
// Look up per-part extruder, falling back to object's default
|
||||
const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
|
||||
const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder;
|
||||
|
||||
const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
|
||||
|
||||
// Apply component transform if present
|
||||
const compTransformStr = compEl.getAttribute('transform');
|
||||
@@ -202,7 +269,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
v.applyMatrix4(compTransform);
|
||||
transformedVertices.push(v.x, v.y, v.z);
|
||||
}
|
||||
meshes.push({ vertices: transformedVertices, triangles: mesh.triangles });
|
||||
meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });
|
||||
} else {
|
||||
meshes.push(mesh);
|
||||
}
|
||||
@@ -212,7 +279,7 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<{ objects: Map<string
|
||||
}
|
||||
|
||||
if (meshes.length > 0) {
|
||||
objects.set(objectId, { id: objectId, meshes });
|
||||
objects.set(objectId, { id: objectId, meshes, defaultExtruder });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +321,7 @@ function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, className = '' }: ModelViewerProps) {
|
||||
export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, className = '' }: ModelViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
@@ -345,14 +412,22 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
|
||||
throw new Error('No meshes found in 3MF file');
|
||||
}
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
color: 0x00ae42,
|
||||
shininess: 30,
|
||||
flatShading: false,
|
||||
});
|
||||
// Create materials for each extruder color
|
||||
const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
|
||||
const defaultColor = '#00ae42';
|
||||
const colorStr = filamentColors?.[extruder] || defaultColor;
|
||||
// Convert hex color string to THREE.js color
|
||||
const color = new THREE.Color(colorStr);
|
||||
return new THREE.MeshPhongMaterial({
|
||||
color,
|
||||
shininess: 30,
|
||||
flatShading: false,
|
||||
});
|
||||
};
|
||||
|
||||
const group = new THREE.Group();
|
||||
const allGeometries: THREE.BufferGeometry[] = [];
|
||||
// Group geometries by extruder index (using per-mesh extruder)
|
||||
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
|
||||
|
||||
// If we have build items, use them for positioning
|
||||
if (buildItems.length > 0) {
|
||||
@@ -361,6 +436,9 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
|
||||
if (!objectData) continue;
|
||||
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use mesh's extruder, or item override, or object default
|
||||
const extruder = item.extruder ?? meshData.extruder;
|
||||
|
||||
// Apply build transform to vertices in 3MF space BEFORE coordinate conversion
|
||||
const transformedVertices: number[] = [];
|
||||
for (let k = 0; k < meshData.vertices.length; k += 3) {
|
||||
@@ -376,34 +454,47 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
|
||||
const geometry = createGeometryFromMesh({
|
||||
vertices: transformedVertices,
|
||||
triangles: meshData.triangles,
|
||||
extruder: extruder,
|
||||
});
|
||||
allGeometries.push(geometry);
|
||||
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: just add all objects without transforms
|
||||
for (const objectData of objects.values()) {
|
||||
for (const meshData of objectData.meshes) {
|
||||
// Use per-mesh extruder
|
||||
const extruder = meshData.extruder;
|
||||
const geometry = createGeometryFromMesh(meshData);
|
||||
allGeometries.push(geometry);
|
||||
if (!geometriesByExtruder.has(extruder)) {
|
||||
geometriesByExtruder.set(extruder, []);
|
||||
}
|
||||
geometriesByExtruder.get(extruder)!.push(geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all geometries into one for better performance
|
||||
if (allGeometries.length > 0) {
|
||||
const mergedGeometry = allGeometries.length === 1
|
||||
? allGeometries[0]
|
||||
: mergeGeometries(allGeometries, false);
|
||||
// Create meshes for each extruder group
|
||||
for (const [extruder, geometries] of geometriesByExtruder) {
|
||||
if (geometries.length === 0) continue;
|
||||
|
||||
const mergedGeometry = geometries.length === 1
|
||||
? geometries[0]
|
||||
: mergeGeometries(geometries, false);
|
||||
|
||||
if (mergedGeometry) {
|
||||
const material = getMaterial(extruder);
|
||||
const mesh = new THREE.Mesh(mergedGeometry, material);
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
// Dispose individual geometries if merged
|
||||
if (allGeometries.length > 1) {
|
||||
for (const geom of allGeometries) {
|
||||
if (geometries.length > 1) {
|
||||
for (const geom of geometries) {
|
||||
geom.dispose();
|
||||
}
|
||||
}
|
||||
@@ -465,7 +556,7 @@ export function ModelViewer({ url, buildVolume = { x: 256, y: 256, z: 256 }, cla
|
||||
renderer.dispose();
|
||||
container.removeChild(renderer.domElement);
|
||||
};
|
||||
}, [url, buildVolume]);
|
||||
}, [url, buildVolume, filamentColors]);
|
||||
|
||||
const resetView = () => {
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ModelViewerModalProps {
|
||||
interface Capabilities {
|
||||
has_model: boolean;
|
||||
has_gcode: boolean;
|
||||
has_source: boolean;
|
||||
build_volume: { x: number; y: number; z: number };
|
||||
filament_colors: string[];
|
||||
}
|
||||
@@ -49,7 +50,7 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to 3D model tab if capabilities check fails
|
||||
setCapabilities({ has_model: true, has_gcode: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
|
||||
setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
|
||||
setActiveTab('3d');
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -129,14 +130,16 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
|
||||
</div>
|
||||
) : activeTab === '3d' && capabilities ? (
|
||||
<ModelViewer
|
||||
url={api.getArchiveDownload(archiveId)}
|
||||
url={capabilities.has_source
|
||||
? api.getSource3mfDownloadUrl(archiveId)
|
||||
: api.getArchiveDownload(archiveId)}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : activeTab === 'gcode' && capabilities ? (
|
||||
<GcodeViewer
|
||||
gcodeUrl={api.getArchiveGcode(archiveId)}
|
||||
buildVolume={capabilities.build_volume}
|
||||
filamentColors={capabilities.filament_colors}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DZu3XO3T.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-KNsnljef.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-j3n1gAEX.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user