Skip to content

feat: add rotatable workspace orientation - #334

Closed
pborges wants to merge 2 commits into
barebaric:mainfrom
pborges:feat/workspace-orientation
Closed

feat: add rotatable workspace orientation#334
pborges wants to merge 2 commits into
barebaric:mainfrom
pborges:feat/workspace-orientation

Conversation

@pborges

@pborges pborges commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a machine-level Workspace Orientation setting with Native, Rotate Left, and Rotate Right options in Machine Settings and the machine wizard.
  • Present portrait machine beds in landscape while keeping physical machine extents, margins, origins, calibration data, and device configuration in native coordinates.
  • Transform generated output back into native machine coordinates. This uses a true 90° rotation instead of a bare X/Y swap, so designs keep their handedness rather than being mirrored.
  • Apply the workspace projection consistently across the 2D canvas, axis labels, jogging, WCS/work-area handling, no-go zones, camera alignment, sanity checks, and 3D preview.
  • Persist the setting in machine files and device profiles, with legacy files defaulting to Native, and document the behavior in the user guide and changelog.

Rotary limitation

Rotary layers use a separate unrolled-cylinder coordinate system whose Y mapping currently happens before the workspace transform in Raygeo. To avoid silently generating incorrect output, rotary layers currently require Native workspace orientation.

The layer dialog prevents enabling rotary while the workspace is rotated, generation validates the combination again, stale generated output is cleared, and the resulting error explains how to correct the setting. Supporting a transparent rotary bypass can be handled separately by reordering the composed transform in the Raygeo rotary stage.

Verification

  • 457 affected tests passed after rebasing onto current main (86 unrelated marked tests deselected).
  • Ruff formatting, import ordering, and full lint checks pass.
  • Flake8 and Pyflakes pass.
  • Pyright reports no errors in the changed files. A repository-wide local run retains the two pre-existing sys._MEIPASS diagnostics in rayforge/app.py and rayforge/worker_init.py.

AI disclosure

This contribution was developed with assistance from OpenAI Codex and Anthropic Claude for implementation, test development, and code review. I reviewed the resulting changes, resolved the identified issues, and verified the final behavior and test results. No AI tool is included as a commit co-author.

@pborges

pborges commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

As this touches some core functionality I understand not wanting to merge it, but as someone with an "extended" AtomStack A40, being able to see the work area in "landscape" mode is very nice.

image

Full disclosure, I do not have a camera on my machine so had to rely on tests for that.

@knipknap

knipknap commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Thank you for the PR! This is an interesting feature I never considered!

I am open to see this merged, but since this touches on the coordinate logic - code that required months to mature and work on all possible machines - I will have to be more careful here.

Overall, the patch is quite good and clean! The architecture was taken well into consideration, an in spite of you writing that it was made with the help of AI tools, it looks well cleaned up!

My main worry is coordspace.py - this one is the hub that I would like to operate on only with minimally invasive changes. While replacing the if-conditions with matrix transformations looks much cleaner, this was actually tried before and led to a huge cascade in bugs. I am open to trying to clean this up again (the codebase has move much since this was last attempted), but would not want to mix this into a PR that also adds new features at the same time.

I also see what I am 95% sure is a bug: rayforge/machine/models/machine.py:1480 adds an early return for wcs_origin_is_workarea_origin, returning get_reference_offset() directly instead of passing it through machine_point_to_world. In native mode with BOTTOM_LEFT origin this is identical, but for TOP_LEFT / TOP_RIGHT / BOTTOM_RIGHT origins it changes the result — e.g. TOP_LEFT goes from (ml, mt) to (ml, height - mt).

Below are some areas of improvement that I found.

Encapsulation

The PR adds workspace_x_negative / workspace_y_negative properties to CoordinateSpace but not workspace_x_right / workspace_y_down. As a result, the UI re-derives axis orientation by string-parsing enum values in four places:

  • rayforge/ui_gtk/canvas2d/surface.py:1471 and :1501workspace_origin.value.startswith("top") / .endswith("right")
  • rayforge/ui_gtk/sim3d/viewport.py:226227 — same parsing

This is brittle (the OriginCorner enum values become load-bearing API) and inconsistent with the explicit origin in (OriginCorner.TOP_LEFT, ...) style used in axis.py and work_origin.py. These four call sites should call space.workspace_x_right / space.workspace_y_down properties

Presentation logic has crept into the Machine model

This is something that has arguably already started in current main (get_visual_wcs_offset and get_visual_extent_frame). The PR adds more UI logic on top of that. Machine.workspace_nogo_zones does projection, caching, and copy semantics inside the model. The cache must be manually invalidated at every mutation site (add/remove/param-change/set_axis_extents/set_workspace_orientation) — any future mutation path that forgets will show stale zones silently. The API is also asymmetric: native returns the live zones, rotated returns unconnected copies, so a caller mutating them silently no-ops in rotated mode (documented, but inconsistent).

Overall, the amount of view logic in the machine model is growing to a point where I think we need to decouple them. perhaps a MachineView, or a small Workspace facade for the projected zones + jog mapping makes sense at this point to pull these APIs out of the machine model itself.

Minor: "Rotary requires Native" is enforced in 5 places

layer_settings_dialog.py, surface.py, intent_builder.py (validate_workspace_configuration), intent_controller.py, and pipeline.py. The core validation call in intent_builder + the defensive call in pipeline.generate_job_artifact are reasonable, but the UI-side guards are duplicated logic that must all change in lockstep when Raygeo's rotary stage learns to compose with the workspace transform. Consider a single machine.rotary_supported() style predicate consumed everywhere.

Test coverage

Very good. One gap is related to the (likely) bug above: no native-mode get_reference_position_world test for non-BL origins. This should be tested with a parametric test matrix that was manually checked and covering all combinations of origin, axis negation, and "workarea origin is coordinate zero".

I think the changes required are not that many - one key question is in which order to merge this. The cleanest approach would probably start by a PR for the "coordspace.py goes matrix math" refactor first - with the rotatable workspace orientation support not yet included.

Once that has landed, this PR could be added on top. Alternatively, most of the changes in coordspace.py in this PR could be reverted and implemented using the old coordspace.py (minimally invasive approach), but that could be headache inducing and hard to understand / validate. So my recommendation would be the refactor-first approach.

@pborges

pborges commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the detailed review. You were right about the get_reference_position_world early return.

I’ve pushed a follow-up addressing each point:

  • I deliberately chose the minimally invasive coordspace.py approach rather than introducing the matrix refactor first. The original conditional bodies for point and item conversion are restored, with workspace rotation applied only as a pre/post step. tests/machine/models/test_coordinate_spaces.py remains untouched and passes unchanged.
  • The reference-position early return and work-area-origin rewrite were reverted to main’s original behavior. I added a 32-case native regression matrix covering every origin, X/Y reversal combination, and work-area-origin mode. I also checked its expected values against the pre-PR implementation rather than deriving them from the new code.
  • Because the scalar UI path and encoder matrix path are now intentionally separate, I added an equivalence test across all 48 origin/reversal/orientation configurations at four workspace points. This guards against the two implementations drifting apart.
  • Added workspace_x_right and workspace_y_down to CoordinateSpace and replaced the enum-value string parsing.
  • Moved workspace-facing geometry, projected no-go zones, and visual jog mapping into a MachineView facade exposed as machine.workspace.
  • Removed the manually invalidated no-go-zone cache. Workspace projections are now consistently detached copies in both Native and rotated modes. This trades a small amount of allocation at realistic zone counts for correctness without invalidation bookkeeping.
  • Centralized rotary compatibility in machine.supports_rotary_workspace(), which is now consumed by the UI and pipeline validation paths.
  • JogDirection moved alongside the view-facing jog mapping, but remains re-exported through machine.py, so existing imports continue to work.

Local validation passed with 5,355 runnable backend tests and 561 UI tests, plus Ruff, Flake8, Pyflakes, and changed-file Pyright. CI has restarted on the follow-up commit.

Thanks again for entertaining this, I know how risky these sorts of changes are, especially since it's impossible to physically test all the permutations.

I really have been enjoying the software though!

I have also been working on https://github.com/pborges/rayforged (which is pure AI slop atm) as a way to programatically produce rayforge project files, its hooked into my inventory management system to make it easy to laser out some asset tags.

image

The computer running my CNC machines runs linux and I was really tired of leaving my Mac out there to run LightBurn so I was really stoked to find this software!

@pborges

pborges commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Following up on a few things that cut across the individual replies.

Merge order. I went with your second option — minimally invasive — rather
than the matrix refactor. world_point_to_machine, machine_point_to_world
and both *_item_to_* methods are back to the existing if-condition bodies,
with the rotation applied as a pre/post step around them.
get_command_offset is untouched, and get_world_to_machine_matrix still
uses the existing get_transform_to_world, composing the rotation
immediately before it. The check I'd point at is that
tests/machine/models/test_coordinate_spaces.py has no diff in this PR — the
existing coordspace suite passes unmodified.

If you'd still rather land the refactor first as its own PR, I'm happy to wait
and rebase. This change would only get smaller on top of it, so it's your
call which order is less risky.

On dropping the projection cache. Rather than fixing the invalidation, I
removed the cache entirely — the projection is now computed on access inside
MachineView, so there are no invalidation sites left to forget, and a
detached copy is returned for every orientation including Native, so mutation
semantics no longer change when the workspace rotates. The tradeoff is that
zones are re-copied on each access. At realistic zone counts that's cheap, and
NogoZoneElement doesn't subscribe to zone.changed, so detached copies are
safe for canvas sync. Now that this is isolated in the view, caching can be
reintroduced there if you'd prefer it.

One addition of my own. Keeping coordspace minimally invasive leaves the
scalar path (UI) and the matrix path (encoder) describing the same transform
independently, so I added test_scalar_and_matrix_paths_agree: 48
configurations × 4 probe points asserting world_point_to_machine matches the
matrix applied to the same point. I checked it actually catches drift by
swapping the composition order — 24 of 48 fail, all of them rotated
configurations, with native staying green.

Two changes you may want to look at specifically, since neither is
obvious from the diff:

  • JogDirection moved from machine.py to machine_view.py. It's
    re-exported from machine.py so imports are unaffected, but it is an API
    move inside a feature PR — happy to pull it out if you'd rather keep this
    focused.
  • Camera alignment is now re-projected on set_axis_extents, not just on
    orientation changes. A rotated presentation's translation depends on the bed
    dimensions, so resizing the bed would otherwise silently invalidate an
    existing calibration.

@knipknap

Copy link
Copy Markdown
Contributor

@pborges — thanks for the follow-up, and glad you're enjoying Rayforge! The regression work looks good - the 32-case native matrix is great, and the 48-config scalar/matrix equivalence test is a clever guard.

That equivalence test is also the thing I want to talk about, because I think it's pointing at an architectural problem I'm not comfortable merging as-is.

The two-path split. Right now the same transform is described twice: once as the scalar world_point_to_machine / *_item_to_* conditionals, once as the get_world_to_machine_matrix path the encoder uses. Your PR threads the rotation through both and adds test_scalar_and_matrix_paths_agree to keep them in sync. But an equivalence test exists because the two can drift — and I don't want a coordinate hub where correctness depends on a test catching drift between two implementations of the same math. There should be one source of truth.

It looks like they already with everything MachineSpace.from_machine can produce. So this isn't a "rewrite everything" situation — it's "make the matrix the single source of truth and turn the scalar methods into thin wrappers." That's pretty much the refactor-first PR I floated in review; the current route unfortunately re-creates the very duplication that refactor would eliminate. Once there's one path, the equivalence test goes away by construction.

On rayforged — quick tip that should save you fragility: you don't need to re-implement the doc model. Rayforge is on PyPI, and the model is importable:

from rayforge.core.doc import Doc        # has to_dict() / from_dict()
from rayforge.core.item import DocItem

Doc round-trips via to_dict() / from_dict() — the exact serialization the .rayforge project files use — so you can build a doc in code and write it straight to a project file. That way your software won't desync when the format changes. Let me know if you need more info about this approach.

Proposed merge path

Since this is on such a critical code path, we need to find a way to land this incrementally, each step small enough to review and test thoroughly, and each one shrinking the PR on rebase. My proposal for an integration order:

  1. Encapsulation cleanup: add workspace_x_right / workspace_y_down and replace the four enum-string-parsing sites in surface.py / viewport.py. Already in your branch; I can cherry-pick verbatim.
  2. Port your 48-config point-equivalence test to main as a pre-refactor guard.
  3. Unify the point methods onto the matrix — behavior-preserving so the existing suite stays green.
  4. Unify the item methods (point matrix + bbox offset).
  5. Your rotation, now a single matrix composition. We delete test_scalar_and_matrix_paths_agree — its job is done by construction.
  6. Then MachineView, supports_rotary_workspace, and the UI surfaces.
  7. ...

Credit & ownership — I can slice the PR if you want, but if I land slices as my own commits I'll add Co-Authored-By: pborges on each and reference this PR; you wrote the code, I'm just de-risking the path. If you'd rather author the slice-PRs yourself after each step lands, that's fine too. This stays your PR — every slice is already in your branch, so each rebase just deletes lines from your diff until it's small enough to merge as-is. I'm not trying to take it over.

How does that sound? And would you rather author the slices yourself, or have me pick them with co-author credit?

@pborges

pborges commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Proposed merge path sounds good to me, Co-Authored-By: pborges would be good enough for me, I just appreciate you taking the time to even entertain this feature for us portrait mode laser people. I feel like it would go much smoother with you behind the wheel if you want to take on the split and merge, and if you need any help I usually have a few hours on the weekends!

knipknap added a commit that referenced this pull request Aug 11, 2026
Make world_point_to_machine and machine_point_to_world delegate to the
world<->machine matrices so the scalar UI path and the matrix encoder
path share one source of truth. Drop the unused get_transform_from_world
and add get_machine_to_world_matrix as its inverse.

Port regression guards from #334: scalar/matrix path equivalence,
axis-label origin native behavior, and a reference-position matrix.

Co-Authored-By: pborges <pborges475@gmail.com>
knipknap added a commit that referenced this pull request Aug 11, 2026
Rework world_item_to_machine and machine_item_to_world to transform the
bounding-box corners through the matrix-based point methods, leaving
only the corner selection (which bbox corner the position refers to) as
scalar logic. The point transform itself now flows through a single
source of truth.

Add regression guards: a 16-config item round-trip across every native
origin/reversal combination, plus forward pins for origin-with-reversal
configs and broader machine_item_to_world coverage.

Co-Authored-By: pborges <pborges475@gmail.com>
knipknap added a commit that referenced this pull request Aug 11, 2026
Introduce WorkspaceOrientation (native/rotated_left/rotated_right) on
MachineSpace and compose the 90-degree rotation into the single
world<->machine matrix path, so every point and item transform gains
rotation automatically. The machine keeps reading native coordinates;
the rotation only controls how the bed is presented in world space.
machine_item_to_world swaps item extents under rotation to keep its
bounding-box corner selection correct.

Add a 48-config point+item round-trip and rotation-direction forward
pins. Remove the now-tautological scalar/matrix equivalence guard,
since the point methods delegate to the matrix and there is a single
path.

Co-Authored-By: pborges <pborges475@gmail.com>
knipknap added a commit that referenced this pull request Aug 11, 2026
Introduce MachineView, a display-facing projection of MachineSpace that
exposes how the bed appears after the workspace rotation (visible origin
corner, axis directions). It lives in machine/models so presentation
concerns stay off the coordinate model.

WorkOriginElement no longer imports MachineSpace; it takes the two axis
booleans it needs through set_axis_direction, matching AxisRenderer's
primitive-setter pattern. WorkSurface reads MachineView and passes the
primitives. This is a breaking addon-API change; PLUGIN_API_VERSION is
bumped to 21.

Co-Authored-By: pborges <pborges475@gmail.com>
@knipknap

Copy link
Copy Markdown
Contributor

Closing this one, it is superseded by #342. Thank you for the PR.

@knipknap knipknap closed this Aug 12, 2026
@pborges

pborges commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants