research: characterize Rayforge layer output semantics

This commit is contained in:
Codex 2026-10-20 15:07:03 +02:00
parent 71067308d7
commit 232f1119d9
6 changed files with 331 additions and 0 deletions

View file

@ -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.

View file

@ -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

View file

@ -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 <rayforge-checkout> `
--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

View file

@ -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())

View file

@ -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
}
}

View file

@ -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()