diff --git a/carveracontroller/GcodeViewer.py b/carveracontroller/GcodeViewer.py index 79a3ba96..0dc5f079 100644 --- a/carveracontroller/GcodeViewer.py +++ b/carveracontroller/GcodeViewer.py @@ -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 ( @@ -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") @@ -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 = [] @@ -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 @@ -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: @@ -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]] @@ -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): @@ -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 @@ -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 @@ -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). @@ -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) @@ -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() @@ -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() @@ -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: @@ -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: @@ -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() diff --git a/carveracontroller/addons/tool_visualization/__init__.py b/carveracontroller/addons/tool_visualization/__init__.py new file mode 100644 index 00000000..f1c33c03 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/__init__.py @@ -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", +] diff --git a/carveracontroller/addons/tool_visualization/extractor.py b/carveracontroller/addons/tool_visualization/extractor.py new file mode 100644 index 00000000..c954d84c --- /dev/null +++ b/carveracontroller/addons/tool_visualization/extractor.py @@ -0,0 +1,42 @@ +"""Registry of CAM tool table parsers and the extraction entry point.""" + +import logging + +from carveracontroller.addons.tool_visualization.parsers.fusion360_makera import Fusion360MakeraParser +from carveracontroller.addons.tool_visualization.parsers.makera_studio import MakeraStudioParser + +logger = logging.getLogger(__name__) + +# Ordered list of parsers to try when extracting a tool table from a loaded +# G-code file. Add new CAM/post-processor parsers here. +TOOL_TABLE_PARSERS = [ + MakeraStudioParser(), + Fusion360MakeraParser(), +] + + +def extract_tool_table(lines): + """Try each registered parser and return the first non-empty tool table. + + `lines` is passed as-is to each parser; well-behaved parsers bail out + early (e.g. via `ToolTableParser.iter_header_lines`) instead of scanning + the whole file, so this stays cheap even for very large files. + + Returns a dict mapping tool number (int) -> ToolDefinition. If no parser + recognises the file (or the file has no tool table at all), an empty + dict is returned; callers should then fall back to default tool + geometry for every tool. + """ + for parser in TOOL_TABLE_PARSERS: + try: + tool_table = parser.parse(lines) + except Exception: + logger.exception(f"Tool table parser '{parser.name}' raised an exception, skipping it") + tool_table = {} + if tool_table: + tool_numbers = ", ".join(f"T{number}" for number in sorted(tool_table)) + logger.info(f"Tool table extracted using parser '{parser.name}': {tool_numbers}") + return tool_table + + logger.debug("No tool table detected in loaded file (no parser recognised it); using default tool geometry") + return {} diff --git a/carveracontroller/addons/tool_visualization/icon_builder.py b/carveracontroller/addons/tool_visualization/icon_builder.py new file mode 100644 index 00000000..c8914455 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/icon_builder.py @@ -0,0 +1,49 @@ +"""Map tool definitions to toolbar / tooltip icon paths for the G-code viewer.""" + +from carveracontroller.addons.tool_visualization.tool_definition import ToolType + +TOOLS_ICON_DIR = "data/GcodeViewer/tools" +DEFAULT_ICON = f"{TOOLS_ICON_DIR}/pointed_mill.png" +DEFAULT_THUMB_ICON = f"{TOOLS_ICON_DIR}/pointed_mill_thumb.png" + +# Full-body icons (used in tooltips) +TOOL_TYPE_ICONS = { + ToolType.FLAT_END_MILL: f"{TOOLS_ICON_DIR}/flat_end_mill.png", + ToolType.BALL_END_MILL: f"{TOOLS_ICON_DIR}/ball_end_mill.png", + ToolType.BULL_NOSE_END_MILL: f"{TOOLS_ICON_DIR}/bull_nose_end_mill.png", + ToolType.RADIUS_MILL: f"{TOOLS_ICON_DIR}/radius_mill.png", + ToolType.CHAMFER_MILL: DEFAULT_ICON, + ToolType.ENGRAVING: DEFAULT_ICON, + ToolType.TAPERED_MILL: f"{TOOLS_ICON_DIR}/tapered_mill.png", + ToolType.LOLLIPOP_MILL: f"{TOOLS_ICON_DIR}/lollipop_mill.png", + ToolType.THREAD_MILL: f"{TOOLS_ICON_DIR}/thread_mill.png", + ToolType.UNKNOWN: DEFAULT_ICON, +} + +# Zoomed thumbnails (used on the T1–T6 buttons) +TOOL_TYPE_THUMB_ICONS = { + ToolType.FLAT_END_MILL: f"{TOOLS_ICON_DIR}/flat_end_mill_thumb.png", + ToolType.BALL_END_MILL: f"{TOOLS_ICON_DIR}/ball_end_mill_thumb.png", + ToolType.BULL_NOSE_END_MILL: f"{TOOLS_ICON_DIR}/bull_nose_end_mill_thumb.png", + ToolType.RADIUS_MILL: f"{TOOLS_ICON_DIR}/radius_mill_thumb.png", + ToolType.CHAMFER_MILL: DEFAULT_THUMB_ICON, + ToolType.ENGRAVING: DEFAULT_THUMB_ICON, + ToolType.TAPERED_MILL: f"{TOOLS_ICON_DIR}/tapered_mill_thumb.png", + ToolType.LOLLIPOP_MILL: f"{TOOLS_ICON_DIR}/lollipop_mill_thumb.png", + ToolType.THREAD_MILL: f"{TOOLS_ICON_DIR}/thread_mill_thumb.png", + ToolType.UNKNOWN: DEFAULT_THUMB_ICON, +} + + +def get_tool_icon_path(tool_def): + """Return the toolbar thumb icon path for a tool definition.""" + if tool_def is None: + return DEFAULT_THUMB_ICON + return TOOL_TYPE_THUMB_ICONS.get(tool_def.tool_type, DEFAULT_THUMB_ICON) + + +def get_tool_tooltip_icon_path(tool_def): + """Return the full-body tooltip icon path for a tool definition.""" + if tool_def is None: + return DEFAULT_ICON + return TOOL_TYPE_ICONS.get(tool_def.tool_type, DEFAULT_ICON) diff --git a/carveracontroller/addons/tool_visualization/mesh_builder.py b/carveracontroller/addons/tool_visualization/mesh_builder.py new file mode 100644 index 00000000..2f2bb9c1 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/mesh_builder.py @@ -0,0 +1,844 @@ +"""Procedural generation of very basic 3D tool meshes. +Each tool is approximated by revolving a simple 2D profile. +""" + +import math + +from carveracontroller.addons.tool_visualization.tool_definition import ToolType + +FLUTE_COLOR = (0.85, 0.65, 0.15, 0.45) +SHANK_COLOR = (0.5, 0.5, 0.52, 0.3) + +VERTEX_FORMAT = [ + (b"v_pos", 3, "float"), + (b"v_normal", 3, "float"), + (b"v_color", 4, "float"), + (b"v_tc0", 2, "float"), +] + +FLOATS_PER_VERTEX = 12 +RADIAL_SEGMENTS = 20 +ROUND_SEGMENTS = 10 +DEFAULT_TAPER_ANGLE_DEG = 30.0 + +# Factor used to compute overall stick-out when length metadata is missing. +LENGTH_DIAMETER_FACTOR = 6.0 + +# When flute length is missing, inferred/fallback flute height is capped to this +# multiple of diameter so long stick-outs are not painted entirely as cutting. +FALLBACK_FLUTE_DIAMETER_FACTOR = 4.0 + +# Flute -> shank blend: axial rise per unit radial change (~45°), capped as a +# fraction of the available shank length so some cylinder remains above. +SHANK_TRANSITION_SLOPE = 1.0 +SHANK_TRANSITION_MAX_FRACTION = 0.5 + +# When flute is tip-inferred and shoulder metadata is missing, extend +# a short cutting-diameter body before the shank so the tip does not +# jump straight into the handle. Capped to half the remaining stick-out. +INFERRED_SHOULDER_DIAMETER_FACTOR = 1.0 +INFERRED_SHOULDER_MAX_FRACTION = 0.5 + +# Minimum radius and length for tools to be visible on screen. +MIN_VISIBLE_RADIUS = 0.02 +MIN_SHANK_LENGTH = 0.05 + +# Fixed on-screen size for tools with no CAM metadata (scaled viewer coordinates). +FALLBACK_TOOL_DISPLAY_RADIUS = 0.04 +FALLBACK_TOOL_DISPLAY_HEIGHT = 1.3 + + +def _segment_tangent(profile, start, end): + z0, r0 = profile[start] + z1, r1 = profile[end] + return (z1 - z0, r1 - r0) + + +def _ring_tangent(profile, index): + """Tangent in the (z, radius) plane used for normals at a profile ring.""" + z, r = profile[index] + if r <= 1e-9: + if index + 1 < len(profile): + return _segment_tangent(profile, index, index + 1) + return _segment_tangent(profile, index - 1, index) + if index > 0 and profile[index - 1][1] <= 1e-9: + return _segment_tangent(profile, index - 1, index) + if index + 1 < len(profile) and profile[index + 1][1] <= 1e-9: + return _segment_tangent(profile, index, index + 1) + if index == 0: + return _segment_tangent(profile, index, index + 1) + if index == len(profile) - 1: + return _segment_tangent(profile, index - 1, index) + z_prev, r_prev = profile[index - 1] + z_next, r_next = profile[index + 1] + return (z_next - z_prev, r_next - r_prev) + + +def _surface_normal(dz, dr, theta): + """Outward normal for a surface of revolution at angle theta. + + Derived from cross(circumferential_tangent, profile_tangent) for a profile + segment with delta (dz, dr) in the (z, radius) plane. + """ + nx = dz * math.cos(theta) + ny = dz * math.sin(theta) + nz = -dr + length = math.sqrt(nx * nx + ny * ny + nz * nz) + if length < 1e-12: + return (0.0, 0.0, 1.0) + return (nx / length, ny / length, nz / length) + + +def _profile_color(index, shank_start_index): + return SHANK_COLOR if index >= shank_start_index else FLUTE_COLOR + + +def _add_vertex(positions, normals, colors, x, y, z, nx, ny, nz, color): + positions.extend([x, y, z]) + normals.extend([nx, ny, nz]) + colors.extend(color) + return (len(positions) // 3) - 1 + + +def _build_side_ring(positions, normals, colors, z, radius, dz, dr, segments, color): + """Add one ring of side-wall vertices with smooth analytical normals.""" + ring_indices = [] + for j in range(segments): + theta = 2.0 * math.pi * j / segments + cos_t = math.cos(theta) + sin_t = math.sin(theta) + nx, ny, nz = _surface_normal(dz, dr, theta) + idx = _add_vertex(positions, normals, colors, radius * cos_t, radius * sin_t, z, nx, ny, nz, color) + ring_indices.append(idx) + return ring_indices + + +def _build_cap_ring(positions, normals, colors, z, radius, nz_sign, segments, color): + """Add a ring of cap vertices with a flat face normal (±Z).""" + ring_indices = [] + for j in range(segments): + theta = 2.0 * math.pi * j / segments + cos_t = math.cos(theta) + sin_t = math.sin(theta) + idx = _add_vertex(positions, normals, colors, radius * cos_t, radius * sin_t, z, 0.0, 0.0, nz_sign, color) + ring_indices.append(idx) + return ring_indices + + +def _pack_vertices(positions, normals, colors): + vertices = [] + for i in range(len(positions) // 3): + p = 3 * i + c = 4 * i + vertices.extend( + [ + positions[p], + positions[p + 1], + positions[p + 2], + normals[p], + normals[p + 1], + normals[p + 2], + colors[c], + colors[c + 1], + colors[c + 2], + colors[c + 3], + 0.0, + 0.0, + ] + ) + return vertices + + +def _coincident_profile_points(profile, start, end): + z0, r0 = profile[start] + z1, r1 = profile[end] + return abs(z0 - z1) <= 1e-9 and abs(r0 - r1) <= 1e-9 + + +def _build_revolve_mesh(profile, segments=RADIAL_SEGMENTS, shank_start_index=None): + """Revolve a `(z, radius)` profile (sorted by increasing z) into a triangle mesh. + + A profile point with `radius <= 0` is treated as a single point on the + axis (e.g. a pointed tip, or the pole of a ball nose) rather than a ring. + + Vertices are indexed and share smooth analytical normals derived from the + profile tangent. Flat end caps use separate vertices with ±Z normals. + + Profile points at index >= `shank_start_index` are colored as SHANK_COLOR; + earlier points keep the flute FLUTE_COLOR. When omitted, the whole tool is color + using FLUTE_COLOR. + """ + if len(profile) < 2: + raise ValueError("A tool profile needs at least two points") + + if shank_start_index is None: + shank_start_index = len(profile) + + positions = [] + normals = [] + colors = [] + indices = [] + + side_rings = [] + for i, (z, r) in enumerate(profile): + if r <= 1e-9: + side_rings.append(None) + else: + dz, dr = _ring_tangent(profile, i) + color = _profile_color(i, shank_start_index) + side_rings.append(_build_side_ring(positions, normals, colors, z, r, dz, dr, segments, color)) + + # Side walls, connecting each consecutive pair of profile points. + for i in range(len(profile) - 1): + # Duplicate rings at the flute/shank boundary create a hard color edge + # without emitting a degenerate zero-area quad. + if _coincident_profile_points(profile, i, i + 1): + continue + ring_a = side_rings[i] + ring_b = side_rings[i + 1] + if ring_a is None and ring_b is None: + continue + if ring_a is None: + z_apex = profile[i][0] + dz, dr = _segment_tangent(profile, i, i + 1) + nx, ny, nz = _surface_normal(dz, dr, 0.0) + color = _profile_color(i, shank_start_index) + apex_idx = _add_vertex(positions, normals, colors, 0.0, 0.0, z_apex, nx, ny, nz, color) + for j in range(segments): + b0 = ring_b[j] + b1 = ring_b[(j + 1) % segments] + indices.extend([apex_idx, b0, b1]) + elif ring_b is None: + z_apex = profile[i + 1][0] + dz, dr = _segment_tangent(profile, i, i + 1) + nx, ny, nz = _surface_normal(dz, dr, 0.0) + color = _profile_color(i + 1, shank_start_index) + apex_idx = _add_vertex(positions, normals, colors, 0.0, 0.0, z_apex, nx, ny, nz, color) + for j in range(segments): + a0 = ring_a[j] + a1 = ring_a[(j + 1) % segments] + indices.extend([a0, a1, apex_idx]) + else: + for j in range(segments): + a0 = ring_a[j] + a1 = ring_a[(j + 1) % segments] + b1 = ring_b[(j + 1) % segments] + b0 = ring_b[j] + indices.extend([a0, a1, b1, a0, b1, b0]) + + # Flat caps use their own vertices so side-wall normals are not overwritten. + z_bottom, r_bottom = profile[0] + if r_bottom > 1e-9: + bottom_color = _profile_color(0, shank_start_index) + center_idx = _add_vertex(positions, normals, colors, 0.0, 0.0, z_bottom, 0.0, 0.0, -1.0, bottom_color) + cap_ring = _build_cap_ring(positions, normals, colors, z_bottom, r_bottom, -1.0, segments, bottom_color) + for j in range(segments): + indices.extend([center_idx, cap_ring[(j + 1) % segments], cap_ring[j]]) + + z_top, r_top = profile[-1] + if r_top > 1e-9: + top_color = _profile_color(len(profile) - 1, shank_start_index) + center_idx = _add_vertex(positions, normals, colors, 0.0, 0.0, z_top, 0.0, 0.0, 1.0, top_color) + cap_ring = _build_cap_ring(positions, normals, colors, z_top, r_top, 1.0, segments, top_color) + for j in range(segments): + indices.extend([center_idx, cap_ring[j], cap_ring[(j + 1) % segments]]) + + return _pack_vertices(positions, normals, colors), indices, VERTEX_FORMAT + + +def _tool_diameter(tool_def, scale): + diameter = getattr(tool_def, "diameter", None) if tool_def else None + if diameter and diameter > 0: + return diameter + return fallback_tool_dimensions(scale)[0] + + +def _effective_tool_diameter(tool_def, scale): + """Return the outer cutting diameter used for sizing and proportions.""" + diameter = _tool_diameter(tool_def, scale) + if tool_def and getattr(tool_def, "tool_type", None) is ToolType.RADIUS_MILL: + corner_radius = getattr(tool_def, "corner_radius", None) or 0.0 + if corner_radius > 0: + return diameter + 2.0 * corner_radius + return diameter + + +def _profile_length(tool_def, scale, diameter, shared_length=None): + if tool_def: + length = getattr(tool_def, "length", None) + if length is not None and length > 0: + return length + shoulder_length = getattr(tool_def, "shoulder_length", None) + if shoulder_length is not None and shoulder_length > 0: + return shoulder_length + flute_length = getattr(tool_def, "flute_length", None) + if flute_length is not None and flute_length > 0: + return flute_length + if shared_length is not None and shared_length > 0: + return shared_length + if tool_def and getattr(tool_def, "diameter", None) and tool_def.diameter > 0: + return diameter * LENGTH_DIAMETER_FACTOR + return fallback_tool_dimensions(scale)[1] + + +def _chamfer_cone_height(diameter, taper_angle_deg=DEFAULT_TAPER_ANGLE_DEG, tip_diameter=0.0): + """Axial height of the conical cutting tip for chamfer / engraving tools.""" + radius = diameter / 2.0 + tip_radius = max(tip_diameter or 0.0, 0.0) / 2.0 + tip_radius = min(tip_radius, radius) + + half_angle_rad = math.radians(taper_angle_deg) / 2.0 + half_angle_rad = min(max(half_angle_rad, math.radians(5.0)), math.radians(85.0)) + + if tip_radius <= 1e-9: + return radius / math.tan(half_angle_rad) + radial_rise = radius - tip_radius + if radial_rise <= 1e-9: + return 0.0 + return radial_rise / math.tan(half_angle_rad) + + +def _lollipop_neck_radius(diameter): + radius = diameter / 2.0 + neck_radius = max(radius * 0.35, diameter * 0.15) + return min(neck_radius, radius * 0.95) + + +def _lollipop_ball_join_z(diameter): + """Z where the spherical cutter meets the neck cylinder.""" + radius = diameter / 2.0 + neck_radius = _lollipop_neck_radius(diameter) + theta_join = math.acos(neck_radius / radius) + return radius + radius * math.sin(theta_join) + + +def _infer_flute_length(tool_def): + """Guess flute length from tip geometry when CAM metadata omits it: + - Chamfer / engraving: Cone height from diameter + taper (+ tip Ø) + - Lollipop: Sphere -> neck join + - Ball end: One diameter (hemisphere + short cylinder) + - Thread mill: One tooth ≈ pitch + + Returns None when the type has no reliable tip-only cue (e.g. flat end mill). + Only used when building the mesh, this doesn't change tool definitions. + """ + if not tool_def: + return None + + tool_type = getattr(tool_def, "tool_type", None) + diameter = getattr(tool_def, "diameter", None) + if diameter is None or diameter <= 0: + return None + + if tool_type is ToolType.LOLLIPOP_MILL: + return _lollipop_ball_join_z(diameter) + + if tool_type in (ToolType.CHAMFER_MILL, ToolType.ENGRAVING): + taper = _safe_taper_angle_deg(tool_def) + tip_diameter = _safe_tip_diameter(tool_def, diameter) + height = _chamfer_cone_height(diameter, taper, tip_diameter) + return height if height > 1e-9 else None + + if tool_type is ToolType.BALL_END_MILL: + # Hemisphere plus a short matching cylinder — distinctive tip region. + return diameter + + if tool_type is ToolType.THREAD_MILL: + return _safe_thread_pitch(tool_def, diameter) + + return None + + +def _inferred_shoulder_length(tool_def, flute, overall): + """Short cutting-diameter body above an inferred flute tip.""" + available = overall - flute + if available <= 1e-9: + return flute + + diameter = getattr(tool_def, "diameter", None) or 0.0 + extra = min( + max(diameter, 0.0) * INFERRED_SHOULDER_DIAMETER_FACTOR, + available * INFERRED_SHOULDER_MAX_FRACTION, + available, + ) + return flute + extra + + +def _resolve_section_lengths(tool_def, fallback_overall): + """Return `(flute_z, shoulder_z, overall_z)` in file units. + + - flute_z: cutting flutes (gold) + - shoulder_z: end of cutting-diameter body; shank blend starts here + - overall_z: total stick-out (sticklength) + + When flute length is missing, tip-defined tools (chamfer, lollipop, etc.) get a + geometry-based guess, otherwise flute falls back to shoulder/overall and is + capped to FALLBACK_FLUTE_DIAMETER_FACTOR × diameter. When shoulder is also + missing after a tip inference, a short cutting-diameter shoulder is added so + the shank does not start at the tip. Explicit flute with no shoulder still + steps at the flute length. Missing stick-out uses + LENGTH_DIAMETER_FACTOR × diameter via `fallback_overall`. + """ + if not tool_def: + return fallback_overall, fallback_overall, fallback_overall + + overall = getattr(tool_def, "length", None) + flute = getattr(tool_def, "flute_length", None) + shoulder = getattr(tool_def, "shoulder_length", None) + diameter = getattr(tool_def, "diameter", None) or 0.0 + + if overall is None or overall <= 0: + overall = fallback_overall + + flute_was_inferred = False + if flute is None or flute <= 0: + inferred = _infer_flute_length(tool_def) + if inferred is not None and inferred > 0: + flute = inferred + flute_was_inferred = True + elif shoulder is not None and shoulder > 0: + flute = shoulder + else: + flute = overall + if diameter > 0: + flute = min(flute, diameter * FALLBACK_FLUTE_DIAMETER_FACTOR) + + flute = min(flute, overall) + + if shoulder is None or shoulder <= 0: + if flute_was_inferred: + shoulder = _inferred_shoulder_length(tool_def, flute, overall) + else: + shoulder = flute + + shoulder = min(max(shoulder, flute), overall) + return flute, shoulder, overall + + +def _shank_radius(tool_def, cutting_radius): + shank_diameter = getattr(tool_def, "shank_diameter", None) if tool_def else None + if shank_diameter is not None and shank_diameter > 0: + return shank_diameter / 2.0 + return cutting_radius + + +def _safe_tip_diameter(tool_def, diameter): + tip_diameter = getattr(tool_def, "tip_diameter", None) if tool_def else None + if tip_diameter is None or tip_diameter < 0: + return 0.0 + return min(tip_diameter, diameter) + + +def _append_shank_geometry(points, shoulder_z, overall_z, shank_radius): + """Append a conical blend + cylindrical shank above `shoulder_z`. + + When the shank radius differs from the body, a short ~45° cone joins them + instead of a hard radial step. Returns the extended profile list. + """ + if not points: + return points + + points = list(points) + last_z, last_r = points[-1] + shoulder_z = max(shoulder_z, last_z) + if shoulder_z > last_z + 1e-9: + points.append((shoulder_z, last_r)) + last_z, last_r = shoulder_z, last_r + + if overall_z <= shoulder_z + 1e-9: + return points + + available = overall_z - shoulder_z + radial_delta = abs(shank_radius - last_r) + if radial_delta > 1e-9: + transition = min( + radial_delta * SHANK_TRANSITION_SLOPE, + available * SHANK_TRANSITION_MAX_FRACTION, + available, + ) + if transition > 1e-9: + points.append((shoulder_z + transition, shank_radius)) + else: + points.append((shoulder_z, shank_radius)) + + points.append((overall_z, shank_radius)) + return points + + +def _safe_corner_radius(tool_def, diameter, tool_type=None): + corner_radius = getattr(tool_def, "corner_radius", None) if tool_def else None + if not corner_radius or corner_radius <= 0: + return 0.0 + if tool_type is ToolType.RADIUS_MILL: + return corner_radius + return min(corner_radius, diameter / 2.0) + + +def _safe_taper_angle_deg(tool_def): + angle = getattr(tool_def, "taper_angle_deg", None) if tool_def else None + if angle is None or angle < 0 or angle >= 180: + return DEFAULT_TAPER_ANGLE_DEG + return angle + + +def _safe_thread_depth(tool_def, diameter): + thread_depth = getattr(tool_def, "thread_depth", None) if tool_def else None + if not thread_depth or thread_depth <= 0: + # No parser-provided value: assume a sensible depth based on diameter. + thread_depth = diameter / 10.0 + return min(thread_depth, diameter / 2.0) + + +def _safe_thread_pitch(tool_def, diameter): + thread_pitch = getattr(tool_def, "thread_pitch", None) if tool_def else None + if not thread_pitch or thread_pitch <= 0: + # No parser-provided value: assume a sensible pitch based on diameter. + thread_pitch = diameter / 5.0 + return thread_pitch + + +def _flat_end_mill_profile(diameter, length, **_kwargs): + radius = diameter / 2.0 + return [(0.0, radius), (length, radius)] + + +def _thread_mill_profile(diameter, length, thread_depth=0.0, thread_pitch=0.0, **_kwargs): + # Thread mills are represented as basic single-point cutters: a flat + # minor-diameter base, rising over half a pitch to the major diameter + # (the single cutting tooth), then back down to the minor diameter + # before continuing as a plain cylindrical shank. + major_radius = diameter / 2.0 + depth = thread_depth if thread_depth > 0 else diameter / 10.0 + depth = min(depth, major_radius) + minor_radius = major_radius - depth + + pitch = thread_pitch if thread_pitch > 0 else diameter / 5.0 + pitch = min(pitch, length) if length > 0 else pitch + half_pitch = pitch / 2.0 + + points = [(0.0, minor_radius), (half_pitch, major_radius), (pitch, minor_radius)] + if length > pitch: + points.append((length, minor_radius)) + return points + + +def _ball_end_mill_profile(diameter, length, **_kwargs): + radius = diameter / 2.0 + points = [] + for i in range(ROUND_SEGMENTS + 1): + theta = -math.pi / 2.0 + (math.pi / 2.0) * (i / ROUND_SEGMENTS) + z = radius + radius * math.sin(theta) + r = radius * math.cos(theta) + points.append((z, r)) + if length > radius + 1e-9: + points.append((length, radius)) + return points + + +def _bull_nose_profile(diameter, length, corner_radius=0.0, **_kwargs): + radius = diameter / 2.0 + corner_radius = corner_radius if corner_radius else radius * 0.25 + corner_radius = min(corner_radius, radius) + flat_radius = radius - corner_radius + + points = [(0.0, max(flat_radius, 0.0))] + for i in range(1, ROUND_SEGMENTS + 1): + theta = -math.pi / 2.0 + (math.pi / 2.0) * (i / ROUND_SEGMENTS) + z = corner_radius + corner_radius * math.sin(theta) + r = flat_radius + corner_radius * math.cos(theta) + points.append((z, r)) + + min_length = points[-1][0] + diameter * 0.5 + points.append((max(length, min_length), radius)) + return points + + +def _radius_mill_profile(diameter, length, corner_radius=0.0, **_kwargs): + # For radius mills the supplied `diameter` is the flat-bottom diameter. + # The outer cutting diameter is flat_diameter + 2 * corner_radius. + flat_radius = diameter / 2.0 + corner_radius = corner_radius if corner_radius else flat_radius * 0.25 + full_radius = flat_radius + corner_radius + full_diameter = diameter + 2.0 * corner_radius + + if corner_radius <= 1e-9: + return _flat_end_mill_profile(diameter, length) + + # Concave corner arc: centre at (z=0, r=full_radius), sweeping from + # the flat bottom out to the full cutting diameter. + points = [(0.0, flat_radius)] + for i in range(1, ROUND_SEGMENTS + 1): + theta = math.pi - (math.pi / 2.0) * (i / ROUND_SEGMENTS) + z = corner_radius * math.sin(theta) + r = full_radius + corner_radius * math.cos(theta) + points.append((z, r)) + + min_length = points[-1][0] + full_diameter * 0.5 + points.append((max(length, min_length), full_radius)) + return points + + +def _chamfer_or_tapered_profile(diameter, length, taper_angle_deg=DEFAULT_TAPER_ANGLE_DEG, tip_diameter=0.0, **_kwargs): + """Flat-tip (or pointed) conical profile for chamfer mills and engraving bits.""" + radius = diameter / 2.0 + tip_radius = max(tip_diameter or 0.0, 0.0) / 2.0 + tip_radius = min(tip_radius, radius) + cone_height = _chamfer_cone_height(diameter, taper_angle_deg, tip_diameter) + + if tip_radius <= 1e-9: + points = [(0.0, 0.0), (cone_height, radius)] + else: + points = [(0.0, tip_radius)] + if cone_height > 1e-9: + points.append((cone_height, radius)) + + if length > points[-1][0]: + points.append((length, radius)) + return points + + +def _tapered_mill_profile(diameter, length, corner_radius=0.0, taper_angle_deg=DEFAULT_TAPER_ANGLE_DEG, **_kwargs): + """Bull-nose tip of diameter `D`, then a conical taper.""" + tip_radius = diameter / 2.0 + # Per-side angle from the axis (Fusion "Taper angle"). + alpha = math.radians(taper_angle_deg) + alpha = min(max(alpha, 0.0), math.radians(85.0)) + + max_corner = tip_radius / max(math.cos(alpha), 1e-6) + corner_radius = min(max(corner_radius or 0.0, 0.0), max_corner) + + if corner_radius <= 1e-9: + # Sharp flat tip at diameter D, then straight taper. + points = [(0.0, tip_radius)] + end_radius = tip_radius + length * math.tan(alpha) + points.append((max(length, diameter * 0.5), end_radius)) + return points + + # Fillet between the flat tip and the cone: same construction as a bull + # nose, but the arc stops at theta=-alpha where it meets the taper. + flat_radius = tip_radius - corner_radius * math.cos(alpha) + theta_start = -math.pi / 2.0 + theta_end = -alpha + + points = [(0.0, max(flat_radius, 0.0))] + for i in range(1, ROUND_SEGMENTS + 1): + t = i / ROUND_SEGMENTS + theta = theta_start + (theta_end - theta_start) * t + z = corner_radius + corner_radius * math.sin(theta) + r = flat_radius + corner_radius * math.cos(theta) + points.append((z, max(r, 0.0))) + + z_tan, r_tan = points[-1] + # Continue along the taper for the remaining tool length. + total_length = max(length, z_tan + diameter * 0.5) + end_radius = r_tan + (total_length - z_tan) * math.tan(alpha) + points.append((total_length, end_radius)) + return points + + +def _lollipop_profile(diameter, length, **_kwargs): + """Full spherical cutter with a thinner cylindrical neck above the undercut.""" + radius = diameter / 2.0 + neck_radius = _lollipop_neck_radius(diameter) + theta_join = math.acos(neck_radius / radius) + + points = [] + # Lower hemisphere: south pole -> equator (guarantees full cutting diameter). + for i in range(ROUND_SEGMENTS + 1): + theta = -math.pi / 2.0 + (math.pi / 2.0) * (i / ROUND_SEGMENTS) + z = radius + radius * math.sin(theta) + r = max(radius * math.cos(theta), 0.0) + points.append((z, r)) + + # Upper hemisphere: equator -> neck join (skip duplicate equator point). + for i in range(1, ROUND_SEGMENTS + 1): + theta = theta_join * (i / ROUND_SEGMENTS) + z = radius + radius * math.sin(theta) + r = max(radius * math.cos(theta), 0.0) + points.append((z, r)) + + # Snap the join so the cylinder meets the ball at exactly neck_radius. + ball_join_z = points[-1][0] + points[-1] = (ball_join_z, neck_radius) + + # Neck extension is optional: overall stick-out above the ball is added by + # the shoulder/shank pass when flute length ends at the sphere. + if length > ball_join_z + 1e-9: + points.append((length, neck_radius)) + return points + + +DEFAULT_PROFILE_BUILDER = _chamfer_or_tapered_profile +PROFILE_BUILDERS = { + ToolType.FLAT_END_MILL: _flat_end_mill_profile, + ToolType.THREAD_MILL: _thread_mill_profile, + ToolType.BALL_END_MILL: _ball_end_mill_profile, + ToolType.BULL_NOSE_END_MILL: _bull_nose_profile, + ToolType.RADIUS_MILL: _radius_mill_profile, + ToolType.CHAMFER_MILL: _chamfer_or_tapered_profile, + ToolType.ENGRAVING: _chamfer_or_tapered_profile, + ToolType.TAPERED_MILL: _tapered_mill_profile, + ToolType.LOLLIPOP_MILL: _lollipop_profile, +} + + +def fallback_tool_dimensions(scale): + """Return (diameter, length) in file units for the no-metadata fallback mesh. + + Values are chosen so that, after multiplication by `scale`, the mesh keeps + a constant on-screen footprint regardless of file units or job bbox. + """ + if not scale or scale <= 0: + scale = 1.0 + diameter = FALLBACK_TOOL_DISPLAY_RADIUS * 2.0 / scale + length = FALLBACK_TOOL_DISPLAY_HEIGHT / scale + return diameter, length + + +def fallback_tool_profile(scale): + """Pointed fallback profile with a fixed on-screen size.""" + diameter, length = fallback_tool_dimensions(scale) + return _chamfer_or_tapered_profile(diameter, length) + + +def _tool_profile_with_shank(tool_def, length=None, scale=1.0): + """Return `(profile, color_shank_start_index)` for a tool definition. + + Geometry sections (when metadata allows): + - tip -> flute_z: cutting flutes at tool diameter (gold) + - flute_z -> shoulder_z: same diameter body / shoulder (gray) + - shoulder_z -> overall_z: blend + handle/shank diameter (gray) + + Falls back to a basic pointed (chamfer-like) profile when `tool_def` is + None or its type is unknown/unsupported (see DEFAULT_PROFILE_BUILDER). + Missing diameters use the fixed on-screen fallback size. + """ + diameter = _tool_diameter(tool_def, scale) + effective_diameter = _effective_tool_diameter(tool_def, scale) + fallback_overall = _profile_length(tool_def, scale, effective_diameter, length) + flute_z, shoulder_z, overall_z = _resolve_section_lengths(tool_def, fallback_overall) + tool_type = getattr(tool_def, "tool_type", ToolType.UNKNOWN) if tool_def else ToolType.UNKNOWN + corner_radius = _safe_corner_radius(tool_def, diameter, tool_type) + taper_angle_deg = _safe_taper_angle_deg(tool_def) + tip_diameter = _safe_tip_diameter(tool_def, diameter) + thread_depth = _safe_thread_depth(tool_def, diameter) + thread_pitch = _safe_thread_pitch(tool_def, diameter) + + builder = PROFILE_BUILDERS.get(tool_type, DEFAULT_PROFILE_BUILDER) + + profile = builder( + diameter, + flute_z, + corner_radius=corner_radius, + taper_angle_deg=taper_angle_deg, + tip_diameter=tip_diameter, + thread_depth=thread_depth, + thread_pitch=thread_pitch, + ) + + profile = list(profile) + last_z, last_r = profile[-1] + # Tip geometry (e.g. ball nose) may already exceed the requested flute length. + flute_tip_z = max(flute_z, last_z) + if flute_tip_z > last_z + 1e-9: + profile.append((flute_tip_z, last_r)) + last_z, last_r = flute_tip_z, last_r + else: + flute_tip_z = last_z + + shoulder_z = max(shoulder_z, flute_tip_z) + overall_z = max(overall_z, shoulder_z) + + has_non_flute = overall_z > flute_tip_z + 1e-9 + if has_non_flute: + color_start = len(profile) + # Coincident ring -> hard gold/gray edge at the end of the flutes. + profile.append((flute_tip_z, last_r)) + else: + color_start = len(profile) + + if shoulder_z > flute_tip_z + 1e-9: + profile.append((shoulder_z, last_r)) + + cutting_radius = last_r + profile = _append_shank_geometry( + profile, + shoulder_z, + overall_z, + _shank_radius(tool_def, cutting_radius), + ) + return profile, color_start + + +def tool_profile(tool_def, length=None, scale=1.0): + """Return the (unscaled) profile for a tool definition.""" + profile, _shank_start = _tool_profile_with_shank(tool_def, length=length, scale=scale) + return profile + + +def _scale_profile(profile, scale): + """Scale profile into viewer units, with a per-tool radius visibility floor. + + If this tool's scaled radius would be below MIN_VISIBLE_RADIUS, enlarge only + that tool (uniformly in Z and R) so it stays visible. Other tools are + unaffected. + """ + if not scale or scale <= 0: + scale = 1.0 + scaled_profile = [(z * scale, r * scale) for z, r in profile] + + max_radius = max((r for _z, r in scaled_profile), default=0.0) + if 0.0 < max_radius < MIN_VISIBLE_RADIUS: + boost = MIN_VISIBLE_RADIUS / max_radius + scaled_profile = [(z * boost, r * boost) for z, r in scaled_profile] + + total_length = scaled_profile[-1][0] if scaled_profile else 0.0 + if total_length < MIN_SHANK_LENGTH: + shank_radius = scaled_profile[-1][1] + scaled_profile = scaled_profile[:-1] + [(MIN_SHANK_LENGTH, shank_radius)] + + return scaled_profile + + +def build_tool_mesh(tool_def, scale=1.0, length=None): + profile, shank_start = _tool_profile_with_shank(tool_def, length=length, scale=scale) + scaled_profile = _scale_profile(profile, scale) + return _build_revolve_mesh(scaled_profile, shank_start_index=shank_start) + + +def build_default_tool_mesh(scale=1.0): + """Mesh used for tools with no metadata (fixed on-screen size).""" + profile = fallback_tool_profile(scale) + scaled_profile = _scale_profile(profile, scale) + return _build_revolve_mesh(scaled_profile) + + +def build_tool_meshes(tool_table, scale=1.0): + """Build a mesh for every tool in `tool_table`, plus a default mesh for + tools with no metadata. + + Tools without stick-out metadata use LENGTH_DIAMETER_FACTOR × their own + diameter. Tools that provide length / shoulder / flute use those heights. + Each tool is scaled independently; only tools below MIN_VISIBLE_RADIUS get + a per-tool visibility floor. + + The default (no-metadata) mesh keeps a fixed on-screen size, independent + of file units. + + Returns `(tool_meshes, default_mesh)`. + """ + default_profile = fallback_tool_profile(scale) + + if tool_table: + tool_meshes = {} + for number, tool_def in tool_table.items(): + profile, shank_start = _tool_profile_with_shank(tool_def, scale=scale) + tool_meshes[number] = _build_revolve_mesh( + _scale_profile(profile, scale), + shank_start_index=shank_start, + ) + else: + tool_meshes = {} + + default_mesh = _build_revolve_mesh(_scale_profile(default_profile, scale)) + return tool_meshes, default_mesh diff --git a/carveracontroller/addons/tool_visualization/parsers/__init__.py b/carveracontroller/addons/tool_visualization/parsers/__init__.py new file mode 100644 index 00000000..45001ce2 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/parsers/__init__.py @@ -0,0 +1,11 @@ +"""CAM-specific tool table parsers. + +Each parser knows how to recognise and extract tool metadata from the +comments a particular CAM software / post processor writes at the top of a +G-code file. Add new parsers here and register them in +`carveracontroller.addons.tool_visualization.extractor.TOOL_TABLE_PARSERS`. + +Available parsers: +- fusion360_makera: Fusion 360 Makera post processor tool comments +- makera_studio: Makera Studio `;@MKR|TOOL|...` metadata block +""" diff --git a/carveracontroller/addons/tool_visualization/parsers/base.py b/carveracontroller/addons/tool_visualization/parsers/base.py new file mode 100644 index 00000000..34f98069 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/parsers/base.py @@ -0,0 +1,51 @@ +"""Base class for CAM tool table parsers.""" + +from abc import ABC, abstractmethod + +# Characters that make a line "safe" to skip over while looking for a tool +# table header (comment, blank, program-marker or variable-assignment lines). +# Mirrors the characters CNC.parseLine() itself treats as non-motion lines. +HEADER_SAFE_PREFIXES = ("%", "(", "#", ";") + +# Hard cap on how many lines a header scan will ever look at, in case a file +# has an unusually long run of leading comments with no actual G-code. +MAX_HEADER_LINES = 5000 + + +class ToolTableParser(ABC): + """Extracts a tool table from the raw lines of a G-code file. + + Implementations should be conservative: if the file does not look like + it was produced by the CAM/post processor they support, `parse()` should + return an empty dict rather than guessing. + """ + + name = "base" + + @abstractmethod + def parse(self, lines): + """Parse (an iterable of) raw G-code file lines. + + Returns a dict mapping tool number (int) -> ToolDefinition. If a + tool number appears in multiple comments, only the first occurrence + found in the file should be kept. + """ + raise NotImplementedError + + @staticmethod + def iter_header_lines(lines, max_lines=MAX_HEADER_LINES): + """Lazily yield the leading blank/comment-only lines of a file. + + CAM post processors write tool tables as a block of comments at the + very top of the file, before any real G-code. So instead of scanning + the whole (potentially huge) file, parsers that rely on this stop + pulling from `lines` as soon as the first "real" line is seen (or + after `max_lines`), without ever materialising a separate list. + """ + for count, line in enumerate(lines): + if count >= max_lines: + return + stripped = line.strip() + if stripped and stripped[0] not in HEADER_SAFE_PREFIXES: + return + yield line diff --git a/carveracontroller/addons/tool_visualization/parsers/fusion360_makera.py b/carveracontroller/addons/tool_visualization/parsers/fusion360_makera.py new file mode 100644 index 00000000..202dd8b5 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/parsers/fusion360_makera.py @@ -0,0 +1,139 @@ +"""Parser for tool tables written by the Fusion 360 Makera post processor. + +The post processor's `dumpToolInformation()` CPS function writes one comment +line per tool, on its own line, with the following layout: + +T D= CR=[ TAPER=deg][ - ZMIN=] - +""" + +import logging +import re + +from carveracontroller.addons.tool_visualization.parsers.base import ToolTableParser +from carveracontroller.addons.tool_visualization.tool_definition import ToolDefinition, ToolType, resolve_tool_type + +logger = logging.getLogger(__name__) + +# Tool type names as exported by the Fusion 360 Makera post processor's getToolTypeName() +TOOL_TYPE_NAME_MAP = { + "flat end mill": ToolType.FLAT_END_MILL, + "ball end mill": ToolType.BALL_END_MILL, + "bull nose end mill": ToolType.BULL_NOSE_END_MILL, + "radius mill": ToolType.RADIUS_MILL, + "chamfer mill": ToolType.CHAMFER_MILL, + "tapered mill": ToolType.TAPERED_MILL, + "lollipop mill": ToolType.LOLLIPOP_MILL, + "thread mill": ToolType.THREAD_MILL, +} + +_NUMBER = r"[-+]?\d+\.?\d*" + +TOOL_LINE_RE = re.compile( + r"^T(?P\d+)\s+(?P.*?)\s+D=(?P" + _NUMBER + r")\s+CR=(?P" + _NUMBER + r")" + r"(?:\s+TAPER=(?P" + _NUMBER + r")deg)?" + r"(?:\s*-\s*ZMIN=" + _NUMBER + r")?" + r"\s*-\s*(?P.+?)\s*$", + re.IGNORECASE, +) + +_FIELD_SPLIT_RE = re.compile(r"\s{2,}") + + +def _to_float(value): + if value is None: + return None + try: + return float(value) + except ValueError: + return None + + +def _infer_shank_diameter(tool_type, diameter, corner_radius=None): + """Infer shank diameter from Fusion cutting geometry. + + Returns None when diameter is missing or the type's shaft cannot be derived + from cutting geometry alone (tapered tip/lollipop ball). + """ + if diameter is None or diameter <= 0: + return None + if tool_type is ToolType.RADIUS_MILL: + return diameter + 2.0 * (corner_radius or 0.0) + if tool_type in (ToolType.TAPERED_MILL, ToolType.LOLLIPOP_MILL): + return None + return diameter + + +def _extract_full_line_comment(line): + """Return the text of a comment if the whole (stripped) line is one, else None.""" + if len(line) >= 2 and line[0] == "(" and line[-1] == ")": + return line[1:-1] + if line.startswith(";"): + return line[1:] + return None + + +class Fusion360MakeraParser(ToolTableParser): + name = "fusion360_makera" + + def parse(self, lines): + tool_table = {} + for raw_line in self.iter_header_lines(lines): + line = raw_line.strip() + if not line: + continue + + comment_text = _extract_full_line_comment(line) + if comment_text is None: + continue + + match = TOOL_LINE_RE.match(comment_text.strip()) + if not match: + continue + + number = int(match.group("number")) + if number in tool_table: + # Only the first occurrence of a given tool should be used. + logger.debug(f"Ignoring duplicate tool table comment for T{number}: {line}") + continue + + tool_def = self._build_tool_definition(number, match) + tool_table[number] = tool_def + + if tool_def.tool_type is ToolType.UNKNOWN: + logger.warning( + f"Detected T{number} ({tool_def.description!r}, D={tool_def.diameter}) " + f"with unrecognised tool type {tool_def.type_name!r}; defaulting to a basic pointed mesh" + ) + else: + dims = f"D={tool_def.diameter}, CR={tool_def.corner_radius}" + if tool_def.taper_angle_deg is not None: + dims += f", TAPER={tool_def.taper_angle_deg}" + logger.info(f"Detected T{number}: {tool_def.tool_type.value} ({dims}) - {tool_def.description!r}") + + return tool_table + + @staticmethod + def _build_tool_definition(number, match): + middle = match.group("middle").strip() + fields = [field for field in _FIELD_SPLIT_RE.split(middle) if field] + description = fields[0] if len(fields) > 0 else "" + vendor = fields[1] if len(fields) > 1 else "" + product_id = fields[2] if len(fields) > 2 else "" + + type_name = match.group("type_name").strip() + tool_type = resolve_tool_type(type_name, TOOL_TYPE_NAME_MAP) + diameter = _to_float(match.group("diameter")) + corner_radius = _to_float(match.group("corner_radius")) + + return ToolDefinition( + number=number, + tool_type=tool_type, + diameter=diameter, + shank_diameter=_infer_shank_diameter(tool_type, diameter, corner_radius), + corner_radius=corner_radius, + taper_angle_deg=_to_float(match.group("taper")), + description=description, + vendor=vendor, + product_id=product_id, + type_name=type_name, + ) diff --git a/carveracontroller/addons/tool_visualization/parsers/makera_studio.py b/carveracontroller/addons/tool_visualization/parsers/makera_studio.py new file mode 100644 index 00000000..b64e4602 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/parsers/makera_studio.py @@ -0,0 +1,160 @@ +"""Parser for tool tables written by Makera Studio. + +Makera Studio writes a `;@MKR|...` metadata block at the top of the G-code +file. Each tool is one line: + +;@MKR|TOOL|number=|id=|name=|type=|handlediameter=|sticklength=|shoulderlength=|flutelength=|diameter=|tipdiameter=|cornerradius=|angle=|halfAngle= +""" + +import logging + +from carveracontroller.addons.tool_visualization.parsers.base import ToolTableParser +from carveracontroller.addons.tool_visualization.tool_definition import ToolDefinition, ToolType, resolve_tool_type + +logger = logging.getLogger(__name__) + +# Tool type names as exported by Makera Studio +# TODO Update with confirmed IDs from Makera Studio. +TOOL_TYPE_NAME_MAP = { + "flat end": ToolType.FLAT_END_MILL, + "ball end": ToolType.BALL_END_MILL, + "bull nose": ToolType.BULL_NOSE_END_MILL, + "bullnose": ToolType.BULL_NOSE_END_MILL, + "radius": ToolType.RADIUS_MILL, + "radius mill": ToolType.RADIUS_MILL, + "engraving": ToolType.ENGRAVING, + "chamfer": ToolType.CHAMFER_MILL, + "chamfer mill": ToolType.CHAMFER_MILL, + "tapered": ToolType.TAPERED_MILL, + "tapered mill": ToolType.TAPERED_MILL, + "lollipop": ToolType.LOLLIPOP_MILL, + "lollipop mill": ToolType.LOLLIPOP_MILL, + "thread": ToolType.THREAD_MILL, + "thread mill": ToolType.THREAD_MILL, +} + +_MKR_PREFIX = ";@MKR|" +_TOOL_TAG = "TOOL" + + +def _to_float(value): + if value is None or value == "": + return None + try: + return float(value) + except ValueError: + return None + + +def _to_int(value): + if value is None or value == "": + return None + try: + return int(float(value)) + except ValueError: + return None + + +def _positive_or_none(value): + if value is None or value <= 0: + return None + return value + + +def _parse_mkr_fields(line): + """Return (tag, fields_dict) for a `;@MKR|TAG|k=v|...` line, or None.""" + stripped = line.strip() + if not stripped.startswith(_MKR_PREFIX): + return None + + parts = stripped[len(_MKR_PREFIX) :].split("|") + if not parts: + return None + + tag = parts[0] + fields = {} + for part in parts[1:]: + if not part: + continue + key, sep, value = part.partition("=") + if not sep: + continue + fields[key] = value + return tag, fields + + +def _taper_angle_from_fields(fields): + angle = _to_float(fields.get("angle")) + if angle is not None and angle > 0: + return angle + half_angle = _to_float(fields.get("halfAngle")) + if half_angle is not None and half_angle > 0: + return 2.0 * half_angle + return None + + +def _stick_length_from_fields(fields): + """Overall stick-out: prefer sticklength, fall back to shoulderlength.""" + stick = _positive_or_none(_to_float(fields.get("sticklength"))) + if stick is not None: + return stick + return _positive_or_none(_to_float(fields.get("shoulderlength"))) + + +class MakeraStudioParser(ToolTableParser): + name = "makera_studio" + + def parse(self, lines): + tool_table = {} + for raw_line in self.iter_header_lines(lines): + parsed = _parse_mkr_fields(raw_line) + if parsed is None: + continue + + tag, fields = parsed + if tag != _TOOL_TAG: + continue + + number = _to_int(fields.get("number")) + if number is None: + continue + if number in tool_table: + logger.debug(f"Ignoring duplicate Makera Studio tool comment for T{number}: {raw_line.strip()}") + continue + + tool_def = self._build_tool_definition(number, fields) + tool_table[number] = tool_def + + if tool_def.tool_type is ToolType.UNKNOWN: + logger.warning( + f"Detected T{number} ({tool_def.description!r}, D={tool_def.diameter}) " + f"with unrecognised tool type {tool_def.type_name!r}; defaulting to a basic pointed mesh" + ) + else: + dims = f"D={tool_def.diameter}, CR={tool_def.corner_radius}" + if tool_def.taper_angle_deg is not None: + dims += f", TAPER={tool_def.taper_angle_deg}" + logger.info(f"Detected T{number}: {tool_def.tool_type.value} ({dims}) - {tool_def.description!r}") + + return tool_table + + @staticmethod + def _build_tool_definition(number, fields): + type_name = fields.get("type", "") or "" + handle_diameter = _positive_or_none(_to_float(fields.get("handlediameter"))) + + return ToolDefinition( + number=number, + tool_type=resolve_tool_type(type_name, TOOL_TYPE_NAME_MAP), + diameter=_to_float(fields.get("diameter")), + shank_diameter=handle_diameter, + tip_diameter=_to_float(fields.get("tipdiameter")), + corner_radius=_to_float(fields.get("cornerradius")), + taper_angle_deg=_taper_angle_from_fields(fields), + length=_stick_length_from_fields(fields), + flute_length=_positive_or_none(_to_float(fields.get("flutelength"))), + shoulder_length=_positive_or_none(_to_float(fields.get("shoulderlength"))), + description=fields.get("name", "") or "", + product_id=fields.get("id", "") or "", + type_name=type_name, + ) diff --git a/carveracontroller/addons/tool_visualization/tool_definition.py b/carveracontroller/addons/tool_visualization/tool_definition.py new file mode 100644 index 00000000..cc7aeed7 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/tool_definition.py @@ -0,0 +1,60 @@ +"""Common data structures describing a CNC tool for 3D visualization purposes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class ToolType(Enum): + """Basic tool shapes that can be visualised in the 3D viewer.""" + + FLAT_END_MILL = "flat_end_mill" + BALL_END_MILL = "ball_end_mill" + BULL_NOSE_END_MILL = "bull_nose_end_mill" + RADIUS_MILL = "radius_mill" # Note: Represents a concave radius mill + CHAMFER_MILL = "chamfer_mill" + ENGRAVING = "engraving" + TAPERED_MILL = "tapered_mill" + LOLLIPOP_MILL = "lollipop_mill" + THREAD_MILL = "thread_mill" + UNKNOWN = "unknown" + + +def normalize_tool_type_name(type_name): + """Normalise a raw tool type name for lookup (strip, lowercase, collapse whitespace).""" + if not type_name: + return "" + return " ".join(type_name.strip().lower().split()) + + +def resolve_tool_type(type_name, name_map): + """Resolve a raw (untrusted) tool type name to a ToolType using a parser-specific map.""" + normalised = normalize_tool_type_name(type_name) + if not normalised: + return ToolType.UNKNOWN + return name_map.get(normalised, ToolType.UNKNOWN) + + +@dataclass +class ToolDefinition: + """Geometry/metadata for a single tool, as extracted from a G-code comment. + Important: All dimensions are in the same units as the loaded G-code file (mm or in). + """ + + number: int + tool_type: ToolType = ToolType.UNKNOWN + diameter: float | None = None + shank_diameter: float | None = None + tip_diameter: float | None = None + corner_radius: float | None = None + taper_angle_deg: float | None = None + length: float | None = None + flute_length: float | None = None + shoulder_length: float | None = None + thread_depth: float | None = None + thread_pitch: float | None = None + description: str = "" + vendor: str = "" + product_id: str = "" + type_name: str = "" diff --git a/carveracontroller/addons/tool_visualization/tooltip_builder.py b/carveracontroller/addons/tool_visualization/tooltip_builder.py new file mode 100644 index 00000000..a22133e8 --- /dev/null +++ b/carveracontroller/addons/tool_visualization/tooltip_builder.py @@ -0,0 +1,115 @@ +"""Format tool definitions as toolbar tooltip text for the G-code viewer.""" + +from carveracontroller.addons.tool_visualization.tool_definition import ToolType +from carveracontroller.CNC import escape_gcode_markup +from carveracontroller.translation import tr + +TOOL_TYPE_MSGIDS = { + ToolType.FLAT_END_MILL: "Flat End Mill", + ToolType.BALL_END_MILL: "Ball End Mill", + ToolType.BULL_NOSE_END_MILL: "Bull Nose End Mill", + ToolType.RADIUS_MILL: "Radius Mill", + ToolType.CHAMFER_MILL: "Chamfer Mill", + ToolType.ENGRAVING: "Engraving", + ToolType.TAPERED_MILL: "Tapered Mill", + ToolType.LOLLIPOP_MILL: "Lollipop Mill", + ToolType.THREAD_MILL: "Thread Mill", +} + +# Title size in px +_TITLE_SIZE = 17 + + +def _format_tool_type_label(tool_def): + # Prefer our canonical labels when the type is known + msgid = TOOL_TYPE_MSGIDS.get(tool_def.tool_type) + if msgid: + return tr._(msgid) + # Unknown types: keep the raw CAM name as-is + return (tool_def.type_name or "").strip() + + +def _is_meaningful(value): + """Return True when a numeric field should be shown (present and non-zero).""" + return value is not None and value != 0 + + +def _format_dimension_lines(tool_def): + """Build one-label-per-line dimension rows, skipping redundant/noise fields.""" + lines = [] + + if tool_def.diameter is not None: + lines.append(tr._("Diameter: {value:g}").format(value=tool_def.diameter)) + + # Shank is noise when it matches the cutting diameter (common inferred default). + if _is_meaningful(tool_def.shank_diameter) and tool_def.shank_diameter != tool_def.diameter: + lines.append(tr._("Shank: {value:g}").format(value=tool_def.shank_diameter)) + + if _is_meaningful(tool_def.corner_radius): + lines.append(tr._("Corner radius: {value:g}").format(value=tool_def.corner_radius)) + + if _is_meaningful(tool_def.taper_angle_deg): + lines.append(tr._("Taper: {value:g}°").format(value=tool_def.taper_angle_deg)) + + if _is_meaningful(tool_def.length): + lines.append(tr._("Length: {value:g}").format(value=tool_def.length)) + + if _is_meaningful(tool_def.flute_length): + lines.append(tr._("Flute length: {value:g}").format(value=tool_def.flute_length)) + + if _is_meaningful(tool_def.shoulder_length): + lines.append(tr._("Shoulder: {value:g}").format(value=tool_def.shoulder_length)) + + if _is_meaningful(tool_def.thread_depth): + lines.append(tr._("Thread depth: {value:g}").format(value=tool_def.thread_depth)) + + if _is_meaningful(tool_def.thread_pitch): + lines.append(tr._("Pitch: {value:g}").format(value=tool_def.thread_pitch)) + + return lines + + +def _format_catalog_line(tool_def): + return tool_def.vendor or "" + + +def format_tool_tooltip(tool_def, *, markup=True): + """Return multi-line tooltip text for a parsed tool, or an empty string. + + When *markup* is True (default), the string includes Kivy Label markup for + hierarchy (larger title). Pass markup=False for plain text consumers such + as the tool-change confirm popup. + """ + if tool_def is None: + return "" + + title = f"T{tool_def.number}" + type_label = _format_tool_type_label(tool_def) + if type_label: + title = f"{title} · {type_label}" + + description = tool_def.description or "" + catalog = _format_catalog_line(tool_def) + + if markup: + title = f"[size={_TITLE_SIZE}]{escape_gcode_markup(title)}[/size]" + if description: + description = escape_gcode_markup(description) + if catalog: + catalog = escape_gcode_markup(catalog) + + identity = [title] + if description: + identity.append(description) + if catalog: + identity.append(catalog) + + sections = ["\n".join(identity)] + + dimension_lines = _format_dimension_lines(tool_def) + if dimension_lines: + if markup: + dimension_lines = [escape_gcode_markup(line) for line in dimension_lines] + sections.append("\n".join(dimension_lines)) + + return "\n\n".join(sections) diff --git a/carveracontroller/addons/tooltips/Tooltips.kv b/carveracontroller/addons/tooltips/Tooltips.kv index 8fa35417..41d2b25b 100644 --- a/carveracontroller/addons/tooltips/Tooltips.kv +++ b/carveracontroller/addons/tooltips/Tooltips.kv @@ -27,8 +27,8 @@ : size_hint: None, None - width: max(tooltip_label.texture_size[0] + 20, tooltip_image.width + 20) - height: tooltip_label.texture_size[1] + tooltip_image.height + 20 + width: (tooltip_label.texture_size[0] + tooltip_image.width + self.spacing + 20) if self.orientation == 'horizontal' and tooltip_image.width else max(tooltip_label.texture_size[0] + 20, tooltip_image.width + 20, 220 if tooltip_label.text else 0) + height: (max(tooltip_label.texture_size[1], tooltip_image.height) + 20) if self.orientation == 'horizontal' and tooltip_image.width else tooltip_label.texture_size[1] + tooltip_image.height + 20 size: self.minimum_size orientation: 'vertical' padding: 10, 10 @@ -48,25 +48,23 @@ BoxLayout: size_hint: None, None size: tooltip_image.size - orientation: 'vertical' + pos_hint: {"center_y": 0.5} if root.orientation == 'horizontal' else {"center_x": 0.5} Image: id: tooltip_image size_hint: None, None fit_mode: 'scale-down' size: self.texture_size - pos_hint: {"center_x": 0.5} ToolTipContentLabel: id: tooltip_label size_hint: None, None - text_size: self.width, None size: self.texture_size halign: 'left' - valign: 'top' + valign: 'middle' + pos_hint: {"center_y": 0.5} if root.orientation == 'horizontal' else {} : id: tooltip_label - text_size: self.width, None size_hint: None, None height: self.texture_size[1] halign: 'left' diff --git a/carveracontroller/addons/tooltips/Tooltips.py b/carveracontroller/addons/tooltips/Tooltips.py index 91a9a916..7dc2bdae 100644 --- a/carveracontroller/addons/tooltips/Tooltips.py +++ b/carveracontroller/addons/tooltips/Tooltips.py @@ -16,21 +16,60 @@ from kivy.uix.switch import Switch from kivy.uix.textinput import TextInput +# Minimum tooltip box width for short labels (matches previous wrap floor). +TOOLTIP_MIN_WIDTH = 200 +# Wrap long tooltip text at this width so multi-line content stays readable. +TOOLTIP_MAX_WIDTH = 360 + + +def _compute_tooltip_box_size( + text_width, text_height, image_width, image_height, *, has_text, has_image, horizontal, spacing=15 +): + """Return (width, height) for a tooltip given content metrics and layout.""" + pad = 20 + if horizontal and has_image: + gap = spacing if has_text else 0 + width = text_width + image_width + pad + gap + if has_text: + width = max(width, TOOLTIP_MIN_WIDTH + pad) + height = max(text_height, image_height) + pad + return width, height + + width = max(text_width + pad, image_width + pad, TOOLTIP_MIN_WIDTH + pad if has_text else 0) + height = text_height + image_height + pad + return width, height + class Tooltip(BoxLayout): pass class ToolTipContentLabel(Label): - min_width = 200 + """Tooltip text label that sizes to content, wrapping only when needed.""" + + min_width = TOOLTIP_MIN_WIDTH + max_width = TOOLTIP_MAX_WIDTH def __init__(self, **kwargs): super().__init__(**kwargs) - self.text_size = (max(self.width, self.min_width), None) + self.text_size = (None, None) + + def on_text(self, *args): + self.refresh_text_size() + + def refresh_text_size(self): + """Size to the longest line, wrapping only when past max_width.""" + if not self.text: + self.text_size = (None, None) + return + + # Measure natural (unwrapped) width first. + self.text_size = (None, None) + self.texture_update() + natural_width = self.texture_size[0] - def on_size(self, *args): - self.text_size = (max(self.width, self.min_width), None) - if hasattr(self, "_label"): + if natural_width > self.max_width: + self.text_size = (self.max_width, None) self.texture_update() @@ -40,7 +79,7 @@ class ToolTipSwitch(Switch): tooltip_image = StringProperty("") tooltip_delay = NumericProperty(0.5) show_tooltips = BooleanProperty(False) - tooltip_image_size = ObjectProperty(None) + tooltip_image_size = ObjectProperty(None, allownone=True) def __init__(self, **kwargs): self._tooltip = None @@ -109,11 +148,17 @@ def _update_image(self, *largs): self._tooltip.ids.tooltip_image.size = (0, 0) self._tooltip.ids.tooltip_image.visible = False - def _update_image_size(self, instance, value): + def _update_image_size(self, *_args): + if not self._tooltip: + return + + tooltip_image = self._tooltip.ids.tooltip_image if self.tooltip_image_size: - self._tooltip.ids.tooltip_image.size = self.tooltip_image_size + tooltip_image.size = self.tooltip_image_size + elif self.tooltip_image: + tooltip_image.size = tooltip_image.texture_size else: - instance.size = instance.texture_size[0], instance.texture_size[1] + tooltip_image.size = (0, 0) def _update_tooltip_size(self): tooltip_label = self._tooltip.ids.tooltip_label @@ -123,7 +168,8 @@ def _update_tooltip_size(self): text_width, text_height = tooltip_label.texture_size image_width, image_height = tooltip_image.size - new_width = max(text_width + 20, image_width + 20) + # Keep a stable minimum width for short labels; long text can grow up to max wrap. + new_width = max(text_width + 20, image_width + 20, TOOLTIP_MIN_WIDTH + 20 if tooltip_label.text else 0) new_height = text_height + image_height + 20 # Update tooltip size @@ -165,7 +211,7 @@ def on_mouse_pos(self, *args): if self.tooltip_image: tooltip_padding = 40 - tooltip_width = max(text_width, image_width) + tooltip_width = max(text_width, image_width, TOOLTIP_MIN_WIDTH if self.tooltip_txt else 0) tooltip_height = tooltip_label.texture_size[1] + tooltip_image.height + tooltip_padding self._tooltip.size = (tooltip_width, tooltip_height) x = pos[0] @@ -197,7 +243,7 @@ class ToolTipTextInput(TextInput): tooltip_image = StringProperty("") tooltip_delay = NumericProperty(0.5) show_tooltips = BooleanProperty(False) - tooltip_image_size = ObjectProperty(None) + tooltip_image_size = ObjectProperty(None, allownone=True) def __init__(self, **kwargs): self._tooltip = None @@ -270,11 +316,17 @@ def _update_image(self, *largs): self._tooltip.ids.tooltip_image.size = (0, 0) self._tooltip.ids.tooltip_image.visible = False - def _update_image_size(self, instance, value): + def _update_image_size(self, *_args): + if not self._tooltip: + return + + tooltip_image = self._tooltip.ids.tooltip_image if self.tooltip_image_size: - self._tooltip.ids.tooltip_image.size = self.tooltip_image_size + tooltip_image.size = self.tooltip_image_size + elif self.tooltip_image: + tooltip_image.size = tooltip_image.texture_size else: - instance.size = instance.texture_size[0], instance.texture_size[1] + tooltip_image.size = (0, 0) def _update_tooltip_size(self): tooltip_label = self._tooltip.ids.tooltip_label @@ -284,7 +336,8 @@ def _update_tooltip_size(self): text_width, text_height = tooltip_label.texture_size image_width, image_height = tooltip_image.size - new_width = max(text_width + 20, image_width + 20) + # Keep a stable minimum width for short labels; long text can grow up to max wrap. + new_width = max(text_width + 20, image_width + 20, TOOLTIP_MIN_WIDTH + 20 if tooltip_label.text else 0) new_height = text_height + image_height + 20 # Update tooltip size @@ -326,7 +379,7 @@ def on_mouse_pos(self, *args): if self.tooltip_image: tooltip_padding = 40 - tooltip_width = max(text_width, image_width) + tooltip_width = max(text_width, image_width, TOOLTIP_MIN_WIDTH if self.tooltip_txt else 0) tooltip_height = tooltip_label.texture_size[1] + tooltip_image.height + tooltip_padding self._tooltip.size = (tooltip_width, tooltip_height) x = pos[0] @@ -358,8 +411,10 @@ class ToolTipButton(Button): tooltip_image = StringProperty("") tooltip_delay = NumericProperty(0.5) show_tooltips = BooleanProperty(False) - tooltip_image_size = ObjectProperty(None) + tooltip_image_size = ObjectProperty(None, allownone=True) tooltip_radius = NumericProperty(0.2) + tooltip_horizontal = BooleanProperty(False) + tooltip_markup = BooleanProperty(False) def __init__(self, **kwargs): self._tooltip = None @@ -372,6 +427,8 @@ def __init__(self, **kwargs): fbind("tooltip_txt", self._update_tooltip) fbind("tooltip_image", self._update_image) fbind("tooltip_image_size", self._update_image_size) + fbind("tooltip_horizontal", self._update_tooltip_layout) + fbind("tooltip_markup", self._update_tooltip_markup) Window.bind(mouse_pos=self.on_mouse_pos) self.bind(on_release=self.close_tooltip) self._build_tooltip() @@ -404,9 +461,23 @@ def _build_tooltip(self, *largs): image_widget = self._tooltip.ids.tooltip_image image_widget.bind(texture_size=self._update_image_size) + self._update_tooltip_layout() + self._update_tooltip_markup() self._update_image() self._update_tooltip() + def _update_tooltip_layout(self, *largs): + if not self._tooltip: + return + self._tooltip.orientation = "horizontal" if self.tooltip_horizontal else "vertical" + self._update_tooltip_size() + + def _update_tooltip_markup(self, *largs): + if not self._tooltip: + return + self._tooltip.ids.tooltip_label.markup = self.tooltip_markup + self._update_tooltip_size() + def _update_tooltip(self, *largs): txt = self.tooltip_txt if txt: @@ -428,24 +499,38 @@ def _update_image(self, *largs): self._tooltip.ids.tooltip_image.size = (0, 0) self._tooltip.ids.tooltip_image.visible = False - def _update_image_size(self, instance, value): + def _update_image_size(self, *_args): + if not self._tooltip: + return + + tooltip_image = self._tooltip.ids.tooltip_image if self.tooltip_image_size: - self._tooltip.ids.tooltip_image.size = self.tooltip_image_size + tooltip_image.size = self.tooltip_image_size + elif self.tooltip_image: + tooltip_image.size = tooltip_image.texture_size else: - instance.size = instance.texture_size[0], instance.texture_size[1] + tooltip_image.size = (0, 0) def _update_tooltip_size(self): + if not self._tooltip: + return tooltip_label = self._tooltip.ids.tooltip_label tooltip_image = self._tooltip.ids.tooltip_image - # Calculate new size based on text and image dimensions text_width, text_height = tooltip_label.texture_size image_width, image_height = tooltip_image.size - new_width = max(text_width + 20, image_width + 20) - new_height = text_height + image_height + 20 + new_width, new_height = _compute_tooltip_box_size( + text_width, + text_height, + image_width, + image_height, + has_text=bool(tooltip_label.text), + has_image=bool(self.tooltip_image), + horizontal=self.tooltip_horizontal, + spacing=self._tooltip.spacing, + ) - # Update tooltip size self._tooltip.size = (new_width, new_height) self._tooltip.canvas.ask_update() # Force UI refresh self._tooltip.ids.tooltip_label.texture_update() @@ -472,20 +557,23 @@ def on_mouse_pos(self, *args): return pos = args[1] - tooltip_width, tooltip_height = self._tooltip.size window_width, window_height = Window.size tooltip_label = self._tooltip.ids.tooltip_label tooltip_image = self._tooltip.ids.tooltip_image - text_width = tooltip_label.texture_size[0] - image_width = tooltip_image.width - - tooltip_padding = 10 - if self.tooltip_image: - tooltip_padding = 40 + text_width, text_height = tooltip_label.texture_size + image_width, image_height = tooltip_image.size - tooltip_width = max(text_width, image_width) - tooltip_height = tooltip_label.texture_size[1] + tooltip_image.height + tooltip_padding + tooltip_width, tooltip_height = _compute_tooltip_box_size( + text_width, + text_height, + image_width, + image_height, + has_text=bool(self.tooltip_txt), + has_image=bool(self.tooltip_image), + horizontal=self.tooltip_horizontal, + spacing=self._tooltip.spacing, + ) self._tooltip.size = (tooltip_width, tooltip_height) x = pos[0] y = pos[1] @@ -516,7 +604,7 @@ class ToolTipDropDown(DropDown): tooltip_image = StringProperty("") tooltip_delay = NumericProperty(0.5) show_tooltips = BooleanProperty(False) - tooltip_image_size = ObjectProperty(None) + tooltip_image_size = ObjectProperty(None, allownone=True) def __init__(self, **kwargs): self._tooltip = None @@ -585,11 +673,17 @@ def _update_image(self, *largs): self._tooltip.ids.tooltip_image.size = (0, 0) self._tooltip.ids.tooltip_image.visible = False - def _update_image_size(self, instance, value): + def _update_image_size(self, *_args): + if not self._tooltip: + return + + tooltip_image = self._tooltip.ids.tooltip_image if self.tooltip_image_size: - self._tooltip.ids.tooltip_image.size = self.tooltip_image_size + tooltip_image.size = self.tooltip_image_size + elif self.tooltip_image: + tooltip_image.size = tooltip_image.texture_size else: - instance.size = instance.texture_size[0], instance.texture_size[1] + tooltip_image.size = (0, 0) def _update_tooltip_size(self): tooltip_label = self._tooltip.ids.tooltip_label @@ -599,7 +693,8 @@ def _update_tooltip_size(self): text_width, text_height = tooltip_label.texture_size image_width, image_height = tooltip_image.size - new_width = max(text_width + 20, image_width + 20) + # Keep a stable minimum width for short labels; long text can grow up to max wrap. + new_width = max(text_width + 20, image_width + 20, TOOLTIP_MIN_WIDTH + 20 if tooltip_label.text else 0) new_height = text_height + image_height + 20 # Update tooltip size @@ -641,7 +736,7 @@ def on_mouse_pos(self, *args): if self.tooltip_image: tooltip_padding = 40 - tooltip_width = max(text_width, image_width) + tooltip_width = max(text_width, image_width, TOOLTIP_MIN_WIDTH if self.tooltip_txt else 0) tooltip_height = tooltip_label.texture_size[1] + tooltip_image.height + tooltip_padding self._tooltip.size = (tooltip_width, tooltip_height) x = pos[0] @@ -673,7 +768,7 @@ class ToolTipLabel(Label): tooltip_image = StringProperty("") tooltip_delay = NumericProperty(0.5) show_tooltips = BooleanProperty(False) - tooltip_image_size = ObjectProperty(None) + tooltip_image_size = ObjectProperty(None, allownone=True) def __init__(self, **kwargs): self._tooltip = None @@ -743,11 +838,17 @@ def _update_image(self, *largs): self._tooltip.ids.tooltip_image.size = (0, 0) self._tooltip.ids.tooltip_image.visible = False - def _update_image_size(self, instance, value): + def _update_image_size(self, *_args): + if not self._tooltip: + return + + tooltip_image = self._tooltip.ids.tooltip_image if self.tooltip_image_size: - self._tooltip.ids.tooltip_image.size = self.tooltip_image_size + tooltip_image.size = self.tooltip_image_size + elif self.tooltip_image: + tooltip_image.size = tooltip_image.texture_size else: - instance.size = instance.texture_size[0], instance.texture_size[1] + tooltip_image.size = (0, 0) def _update_tooltip_size(self): tooltip_label = self._tooltip.ids.tooltip_label @@ -757,7 +858,8 @@ def _update_tooltip_size(self): text_width, text_height = tooltip_label.texture_size image_width, image_height = tooltip_image.size - new_width = max(text_width + 20, image_width + 20) + # Keep a stable minimum width for short labels; long text can grow up to max wrap. + new_width = max(text_width + 20, image_width + 20, TOOLTIP_MIN_WIDTH + 20 if tooltip_label.text else 0) new_height = text_height + image_height + 20 # Update tooltip size @@ -799,7 +901,7 @@ def on_mouse_pos(self, *args): if self.tooltip_image: tooltip_padding = 40 - tooltip_width = max(text_width, image_width) + tooltip_width = max(text_width, image_width, TOOLTIP_MIN_WIDTH if self.tooltip_txt else 0) tooltip_height = tooltip_label.texture_size[1] + tooltip_image.height + tooltip_padding self._tooltip.size = (tooltip_width, tooltip_height) x = pos[0] diff --git a/carveracontroller/data/GcodeViewer/pointer.obj b/carveracontroller/data/GcodeViewer/pointer.obj deleted file mode 100755 index 6edc8153..00000000 --- a/carveracontroller/data/GcodeViewer/pointer.obj +++ /dev/null @@ -1,259 +0,0 @@ -# Blender v2.80 (sub 75) OBJ File: '' -# www.blender.org -o Cylinder -v 0.000000 0.040000 1.300000 -v 0.000000 0.040000 0.600000 -v 0.007804 0.039232 1.300000 -v 0.007804 0.039232 0.600000 -v 0.015307 0.036955 1.300000 -v 0.015307 0.036955 0.600000 -v 0.022223 0.033259 1.300000 -v 0.022223 0.033259 0.600000 -v 0.028284 0.028285 1.300000 -v 0.028284 0.028285 0.600000 -v 0.033259 0.022223 1.300000 -v 0.033259 0.022223 0.600000 -v 0.036955 0.015308 1.300000 -v 0.036955 0.015308 0.600000 -v 0.039232 0.007804 1.300000 -v 0.039232 0.007804 0.600000 -v 0.040000 0.000000 1.300000 -v 0.040000 0.000000 0.600000 -v 0.039232 -0.007803 1.300000 -v 0.039232 -0.007803 0.600000 -v 0.036955 -0.015307 1.300000 -v 0.036955 -0.015307 0.600000 -v 0.033259 -0.022223 1.300000 -v 0.033259 -0.022223 0.600000 -v 0.028284 -0.028284 1.300000 -v 0.028284 -0.028284 0.600000 -v 0.022223 -0.033259 1.300000 -v 0.022223 -0.033259 0.600000 -v 0.015307 -0.036955 1.300000 -v 0.015307 -0.036955 0.600000 -v 0.007804 -0.039231 1.300000 -v 0.007804 -0.039231 0.600000 -v 0.000000 -0.040000 1.300000 -v 0.000000 -0.040000 0.600000 -v -0.007804 -0.039231 1.300000 -v -0.007804 -0.039231 0.600000 -v -0.015307 -0.036955 1.300000 -v -0.015307 -0.036955 0.600000 -v -0.022223 -0.033259 1.300000 -v -0.022223 -0.033259 0.600000 -v -0.028284 -0.028284 1.300000 -v -0.028284 -0.028284 0.600000 -v -0.033259 -0.022223 1.300000 -v -0.033259 -0.022223 0.600000 -v -0.036955 -0.015307 1.300000 -v -0.036955 -0.015307 0.600000 -v -0.039231 -0.007803 1.300000 -v -0.039231 -0.007803 0.600000 -v -0.040000 0.000000 1.300000 -v -0.040000 0.000000 0.600000 -v -0.039231 0.007804 1.300000 -v -0.039231 0.007804 0.600000 -v -0.036955 0.015308 1.300000 -v -0.036955 0.015308 0.600000 -v -0.033259 0.022223 1.300000 -v -0.033259 0.022223 0.600000 -v -0.028284 0.028285 1.300000 -v -0.028284 0.028285 0.600000 -v -0.022223 0.033259 1.300000 -v -0.022223 0.033259 0.600000 -v -0.015307 0.036955 1.300000 -v -0.015307 0.036955 0.600000 -v -0.007803 0.039232 1.300000 -v -0.007803 0.039232 0.600000 -v 0.000000 0.000000 0.000000 -vn 0.0980 0.9952 0.0000 -vn 0.2903 0.9569 0.0000 -vn 0.4714 0.8819 0.0000 -vn 0.6344 0.7730 0.0000 -vn 0.7730 0.6344 0.0000 -vn 0.8819 0.4714 0.0000 -vn 0.9569 0.2903 0.0000 -vn 0.9952 0.0980 0.0000 -vn 0.9952 -0.0980 -0.0000 -vn 0.9569 -0.2903 -0.0000 -vn 0.8819 -0.4714 -0.0000 -vn 0.7730 -0.6344 -0.0000 -vn 0.6344 -0.7730 -0.0000 -vn 0.4714 -0.8819 -0.0000 -vn 0.2903 -0.9569 -0.0000 -vn 0.0980 -0.9952 -0.0000 -vn -0.0980 -0.9952 -0.0000 -vn -0.2903 -0.9569 -0.0000 -vn -0.4714 -0.8819 -0.0000 -vn -0.6344 -0.7730 -0.0000 -vn -0.7730 -0.6344 -0.0000 -vn -0.8819 -0.4714 -0.0000 -vn -0.9569 -0.2903 -0.0000 -vn -0.9952 -0.0980 -0.0000 -vn -0.9952 0.0980 0.0000 -vn -0.9569 0.2903 0.0000 -vn -0.8819 0.4714 0.0000 -vn -0.7730 0.6344 0.0000 -vn -0.6344 0.7730 0.0000 -vn -0.4714 0.8819 0.0000 -vn 0.0000 0.0000 1.0000 -vn -0.2903 0.9569 0.0000 -vn -0.0980 0.9952 0.0000 -vn -0.0000 0.0000 -1.0000 -vn -0.0002 0.0000 1.0000 -vn 0.0001 0.0000 1.0000 -s off -f 2//1 3//1 1//1 -f 4//2 5//2 3//2 -f 6//3 7//3 5//3 -f 8//4 9//4 7//4 -f 10//5 11//5 9//5 -f 12//6 13//6 11//6 -f 14//7 15//7 13//7 -f 16//8 17//8 15//8 -f 18//9 19//9 17//9 -f 20//10 21//10 19//10 -f 22//11 23//11 21//11 -f 24//12 25//12 23//12 -f 26//13 27//13 25//13 -f 28//14 29//14 27//14 -f 30//15 31//15 29//15 -f 32//16 33//16 31//16 -f 34//17 35//17 33//17 -f 36//18 37//18 35//18 -f 38//19 39//19 37//19 -f 40//20 41//20 39//20 -f 42//21 43//21 41//21 -f 44//22 45//22 43//22 -f 46//23 47//23 45//23 -f 48//24 49//24 47//24 -f 50//25 51//25 49//25 -f 52//26 53//26 51//26 -f 54//27 55//27 53//27 -f 56//28 57//28 55//28 -f 58//29 59//29 57//29 -f 60//30 61//30 59//30 -f 38//31 22//31 6//31 -f 62//32 63//32 61//32 -f 64//33 1//33 63//33 -f 31//34 47//34 63//34 -f 2//1 4//1 3//1 -f 4//2 6//2 5//2 -f 6//3 8//3 7//3 -f 8//4 10//4 9//4 -f 10//5 12//5 11//5 -f 12//6 14//6 13//6 -f 14//7 16//7 15//7 -f 16//8 18//8 17//8 -f 18//9 20//9 19//9 -f 20//10 22//10 21//10 -f 22//11 24//11 23//11 -f 24//12 26//12 25//12 -f 26//13 28//13 27//13 -f 28//14 30//14 29//14 -f 30//15 32//15 31//15 -f 32//16 34//16 33//16 -f 34//17 36//17 35//17 -f 36//18 38//18 37//18 -f 38//19 40//19 39//19 -f 40//20 42//20 41//20 -f 42//21 44//21 43//21 -f 44//22 46//22 45//22 -f 46//23 48//23 47//23 -f 48//24 50//24 49//24 -f 50//25 52//25 51//25 -f 52//26 54//26 53//26 -f 54//27 56//27 55//27 -f 56//28 58//28 57//28 -f 58//29 60//29 59//29 -f 60//30 62//30 61//30 -f 6//31 4//31 2//31 -f 2//31 64//31 6//31 -f 64//31 62//31 6//31 -f 62//31 60//31 58//31 -f 58//31 56//31 62//31 -f 56//31 54//31 62//31 -f 54//35 52//35 50//35 -f 50//31 48//31 54//31 -f 48//31 46//31 54//31 -f 46//31 44//31 42//31 -f 42//31 40//31 38//31 -f 38//31 36//31 34//31 -f 34//31 32//31 30//31 -f 30//31 28//31 22//31 -f 28//36 26//36 22//36 -f 26//31 24//31 22//31 -f 22//35 20//35 18//35 -f 18//31 16//31 22//31 -f 16//31 14//31 22//31 -f 14//31 12//31 10//31 -f 10//31 8//31 14//31 -f 8//31 6//31 14//31 -f 46//31 42//31 54//31 -f 42//31 38//31 54//31 -f 38//31 34//31 22//31 -f 34//31 30//31 22//31 -f 6//31 62//31 54//31 -f 22//31 14//31 6//31 -f 6//31 54//31 38//31 -f 62//32 64//32 63//32 -f 64//33 2//33 1//33 -f 63//34 1//34 3//34 -f 3//34 5//34 7//34 -f 7//34 9//34 15//34 -f 9//34 11//34 15//34 -f 11//34 13//34 15//34 -f 15//34 17//34 19//34 -f 19//34 21//34 15//34 -f 21//34 23//34 15//34 -f 23//34 25//34 31//34 -f 25//34 27//34 31//34 -f 27//34 29//34 31//34 -f 31//34 33//34 35//34 -f 35//34 37//34 39//34 -f 39//34 41//34 43//34 -f 43//34 45//34 47//34 -f 47//34 49//34 51//34 -f 51//34 53//34 47//34 -f 53//34 55//34 47//34 -f 55//34 57//34 59//34 -f 59//34 61//34 63//34 -f 63//34 3//34 15//34 -f 3//34 7//34 15//34 -f 31//34 35//34 39//34 -f 39//34 43//34 47//34 -f 55//34 59//34 47//34 -f 59//34 63//34 47//34 -f 15//34 23//34 63//34 -f 23//34 31//34 63//34 -f 31//34 39//34 47//34 -f 2//1 4//1 65//1 -f 4//2 6//2 65//2 -f 6//3 8//3 65//3 -f 8//4 10//4 65//4 -f 10//5 12//5 65//5 -f 12//6 14//6 65//6 -f 14//7 16//7 65//7 -f 16//8 18//8 65//8 -f 18//9 20//9 65//9 -f 20//10 22//10 65//10 -f 22//11 24//11 65//11 -f 24//12 26//12 65//12 -f 26//13 28//13 65//13 -f 28//14 30//14 65//14 -f 30//15 32//15 65//15 -f 32//16 34//16 65//16 -f 34//17 36//17 65//17 -f 36//18 38//18 65//18 -f 38//19 40//19 65//19 -f 40//20 42//20 65//20 -f 42//21 44//21 65//21 -f 44//22 46//22 65//22 -f 46//23 48//23 65//23 -f 48//24 50//24 65//24 -f 50//25 52//25 65//25 -f 52//26 54//26 65//26 -f 54//27 56//27 65//27 -f 56//28 58//28 65//28 -f 58//29 60//29 65//29 -f 60//30 2//30 65//30 diff --git a/carveracontroller/data/GcodeViewer/tools/ball_end_mill.png b/carveracontroller/data/GcodeViewer/tools/ball_end_mill.png new file mode 100644 index 00000000..08903963 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/ball_end_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/ball_end_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/ball_end_mill_thumb.png new file mode 100644 index 00000000..82895760 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/ball_end_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill.png b/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill.png new file mode 100644 index 00000000..b34689f5 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill_thumb.png new file mode 100644 index 00000000..76b36925 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/bull_nose_end_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/flat_end_mill.png b/carveracontroller/data/GcodeViewer/tools/flat_end_mill.png new file mode 100644 index 00000000..e6113f88 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/flat_end_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/flat_end_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/flat_end_mill_thumb.png new file mode 100644 index 00000000..e1a76f5b Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/flat_end_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/lollipop_mill.png b/carveracontroller/data/GcodeViewer/tools/lollipop_mill.png new file mode 100644 index 00000000..75239526 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/lollipop_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/lollipop_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/lollipop_mill_thumb.png new file mode 100644 index 00000000..736fe975 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/lollipop_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/pointed_mill.png b/carveracontroller/data/GcodeViewer/tools/pointed_mill.png new file mode 100644 index 00000000..56631792 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/pointed_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/pointed_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/pointed_mill_thumb.png new file mode 100644 index 00000000..665ef91c Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/pointed_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/radius_mill.png b/carveracontroller/data/GcodeViewer/tools/radius_mill.png new file mode 100644 index 00000000..6cc645f2 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/radius_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/radius_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/radius_mill_thumb.png new file mode 100644 index 00000000..e7eab6a9 Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/radius_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/tapered_mill.png b/carveracontroller/data/GcodeViewer/tools/tapered_mill.png new file mode 100644 index 00000000..a4ebce8a Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/tapered_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/tapered_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/tapered_mill_thumb.png new file mode 100644 index 00000000..560f80ee Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/tapered_mill_thumb.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/thread_mill.png b/carveracontroller/data/GcodeViewer/tools/thread_mill.png new file mode 100644 index 00000000..79c7890a Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/thread_mill.png differ diff --git a/carveracontroller/data/GcodeViewer/tools/thread_mill_thumb.png b/carveracontroller/data/GcodeViewer/tools/thread_mill_thumb.png new file mode 100644 index 00000000..a5e8be9b Binary files /dev/null and b/carveracontroller/data/GcodeViewer/tools/thread_mill_thumb.png differ diff --git a/carveracontroller/main.py b/carveracontroller/main.py index be17e511..18956916 100644 --- a/carveracontroller/main.py +++ b/carveracontroller/main.py @@ -83,7 +83,14 @@ def is_ios(): from kivy.factory import Factory from kivy.graphics import Color, Ellipse, Line, PopMatrix, PushMatrix, Rectangle, Rotate, Translate from kivy.metrics import Metrics, dp -from kivy.properties import BooleanProperty, ListProperty, NumericProperty, ObjectProperty, StringProperty +from kivy.properties import ( + BooleanProperty, + DictProperty, + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) from kivy.uix.behaviors import FocusBehavior from kivy.uix.boxlayout import BoxLayout from kivy.uix.button import Button @@ -188,6 +195,12 @@ def ios_webbrowser_open(url, new=None): from . import Utils, custom_widgets from .__version__ import __version__ from .addons.probing.ProbingControls import ProbeButton +from .addons.tool_visualization import ( + extract_tool_table, + format_tool_tooltip, + get_tool_icon_path, + get_tool_tooltip_icon_path, +) from .addons.tooltips.Tooltips import Tooltip, ToolTipButton, ToolTipDropDown from .CNC import ( CNC, @@ -2918,6 +2931,9 @@ class Makera(RelativeLayout): upcoming_tool = 0 file_has_ocodes = False tool_change_markers = [] + tool_table = {} + tool_icons = DictProperty({}) + show_tool_button_icons = BooleanProperty(False) # Custom property to monitor CNC light state light_state = LightProperty(False) @@ -3095,6 +3111,12 @@ def __init__(self, ctl_version): self.gcode_viewer.time_estimate_progress_callback = self._on_time_estimate_progress self.float_layout.tool_bar.show_grid = self.gcode_viewer.is_grid_visible() + # Handle tool button icons visibility when the window is resized + self.bind(show_tool_button_icons=self._refresh_tool_filter_buttons) + self.float_layout.tool_bar.bind(width=self._update_tool_button_icon_visibility) + Window.bind(on_resize=self._update_tool_button_icon_visibility) + Clock.schedule_once(lambda _dt: self._refresh_tool_filter_buttons(), 0) + # init settings self.config = ConfigParser() self.config_popup = ConfigPopup() @@ -4243,21 +4265,30 @@ def open_sleep_confirm_popup(self): self.confirm_popup.confirm = partial(self.resetMachine) self.confirm_popup.open(self) + # ----------------------------------------------------------------------- + def _format_target_tool_text(self): + """Return display text for the tool being requested in a tool-change popup.""" + tool_number = CNC.vars["target_tool"] + if tool_number == ZPROBE_TOOL_NUMBER: + return "Probe" + if tool_number == LASER_TOOL_NUMBER: + return "Laser" + if tool_number == PROBE_3D_TOOL_NUMBER: + return "3D Probe" + if is_probe_tools_range(tool_number): + return "Custom Probe" + + tool_def = self.tool_table.get(tool_number) + tooltip = format_tool_tooltip(tool_def, markup=False) if tool_def else "" + return tooltip if tooltip else str(tool_number) + # ----------------------------------------------------------------------- def open_tool_confirm_popup(self): if self.confirm_popup.showing: return - target_tool = str(CNC.vars["target_tool"]) + target_tool = self._format_target_tool_text() target_collet_type = CNC.vars["target_collet_type"] target_collet_type_text = ["Undefined", "3mm", '1/8"', "4mm", "6mm", '1/4"', "8mm"] - if CNC.vars["target_tool"] == ZPROBE_TOOL_NUMBER: - target_tool = "Probe" - elif CNC.vars["target_tool"] == LASER_TOOL_NUMBER: - target_tool = "Laser" - elif CNC.vars["target_tool"] == PROBE_3D_TOOL_NUMBER: - target_tool = "3D Probe" - elif is_probe_tools_range(CNC.vars["target_tool"]): - target_tool = "Custom Probe" app = App.get_running_app() if app.has_atc: @@ -4268,7 +4299,7 @@ def open_tool_confirm_popup(self): self.confirm_popup.lb_title.text = tr._("Manual toolchange") self.confirm_popup.lb_content.text = ( tr._("Insert tool: ") - + "%s\n" % (target_tool) + + "%s\n\n" % (target_tool) + tr._("Then press ' Confirm' or main button to clamp.\n") ) else: @@ -6941,6 +6972,9 @@ def clear_selection(self): self.wpb_play.value = 0 self.used_tools = [] self.upcoming_tool = 0 + self.tool_table = {} + self.gcode_viewer.tool_table = {} + self._refresh_tool_filter_buttons() self._clear_tool_change_markers() app = App.get_running_app() app.curr_page = 1 @@ -7133,6 +7167,8 @@ def load_gcode_file(self, filepath): self.file_has_ocodes = False self.used_tools = [] self.tool_change_markers = [] + self.tool_table = {} + self.gcode_viewer.tool_table = {} Clock.schedule_once(self.load_start) f = None try: @@ -7157,6 +7193,9 @@ def load_gcode_file(self, filepath): self.lines = f.readlines() self.selected_file_line_count = len(self.lines) f.close() + self.tool_table = extract_tool_table(self.lines) + self.gcode_viewer.tool_table = self.tool_table + self._refresh_tool_filter_buttons() app = App.get_running_app() app.total_pages = int(self.selected_file_line_count / MAX_LOAD_LINES) + ( 0 if self.selected_file_line_count % MAX_LOAD_LINES == 0 else 1 @@ -7224,6 +7263,50 @@ def load_gcode_file(self, filepath): Clock.schedule_once(self.load_end, 0) + # ----------------------------------------------------------------------- + def _update_tool_button_icon_visibility(self, *_args): + tool_bar = self.float_layout.tool_bar + tool_bar_icons_required_width = dp(438 + 6 * 66) + if tool_bar.width <= 0: + return + has_parsed_tools = bool(self.tool_table) + show_icons = has_parsed_tools and tool_bar.width >= tool_bar_icons_required_width + if show_icons != self.show_tool_button_icons: + self.show_tool_button_icons = show_icons + + @mainthread + def _refresh_tool_filter_buttons(self, *_args): + """Update T1..T6 toolbar icons and tooltips from the current tool table.""" + self.tool_icons = {number: get_tool_icon_path(tool_def) for number, tool_def in self.tool_table.items()} + self._update_tool_button_icon_visibility() + default_icon = get_tool_icon_path(None) + tool_buttons = [ + self.float_layout.t1, + self.float_layout.t2, + self.float_layout.t3, + self.float_layout.t4, + self.float_layout.t5, + self.float_layout.t6, + ] + for number, tool_button in enumerate(tool_buttons, start=1): + if self.show_tool_button_icons: + tool_button.icon = self.tool_icons.get(number, default_icon) + else: + tool_button.icon = "" + tool_def = self.tool_table.get(number) + if tool_def: + tool_button.tooltip_markup = True + tool_button.tooltip_horizontal = True + tool_button.tooltip_image_size = (64, 64) + tool_button.tooltip_image = get_tool_tooltip_icon_path(tool_def) + tool_button.tooltip_txt = format_tool_tooltip(tool_def) + else: + tool_button.tooltip_txt = "" + tool_button.tooltip_image = "" + tool_button.tooltip_image_size = None + tool_button.tooltip_horizontal = False + tool_button.tooltip_markup = False + # ----------------------------------------------------------------------- def init_tool_filter(self): tool_buttons = [ diff --git a/carveracontroller/makera.kv b/carveracontroller/makera.kv index d00ff6d7..f29ae44c 100644 --- a/carveracontroller/makera.kv +++ b/carveracontroller/makera.kv @@ -91,6 +91,9 @@ width: '60dp' icon_size: 24 icon: '' + # 'center' (default): icon centered behind the text, like the existing icon-only buttons. + # 'left': icon placed to the left of the text, side by side (used by the tool buttons). + icon_align: 'center' text: '' min_icon: '' min_icon_size: 12 @@ -102,22 +105,24 @@ disabled_color: 0, 0, 0, 0 Label: text: root.text if root.text else '' - halign: 'center' - valign: 'top' if root.min_icon else 'middle' - text_size: self.width, self.height - dp(root.min_icon_size + 12) if root.min_icon else None + halign: 'right' if root.icon_align == 'left' and root.icon else 'center' + valign: 'middle' if root.icon_align == 'left' else ('top' if root.min_icon else 'middle') + # Reserve a bit more bottom space when an eye icon is present so text/tool icons sit higher. + text_size: (self.width - dp(root.icon_size + 8), self.height - dp(root.min_icon_size + 12) if root.min_icon else self.height) if root.icon_align == 'left' and root.icon else (self.width, self.height - dp(root.min_icon_size + 12) if root.min_icon else None) color: tuple(app.active_color) if (root.active and not root.disabled) else ((110/255, 110/255, 110/255, 1) if root.disabled else (250/255, 250/255, 250/255, 1)) canvas.before: Color: rgba: tuple(app.active_color) if (root.active and not root.disabled) else ((110/255, 110/255, 110/255, 1) if root.disabled else (250/255, 250/255, 250/255, 1)) Rectangle: source: root.icon - pos: self.center_x - dp(root.icon_size / 2), self.center_y - dp(root.icon_size / 2) + # Nudge tool icons up slightly when an eye is shown, to keep a gap above it. + pos: (self.x + dp(4), self.center_y - dp(root.icon_size / 2) + (dp(4) if root.min_icon else 0)) if root.icon_align == 'left' else (self.center_x - dp(root.icon_size / 2), self.center_y - dp(root.icon_size / 2)) size: dp(root.icon_size) if root.icon else 0, dp(root.icon_size) if root.icon else 0 Color: rgba: (250/255, 250/255, 250/255, 1) if root.min_active else (60/255, 60/255, 60/255, 1) Rectangle: source: root.min_icon if root.min_icon and not root.disabled else '' - pos: self.center_x - dp(root.min_icon_size / 2), self.y + dp(4) + pos: self.center_x - dp(root.min_icon_size / 2), self.y + dp(1) size: dp(root.min_icon_size) if root.min_icon and not root.disabled else 0, dp(root.min_icon_size) if root.min_icon and not root.disabled else 0 : @@ -1436,22 +1441,25 @@ : lb_title: lb_title lb_content: lb_content - size_hint: 0.5, 0.4 + size_hint: 0.5, 0.45 pos_hint: {"right": 0.75, "top": 0.7} auto_dismiss: False BoxLayout: padding: '15dp' orientation: 'vertical' Label: - size_hint_y: 0.4 + size_hint_y: 0.25 id: lb_title text: tr._('Confirm Box') bold: True Label: id: lb_content text: tr._('Confirm to continue?') + text_size: self.size + halign: 'center' + valign: 'middle' BoxLayout: - size_hint_y: 0.4 + size_hint_y: 0.3 Button: text: tr._('Cancel') on_release: @@ -4260,9 +4268,12 @@ # tools GcodeViewerToolBarButton: id: t1 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T1' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 1 disabled: 1 not in root.used_tools min_icon: 'data/eye.png' @@ -4271,9 +4282,12 @@ app.root.filter_tool() GcodeViewerToolBarButton: id: t2 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T2' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 2 disabled: 2 not in root.used_tools min_icon: 'data/eye.png' @@ -4282,9 +4296,12 @@ app.root.filter_tool() GcodeViewerToolBarButton: id: t3 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T3' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 3 disabled: 3 not in root.used_tools min_icon: 'data/eye.png' @@ -4293,9 +4310,12 @@ app.root.filter_tool() GcodeViewerToolBarButton: id: t4 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T4' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 4 disabled: 4 not in root.used_tools min_icon: 'data/eye.png' @@ -4304,9 +4324,12 @@ app.root.filter_tool() GcodeViewerToolBarButton: id: t5 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T5' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 5 disabled: 5 not in root.used_tools min_icon: 'data/eye.png' @@ -4315,9 +4338,12 @@ app.root.filter_tool() GcodeViewerToolBarButton: id: t6 - width: '44dp' + width: '66dp' if root.show_tool_button_icons else '44dp' valign: 'middle' text: 'T6' + icon: '' + icon_align: 'left' + icon_size: 28 active: app.tool == 6 disabled: 6 not in root.used_tools min_icon: 'data/eye.png' diff --git a/carveracontroller/pointer2.obj b/carveracontroller/pointer2.obj deleted file mode 100755 index d0bd459f..00000000 --- a/carveracontroller/pointer2.obj +++ /dev/null @@ -1,103 +0,0 @@ -# Blender v2.80 (sub 75) OBJ File: '' -# www.blender.org -o Cone -v -0.357218 0.000000 2.000000 -v -0.350355 0.069690 2.000000 -v -0.330027 0.136702 2.000000 -v -0.297016 0.198460 2.000000 -v -0.252592 0.252591 2.000000 -v -0.198460 0.297016 2.000000 -v -0.136702 0.330027 2.000000 -v -0.069690 0.350354 2.000000 -v -0.000000 0.357218 2.000000 -v 0.069690 0.350354 2.000000 -v 0.136701 0.330027 2.000000 -v 0.198460 0.297016 2.000000 -v 0.252591 0.252591 2.000000 -v 0.297016 0.198460 2.000000 -v 0.330026 0.136701 2.000000 -v 0.350354 0.069690 2.000000 -v 0.357218 -0.000000 2.000000 -v 0.350354 -0.069690 2.000000 -v 0.330026 -0.136702 2.000000 -v 0.297016 -0.198460 2.000000 -v 0.252591 -0.252591 2.000000 -v 0.198459 -0.297016 2.000000 -v 0.136701 -0.330027 2.000000 -v 0.000000 0.000000 0.000000 -v 0.069689 -0.350354 2.000000 -v -0.000001 -0.357218 2.000000 -v -0.069690 -0.350354 2.000000 -v -0.136702 -0.330026 2.000000 -v -0.198460 -0.297016 2.000000 -v -0.252592 -0.252591 2.000000 -v -0.297017 -0.198459 2.000000 -v -0.330027 -0.136701 2.000000 -v -0.350355 -0.069689 2.000000 -vn -0.9798 0.0965 -0.1750 -vn -0.9422 0.2858 -0.1750 -vn -0.8683 0.4641 -0.1750 -vn -0.7611 0.6246 -0.1750 -vn -0.6246 0.7611 -0.1750 -vn -0.4641 0.8683 -0.1750 -vn -0.2858 0.9422 -0.1750 -vn -0.0965 0.9798 -0.1750 -vn 0.0965 0.9798 -0.1750 -vn 0.2858 0.9422 -0.1750 -vn 0.4641 0.8683 -0.1750 -vn 0.6246 0.7611 -0.1750 -vn 0.7611 0.6246 -0.1750 -vn 0.8683 0.4641 -0.1750 -vn 0.9422 0.2858 -0.1750 -vn 0.9798 0.0965 -0.1750 -vn 0.9798 -0.0965 -0.1750 -vn 0.9422 -0.2858 -0.1750 -vn 0.8683 -0.4641 -0.1750 -vn 0.7611 -0.6246 -0.1750 -vn 0.6246 -0.7611 -0.1750 -vn 0.4641 -0.8683 -0.1750 -vn 0.2858 -0.9422 -0.1750 -vn 0.0965 -0.9798 -0.1750 -vn -0.0965 -0.9798 -0.1750 -vn -0.2858 -0.9422 -0.1750 -vn -0.4641 -0.8683 -0.1750 -vn -0.6246 -0.7611 -0.1750 -vn -0.7611 -0.6246 -0.1750 -vn -0.8683 -0.4641 -0.1750 -vn -0.9422 -0.2858 -0.1750 -vn -0.9798 -0.0965 -0.1750 -vn -0.0000 0.0000 1.0000 -s off -f 1//1 2//1 24//1 -f 2//2 3//2 24//2 -f 3//3 4//3 24//3 -f 4//4 5//4 24//4 -f 5//5 6//5 24//5 -f 6//6 7//6 24//6 -f 7//7 8//7 24//7 -f 8//8 9//8 24//8 -f 9//9 10//9 24//9 -f 10//10 11//10 24//10 -f 11//11 12//11 24//11 -f 12//12 13//12 24//12 -f 13//13 14//13 24//13 -f 14//14 15//14 24//14 -f 15//15 16//15 24//15 -f 16//16 17//16 24//16 -f 17//17 18//17 24//17 -f 18//18 19//18 24//18 -f 19//19 20//19 24//19 -f 20//20 21//20 24//20 -f 21//21 22//21 24//21 -f 22//22 23//22 24//22 -f 23//23 25//23 24//23 -f 25//24 26//24 24//24 -f 26//25 27//25 24//25 -f 27//26 28//26 24//26 -f 28//27 29//27 24//27 -f 29//28 30//28 24//28 -f 30//29 31//29 24//29 -f 31//30 32//30 24//30 -f 32//31 33//31 24//31 -f 33//32 1//32 24//32 -f 1//33 33//33 32//33 31//33 30//33 29//33 28//33 27//33 26//33 25//33 23//33 22//33 21//33 20//33 19//33 18//33 17//33 16//33 15//33 14//33 13//33 12//33 11//33 10//33 9//33 8//33 7//33 6//33 5//33 4//33 3//33 2//33 diff --git a/carveracontroller/shaders/tool_pointer.glsl b/carveracontroller/shaders/tool_pointer.glsl index 689432cb..9064ec4b 100644 --- a/carveracontroller/shaders/tool_pointer.glsl +++ b/carveracontroller/shaders/tool_pointer.glsl @@ -1,22 +1,28 @@ -// Tool position pointer mesh (pointer.obj) with simple diffuse shading +// Tool position pointer mesh with simple diffuse shading. +// Drawn in two passes (back faces, then front faces) for correct translucency. +// Vertex colors distinguish flute (gold, more opaque) from shank (gray). ---vertex $HEADER$ attribute vec3 v_pos; attribute vec3 v_normal; +attribute vec4 v_color; uniform vec3 offset; uniform mat4 rotation; -varying vec4 normal_vec; +varying vec3 normal_vec; +varying vec4 tool_color; void main() { vec4 rotated = rotation * vec4(v_pos, 1.0); vec3 world_pos = rotated.xyz + offset; vec4 eye_pos = modelview_mat * vec4(world_pos, 1.0); - normal_vec = vec4(v_normal, 0.0); + // Transform normals with the same rotation + view as positions (w=0 skips translation). + normal_vec = (modelview_mat * rotation * vec4(v_normal, 0.0)).xyz; + tool_color = v_color; tex_coord0 = vec2(0.0); gl_Position = projection_mat * eye_pos; } @@ -24,11 +30,15 @@ void main() ---fragment $HEADER$ -varying vec4 normal_vec; +varying vec3 normal_vec; +varying vec4 tool_color; void main() { - float shade = abs(dot(normal_vec.xyz, vec3(1.0, 1.0, 1.0))); - vec3 color = vec3(0.3, 0.3, 1.0) * shade; - gl_FragColor = vec4(color, 0.3) * texture2D(texture0, tex_coord0); + // abs() keeps both shell passes lit; light is fixed in view space so shading + // stays stable while orbiting the camera. + vec3 n = normalize(normal_vec); + float shade = 0.35 + 0.65 * abs(dot(n, normalize(vec3(0.35, 0.55, 1.0)))); + vec3 color = tool_color.rgb * shade; + gl_FragColor = vec4(color, tool_color.a) * texture2D(texture0, tex_coord0); } diff --git a/tests/unit/test_tool_visualization.py b/tests/unit/test_tool_visualization.py new file mode 100644 index 00000000..5f05ee34 --- /dev/null +++ b/tests/unit/test_tool_visualization.py @@ -0,0 +1,1029 @@ +"""Tests for CAM tool metadata extraction and procedural tool mesh generation.""" + +import math + +import pytest + +from carveracontroller.addons.tool_visualization import ( + ToolDefinition, + ToolType, + extract_tool_table, + format_tool_tooltip, + get_tool_icon_path, +) +from carveracontroller.addons.tool_visualization.mesh_builder import ( + FALLBACK_FLUTE_DIAMETER_FACTOR, + FALLBACK_TOOL_DISPLAY_HEIGHT, + FALLBACK_TOOL_DISPLAY_RADIUS, + FLOATS_PER_VERTEX, + FLUTE_COLOR, + INFERRED_SHOULDER_DIAMETER_FACTOR, + LENGTH_DIAMETER_FACTOR, + MIN_VISIBLE_RADIUS, + SHANK_COLOR, + VERTEX_FORMAT, + build_tool_mesh, + build_tool_meshes, + tool_profile, +) +from carveracontroller.addons.tool_visualization.parsers.fusion360_makera import ( + TOOL_TYPE_NAME_MAP, + Fusion360MakeraParser, + _infer_shank_diameter, +) +from carveracontroller.addons.tool_visualization.parsers.makera_studio import ( + TOOL_TYPE_NAME_MAP as MAKERA_STUDIO_TOOL_TYPE_NAME_MAP, +) +from carveracontroller.addons.tool_visualization.parsers.makera_studio import ( + MakeraStudioParser, +) +from carveracontroller.addons.tool_visualization.tool_definition import resolve_tool_type + + +def _max_xy_radius(vertices): + radii = [] + for i in range(0, len(vertices), FLOATS_PER_VERTEX): + x = vertices[i] + y = vertices[i + 1] + radii.append(math.hypot(x, y)) + return max(radii) if radii else 0.0 + + +def _mesh_height(vertices): + zs = [vertices[i + 2] for i in range(0, len(vertices), FLOATS_PER_VERTEX)] + return max(zs) - min(zs) if zs else 0.0 + + +def _apex_vertex_indices(vertices, tol=1e-6): + zs = [vertices[i + 2] for i in range(0, len(vertices), FLOATS_PER_VERTEX)] + z_min = min(zs) + return [ + i // FLOATS_PER_VERTEX + for i in range(0, len(vertices), FLOATS_PER_VERTEX) + if abs(vertices[i + 2] - z_min) < tol and math.hypot(vertices[i], vertices[i + 1]) < tol + ] + + +def _vertex_normal(vertices, index): + base = index * FLOATS_PER_VERTEX + return (vertices[base + 3], vertices[base + 4], vertices[base + 5]) + + +def _vertex_color(vertices, index): + base = index * FLOATS_PER_VERTEX + return (vertices[base + 6], vertices[base + 7], vertices[base + 8], vertices[base + 9]) + + +def _vertex_z(vertices, index): + return vertices[index * FLOATS_PER_VERTEX + 2] + + +class TestFusion360MakeraParser: + @pytest.fixture + def parser(self): + return Fusion360MakeraParser() + + def test_parses_basic_flat_end_mill_comment(self, parser): + lines = ["(T1 Flat end mill Vendor PID D=6 CR=0 - flat end mill)\n", "G0 X0\n"] + table = parser.parse(lines) + + assert table[1].number == 1 + assert table[1].tool_type == ToolType.FLAT_END_MILL + assert table[1].diameter == 6.0 + assert table[1].shank_diameter == 6.0 + assert table[1].corner_radius == 0.0 + assert table[1].description == "Flat end mill" + assert table[1].vendor == "Vendor" + assert table[1].product_id == "PID" + assert table[1].length is None + assert table[1].flute_length is None + assert table[1].tip_diameter is None + + def test_parses_optional_taper_and_ignored_zmin(self, parser): + lines = ["(T2 Bull tool D=3.175 CR=0.5 TAPER=45deg - ZMIN=-5 - bull nose end mill)\n"] + table = parser.parse(lines) + + assert table[2].tool_type == ToolType.BULL_NOSE_END_MILL + assert table[2].taper_angle_deg == 45.0 + assert table[2].shank_diameter == 3.175 + + def test_parses_tapered_mill_comment(self, parser): + lines = ["(T12 D=1.872 CR=1. TAPER=3.8deg - ZMIN=-0.7 - tapered mill)\n"] + table = parser.parse(lines) + + assert table[12].tool_type == ToolType.TAPERED_MILL + assert table[12].diameter == 1.872 + assert table[12].corner_radius == 1.0 + assert table[12].taper_angle_deg == 3.8 + assert table[12].type_name == "tapered mill" + assert table[12].shank_diameter is None + + def test_infers_radius_mill_shank_from_outer_diameter(self, parser): + lines = ["(T5 Concave D=2 CR=2 - radius mill)\n"] + table = parser.parse(lines) + + assert table[5].tool_type == ToolType.RADIUS_MILL + assert table[5].shank_diameter == 6.0 + + def test_chamfer_mill_keeps_chamfer_type(self, parser): + lines = [ + "(T1 Single Flute Engraving Metal 60 deg*.1mm D=3.175 CR=0. TAPER=30deg - ZMIN=0. - chamfer mill)\n" + ] + table = parser.parse(lines) + + assert table[1].tool_type == ToolType.CHAMFER_MILL + assert table[1].shank_diameter == 3.175 + assert table[1].taper_angle_deg == 30.0 + + def test_lollipop_leaves_shank_unset(self, parser): + lines = ["(T8 Under D=6 CR=0 - lollipop mill)\n"] + table = parser.parse(lines) + + assert table[8].tool_type == ToolType.LOLLIPOP_MILL + assert table[8].shank_diameter is None + + def test_parses_semicolon_comments(self, parser): + lines = [";T3 Thread cutter D=4 CR=0 - thread mill\n"] + table = parser.parse(lines) + + assert table[3].tool_type == ToolType.THREAD_MILL + assert table[3].shank_diameter == 4.0 + + def test_ignores_duplicate_tool_entries(self, parser): + lines = [ + "(T1 First D=6 CR=0 - flat end mill)\n", + "(T1 Second D=8 CR=0 - ball end mill)\n", + ] + table = parser.parse(lines) + + assert table[1].diameter == 6.0 + assert table[1].tool_type == ToolType.FLAT_END_MILL + + def test_unknown_tool_type_falls_back_to_unknown(self, parser): + lines = ["(T4 Mystery D=6 CR=0 - mystery cutter)\n"] + table = parser.parse(lines) + + assert table[4].tool_type == ToolType.UNKNOWN + assert table[4].type_name == "mystery cutter" + assert table[4].shank_diameter == 6.0 + + +class TestMakeraStudioParser: + @pytest.fixture + def parser(self): + return MakeraStudioParser() + + def test_parses_sample_flat_end_header(self, parser): + lines = [ + ";@MKR|BEGIN\n", + ";@MKR|SCHEMA|v=1.0.0\n", + ";@MKR|TOOL|number=1|id=111011203812|name=3.175*2*12mm Flat End|type=Flat End|" + "handlediameter=3.175|sticklength=0|shoulderlength=12|flutelength=12|diameter=2|" + "tipdiameter=2|cornerradius=0|angle=0|halfAngle=0\n", + ";@MKR|END\n", + "G0 X0\n", + ] + table = parser.parse(lines) + + tool = table[1] + assert tool.tool_type == ToolType.FLAT_END_MILL + assert tool.diameter == 2.0 + assert tool.shank_diameter == 3.175 + assert tool.tip_diameter == 2.0 + assert tool.corner_radius == 0.0 + assert tool.length == 12.0 + assert tool.flute_length == 12.0 + assert tool.shoulder_length == 12.0 + assert tool.description == "3.175*2*12mm Flat End" + assert tool.product_id == "111011203812" + assert tool.type_name == "Flat End" + assert tool.taper_angle_deg is None + + def test_parses_distinct_flute_shoulder_and_stick_lengths(self, parser): + lines = [ + ";@MKR|TOOL|number=1|id=111011203812|name=3.175*2*12mm Flat End|type=Flat End|" + "handlediameter=3.175|sticklength=12|shoulderlength=6|flutelength=6|diameter=2|" + "tipdiameter=2|cornerradius=0|angle=0|halfAngle=0\n" + ] + tool = parser.parse(lines)[1] + + assert tool.diameter == 2.0 + assert tool.shank_diameter == 3.175 + assert tool.flute_length == 6.0 + assert tool.shoulder_length == 6.0 + assert tool.length == 12.0 + + def test_parses_shoulder_equal_to_stick_without_shank_body(self, parser): + lines = [ + ";@MKR|TOOL|number=1|id=111011203812|name=3.175*2*12mm Flat End|type=Flat End|" + "handlediameter=3.175|sticklength=12|shoulderlength=12|flutelength=6|diameter=2|" + "tipdiameter=2|cornerradius=0|angle=0|halfAngle=0\n" + ] + tool = parser.parse(lines)[1] + + assert tool.flute_length == 6.0 + assert tool.shoulder_length == 12.0 + assert tool.length == 12.0 + + def test_parses_engraving_half_angle_as_included_taper(self, parser): + lines = [ + ";@MKR|TOOL|number=2|id=abc|name=V-bit|type=Engraving|handlediameter=3.175|" + "sticklength=20|shoulderlength=0|flutelength=12|diameter=3.175|tipdiameter=0.1|" + "cornerradius=0|angle=0|halfAngle=15\n" + ] + table = parser.parse(lines) + + tool = table[2] + assert tool.tool_type == ToolType.ENGRAVING + assert tool.taper_angle_deg == 30.0 + assert tool.tip_diameter == 0.1 + assert tool.length == 20.0 + assert tool.flute_length == 12.0 + assert tool.shoulder_length is None + assert tool.shank_diameter == 3.175 + + def test_unknown_type_falls_back_to_unknown(self, parser): + lines = [";@MKR|TOOL|number=3|id=x|name=Mystery|type=Mystery Cutter|diameter=6|cornerradius=0\n"] + table = parser.parse(lines) + + assert table[3].tool_type == ToolType.UNKNOWN + assert table[3].type_name == "Mystery Cutter" + + def test_returns_empty_for_non_mkr_header(self, parser): + lines = ["(T1 Flat end mill D=6 CR=0 - flat end mill)\n", "G0 X0\n"] + assert parser.parse(lines) == {} + + +class TestToolDefinitionHelpers: + def test_resolve_tool_type_normalises_names(self): + assert resolve_tool_type(" Flat End Mill ", TOOL_TYPE_NAME_MAP) == ToolType.FLAT_END_MILL + assert resolve_tool_type("", TOOL_TYPE_NAME_MAP) == ToolType.UNKNOWN + assert resolve_tool_type("Flat End", MAKERA_STUDIO_TOOL_TYPE_NAME_MAP) == ToolType.FLAT_END_MILL + assert resolve_tool_type("Engraving", MAKERA_STUDIO_TOOL_TYPE_NAME_MAP) == ToolType.ENGRAVING + + def test_infer_shank_diameter(self): + assert _infer_shank_diameter(ToolType.FLAT_END_MILL, 6.0) == 6.0 + assert _infer_shank_diameter(ToolType.RADIUS_MILL, 2.0, 2.0) == 6.0 + assert _infer_shank_diameter(ToolType.TAPERED_MILL, 1.5) is None + assert _infer_shank_diameter(ToolType.LOLLIPOP_MILL, 6.0) is None + assert _infer_shank_diameter(ToolType.CHAMFER_MILL, None) is None + + +class TestIconBuilder: + def test_default_icon_for_missing_tool(self): + assert get_tool_icon_path(None) == "data/GcodeViewer/tools/pointed_mill_thumb.png" + + def test_maps_known_tool_types(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.BALL_END_MILL) + assert get_tool_icon_path(tool_def) == "data/GcodeViewer/tools/ball_end_mill_thumb.png" + + def test_maps_tapered_mill_icon(self): + tool_def = ToolDefinition(number=12, tool_type=ToolType.TAPERED_MILL) + assert get_tool_icon_path(tool_def) == "data/GcodeViewer/tools/tapered_mill_thumb.png" + + def test_maps_engraving_to_default_pointed_icon(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.ENGRAVING) + assert get_tool_icon_path(tool_def) == "data/GcodeViewer/tools/pointed_mill_thumb.png" + + def test_tooltip_icons_use_full_body_assets(self): + from carveracontroller.addons.tool_visualization.icon_builder import get_tool_tooltip_icon_path + + assert get_tool_tooltip_icon_path(None) == "data/GcodeViewer/tools/pointed_mill.png" + tool_def = ToolDefinition(number=1, tool_type=ToolType.BALL_END_MILL) + assert get_tool_tooltip_icon_path(tool_def) == "data/GcodeViewer/tools/ball_end_mill.png" + + +class TestTooltipBuilder: + def test_empty_tooltip_for_missing_tool(self): + assert format_tool_tooltip(None) == "" + + def test_formats_full_tool_definition(self): + tool_def = ToolDefinition( + number=2, + tool_type=ToolType.BULL_NOSE_END_MILL, + diameter=6.0, + corner_radius=0.5, + taper_angle_deg=5.0, + description="Roughing endmill", + vendor="Makera", + product_id="EM-6-05", + type_name="Bull nose end mill", + ) + tooltip = format_tool_tooltip(tool_def) + + assert tooltip == ( + "[size=17]T2 · Bull Nose End Mill[/size]\n" + "Roughing endmill\n" + "Makera\n" + "\n" + "Diameter: 6\n" + "Corner radius: 0.5\n" + "Taper: 5°" + ) + + def test_uses_enum_label_when_type_name_missing(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=3.0) + assert format_tool_tooltip(tool_def) == "[size=17]T1 · Flat End Mill[/size]\n\nDiameter: 3" + + def test_prefers_enum_label_over_raw_type_name(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=6.0, + type_name="flat end mill", + ) + assert format_tool_tooltip(tool_def).startswith("[size=17]T1 · Flat End Mill[/size]") + + def test_falls_back_to_raw_type_name_for_unknown_type(self): + tool_def = ToolDefinition( + number=9, + tool_type=ToolType.UNKNOWN, + diameter=4.0, + type_name="CNC HSS Slot Drill", + ) + assert format_tool_tooltip(tool_def).startswith("[size=17]T9 · CNC HSS Slot Drill[/size]") + + def test_plain_text_without_markup(self): + tool_def = ToolDefinition( + number=2, + tool_type=ToolType.BULL_NOSE_END_MILL, + diameter=6.0, + description="Roughing endmill", + vendor="Makera", + type_name="Bull nose end mill", + ) + assert format_tool_tooltip(tool_def, markup=False) == ( + "T2 · Bull Nose End Mill\nRoughing endmill\nMakera\n\nDiameter: 6" + ) + + def test_escapes_markup_in_user_fields(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=3.0, + description="Bit [special] & co", + vendor="Acme [Tools]", + type_name="flat end mill", + ) + tooltip = format_tool_tooltip(tool_def) + assert "Bit &bl;special&br; & co" in tooltip + assert "Acme &bl;Tools&br;" in tooltip + assert "[special]" not in tooltip + + def test_includes_length_and_flute_when_present(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.ENGRAVING, + diameter=3.175, + shank_diameter=3.175, + length=20.0, + flute_length=12.0, + type_name="Engraving", + ) + tooltip = format_tool_tooltip(tool_def) + + assert "Diameter: 3.175" in tooltip + assert "Length: 20" in tooltip + assert "Flute length: 12" in tooltip + assert "Engraving" in tooltip + # Redundant shank matching diameter is omitted. + assert "Shank:" not in tooltip + + def test_hides_zero_corner_radius_and_shows_distinct_shank(self): + tool_def = ToolDefinition( + number=5, + tool_type=ToolType.RADIUS_MILL, + diameter=2.0, + shank_diameter=6.0, + corner_radius=0.0, + type_name="Radius mill", + ) + tooltip = format_tool_tooltip(tool_def) + + assert "Corner radius:" not in tooltip + assert "Shank: 6" in tooltip + assert tooltip.startswith("[size=17]T5 · Radius Mill[/size]") + + +class TestMeshBuilder: + def test_build_tool_mesh_uses_expected_vertex_layout(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0) + vertices, indices, fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + assert fmt == VERTEX_FORMAT + assert len(vertices) % FLOATS_PER_VERTEX == 0 + assert len(indices) % 3 == 0 + assert all(0 <= index < vertex_count for index in indices) + assert len(indices) > vertex_count + + def test_missing_stickout_uses_each_tools_own_diameter(self): + tool_table = { + 1: ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=3.0), + 2: ToolDefinition(number=2, tool_type=ToolType.FLAT_END_MILL, diameter=10.0), + } + meshes, _ = build_tool_meshes(tool_table, scale=1.0) + + assert _mesh_height(meshes[1][0]) == pytest.approx(3.0 * LENGTH_DIAMETER_FACTOR) + assert _mesh_height(meshes[2][0]) == pytest.approx(10.0 * LENGTH_DIAMETER_FACTOR) + + def test_tiny_tool_gets_per_tool_visibility_floor(self): + """Only the undersized tool is enlarged; a normal tool keeps true scale.""" + tool_table = { + 1: ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=0.01), + 2: ToolDefinition(number=2, tool_type=ToolType.FLAT_END_MILL, diameter=1.0), + } + meshes, _ = build_tool_meshes(tool_table, scale=1.0) + small_radius = _max_xy_radius(meshes[1][0]) + large_radius = _max_xy_radius(meshes[2][0]) + + assert small_radius == pytest.approx(MIN_VISIBLE_RADIUS) + assert large_radius == pytest.approx(0.5) + + def test_default_mesh_unaffected_by_tiny_tools(self): + """Default mesh keeps fixed on-screen size when known tools need a floor.""" + tool_table = { + 1: ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=0.01), + 2: ToolDefinition(number=2, tool_type=ToolType.FLAT_END_MILL, diameter=1.0), + } + _meshes, default_mesh = build_tool_meshes(tool_table, scale=0.01) + vertices = default_mesh[0] + + assert _max_xy_radius(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_RADIUS, rel=1e-4) + assert _mesh_height(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_HEIGHT, rel=1e-4) + + def test_default_mesh_is_generated_for_empty_tool_table(self): + meshes, default_mesh = build_tool_meshes({}) + + assert meshes == {} + assert len(default_mesh[0]) > 0 + assert len(default_mesh[1]) > 0 + + @pytest.mark.parametrize("scale", [0.02, 0.5, 1.0, 2.0]) + def test_fallback_mesh_has_fixed_display_size(self, scale): + """No-metadata fallback keeps a constant on-screen footprint.""" + _meshes, default_mesh = build_tool_meshes({}, scale=scale) + vertices = default_mesh[0] + + assert _max_xy_radius(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_RADIUS, rel=1e-4) + assert _mesh_height(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_HEIGHT, rel=1e-4) + + @pytest.mark.parametrize("scale", [0.02, 0.5]) + def test_missing_diameter_uses_fixed_display_size(self, scale): + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=None) + meshes, _ = build_tool_meshes({1: tool_def}, scale=scale) + vertices = meshes[1][0] + + assert _max_xy_radius(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_RADIUS, rel=1e-4) + assert _mesh_height(vertices) == pytest.approx(FALLBACK_TOOL_DISPLAY_HEIGHT, rel=1e-4) + + def test_ball_end_mill_profile_has_more_detail_than_flat_end_mill(self): + shared_length = 18.0 + flat_profile = tool_profile( + ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0), + length=shared_length, + ) + ball_profile = tool_profile( + ToolDefinition(number=2, tool_type=ToolType.BALL_END_MILL, diameter=6.0), + length=shared_length, + ) + + assert flat_profile[0] == (0.0, 3.0) + assert ball_profile[0][0] == pytest.approx(0.0) + assert ball_profile[0][1] == pytest.approx(0.0) + assert ball_profile[-1][0] == flat_profile[-1][0] + assert len(ball_profile) > len(flat_profile) + + def test_build_tool_meshes_scales_geometry(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0) + mesh_a, _ = build_tool_meshes({1: tool_def}, scale=1.0) + mesh_b, _ = build_tool_meshes({1: tool_def}, scale=2.0) + + assert _mesh_height(mesh_b[1][0]) == pytest.approx(_mesh_height(mesh_a[1][0]) * 2.0) + + def test_flat_end_mill_side_normals_are_radial(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0) + vertices, indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + assert vertex_count < len(indices) + + for index in range(vertex_count): + nx, ny, nz = _vertex_normal(vertices, index) + length = math.hypot(nx, ny, nz) + assert length == pytest.approx(1.0, abs=1e-6) + if abs(nz) < 0.5: + assert abs(nz) == pytest.approx(0.0, abs=1e-6) + assert math.hypot(nx, ny) == pytest.approx(1.0, abs=1e-6) + + def test_ball_end_mill_reuses_vertices_across_triangles(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.BALL_END_MILL, diameter=6.0) + vertices, indices, _fmt = build_tool_mesh(tool_def) + + assert len(indices) > len(vertices) // FLOATS_PER_VERTEX + + def test_pointed_tip_mesh_shares_single_apex_vertex(self): + tool_def = ToolDefinition(number=1, tool_type=ToolType.CHAMFER_MILL, diameter=6.0) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + + assert len(_apex_vertex_indices(vertices)) == 1 + + @pytest.mark.parametrize( + "tool_type", + [ + ToolType.BULL_NOSE_END_MILL, + ToolType.RADIUS_MILL, + ToolType.CHAMFER_MILL, + ToolType.TAPERED_MILL, + ToolType.LOLLIPOP_MILL, + ToolType.THREAD_MILL, + ], + ) + def test_tool_profiles_have_expected_shape(self, tool_type): + diameter = 6.0 + shared_length = diameter * LENGTH_DIAMETER_FACTOR + profile = tool_profile( + ToolDefinition(number=1, tool_type=tool_type, diameter=diameter, corner_radius=0.5), + length=shared_length, + ) + + assert profile[-1][0] == shared_length + + if tool_type == ToolType.BULL_NOSE_END_MILL: + assert profile[0][1] < diameter / 2.0 + assert len(profile) > 2 + elif tool_type == ToolType.RADIUS_MILL: + corner_radius = 0.5 + assert profile[0] == (0.0, pytest.approx(diameter / 2.0)) + assert profile[-1][1] == pytest.approx(diameter / 2.0 + corner_radius) + assert len(profile) > 2 + elif tool_type == ToolType.CHAMFER_MILL: + assert profile[0] == (0.0, 0.0) + assert len(profile) >= 2 + elif tool_type == ToolType.TAPERED_MILL: + # Tip diameter D with a bull-nose corner; sides widen above the tip. + assert profile[0][0] == pytest.approx(0.0) + assert 0.0 < profile[0][1] < diameter / 2.0 + assert profile[-1][1] > diameter / 2.0 + assert len(profile) > 3 + elif tool_type == ToolType.LOLLIPOP_MILL: + assert len(profile) > 4 + assert profile[0] == (0.0, pytest.approx(0.0)) + assert max(r for _, r in profile) == pytest.approx(diameter / 2.0) + # Neck is a plain cylinder: last two points share the undercut radius. + assert profile[-1][1] < diameter / 2.0 + assert profile[-2][1] == pytest.approx(profile[-1][1]) + assert profile[-1][0] == shared_length + elif tool_type == ToolType.THREAD_MILL: + assert profile[0][1] < diameter / 2.0 + assert max(r for _, r in profile) == pytest.approx(diameter / 2.0) + assert len(profile) >= 3 + + def test_tapered_mill_is_bull_nose_tip_with_taper(self): + diameter = 6.0 + corner_radius = 1.0 + taper_angle_deg = 30.0 + shared_length = diameter * LENGTH_DIAMETER_FACTOR + flute_length = diameter * FALLBACK_FLUTE_DIAMETER_FACTOR + profile = tool_profile( + ToolDefinition( + number=12, + tool_type=ToolType.TAPERED_MILL, + diameter=diameter, + corner_radius=corner_radius, + taper_angle_deg=taper_angle_deg, + ), + length=shared_length, + ) + + alpha = math.radians(taper_angle_deg) + flat_radius = diameter / 2.0 - corner_radius * math.cos(alpha) + z_tan = corner_radius * (1.0 - math.sin(alpha)) + r_tan = diameter / 2.0 + # Missing flute → taper only through the capped flute region; shank stays cylindrical. + end_radius = r_tan + (flute_length - z_tan) * math.tan(alpha) + + assert profile[0] == (0.0, pytest.approx(flat_radius)) + assert any(math.isclose(z, z_tan, abs_tol=1e-6) and math.isclose(r, r_tan, abs_tol=1e-6) for z, r in profile) + assert profile[-1][0] == pytest.approx(shared_length) + assert profile[-1][1] == pytest.approx(end_radius) + assert profile[-1][1] > diameter / 2.0 + + def test_tapered_mill_without_corner_radius_has_flat_tip(self): + diameter = 6.0 + taper_angle_deg = 30.0 + shared_length = diameter * LENGTH_DIAMETER_FACTOR + flute_length = diameter * FALLBACK_FLUTE_DIAMETER_FACTOR + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.TAPERED_MILL, + diameter=diameter, + corner_radius=0.0, + taper_angle_deg=taper_angle_deg, + ), + length=shared_length, + ) + + tip_radius = diameter / 2.0 + end_radius = tip_radius + flute_length * math.tan(math.radians(taper_angle_deg)) + assert profile[0] == (0.0, tip_radius) + assert profile[-1][0] == pytest.approx(shared_length) + assert profile[-1][1] == pytest.approx(end_radius) + + def test_tapered_mill_with_zero_taper_matches_bull_nose(self): + diameter = 6.0 + corner_radius = 1.0 + shared_length = diameter * LENGTH_DIAMETER_FACTOR + tapered = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.TAPERED_MILL, + diameter=diameter, + corner_radius=corner_radius, + taper_angle_deg=0.0, + ), + length=shared_length, + ) + bull = tool_profile( + ToolDefinition( + number=2, + tool_type=ToolType.BULL_NOSE_END_MILL, + diameter=diameter, + corner_radius=corner_radius, + ), + length=shared_length, + ) + + assert tapered[0] == bull[0] + assert tapered[-1] == bull[-1] + assert len(tapered) == len(bull) + for (z0, r0), (z1, r1) in zip(tapered, bull): + assert z0 == pytest.approx(z1) + assert r0 == pytest.approx(r1) + + def test_radius_mill_interprets_diameter_as_flat_bottom(self): + profile = tool_profile( + ToolDefinition(number=2, tool_type=ToolType.RADIUS_MILL, diameter=2.0, corner_radius=2.0), + length=20.0, + ) + + assert profile[0] == (0.0, 1.0) + assert profile[-1][1] == pytest.approx(3.0) + + def test_radius_mill_corner_arc_is_reversed_from_bull_nose(self): + flat_diameter = 4.0 + full_diameter = 6.0 + corner_radius = 1.0 + shared_length = full_diameter * LENGTH_DIAMETER_FACTOR + radius_profile = tool_profile( + ToolDefinition( + number=1, tool_type=ToolType.RADIUS_MILL, diameter=flat_diameter, corner_radius=corner_radius + ), + length=shared_length, + ) + bull_profile = tool_profile( + ToolDefinition( + number=2, tool_type=ToolType.BULL_NOSE_END_MILL, diameter=full_diameter, corner_radius=corner_radius + ), + length=shared_length, + ) + + assert radius_profile[0] == bull_profile[0] + assert radius_profile[-1] == bull_profile[-1] + + mid_z = corner_radius / 2.0 + full_radius = full_diameter / 2.0 + radius_mid_r = full_radius + corner_radius * math.cos(5.0 * math.pi / 6.0) + bull_mid_r = (full_radius - corner_radius) + corner_radius * math.cos(-math.pi / 6.0) + profile_radius_mid_r = min(radius_profile, key=lambda point: abs(point[0] - mid_z))[1] + profile_bull_mid_r = min(bull_profile, key=lambda point: abs(point[0] - mid_z))[1] + + assert profile_radius_mid_r == pytest.approx(radius_mid_r, abs=0.05) + assert profile_bull_mid_r == pytest.approx(bull_mid_r, abs=0.05) + assert profile_radius_mid_r < profile_bull_mid_r + + def test_explicit_length_and_flute_produce_shank_blend(self): + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + shank_diameter=3.175, + length=20.0, + flute_length=12.0, + ) + ) + flute_r = 1.0 + shank_r = 3.175 / 2.0 + transition = abs(shank_r - flute_r) # ~45° blend + + assert profile[0] == (0.0, flute_r) + assert (12.0, flute_r) in profile + assert any(math.isclose(z, 12.0 + transition) and math.isclose(r, shank_r) for z, r in profile) + assert profile[-1] == (20.0, pytest.approx(shank_r)) + # No hard radial step at the flute tip. + assert not any(math.isclose(z, 12.0) and math.isclose(r, shank_r) for z, r in profile) + + def test_shoulder_keeps_cutting_diameter_before_shank_blend(self): + """Makera: flute < shoulder < stick → body at D, then blend to handle.""" + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + shank_diameter=3.175, + length=15.0, + flute_length=6.0, + shoulder_length=9.0, + ) + ) + flute_r = 1.0 + shank_r = 3.175 / 2.0 + transition = abs(shank_r - flute_r) + + assert (6.0, flute_r) in profile + assert (9.0, flute_r) in profile + # Cutting diameter continues through the shoulder body (flute → shoulder). + assert all(math.isclose(r, flute_r) for z, r in profile if z <= 9.0 + 1e-9) + assert any(math.isclose(z, 9.0 + transition) and math.isclose(r, shank_r) for z, r in profile) + assert profile[-1] == (15.0, pytest.approx(shank_r)) + assert not any(math.isclose(z, 9.0) and math.isclose(r, shank_r) for z, r in profile) + + def test_shoulder_equal_to_stick_has_no_handle_section(self): + """Makera: shoulder == stick → 2mm body to the top, no 3.175 shank.""" + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + shank_diameter=3.175, + length=12.0, + flute_length=6.0, + shoulder_length=12.0, + ) + ) + flute_r = 1.0 + shank_r = 3.175 / 2.0 + + assert profile[0] == (0.0, flute_r) + assert (6.0, flute_r) in profile + assert profile[-1] == (12.0, pytest.approx(flute_r)) + assert all(r <= flute_r + 1e-9 for _z, r in profile) + assert not any(math.isclose(r, shank_r) for _z, r in profile) + + def test_mesh_uses_flute_color_without_flute_length(self): + # Stick-out under the 4×D fallback flute cap → entire mesh is flute-colored. + tool_def = ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0, length=18.0) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + for index in range(vertex_count): + assert _vertex_color(vertices, index) == pytest.approx(FLUTE_COLOR) + + def test_missing_flute_capped_to_diameter_factor(self): + diameter = 6.0 + length = 40.0 + flute_z = diameter * FALLBACK_FLUTE_DIAMETER_FACTOR + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=diameter, + length=length, + ) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + colors = {_vertex_color(vertices, index) for index in range(vertex_count)} + assert FLUTE_COLOR in colors + assert SHANK_COLOR in colors + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < flute_z - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > flute_z + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + profile = tool_profile(tool_def) + assert profile[-1][0] == pytest.approx(length) + + def test_mesh_colors_flute_and_shank_when_flute_length_available(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + shank_diameter=3.175, + length=20.0, + flute_length=12.0, + ) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + colors = {_vertex_color(vertices, index) for index in range(vertex_count)} + assert FLUTE_COLOR in colors + assert SHANK_COLOR in colors + + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < 12.0 - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > 12.0 + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + def test_mesh_colors_flute_and_shoulder_when_no_handle_section(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + shank_diameter=3.175, + length=12.0, + flute_length=6.0, + shoulder_length=12.0, + ) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + colors = {_vertex_color(vertices, index) for index in range(vertex_count)} + assert FLUTE_COLOR in colors + assert SHANK_COLOR in colors + + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < 6.0 - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > 6.0 + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + def test_infers_chamfer_flute_from_cone_when_metadata_missing(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.CHAMFER_MILL, + diameter=6.0, + taper_angle_deg=90.0, + ) + # 90° included → 45° half-angle → cone height == radius. + vertices, _indices, _fmt = build_tool_mesh(tool_def, length=18.0) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + colors = {_vertex_color(vertices, index) for index in range(vertex_count)} + assert FLUTE_COLOR in colors + assert SHANK_COLOR in colors + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < 3.0 - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > 3.0 + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + def test_inferred_flute_adds_short_shoulder_before_shank(self): + """Fusion-like tip cue only: short body at D before the handle blend.""" + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.CHAMFER_MILL, + diameter=6.0, + shank_diameter=8.0, + taper_angle_deg=90.0, + length=18.0, + ) + ) + flute_z = 3.0 + cutting_r = 3.0 + shank_r = 4.0 + shoulder_z = flute_z + 6.0 * INFERRED_SHOULDER_DIAMETER_FACTOR + transition = abs(shank_r - cutting_r) + + assert any(math.isclose(z, flute_z) and math.isclose(r, cutting_r) for z, r in profile) + assert (shoulder_z, cutting_r) in profile + # Shoulder body only — tip cone points below flute_z have r < cutting_r. + assert all(math.isclose(r, cutting_r) for z, r in profile if flute_z - 1e-9 <= z <= shoulder_z + 1e-9) + assert any(math.isclose(z, shoulder_z + transition) and math.isclose(r, shank_r) for z, r in profile) + assert profile[-1] == (18.0, pytest.approx(shank_r)) + # Shank blend starts after the inferred shoulder, not at the tip. + assert not any(math.isclose(z, flute_z) and math.isclose(r, shank_r) for z, r in profile) + + def test_infers_lollipop_flute_at_ball_neck_join(self): + from carveracontroller.addons.tool_visualization.mesh_builder import _lollipop_ball_join_z + + diameter = 6.0 + ball_join = _lollipop_ball_join_z(diameter) + tool_def = ToolDefinition(number=1, tool_type=ToolType.LOLLIPOP_MILL, diameter=diameter) + vertices, _indices, _fmt = build_tool_mesh(tool_def, length=18.0) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + colors = {_vertex_color(vertices, index) for index in range(vertex_count)} + assert FLUTE_COLOR in colors + assert SHANK_COLOR in colors + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < ball_join - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > ball_join + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + def test_explicit_flute_length_not_overridden_by_inference(self): + tool_def = ToolDefinition( + number=1, + tool_type=ToolType.CHAMFER_MILL, + diameter=6.0, + taper_angle_deg=90.0, + length=20.0, + flute_length=12.0, + ) + vertices, _indices, _fmt = build_tool_mesh(tool_def) + vertex_count = len(vertices) // FLOATS_PER_VERTEX + + for index in range(vertex_count): + z = _vertex_z(vertices, index) + color = _vertex_color(vertices, index) + if z < 12.0 - 1e-6: + assert color == pytest.approx(FLUTE_COLOR) + elif z > 12.0 + 1e-6: + assert color == pytest.approx(SHANK_COLOR) + + def test_missing_lengths_still_use_diameter_factor(self): + profile = tool_profile( + ToolDefinition(number=1, tool_type=ToolType.FLAT_END_MILL, diameter=6.0), + length=None, + ) + overall = 6.0 * LENGTH_DIAMETER_FACTOR + flute_z = 6.0 * FALLBACK_FLUTE_DIAMETER_FACTOR + assert profile[-1][0] == pytest.approx(overall) + assert any(math.isclose(z, flute_z) for z, _r in profile) + + def test_engraving_tip_diameter_zero_is_pointed(self): + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.ENGRAVING, + diameter=6.0, + tip_diameter=0.0, + taper_angle_deg=30.0, + ), + length=18.0, + ) + assert profile[0] == (0.0, 0.0) + + def test_engraving_with_flat_tip(self): + profile = tool_profile( + ToolDefinition( + number=1, + tool_type=ToolType.ENGRAVING, + diameter=6.0, + tip_diameter=1.0, + taper_angle_deg=30.0, + ), + length=18.0, + ) + assert profile[0] == (0.0, 0.5) + assert profile[0][1] > 0.0 + + def test_build_tool_meshes_uses_per_tool_explicit_lengths(self): + tool_table = { + 1: ToolDefinition( + number=1, + tool_type=ToolType.FLAT_END_MILL, + diameter=2.0, + length=12.0, + flute_length=12.0, + ), + 2: ToolDefinition(number=2, tool_type=ToolType.FLAT_END_MILL, diameter=10.0), + } + meshes, _ = build_tool_meshes(tool_table, scale=1.0) + + assert _mesh_height(meshes[1][0]) == pytest.approx(12.0) + assert _mesh_height(meshes[2][0]) == pytest.approx(10.0 * LENGTH_DIAMETER_FACTOR) + + +class TestExtractor: + def test_extract_tool_table_returns_first_non_empty_parser_result(self): + lines = ["(T1 End mill D=6 CR=0 - flat end mill)\n", "G0 X0\n"] + table = extract_tool_table(lines) + + assert 1 in table + assert table[1].tool_type == ToolType.FLAT_END_MILL + + def test_extract_tool_table_returns_empty_dict_when_unrecognised(self): + lines = ["G0 X0 Y0\n", "G1 X1 F100\n"] + assert extract_tool_table(lines) == {} + + def test_extract_prefers_makera_studio_when_mkr_header_present(self): + lines = [ + ";@MKR|BEGIN\n", + ";@MKR|TOOL|number=1|id=1|name=Flat|type=Flat End|handlediameter=3.175|" + "sticklength=0|shoulderlength=12|flutelength=12|diameter=2|tipdiameter=2|" + "cornerradius=0|angle=0|halfAngle=0\n", + ";@MKR|END\n", + "(T1 Should be ignored D=99 CR=0 - flat end mill)\n", + "G0 X0\n", + ] + table = extract_tool_table(lines) + + assert table[1].diameter == 2.0 + assert table[1].shank_diameter == 3.175 + assert table[1].product_id == "1"