Skip to content
Draft
244 changes: 106 additions & 138 deletions carveracontroller/GcodeViewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def get_elapsed(str):
# input
from kivy.input.provider import MotionEventProvider

from .addons.tool_visualization.mesh_builder import build_tool_meshes
from .arcball_from_cpp import *
from .Objloader import ObjFile
from .ui.ViewCube import (
Expand Down Expand Up @@ -326,7 +327,6 @@ def _gcode_viewer_asset(name):

VIEW_CUBE_ATLAS_PATH = _gcode_viewer_asset("view_cube_atlas.png")
VIEW_CUBE_MODEL_PATH = _gcode_viewer_asset("view_cube_model.obj")
POINTER_OBJ_PATH = _gcode_viewer_asset("pointer.obj")
AXIS_OBJ_PATH = _gcode_viewer_asset("axis.obj")


Expand All @@ -345,6 +345,8 @@ def __init__(self):
self.raw_linenumbers = []
# feed rate (mm/min) per vertex, from CNC parser
self.raw_feed_rates = []
# active tool number per vertex, used to pick the tool mesh to display
self.raw_tools = []
# angles of vertices [4 axis]
self.angles_of_vertices = []

Expand Down Expand Up @@ -378,6 +380,7 @@ def clear(self):
# raw numbers
self.raw_linenumbers.clear()
self.raw_feed_rates.clear()
self.raw_tools.clear()
# angles of vertices [4 axis]
self.angles_of_vertices.clear()
# mesh container
Expand All @@ -397,13 +400,6 @@ def clear(self):
def get_pt_count(self):
return len(self.positions)

def map_color(self, color_str):
if color_str == "Green":
return [0.0, 1.0, 0.0]
if color_str == "Red":
return [1.0, 0.0, 0.0]
return [1.0, 1.0, 1.0]

# get center of meshes
def get_center(self):
if self.area_center_sum_index == 0:
Expand All @@ -418,109 +414,6 @@ def get_vertex_position(self, idx):
base = idx * VERTEX_FLOAT_NUM
return [self.vertices[base], self.vertices[base + 1], self.vertices[base + 2]]

# parse single line
def parse_line(self, line):
arr_pt = line.split(" ")

# position (raw G-code coordinates)
raw_pos = [float(arr_pt[1]), float(arr_pt[3]), float(arr_pt[5])]

# Store raw positions before rotation
self.raw_positions.append(raw_pos[0])
self.raw_positions.append(raw_pos[1])
self.raw_positions.append(raw_pos[2])

pos = raw_pos
if self.is_4_axis:
angle = float(arr_pt[7])
pos = rotate_pt_by_x_axis_angle(pos[0], pos[1], pos[2], angle)

self.positions.append(pos[0])
self.positions.append(pos[1])
self.positions.append(pos[2])
self.min_pt = vec3_min(self.min_pt, pos)
self.max_pt = vec3_max(self.max_pt, pos)

# for center calculating
self.area_center_sum = vec3_add(self.area_center_sum, pos)
self.area_center_sum_index += 1

# get attributes of this point
vertex = [0] * VERTEX_FLOAT_NUM
if self.is_4_axis:
# 1 position
vertex[0] = pos[0]
vertex[1] = pos[1]
vertex[2] = pos[2]

# angle
angle = float(arr_pt[7])

# 2 color
color = self.map_color(arr_pt[9])
vertex[3] = color[0]
vertex[4] = color[1]
vertex[5] = color[2]

# 3 line number in gcode
vertex[6] = float(arr_pt[11])

# 4 type id
vertex[7] = len(self.positions) - 1

# 5 distance attribute
vertex[8] = 0 # set after length is calculated

# 6 set tool knife id
vertex[9] = float(arr_pt[13])

# 7 feed rate (mm/min)
is_rapid = arr_pt[9] == "Red"
feed = feed_mm_min_for_move(is_rapid)
vertex[10] = feed

# push this vertex to container
self.vertices.extend(vertex)
self.vertex_types.append(1.0 if arr_pt[9] == "Green" else 2.0) # line type[red | green]
self.raw_linenumbers.append(vertex[6])
self.raw_feed_rates.append(feed)
self.angles_of_vertices.append(angle)
else:
# 1 position
vertex[0] = pos[0]
vertex[1] = pos[1]
vertex[2] = pos[2]

# 2 color
color = self.map_color(arr_pt[7])
vertex[3] = color[0]
vertex[4] = color[1]
vertex[5] = color[2]

# 3 line number in gcode
vertex[6] = float(arr_pt[9])

# 4 type id
vertex[7] = len(self.positions) - 1

# 5 distance attribute
vertex[8] = 0 # set after length is calculated

# 6 set tool knife id
vertex[9] = float(arr_pt[11])

# 7 feed rate (mm/min)
is_rapid = arr_pt[7] == "Red"
feed = feed_mm_min_for_move(is_rapid)
vertex[10] = feed

# push this vertex to container
self.vertices.extend(vertex)

self.vertex_types.append(1.0 if arr_pt[7] == "Green" else 2.0) # line type[red | green]
self.raw_linenumbers.append(vertex[6])
self.raw_feed_rates.append(feed)

def parse_line_data(self, linedata):
# position (raw G-code coordinates)
raw_pos = [linedata[0], linedata[1], linedata[2]]
Expand Down Expand Up @@ -578,6 +471,7 @@ def parse_line_data(self, linedata):
self.vertex_types.append(1.0 if linedata[4] > 0.5 else 2.0) # line type[red | green]
self.raw_linenumbers.append(vertex[6])
self.raw_feed_rates.append(feed)
self.raw_tools.append(vertex[9])
self.angles_of_vertices.append(angle)

def generate_meshes(self):
Expand Down Expand Up @@ -639,25 +533,6 @@ def generate_meshes(self):
mesh_start_id = mesh_end_id - 1
mesh_end_id = min(mesh_start_id + self.seg_mesh_vertex_count, vertex_count)

def add_lines(self, rawlines):
# parse line

# 1 check gcode type
is_4_axis = False
if len(rawlines) > 0 and "A:" in rawlines[0]:
is_4_axis = True

if self.is_4_axis is None:
self.is_4_axis = is_4_axis
elif self.is_4_axis != is_4_axis:
print("conflict line type!")

# 2 parse single line
for line in rawlines:
self.parse_line(line.strip())

self.generate_meshes()

def add_data_arrs(self, rawdata, is_end=True):
# parse line

Expand Down Expand Up @@ -710,7 +585,12 @@ class GCodeViewer(Widget):
raw_linenumbers = []
raw_positions = []
raw_feed_rates = []
raw_tools = []
frame_callback = None

# Tool number -> ToolDefinition extracted from the loaded file's CAM comments.
# Set from outside (see main.py) before/at the final load_array() call.
tool_table = {}
time_estimate_progress_callback = None
log_callback = None
error_popup_callback = None
Expand Down Expand Up @@ -791,6 +671,14 @@ def __init__(self):
self.meshmanager = MeshManager()
self.positions = []

# Per-tool generated meshes (tool number -> (vertices, indices, vertex_format)),
# rebuilt whenever a new file finishes loading. `pointer_mesh_instrs` holds the
# back-face then front-face Mesh pair whose geometry is swapped on tool change.
self._tool_meshes = {}
self._default_tool_mesh = None
self.pointer_mesh_instrs = []
self._active_tool_number = None

# Dirty flags: set True whenever the scene must be re-rendered.
# _scene_dirty covers view/pointer/axis uniform changes; _proj_dirty
# covers the projection matrix (zoom, pan, resize).
Expand Down Expand Up @@ -1000,6 +888,11 @@ def clearDisplay(self):
self.line_times = []
self.total_time = 0.0
self.raw_feed_rates = []
self.raw_tools = []
self._tool_meshes = {}
self._default_tool_mesh = None
self.pointer_mesh_instrs = []
self._active_tool_number = None
self.linemesh.clear()
self.canvas.remove(self.linemesh)
self.canvas.remove(self.gridmesh)
Expand Down Expand Up @@ -1031,6 +924,60 @@ def clear_loaded_memery(self):

self.meshmanager.clear()

def _tool_number_at_index(self, vertex_idx):
"""Return the active tool number (int) at a given vertex index, or None."""
if not self.raw_tools:
return None
vertex_idx = max(0, min(vertex_idx, len(self.raw_tools) - 1))
return int(self.raw_tools[vertex_idx])

def _get_tool_mesh(self, tool_number):
"""Return the (vertices, indices, vertex_format) mesh for a tool number.

Falls back to the default (basic pointed) mesh when the tool number
is unknown or has no metadata in the loaded file.
"""
if tool_number is not None and tool_number in self._tool_meshes:
return self._tool_meshes[tool_number]
return self._default_tool_mesh

def _log_tool_mesh_summary(self):
"""Log which tools used in the loaded file have real geometry vs. a fallback mesh."""
used_tool_numbers = sorted({int(t) for t in self.raw_tools}) if self.raw_tools else []
known = [t for t in used_tool_numbers if t in self._tool_meshes]
unknown = [t for t in used_tool_numbers if t not in self._tool_meshes]
logger.info(
f"Tool meshes ready: {len(known)}/{len(used_tool_numbers)} used tools have geometry "
f"from the tool table ({known}); using default (pointed) mesh for {unknown}"
)

def _setup_pointer_gl_back(self, *args):
"""Draw back faces first so translucent tool surfaces sort correctly."""
glEnable(GL_CULL_FACE)
glCullFace(GL_FRONT)

def _setup_pointer_gl_front(self, *args):
"""Draw front faces second, blending over the back faces."""
glCullFace(GL_BACK)

def _reset_pointer_gl(self, *args):
glDisable(GL_CULL_FACE)
glCullFace(GL_BACK)

def _update_pointer_tool_mesh(self, vertex_idx):
"""Swap the pointer mesh geometry if the active tool changed."""
if not self.pointer_mesh_instrs:
return
tool_number = self._tool_number_at_index(vertex_idx)
if tool_number == self._active_tool_number:
return
vertices, indices, _fmt = self._get_tool_mesh(tool_number)
for mesh in self.pointer_mesh_instrs:
mesh.vertices = vertices
mesh.indices = indices
self._active_tool_number = tool_number
self._scene_dirty = True

def load_array(self, tmpdataarrs, is_end=True):
self.clear_loaded_memery()

Expand Down Expand Up @@ -1077,6 +1024,7 @@ def load_array(self, tmpdataarrs, is_end=True):
self.raw_positions = self.meshmanager.raw_positions
self.raw_linenumbers = self.meshmanager.raw_linenumbers
self.raw_feed_rates = self.meshmanager.raw_feed_rates
self.raw_tools = self.meshmanager.raw_tools
self.angles_of_vertices = self.meshmanager.angles_of_vertices

self.total_line_count = self.meshmanager.get_pt_count()
Expand All @@ -1091,9 +1039,16 @@ def load_array(self, tmpdataarrs, is_end=True):

self._update_feed_range_uniforms()

self.pointer = ObjFile(POINTER_OBJ_PATH)
self.axis_obj = ObjFile(AXIS_OBJ_PATH)

# Build a basic 3D mesh per known tool (from CAM comments), plus a
# default (basic pointed) mesh used for tools with no metadata.
self._tool_meshes, self._default_tool_mesh = build_tool_meshes(
self.tool_table or {}, scale=self.move_scale_by_positon
)
self._active_tool_number = self._tool_number_at_index(0)
self._log_tool_mesh_summary()

# 4-axis: rotate toolhead mesh instead of the toolpath
self.rotate_line_or_knife = False
if self.is_4_axis:
Expand All @@ -1108,15 +1063,26 @@ def load_array(self, tmpdataarrs, is_end=True):
self.cb = Callback(None)

with self.pointermesh:
self.cb = Callback(None)
m = list(self.pointer.objects.values())[0]
self.mesh = Mesh(
vertices=m.vertices,
indices=m.indices,
fmt=m.vertex_format,
# Two-pass translucent draw: back faces, then front faces.
# A single pass fights the depth buffer and makes one side of the
# tool look hollow depending on camera orientation.
verts, idxs, fmt = self._get_tool_mesh(self._active_tool_number)
Callback(self._setup_pointer_gl_back)
back_mesh = Mesh(
vertices=verts,
indices=idxs,
fmt=fmt,
mode="triangles",
)
self.cb = Callback(None)
Callback(self._setup_pointer_gl_front)
front_mesh = Mesh(
vertices=verts,
indices=idxs,
fmt=fmt,
mode="triangles",
)
Callback(self._reset_pointer_gl)
self.pointer_mesh_instrs = [back_mesh, front_mesh]

# axis
with self.axisxmesh:
Expand Down Expand Up @@ -1562,6 +1528,8 @@ def _on_frame_tick(self, _):

self.cur_line_index = line_index_withratio

self._update_pointer_tool_mesh(int(line_index_withratio))

# Per-frame callback during toolpath playback only
if self.frame_callback is not None and self.dynamic_display:
[cur_distance, linenumber] = self.get_cur_pos_index()
Expand Down
21 changes: 21 additions & 0 deletions carveracontroller/addons/tool_visualization/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Tool metadata extraction and 3D mesh generation for the G-code viewer.

This package parses CAM-generated comments in loaded G-code files to build a
per-tool description (type + geometry), then generates a basic 3D mesh for
each tool so the viewer can display a shape that roughly matches the tool
that is currently active instead of a single generic cylinder.
"""

from carveracontroller.addons.tool_visualization.extractor import extract_tool_table
from carveracontroller.addons.tool_visualization.icon_builder import get_tool_icon_path, get_tool_tooltip_icon_path
from carveracontroller.addons.tool_visualization.tool_definition import ToolDefinition, ToolType
from carveracontroller.addons.tool_visualization.tooltip_builder import format_tool_tooltip

__all__ = [
"extract_tool_table",
"format_tool_tooltip",
"get_tool_icon_path",
"get_tool_tooltip_icon_path",
"ToolDefinition",
"ToolType",
]
Loading
Loading