research: characterize Rayforge layer output semantics
This commit is contained in:
parent
71067308d7
commit
232f1119d9
6 changed files with 331 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
103
docs/research/rayforge-layer-model-spike.md
Normal file
103
docs/research/rayforge-layer-model-spike.md
Normal 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
|
||||
|
||||
134
scripts/spike_rayforge_layer_model.py
Normal file
134
scripts/spike_rayforge_layer_model.py
Normal 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())
|
||||
28
tests/fixtures/rayforge-layer-model.json
vendored
Normal file
28
tests/fixtures/rayforge-layer-model.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
49
tests/test_rayforge_layer_model_spike.py
Normal file
49
tests/test_rayforge_layer_model_spike.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue