# profiler.py — Changes from upstream This document summarizes a series of bug fixes and feature additions made on top of the original `profiler.py` (Conversational Profile Editor for LinuxCNC G71/G72), based on hardware-validated testing on a real lathe (LinuxCNC 2.9.8, Mesa 7I96S, Debian 13). Most of these are **bug fixes for G71/G72 G-code generation that caused real crashes on hardware** ("doesn't retract, plunges into the part" / "rapids straight into stock at program start"). A few are UI/UX improvements to the DXF import and preview. --- ## 1. G71/G72 G-code correctness fixes (the important ones) ### 1.1 `D`/`I` parameters were swapped, AND not unit-converted for G7 **Bug:** `_emit_g71`/`_emit_g72`/`wrapper_rough_subprogram_gcode` emitted `G71 ... D{stock_allow} I{doc} ...` — but per LinuxCNC's G71 spec, `D` is the **depth of cut per pass** and `I` is the **finish stock allowance**. These were reversed (D and I swapped relative to their meaning). **Bug 2 (found after fixing the swap):** Confirmed on hardware that G71/G72's `D` and `I` are **always radius-based**, exactly like the `R` word in G2/G3 — LinuxCNC does **not** rescale them for G7 (diameter mode). With G7 active and `doc=0.15` entered in the UI (meaning "0.15mm on the diameter"), the machine actually removed **0.30mm of diameter** per pass — i.e. the unscaled (radius-domain) value was being used directly as a diameter-domain depth. **Fix:** New helper `ProfileGenerator._g71_di_values()`: - G7 (`diameter_mode=True`): emit `D = stock_allow/2`, `I = doc/2` - G8 (`diameter_mode=False`): emit `D = stock_allow`, `I = doc` (no conversion — G8's X is already radius, matching G71/G72's native units) All four emission sites (`_emit_g71`, `_emit_g72`, `wrapper_rough_subprogram_gcode`, and the combined wrapper) now go through this helper and also correctly assign D=stock_allow, I=doc (not swapped). User-facing effect: entering `0.15` in "Глиб різан (I)" with G7 now actually removes 0.15mm of diameter per pass, as the UI implies. --- ### 1.2 Arc segments were measured only at their endpoints — "roughed-out ball" crash and unsafe retracts **Bug:** `_calculate_start_x` / `_calculate_retract_x` (and the DXF "Max X → stock" alignment) computed profile extents as `max(pt.x for pt in pts)` / `min(...)` — i.e. only at segment **endpoints**. An arc (G2/G3) can geometrically bulge **past both of its endpoints**. Test profile: ```python points = [Point(0.0, 0.0), Point(-12.0, 5.0, r=-6.5), Point(-15.0, 5.0)] ``` i.e. `G1 X0 Z0` → `G3 X5 Z-12 R6.5` → `G1 X5 Z-15`. Endpoints suggest X ranges 0→5, but the R6.5 arc (a near-semicircle) actually bulges out to **diameter ≈ 11.261**. Any retract/clearance computed from `max(pt.x)=5` is **6.26mm short** of the real machined surface. **Fix:** New methods: - `ProfileGenerator._arc_extent_x(z0, x0, z1, x1, r, diameter_mode) -> (min_x, max_x)` — computes the true geometric X extent of one G1/G2/G3 segment, including the arc's bulge if its sweep passes through the circle's extreme ±X points. Handles the G7/G8 unit subtlety: the G2/G3 `R` word is **always real-radius** (same issue as 1.1), so in diameter mode the endpoint X values are converted to radius for the circle-center/sweep math, then the result is converted back to diameter. - `ProfileGenerator._profile_extent_x(pts) -> (min_x, max_x)` — extent over the whole profile, calling `_arc_extent_x` per segment. - `_calculate_start_x` and `_calculate_retract_x` now use `_profile_extent_x` instead of `max(pt.x for pt in pts)`. - DXF "Max X → stock" / "Min X → axis" alignment buttons (see §3) now tessellate bulge arcs (12-segment polyline approximation) to find the true max/min X for offset calculation, instead of using polyline vertex endpoints only. --- ### 1.3 `_calculate_start_x` added an unwanted `+ safe_clear_x` for the AUTO case **Bug:** Original: ```python return max(p.stock_diam, profile_max_x) + p.safe_clear_x ``` With `stock_diam=12` and `Clear X=1`, the auto-computed Start X (and the `G0 X` before G71) was **13**, not 12. The first G71 roughing pass therefore air-cut 1mm of clearance before touching real material on its first pass — not what the user expects ("the program should start at 12 and step inward by the depth of cut"). **Fix:** `_calculate_start_x` (OD, auto case) is now: ```python return max(p.stock_diam, profile_extent_max_x) # NO + safe_clear_x ``` `safe_clear_x` is now used **only** for the final retract (`_calculate_retract_x`, unchanged in this respect — still `max(stock_diam, profile_extent_max_x) + safe_clear_x`). --- ### 1.4 G71→G70 transition relied on G71's internal "current position" — unreliable, caused multiple crashes **Background:** Per LinuxCNC docs, G70's Start X/Z "defaults to the initial position", i.e. wherever the preceding `G0 X{start_x}` left the tool, and G71 is expected to leave the tool somewhere sensible for G70 to continue from. On real hardware this was **not reliable** for several Start X values: | Start X | doc (I) | profile range | Result | |---|---|---|---| | 0.10 | 0.15 | X: 0→5 (arc bulges to 11.26) | **crash** | | 0.15 | 0.15 | same | **crash** (start_x - doc == 0 exactly) | | 5.125 | 0.15 | same | **crash** | | 1.0 | 0.5 | same | tool **returns to next Z at X≈5.1 without retracting** | | 19.0 (== profile_min_x) | 0.3 | X: 19→40 | OK | **Fix 1 — hard guard (`_validate_start_x_axis_safety`):** For G71+OD, if `effective_start_x - doc <= 0` (strict), raise `ValueError` before generating any G-code. This catches the X≤0 axis-crossing crashes (cases 1–2 above). The error message explains the issue and suggests either raising Start X or using Start X=0 (auto). **Fix 2 — explicit, deterministic G71→G70 transition (`_explicit_g71_g70_transition`):** Instead of relying on G71's internal retract/reposition, three explicit `G0` moves are now emitted between the G71/G72 line and the G70 line: ``` G71 Q... D... I... R... F... G0 X{retract_x} ; pure X move at WHATEVER Z G71 left the tool G0 Z{start_z} ; X is now outside everything -> Z move is safe G0 X{start_x} ; Z is now outside stock face -> X move is safe G70 Q... F... ``` Safety argument ("L-shaped" move): 1. `retract_x = max(stock_diam, profile_extent_max_x) + safe_clear_x` (from §1.2/§1.3) is, by construction, ≥ the profile's extent at **every** Z (including arc bulges). Moving X-only to `retract_x`, at whatever Z G71 stopped at, can never enter material. 2. With X = retract_x (radially clear at every Z), a Z-only move to `start_z` is safe regardless of starting Z. 3. With Z = start_z (outside the stock face, typically Z>0), an X-only move to `start_x` is safe regardless of X. G70 then starts from `(start_x, start_z)` — the exact position the wrapper approached **from** before G71 — instead of an unspecified position left by G71's internal bookkeeping. This fixed case 4 above (Start X=1.0, previously "returns without retracting"). **Fix 3 — initial safe-position move at program start (`OperationManager.generate`):** Before the **first** subprogram call in the whole program, two `G0` moves are emitted unconditionally: ``` (Main program) G21 G18 G7 M5 T1 M6 G43 G97 S700 M3 G0 X{retract_x} ; first operation's retract_x G0 Z{start_z} ; first operation's start_z o#### call ... ``` Rationale: the tool's position when the program starts is arbitrary (left over from a previous job, manual jog, machine home, etc.). The same "L-shaped" argument applies: `retract_x ≥ stock_diam ≥` the *original* (maximum possible) material radius at any Z, and machining only removes material, so `X = retract_x` is provably clear of material at every Z regardless of where the tool started; then `Z = start_z` is safe at `X = retract_x`. --- ### 1.5 New "Тільки фініш (без G71/G70)" / "Finish only (no G71/G70)" mode **Motivation:** Even with fixes 1.3–1.4, profiles whose first point sits exactly on the rotation axis (X=0 at Z=0 — common for a ball/dome shape, the test profile above) repeatedly produced unreliable G70 behavior with various Start X values (crashes or "returns without retracting" — cases 1, 2, 3, 4 in the table above), independent of how many roughing passes G71 made. Root cause not fully identified, but **completely avoiding G71/G70** for a "redo just the finishing pass" use case sidesteps it entirely. **New:** `ProfileParams.direct_finish_only: bool = False` and `ProfileGenerator.wrapper_finish_direct_subprogram_gcode(points, contour_sub_id, wrapper_sub_id)`: ``` o#### sub G0 X{profile[0].x} G0 Z{profile[0].z + safe_clear_z} F{feed_finish} o{contour_sub_id} call G0 X{retract_x} G0 Z{start_z} o#### endsub ``` i.e. position directly above the contour's own starting point, call the contour subprogram as a **plain subroutine** (no G70 involved at all) at finish feed, then retract. For a profile starting at X=0, the first move inside the contour becomes a straight plunge down the centerline (X=0 the whole time) — not a diagonal G70 rapid that can clip a pointed/curved tip. `ProfileOperation.subprograms()`/`main_lines()` branch on `params.direct_finish_only` to emit this wrapper instead of the normal G71+G70 wrapper, and skip the rough wrapper/tool-change entirely. `check_gcode()` gained a `require_g71_g72: bool = True` parameter (`G71_G72_PATTERN` constant extracted) so that a program consisting entirely of `direct_finish_only` operations doesn't fail the "must contain G71/G72" invariant check. UI: checkbox "Тільки фініш (без G71/G70)" in the CYCLE section, between "Start X (optional)" and "Start Z (optional)". --- ### 1.6 Remaining "between zone" Start X values — converted from hard error to advisory An earlier iteration added a **second** hard `ValueError` for any Start X strictly between `profile_min_x` and `profile_extent_max_x + stock_allow` (based on cases 1–4 above all falling in that range and all being bad). This was **too aggressive** — it's a pattern from 4 data points, not a confirmed mechanism, and it blocked the user from testing intermediate values that might be fine (especially now that 1.4's explicit transition may have fixed some of them). **Current behavior:** `ProfileGenerator.check_start_x_advisory(pts, p) -> Optional[str]` returns a **non-fatal** warning string (shown in the UI status bar prefixed with `⚠`, doesn't block generation) when Start X is in this "unverified zone". Two configurations are considered confirmed-OK and return `None`: - **Branch A** ("full roughing from outside"): `start_x >= profile_extent_max_x + stock_allow` (always true for Start X=0/auto, by construction of `_calculate_start_x`) - **Branch B** ("enter at the profile's own start"): `start_x == profile_min_x` (and `profile_min_x > 0`) — this is exactly the test4.ngc case (`start_x=19=profile_min_x`, profile range [19,40]), which worked correctly. --- ## 2. UI / preview fixes ### 2.1 G72 drag-monotonicity check used stale `self.params.cycle` **Bug:** `_on_canvas_pan_end`'s monotonicity check for canvas drag used `self.params.cycle`, which is only set once in `__init__` (default `"G71"`) and on session load — **never** updated when the user changes the "Cycle" dropdown. Result: in G72 mode, any point drag was checked against G71's Z-monotonicity rule instead of G72's X-monotonicity rule, and (if the drag violated Z-monotonicity, which is normal for G72 profiles) was immediately reverted — looking like "drag doesn't work" / "undoes itself". **Fix:** Read the cycle live from the UI entry on every drag event: ```python cycle_upper = self.entries["cycle"].get().strip().upper() or "G71" ``` --- ### 2.2 Symmetric preview with centerline **Before:** preview showed only the upper half (profile above X=0), with the stock rectangle drawn from X=0 to X=stock_diam (i.e. spanning the full "diameter" value on one side of the axis, doubling the apparent size and making X=0 sit at the *edge* rather than the center). **After:** - Preview bounds are now always symmetric (`min_x = -max_x`), so X=0 (the rotation axis) is rendered at the vertical center of the canvas. - Stock rectangle is drawn as **two halves**: `[0, +stock_diam]` (upper, the real OD) and `[0, -stock_diam]` (lower, mirrored for display). - The profile is drawn normally (upper half) plus a pale-blue mirrored copy below the axis. - A dash-dot centerline at X=0 is drawn across the full Z span. - Applies to both `_draw_preview` and `_draw_preview_empty`. ### 2.3 Mouse interactions in the mirrored lower half Since the lower half is display-only (a mirror), mouse tooltips, clicks, and drags there now map back to the real (non-negative) lathe X: - New `ProfileApp._canvas_to_model_lathe(canvas_x, canvas_y) -> (z, x)` = `_canvas_to_model` with `x = abs(x)`. - `_on_canvas_motion` tooltip shows `abs(x)`. - Point-add/drag handlers (`mz, mx = ...` / `cur_z, cur_x = ...`) now use `_canvas_to_model_lathe`. --- ## 3. DXF import improvements - **`dxf_x_is_radius` checkbox default changed `True` → `False`** (most imported drawings are already diameter-correct; the previous default silently doubled X offsets for the common case). - **"Max X → stock" button** (`_dxf_align_max_x_to_stock`): now tessellates bulge arcs in the raw DXF vertex list (12-segment polyline per arc, via the standard bulge-to-arc formula) before computing the maximum X, instead of using polyline vertex endpoints only — see §1.2 for why this matters for curved profiles. - **New "Min X → axis" button** (`_dxf_align_min_x_to_axis`): same tessellation approach, computes the X offset that brings the profile's minimum X to 0 (rotation axis). Lets the user first snap the profile's inner edge to the centerline, then snap the outer edge to the stock surface. - **DXF options layout**: the alignment button(s) moved to their own row below the X-offset entry (previously crowded the window, button was partially off-screen). --- ## 4. Misc - Window now opens maximized on startup: `root.state("zoomed")` with a fallback to `root.attributes("-zoomed", True)` for Linux window managers that don't support `"zoomed"` state. --- ## 5. UI language selector (English / Ukrainian) A language switcher — `English` / `Українська`, **English selected by default** — now sits at the very top of the window, above the File menu, as the very first thing the user sees. It translates the static UI shell: section headers (MACHINING, CYCLE, STOCK & CLEAR, PREVIEW, OUTPUT), all field labels, buttons (Add/Remove/Undo/ Generate G-code/etc.), tab names (Segments/Import/DXF/3MF), DXF/3MF option checkboxes, the File menu, and the two dynamic-text buttons (background show/hide, max-canvas/restore). Switching is instant, no restart, and doesn't touch session files or G-code generation. **Deliberately NOT translated** (see `TRANSLATIONS` dict docstring for rationale): - **OptionMenu/dropdown VALUES** — `"G7 (діаметр)"`/`"G8 (радіус)"`, `"M3 (CW)"`/`"M4 (CCW)"`, `"OD"`/`"ID"`, corner type `"none"`/`"A (fillet)"`/`"C (chamfer)"`, `"G21 (mm)"`/`"G20 (in)"`, etc. `_read_params` parses these by substring (`"G7" in value.upper()`, etc.) and `corner_type` is stored verbatim in session JSON. The G-code-relevant tokens (G7/G8/M3/M4/OD/ID/A/C/G21/G20/G94/G95) are identical in both languages — only the decorative parenthetical would change — so translating these risked breaking parsing/session load for purely cosmetic benefit, and was scoped out. - **Dynamic status-bar messages, validation errors, and advisories** (`_validate_start_x_axis_safety`, `check_start_x_advisory`, DXF status messages, etc.) remain Ukrainian-only. These are produced as f-strings throughout `ProfileGenerator`/`ProfileApp` (tens of call sites) and are a separate, larger follow-up if wanted. New module-level `TRANSLATIONS: dict[str, dict[str, str]]` constant (~80 entries) plus `ProfileApp` helpers: `_tr(key)`, `_i18n_text(widget, key)`, `_i18n_tab(notebook, tab, key)`, `_i18n_menu_entry(menu, index, key)`, `_apply_language()`, `_bg_btn_key()`, `_max_canvas_btn_key()`. --- ## Summary of new `ProfileGenerator` / `ProfileApp` methods | Method | Purpose | |---|---| | `_g71_di_values()` | G7/G8-correct D/I for G71/G72 (§1.1) | | `_arc_extent_x(...)` | True X extent of one G1/G2/G3 segment, incl. arc bulge (§1.2) | | `_profile_extent_x(pts)` | True X extent of whole profile (§1.2) | | `_validate_start_x_axis_safety(pts, p)` | Hard guard: G71 first pass can't cross X=0 (§1.4) | | `check_start_x_advisory(pts, p)` | Non-fatal "unverified Start X zone" warning (§1.6) | | `_explicit_g71_g70_transition(pts)` | Deterministic G0 moves between G71/G72 and G70 (§1.4) | | `wrapper_finish_direct_subprogram_gcode(...)` | "Finish only" mode, no G71/G70 (§1.5) | | `_canvas_to_model_lathe(cx, cy)` | Mirror-aware canvas→model for UI interactions (§2.3) | | `_dxf_align_min_x_to_axis()` | New DXF alignment button (§3) | | `_tr(key)` / `_i18n_text(...)` / `_apply_language()` | UI language switcher (English/Ukrainian) (§5) | ## Summary of new/changed `ProfileParams` fields | Field | Purpose | |---|---| | `direct_finish_only: bool = False` | Enable "Тільки фініш (без G71/G70)" mode (§1.5) | --- *All of the above were validated against unit tests covering the specific hardware-reproduced scenarios (test0.1, test0.15, test4, test6, and the arc-bulge profile `[Point(0,0), Point(-12,5,r=-6.5), Point(-15,5)]`), plus headless Tk UI tests (`xvfb-run`) for the UI-facing changes.*