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)
|
- [Project charter](docs/project-charter.md)
|
||||||
- [Product and research roadmap](docs/roadmap.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)
|
- [Current Phase 0 status](docs/status/phase-0.md)
|
||||||
- [Open-source reuse landscape](docs/research/open-source-landscape.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)
|
- [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)
|
- [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)
|
- [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
|
disallowed binary types, private identifiers, local user paths, and other
|
||||||
likely data leaks. They do not certify hardware safety.
|
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
|
## Safety boundary
|
||||||
|
|
||||||
- Simulation precedes equivalent hardware behavior.
|
- Simulation precedes equivalent hardware behavior.
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,14 @@ Subsequent offline evidence:
|
||||||
Rayforge's generated settings contract exposes frequency and pulse width,
|
Rayforge's generated settings contract exposes frequency and pulse width,
|
||||||
preserves them through step serialization and supplies preview Ops without
|
preserves them through step serialization and supplies preview Ops without
|
||||||
transport; packaged GTK interaction remains untested.
|
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
|
These results support continuing toward complete list buffering and production
|
||||||
integration, but they do not yet satisfy every proof obligation or change this
|
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