diff --git a/README.md b/README.md index 3910982..7764878 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,11 @@ project workspace or development destination. - [Project charter](docs/project-charter.md) - [Product and research roadmap](docs/roadmap.md) +- [MOPA workbench UX requirements](docs/design/mopa-workbench-ux.md) - [Current Phase 0 status](docs/status/phase-0.md) - [Open-source reuse landscape](docs/research/open-source-landscape.md) +- [LightBurn-to-Rayforge UX capability map](docs/research/lightburn-rayforge-ux-capability-map.md) +- [Rayforge layer visibility and output spike](docs/research/rayforge-layer-model-spike.md) - [Proposed Rayforge/BalorMK reuse decision](docs/adr/0002-reuse-rayforge-and-balormk.md) - [Live USB enumeration](docs/research/live-usb-enumeration-2026-10-17.md) - [LightBurn device-export analysis](docs/research/lightburn-device-exports-2026-10-19.md) @@ -121,6 +124,12 @@ The checks verify manifest integrity, public/private evidence contracts, disallowed binary types, private identifiers, local user paths, and other likely data leaks. They do not certify hardware safety. +Rayforge source and its Windows build stack are not vendored or downloaded by +these checks. The exact upstream revision is recorded in +`upstreams/rayforge.json`; run `scripts/prepare_rayforge_build.ps1` explicitly +to populate the ignored build cache. Dependency installation and bundle +creation require separate command switches. + ## Safety boundary - Simulation precedes equivalent hardware behavior. diff --git a/docs/adr/0002-reuse-rayforge-and-balormk.md b/docs/adr/0002-reuse-rayforge-and-balormk.md index 1b02e9f..9851a16 100644 --- a/docs/adr/0002-reuse-rayforge-and-balormk.md +++ b/docs/adr/0002-reuse-rayforge-and-balormk.md @@ -110,6 +110,14 @@ Subsequent offline evidence: Rayforge's generated settings contract exposes frequency and pulse width, preserves them through step serialization and supplies preview Ops without transport; packaged GTK interaction remains untested. +- [LightBurn-to-Rayforge UX capability map](../research/lightburn-rayforge-ux-capability-map.md): + the casual-maker workflow maps mostly to existing Rayforge product seams; + the remaining UX gaps are narrow, testable extensions rather than evidence + for a separate desktop application. +- [Rayforge layer visibility and output spike](../research/rayforge-layer-model-spike.md): + canvas visibility and job participation are already independent serialized + states; LightBurn-style layer Output is a narrow aggregate UI concern, not a + new pipeline or document-model requirement. These results support continuing toward complete list buffering and production integration, but they do not yet satisfy every proof obligation or change this diff --git a/docs/research/rayforge-layer-model-spike.md b/docs/research/rayforge-layer-model-spike.md new file mode 100644 index 0000000..ba9643e --- /dev/null +++ b/docs/research/rayforge-layer-model-spike.md @@ -0,0 +1,103 @@ +# Rayforge layer visibility and output spike + +- Date: 2026-10-20 +- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5` +- Scope: layer model, workflow output, ordering and serialization +- Transport: none + +## Result + +Rayforge already separates the two behaviors required by the PirateRay UX, +but at different levels of its model: + +- `Layer.visible` controls whether a layer is rendered on the canvas and in + visual previews; +- `Step.visible` controls whether a workflow operation participates in the + generated job pipeline. + +The states are independent. A hidden layer can retain an enabled operation, +which matches LightBurn's rule that hiding artwork does not silently disable +output. Conversely, disabling a workflow step removes that operation from job +generation without changing the layer's canvas visibility. + +The pinned model round-trip preserved: + +- layer name `Engraving`; +- layer color `#123456`; +- hidden layer state; +- operation name `Mark outline`; +- enabled operation state; +- an explicitly reordered layer list (`Second`, then `First`). + +The deterministic result is stored in +`tests/fixtures/rayforge-layer-model.json`. + +## UX fit + +Rayforge's existing layer header exposes one eye control for +`Layer.visible`. Its operation editor exposes a visibility switch for each +`Step.visible`. The pipeline checks step visibility and does not check layer +visibility when constructing job nodes. + +This means the required Show/Output semantics do **not** require a new pipeline +field or a duplicate layer model. The smallest PirateRay-facing extension is +an aggregate **Output** control in the layer presentation: + +- on: all supported workflow steps in the layer are enabled; +- off: all workflow steps in the layer are disabled; +- mixed: some workflow steps are enabled and some are disabled. + +An aggregate toggle must preserve or deliberately define what happens to a +previous mixed selection. The safest interaction is to expose the mixed state, +let the layer toggle affect all steps, and leave per-operation switches +available in Advanced view. This is a UI policy decision over existing model +state, not a serialization migration. + +## Ordering + +`Doc.layers` is the single ordered layer list. Drag-and-drop calls the +undoable `reorder_layers` command, canvas stacking follows the same list, and +the intent builder iterates it in order when constructing job nodes. The +serialized document retains that order. + +The source documentation states that layers execute left to right. A packaged +no-device GUI checkpoint should still confirm that the visible ordering affordance +is understandable to a casual maker and that preview communicates the same +order. + +## Remaining interaction evidence + +The source and model evidence close the data-model question, but not the human +interaction checkpoint. A packaged no-device test should verify: + +1. the layer eye changes only canvas/preview visibility; +2. a disabled operation remains visibly distinct from a hidden layer; +3. the proposed aggregate Output control reports on/off/mixed correctly; +4. save/reload retains names, colors, order and both states; +5. preview and no-device job output omit disabled steps but retain hidden, + enabled layers. + +No USB enumeration, controller command, motion, physical framing, or emission +occurred. + +## Reproduce + +Run against the pinned checkout with the already isolated Rayforge Python +environment: + +```powershell +python scripts/spike_rayforge_layer_model.py ` + --output tests/fixtures/rayforge-layer-model.json +``` + +## Source anchors + +- `rayforge/core/layer.py`: layer visibility, color and serialization +- `rayforge/core/step.py`: operation visibility and serialization +- `rayforge/pipeline/intent_builder.py`: step filtering and ordered job nodes +- `rayforge/ui_gtk/doceditor/layer_column.py`: layer eye control +- `rayforge/ui_gtk/doceditor/step_box.py`: operation visibility control +- `rayforge/ui_gtk/doceditor/layers_tab.py`: undoable drag ordering +- `rayforge/ui_gtk/canvas2d/surface.py`: canvas stacking order +- `website/docs/features/multi-layer.md`: documented execution semantics + diff --git a/scripts/spike_rayforge_layer_model.py b/scripts/spike_rayforge_layer_model.py new file mode 100644 index 0000000..826b1ca --- /dev/null +++ b/scripts/spike_rayforge_layer_model.py @@ -0,0 +1,134 @@ +"""Characterize Rayforge layer visibility, output, order, and persistence.""" + +from __future__ import annotations + +import argparse +import hashlib +import inspect +import json +import subprocess +import sys +from pathlib import Path + + +RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5" + + +def checkout_commit(root: Path) -> str: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def characterize(root: Path) -> dict: + commit = checkout_commit(root) + if commit != RAYFORGE_COMMIT: + raise RuntimeError( + f"Expected Rayforge {RAYFORGE_COMMIT}, found {commit}" + ) + + sys.path.insert(0, str(root)) + from rayforge.core.doc import Doc + from rayforge.core.layer import Layer + from rayforge.core.step import Step + from rayforge.pipeline.intent_builder import IntentBuilder + + layer = Layer("Engraving") + layer.set_color("#123456") + workflow = layer.workflow + if workflow is None: + raise RuntimeError("Test layer has no workflow") + step = Step("Contour", "Mark outline") + workflow.add_step(step) + + layer.set_visible(False) + step.set_visible(True) + restored = Layer.from_dict(layer.to_dict()) + restored_step = restored.workflow.steps[0] if restored.workflow else None + + first = Layer("First") + second = Layer("Second") + doc = Doc() + doc.set_layers([second, first]) + serialized_order = [ + child["name"] + for child in doc.to_dict()["children"] + if child.get("type") == "layer" + ] + + build_source = inspect.getsource(IntentBuilder.build) + render_source = inspect.getsource(Layer.get_renderable_items) + layer_ui = ( + root / "rayforge" / "ui_gtk" / "doceditor" / "layer_column.py" + ).read_text(encoding="utf-8") + step_ui = ( + root / "rayforge" / "ui_gtk" / "doceditor" / "step_box.py" + ).read_text(encoding="utf-8") + + return { + "rayforge_commit": commit, + "hardware_access": False, + "model": { + "layer_visible": layer.visible, + "step_visible": step.visible, + "states_are_independent": ( + layer.visible is False and step.visible is True + ), + "roundtrip": { + "layer_name": restored.name, + "layer_color": restored.color, + "layer_visible": restored.visible, + "step_name": restored_step.name if restored_step else None, + "step_visible": ( + restored_step.visible if restored_step else None + ), + }, + "serialized_layer_order": serialized_order, + }, + "source_contract": { + "canvas_checks_layer_visibility": ( + "if not self.visible" in render_source + ), + "job_pipeline_checks_step_visibility": ( + "if not step.visible" in build_source + ), + "job_pipeline_checks_layer_visibility": ( + "layer.visible" in build_source + ), + "layer_visibility_control": ( + "Toggle layer visibility" in layer_ui + ), + "step_visibility_control": "Toggle step visibility" in step_ui, + }, + "conclusion": ( + "Rayforge separates canvas visibility at layer level from output " + "participation at workflow-step level; a LightBurn-style layer " + "Output control can be an aggregate UI over existing step states." + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("rayforge_checkout", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + result = characterize(args.rayforge_checkout.resolve()) + payload = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload, encoding="utf-8") + else: + print(payload, end="") + print(f"sha256={hashlib.sha256(payload.encode()).hexdigest()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/rayforge-layer-model.json b/tests/fixtures/rayforge-layer-model.json new file mode 100644 index 0000000..2a156ab --- /dev/null +++ b/tests/fixtures/rayforge-layer-model.json @@ -0,0 +1,28 @@ +{ + "conclusion": "Rayforge separates canvas visibility at layer level from output participation at workflow-step level; a LightBurn-style layer Output control can be an aggregate UI over existing step states.", + "hardware_access": false, + "model": { + "layer_visible": false, + "roundtrip": { + "layer_color": "#123456", + "layer_name": "Engraving", + "layer_visible": false, + "step_name": "Mark outline", + "step_visible": true + }, + "serialized_layer_order": [ + "Second", + "First" + ], + "states_are_independent": true, + "step_visible": true + }, + "rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5", + "source_contract": { + "canvas_checks_layer_visibility": true, + "job_pipeline_checks_layer_visibility": false, + "job_pipeline_checks_step_visibility": true, + "layer_visibility_control": true, + "step_visibility_control": true + } +} diff --git a/tests/test_rayforge_layer_model_spike.py b/tests/test_rayforge_layer_model_spike.py new file mode 100644 index 0000000..1d05e5d --- /dev/null +++ b/tests/test_rayforge_layer_model_spike.py @@ -0,0 +1,49 @@ +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "rayforge-layer-model.json" +SCRIPT = ROOT / "scripts" / "spike_rayforge_layer_model.py" + + +class RayforgeLayerModelSpikeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.result = json.loads(FIXTURE.read_text(encoding="utf-8")) + + def test_visibility_and_output_states_are_independent(self): + self.assertTrue(self.result["model"]["states_are_independent"]) + self.assertFalse(self.result["model"]["layer_visible"]) + self.assertTrue(self.result["model"]["step_visible"]) + + def test_states_name_color_and_order_survive_serialization(self): + restored = self.result["model"]["roundtrip"] + self.assertEqual(restored["layer_name"], "Engraving") + self.assertEqual(restored["layer_color"], "#123456") + self.assertFalse(restored["layer_visible"]) + self.assertEqual(restored["step_name"], "Mark outline") + self.assertTrue(restored["step_visible"]) + self.assertEqual( + self.result["model"]["serialized_layer_order"], + ["Second", "First"], + ) + + def test_canvas_and_job_pipeline_use_different_states(self): + contract = self.result["source_contract"] + self.assertTrue(contract["canvas_checks_layer_visibility"]) + self.assertTrue(contract["job_pipeline_checks_step_visibility"]) + self.assertFalse(contract["job_pipeline_checks_layer_visibility"]) + self.assertTrue(contract["layer_visibility_control"]) + self.assertTrue(contract["step_visibility_control"]) + + def test_spike_is_hardware_free(self): + self.assertFalse(self.result["hardware_access"]) + source = SCRIPT.read_text(encoding="utf-8").lower() + for forbidden in ("pyusb", "usb.core", "9588", "9899"): + self.assertNotIn(forbidden, source) + + +if __name__ == "__main__": + unittest.main()