Stage imported MOPA settings in disabled Rayforge workflows
This commit is contained in:
parent
583a0d573a
commit
be3fb933c1
9 changed files with 327 additions and 20 deletions
|
|
@ -68,20 +68,23 @@ application until both gates below have real evidence:
|
|||
reviewed build runner, verified without execution, and pass the manual
|
||||
no-device desktop checklist.
|
||||
2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
|
||||
Import mapping still requires explicit unit conversion and fail-closed
|
||||
handling of absent pulse width, with synthetic offline tests.
|
||||
Offline mapping and disabled-output workflow staging now have synthetic
|
||||
tests. Promotion still requires an independently established unit contract,
|
||||
explicit resolution of absent pulse width and machine-context validation.
|
||||
|
||||
Raw projects, screenshots containing identifiers, machine data and build
|
||||
binaries stay out of the public repository.
|
||||
|
||||
## Suggested first task
|
||||
|
||||
An offline project MOPA seam and synthetic tests now exist; see
|
||||
An offline project MOPA seam, disabled-output Rayforge workflow bridge and
|
||||
synthetic tests now exist; see
|
||||
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
|
||||
element types and requires an explicit unit contract for normalization. It is
|
||||
not wired into Rayforge's workflow and never authorizes encoding. The next
|
||||
import task is host integration with layer identity and independently
|
||||
established units; absent pulse width and image equivalence remain blockers.
|
||||
element types and requires an explicit unit contract for normalization. The
|
||||
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
||||
metadata and disables every staged step. It never authorizes encoding. The
|
||||
next import task is UI/preflight presentation plus independently established
|
||||
units; absent pulse width and image equivalence remain blockers.
|
||||
|
||||
If a Windows artifact is available, keep the machine disconnected and run, in
|
||||
order:
|
||||
|
|
|
|||
|
|
@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including
|
|||
|
||||
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
|
||||
`transport=None`, even with complete normalized values. This is a callable
|
||||
offline metadata seam; it is not yet wired to Rayforge's project importer or
|
||||
workflow. Host integration must bind settings to layer indices, establish
|
||||
serialized units from independent evidence, resolve absent pulse width and
|
||||
validate exact machine, lens, correction and parameter limits. No existing
|
||||
golden lifecycle or upstream importer run was repeated; the package inventory
|
||||
was extended to include the new module.
|
||||
offline metadata seam. The companion workflow bridge matches the normalized
|
||||
LightBurn layer index to Rayforge's source-segment layer ID and stages complete
|
||||
vector MOPA values through the host's setters. Every staged step is disabled
|
||||
through `Step.set_visible(False)`, and its raw values, normalized values,
|
||||
review status and blockers are saved in the serializable `Step.extra` data.
|
||||
Image settings and incomplete settings remain disabled without applying MOPA
|
||||
values. Multi-step staging is preflighted and rolled back on setter failure.
|
||||
|
||||
The bridge deliberately has no function that enables output or removes a
|
||||
blocker. Host UI and preflight integration must still establish serialized
|
||||
units from independent evidence, resolve absent pulse width and validate exact
|
||||
machine, lens, correction and parameter limits before a separate reviewed
|
||||
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
|
||||
included in the pinned upstream contract manifest.
|
||||
|
||||
Validation covers unit conversion, absent fields, case sensitivity, duplicate
|
||||
values, invalid numbers, image modes, mixed layers and malformed XML. Existing
|
||||
packaging, no-device and public evidence checks remain part of the test suite.
|
||||
values, invalid numbers, image modes, mixed layers, exact layer binding,
|
||||
transactional rollback and malformed XML. Existing packaging, no-device and
|
||||
public evidence checks remain part of the test suite.
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ its manifest and sidecar; installer-only publication is not accepted.
|
|||
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
|
||||
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
|
||||
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
|
||||
| `Step.extra`, visibility serialization, MOPA setters | Imported host workflow interfaces | Used by the disabled-output import bridge; exact source files and symbols are pinned before host integration |
|
||||
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
|
||||
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None:
|
|||
return raw
|
||||
|
||||
|
||||
def _layer_id(element: ET.Element) -> str | None:
|
||||
children = element.findall("index")
|
||||
if not children:
|
||||
return None
|
||||
if len(children) != 1 or set(children[0].attrib) != {"Value"}:
|
||||
raise ProjectImportError("ambiguous layer index")
|
||||
raw = children[0].get("Value", "")
|
||||
if not raw.isdecimal():
|
||||
raise ProjectImportError("invalid layer index")
|
||||
return str(int(raw))
|
||||
|
||||
|
||||
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
|
||||
"""Preserve observed XML fields and optionally normalize declared units.
|
||||
|
||||
|
|
@ -51,6 +63,7 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
|||
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
|
||||
raise ProjectImportError("unsupported project format")
|
||||
layers = []
|
||||
layer_ids: set[str] = set()
|
||||
for setting in root:
|
||||
if setting.tag not in ("CutSetting", "CutSetting_Img"):
|
||||
continue
|
||||
|
|
@ -58,6 +71,11 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
|||
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
|
||||
if kind not in expected:
|
||||
raise ProjectImportError("unsupported layer type")
|
||||
layer_id = _layer_id(setting)
|
||||
if layer_id is not None and layer_id in layer_ids:
|
||||
raise ProjectImportError("duplicate layer index")
|
||||
if layer_id is not None:
|
||||
layer_ids.add(layer_id)
|
||||
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
|
||||
blockers = []
|
||||
if unit_contract is None:
|
||||
|
|
@ -78,7 +96,8 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
|||
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
|
||||
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
|
||||
}
|
||||
layers.append({"element": setting.tag, "type": kind, "raw": raw,
|
||||
layers.append({"element": setting.tag, "type": kind,
|
||||
"source_layer_id": layer_id, "raw": raw,
|
||||
"image_mode": image_mode,
|
||||
"normalized": normalized, "blockers": blockers})
|
||||
if not layers:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
"""Stage MOPA metadata on Rayforge workflow steps with output disabled."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
|
||||
METADATA_KEY = "pirateray_import"
|
||||
|
||||
|
||||
class WorkflowImportError(ValueError):
|
||||
"""The imported layer cannot be bound safely to a Rayforge step."""
|
||||
|
||||
|
||||
def _host_integer(value: Any, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise WorkflowImportError(f"{label} must be numeric")
|
||||
result = int(value)
|
||||
if result <= 0 or result != value:
|
||||
raise WorkflowImportError(f"{label} must be a positive integer")
|
||||
return result
|
||||
|
||||
|
||||
def stage_mopa_workflow_steps(
|
||||
imported: Mapping[str, Any],
|
||||
steps_by_layer_id: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Bind complete imported MOPA settings while keeping every step disabled.
|
||||
|
||||
The mapping is constructed by the host integration from the source segment's
|
||||
``layer_id`` to the single workflow step created by Rayforge's assembler.
|
||||
This seam never enables a step and never removes an existing blocker.
|
||||
"""
|
||||
if imported.get("encoding_allowed") is not False:
|
||||
raise WorkflowImportError("import must explicitly prohibit encoding")
|
||||
if imported.get("hardware_use_allowed") is not False:
|
||||
raise WorkflowImportError("import must explicitly prohibit hardware use")
|
||||
layers = imported.get("layers")
|
||||
if not isinstance(layers, list):
|
||||
raise WorkflowImportError("imported layers are missing")
|
||||
|
||||
by_id: dict[str, Mapping[str, Any]] = {}
|
||||
for layer in layers:
|
||||
if not isinstance(layer, Mapping):
|
||||
raise WorkflowImportError("imported layer must be an object")
|
||||
layer_id = layer.get("source_layer_id")
|
||||
if layer_id is None:
|
||||
continue
|
||||
if not isinstance(layer_id, str) or layer_id in by_id:
|
||||
raise WorkflowImportError("imported layer identity is ambiguous")
|
||||
by_id[layer_id] = layer
|
||||
if not steps_by_layer_id:
|
||||
raise WorkflowImportError("no Rayforge workflow steps were supplied")
|
||||
|
||||
global_blockers = imported.get("blockers")
|
||||
if not isinstance(global_blockers, list) or not all(
|
||||
isinstance(item, str) and item for item in global_blockers
|
||||
):
|
||||
raise WorkflowImportError("global blockers are missing")
|
||||
|
||||
plans = []
|
||||
step_identities: set[int] = set()
|
||||
for layer_id, step in steps_by_layer_id.items():
|
||||
if not isinstance(layer_id, str):
|
||||
raise WorkflowImportError("Rayforge layer ID must be text")
|
||||
if id(step) in step_identities:
|
||||
raise WorkflowImportError("one Rayforge step cannot represent multiple layers")
|
||||
step_identities.add(id(step))
|
||||
layer = by_id.get(layer_id)
|
||||
if layer is None:
|
||||
raise WorkflowImportError(f"no imported settings for layer {layer_id}")
|
||||
if not isinstance(getattr(step, "extra", None), dict):
|
||||
raise WorkflowImportError("Rayforge step has no serializable extra data")
|
||||
if METADATA_KEY in step.extra:
|
||||
raise WorkflowImportError("Rayforge step already has PirateRay import data")
|
||||
if not hasattr(step, "visible") or not callable(
|
||||
getattr(step, "set_visible", None)
|
||||
):
|
||||
raise WorkflowImportError("Rayforge step has no output visibility gate")
|
||||
normalized = layer.get("normalized")
|
||||
layer_blockers = layer.get("blockers")
|
||||
if not isinstance(layer_blockers, list):
|
||||
raise WorkflowImportError("layer blockers are missing")
|
||||
apply_values = normalized is not None and not layer_blockers
|
||||
frequency = pulse_width = None
|
||||
if apply_values:
|
||||
if not callable(getattr(step, "set_frequency", None)) or not callable(
|
||||
getattr(step, "set_pulse_width", None)
|
||||
):
|
||||
raise WorkflowImportError("Rayforge step has no MOPA setters")
|
||||
frequency = _host_integer(normalized.get("frequency_hz"), "frequency")
|
||||
pulse_width = _host_integer(
|
||||
normalized.get("pulse_width_ns"), "pulse width"
|
||||
)
|
||||
plans.append((layer_id, step, layer, frequency, pulse_width))
|
||||
|
||||
snapshots = [
|
||||
(step, step.visible, deepcopy(step.extra), getattr(step, "frequency", None),
|
||||
getattr(step, "pulse_width", None))
|
||||
for _layer_id, step, _layer, _frequency, _pulse_width in plans
|
||||
]
|
||||
staged = []
|
||||
try:
|
||||
for layer_id, step, layer, frequency, pulse_width in plans:
|
||||
step.set_visible(False)
|
||||
blockers = list(global_blockers) + list(layer["blockers"])
|
||||
if frequency is not None and pulse_width is not None:
|
||||
step.set_frequency(frequency)
|
||||
step.set_pulse_width(pulse_width)
|
||||
step.extra[METADATA_KEY] = {
|
||||
"status": "staged-review-required",
|
||||
"source_layer_id": layer_id,
|
||||
"raw": deepcopy(layer.get("raw")),
|
||||
"normalized": deepcopy(layer.get("normalized")),
|
||||
"blockers": blockers,
|
||||
"encoding_allowed": False,
|
||||
"hardware_use_allowed": False,
|
||||
}
|
||||
staged.append({
|
||||
"source_layer_id": layer_id,
|
||||
"parameters_applied": frequency is not None,
|
||||
"output_enabled": False,
|
||||
"blockers": blockers,
|
||||
})
|
||||
except Exception:
|
||||
for step, visible, extra, frequency, pulse_width in snapshots:
|
||||
step.set_visible(visible)
|
||||
step.extra.clear()
|
||||
step.extra.update(extra)
|
||||
if frequency is not None:
|
||||
step.frequency = frequency
|
||||
if pulse_width is not None:
|
||||
step.pulse_width = pulse_width
|
||||
raise
|
||||
return {"steps": staged, "encoding_allowed": False,
|
||||
"hardware_use_allowed": False, "transport": None}
|
||||
1
tests/fixtures/rayforge-addon-spike.json
vendored
1
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -23,6 +23,7 @@
|
|||
"pirate_ray_jcz/rayforge_compat.py",
|
||||
"pirate_ray_jcz/recipe_metadata.py",
|
||||
"pirate_ray_jcz/worker.py",
|
||||
"pirate_ray_jcz/workflow_import.py",
|
||||
"rayforge-addon.yaml"
|
||||
],
|
||||
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ from pirate_ray_jcz.project_import import (
|
|||
)
|
||||
|
||||
|
||||
def project(fields, tag="CutSetting", kind="Scan"):
|
||||
def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
|
||||
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||
f'{fields}</{tag}></LightBurnProject>').encode()
|
||||
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||
|
||||
|
||||
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
|
||||
|
|
@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
self.assertFalse(result["encoding_allowed"])
|
||||
self.assertFalse(result["hardware_use_allowed"])
|
||||
self.assertIsNone(result["transport"])
|
||||
self.assertEqual(result["layers"][0]["source_layer_id"], "0")
|
||||
|
||||
def test_units_are_never_inferred_from_magnitude(self):
|
||||
result = import_project_mopa(project(FIELDS))
|
||||
|
|
@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
with self.assertRaises(ProjectImportError):
|
||||
import_project_mopa(data)
|
||||
|
||||
def test_layer_indices_are_normalized_and_must_be_unique(self):
|
||||
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
|
||||
self.assertEqual(layer["source_layer_id"], "7")
|
||||
duplicate = ('<LightBurnProject FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode()
|
||||
with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"):
|
||||
import_project_mopa(duplicate)
|
||||
|
||||
def test_multiple_layers_preserve_order_and_missing_pulse(self):
|
||||
data = ('<LightBurnProject FormatVersion="1">'
|
||||
'<CutSetting type="Cut">'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting_Img type="Image"><frequency Value="125000"/>'
|
||||
'<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
|
||||
'</CutSetting_Img></LightBurnProject>').encode()
|
||||
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
|
||||
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
|
||||
|
|
|
|||
113
tests/test_lightburn_workflow_import.py
Normal file
113
tests/test_lightburn_workflow_import.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
|
||||
from pirate_ray_jcz.project_import import UNIT_CONTRACT, import_project_mopa
|
||||
from pirate_ray_jcz.workflow_import import (
|
||||
METADATA_KEY, WorkflowImportError, stage_mopa_workflow_steps,
|
||||
)
|
||||
|
||||
|
||||
class Step:
|
||||
def __init__(self, fail=False):
|
||||
self.visible = True
|
||||
self.frequency = 1
|
||||
self.pulse_width = 2
|
||||
self.extra = {"preserved": True}
|
||||
self.fail = fail
|
||||
|
||||
def set_frequency(self, value):
|
||||
self.frequency = value
|
||||
|
||||
def set_visible(self, value):
|
||||
self.visible = value
|
||||
|
||||
def set_pulse_width(self, value):
|
||||
if self.fail:
|
||||
raise ValueError("synthetic setter failure")
|
||||
self.pulse_width = value
|
||||
|
||||
|
||||
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
|
||||
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
|
||||
|
||||
|
||||
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
|
||||
|
||||
|
||||
class WorkflowImportTests(unittest.TestCase):
|
||||
def test_complete_vector_settings_are_staged_but_output_is_disabled(self):
|
||||
step = Step()
|
||||
result = stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
|
||||
self.assertEqual((step.frequency, step.pulse_width), (125000, 25))
|
||||
self.assertFalse(step.visible)
|
||||
self.assertFalse(result["encoding_allowed"])
|
||||
self.assertIsNone(result["transport"])
|
||||
gate = step.extra[METADATA_KEY]
|
||||
self.assertEqual(gate["status"], "staged-review-required")
|
||||
self.assertFalse(gate["encoding_allowed"])
|
||||
self.assertTrue(gate["blockers"])
|
||||
|
||||
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
|
||||
for result in (
|
||||
imported('<frequency Value="125000"/>'),
|
||||
imported(FIELDS + '<ditherMode Value="3dslice"/>', kind="Image", tag="CutSetting_Img"),
|
||||
):
|
||||
with self.subTest(result=result):
|
||||
step = Step()
|
||||
staged = stage_mopa_workflow_steps(result, {"3": step})
|
||||
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
|
||||
self.assertFalse(step.visible)
|
||||
self.assertFalse(staged["steps"][0]["parameters_applied"])
|
||||
|
||||
def test_layer_identity_must_match(self):
|
||||
with self.assertRaisesRegex(WorkflowImportError, "no imported settings"):
|
||||
stage_mopa_workflow_steps(imported(FIELDS), {"4": Step()})
|
||||
|
||||
def test_existing_metadata_and_reused_step_are_rejected(self):
|
||||
step = Step()
|
||||
step.extra[METADATA_KEY] = {"status": "existing"}
|
||||
with self.assertRaisesRegex(WorkflowImportError, "already has"):
|
||||
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
|
||||
|
||||
two_layers = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
reused = Step()
|
||||
with self.assertRaisesRegex(WorkflowImportError, "multiple layers"):
|
||||
stage_mopa_workflow_steps(two_layers, {"1": reused, "2": reused})
|
||||
|
||||
def test_preflight_failure_does_not_mutate_any_step(self):
|
||||
result = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
first = Step()
|
||||
with self.assertRaisesRegex(WorkflowImportError, "no serializable extra"):
|
||||
stage_mopa_workflow_steps(result, {"1": first, "2": object()})
|
||||
self.assertTrue(first.visible)
|
||||
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
|
||||
|
||||
def test_setter_failure_rolls_back_every_step(self):
|
||||
result = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
first, second = Step(), Step(fail=True)
|
||||
with self.assertRaisesRegex(ValueError, "synthetic setter failure"):
|
||||
stage_mopa_workflow_steps(result, {"1": first, "2": second})
|
||||
for step in (first, second):
|
||||
self.assertTrue(step.visible)
|
||||
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
|
||||
self.assertEqual(step.extra, {"preserved": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -38,6 +38,18 @@
|
|||
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
|
||||
"stability": "host-lifecycle-implementation",
|
||||
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
|
||||
},
|
||||
{
|
||||
"path": "rayforge/core/step.py",
|
||||
"sha256": "4d25da9e4fb9f854562bfd33d238db3660c3056eeadfaa95c8669c6770d504f4",
|
||||
"stability": "imported-host-workflow-interface",
|
||||
"required_symbols": ["Step", "Step.set_visible", "Step.to_dict", "Step.from_dict"]
|
||||
},
|
||||
{
|
||||
"path": "rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py",
|
||||
"sha256": "59c94fec6861edc94383db856bba0c335e0e7c4d16a878e7c2a2c62b657e06c5",
|
||||
"stability": "imported-host-workflow-interface",
|
||||
"required_symbols": ["LaserStep", "LaserStep.set_frequency", "LaserStep.set_pulse_width", "LaserStep.to_dict", "LaserStep.from_dict"]
|
||||
}
|
||||
],
|
||||
"purpose": "Pinned host application for offline reuse and JCZ addon validation"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue