Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/skill/references/discipline/core-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ the durable export call `capture(result, path)`, whose two required arguments ar
notebook; one assembly notebook per product, composing children with `scad.use(...)`; parameters live in the notebook that
consumes them. Cell boundaries, `_private` names and `var()` follow `discipline/notebook-cell-design.md`; block structure
and the mandatory header `# ---- feature: <slug> (<role>) ----` follow `discipline/feature-tree-convention.md`. Read both
before writing any cell.
before writing any cell. The one exception is a task that itself asks for a single script
(`python model.py --output part.step`, an unattended batch): that is a plain script, see "One-shot script" in
`domains/export-and-translation.md`; the notebook rules do not apply to it.

**R4. Sketch first.** 2D profiles and planar paths go through the sketch API; primitives only when the shape is entirely
the basic form.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def export_product_package_to_mjcf(data: ProductPackageInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport
def export_product_package_to_mjcf(data: ProductInput, output_path: str | Path, *, mesh_directory: str | Path | None = None, mapping_path: str | Path | None = None, linear_deflection: float = 0.15, angular_deflection_degrees: float = _DEFAULT_MJCF_ANGULAR_DEFLECTION_DEGREES, default_density_kg_m3: float | None = None) -> ProductMJCFExportReport
```

*Source: exporter/mjcf.py*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def export_product_package_to_obj(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport
def export_product_package_to_obj(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductOBJExportReport
```

*Source: exporter/obj.py*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def export_product_package_to_step(data: ProductPackageInput, output_path: str | Path) -> ProductSTEPExportReport
def export_product_package_to_step(data: ProductInput, output_path: str | Path) -> ProductSTEPExportReport
```

*Source: exporter/step.py*
Expand All @@ -14,4 +14,5 @@ def export_product_package_to_step(data: ProductPackageInput, output_path: str |

## Description

Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP.
Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of
a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def export_product_package_to_stl(data: ProductPackageInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport
def export_product_package_to_stl(data: ProductInput, output_path: str | Path, *, linear_deflection: float = 0.1, angular_deflection_degrees: float = 20.0, relative: bool = False) -> ProductSTLExportReport
```

*Source: exporter/stl.py*
Expand Down
38 changes: 36 additions & 2 deletions docs/skill/references/domains/export-and-translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ Read the exact page under `references/docs/api/` for every API used:

## Export rules

- Translate only from a validated `.scadpkg` package: `capture` the product
first, then export from the package path.
- Translate only from a validated product package: `capture` the product
first, then export from the package path. In a one-shot script (below) the
exporters also take the build result itself and package it in memory, with
the same validation and without the scene projection nobody reads.
- STL and OBJ share one direct OpenCASCADE tessellation of the evaluated BREP;
both contain the same oriented triangles. Control curved-surface accuracy
with `linear_deflection` (chordal deviation in product units) and
Expand All @@ -75,6 +77,38 @@ Read the exact page under `references/docs/api/` for every API used:
- Use `validate_step_roundtrip_rdescriptor` when a STEP export must survive
re-import (roundtrip-sensitive consumers).

## One-shot script (no notebook, no `.scadpkg` on disk)

For a batch or unattended task whose deliverable is "one script, run as
`python model.py --output part.step`" (read-only source directory, no editor),
a plain script is the supported entry. A notebook is still the form for
anything a person will open, edit or reuse (core rule R3). The runtime keeps
no state next to the source when it runs without the cache.

```python
import argparse
from pathlib import Path

import simplecadapi as scad


@scad.part(id="plate", project_root=Path(__file__).parent)
def build_plate() -> scad.Part:
body = scad.make_box_rsolid(width=120.0, height=120.0, depth=10.0)
return scad.make_part_rpart("plate", body)


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True)
args = parser.parse_args()
report = scad.exporter.export_product_package_to_step(build_plate(), args.output)
print(report.output_path, report.definition_ids)
```

A notebook run unattended in a read-only directory uses
`run_notebook(path, cache=False)`.

## Validation gates

- Read the returned export report and print its actual fields per format
Expand Down
4 changes: 2 additions & 2 deletions src/simplecadapi/exporter/_surface_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ..artifacts.assembly_io import materialize_definition
from simplecadapi._cpp import Assembly, make_compound_from_assembly_rcompound, Part
from ..translator.package_units import (
ProductPackageInput,
ProductInput,
read_product_package_translation_units,
)

Expand Down Expand Up @@ -122,7 +122,7 @@ def tessellate_solid(


def tessellate_product_surface(
data: ProductPackageInput,
data: ProductInput,
*,
linear_deflection: float,
angular_deflection_degrees: float,
Expand Down
4 changes: 2 additions & 2 deletions src/simplecadapi/exporter/mjcf.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from simplecadapi._cpp import Assembly, Part
from ..product.placement import Placement, relative_placement
from ..translator.package_units import (
ProductPackageInput,
ProductInput,
read_product_package_translation_units,
)
from ._surface_mesh import tessellate_solid
Expand Down Expand Up @@ -339,7 +339,7 @@ def _part_materials(units: tuple[Any, ...]) -> dict[str, tuple[Part, float, bool


def export_product_package_to_mjcf(
data: ProductPackageInput,
data: ProductInput,
output_path: str | Path,
*,
mesh_directory: str | Path | None = None,
Expand Down
4 changes: 2 additions & 2 deletions src/simplecadapi/exporter/obj.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from dataclasses import dataclass
from pathlib import Path

from ..translator.package_units import ProductPackageInput
from ..translator.package_units import ProductInput
from ._surface_mesh import tessellate_product_surface


Expand All @@ -26,7 +26,7 @@ class ProductOBJExportReport:


def export_product_package_to_obj(
data: ProductPackageInput,
data: ProductInput,
output_path: str | Path,
*,
linear_deflection: float = 0.1,
Expand Down
7 changes: 4 additions & 3 deletions src/simplecadapi/exporter/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from ..artifacts import materialize_definition
from simplecadapi._cpp import Assembly, Part
from ..translator.package_units import (
ProductPackageInput,
ProductInput,
read_product_package_translation_units,
)

Expand Down Expand Up @@ -152,10 +152,11 @@ def visit(value: Part | Assembly) -> str:


def export_product_package_to_step(
data: ProductPackageInput,
data: ProductInput,
output_path: str | Path,
) -> ProductSTEPExportReport:
"""Export a validated `.scadpkg` closure as XCAF-backed AP242 STEP."""
"""Export a product as XCAF-backed AP242 STEP: a validated `.scadpkg` closure (package, bytes or path), or the result of
a build (``@scad.part`` / ``@scad.assemble``) or its definition, which is packaged in memory without a scene."""

import simplecadapi._cpp as native

Expand Down
4 changes: 2 additions & 2 deletions src/simplecadapi/exporter/stl.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import numpy as np

from ..translator.package_units import ProductPackageInput
from ..translator.package_units import ProductInput
from ._surface_mesh import TessellatedSurfaceMesh, tessellate_product_surface


Expand Down Expand Up @@ -51,7 +51,7 @@ def _write_binary_stl(path: Path, mesh: TessellatedSurfaceMesh) -> None:


def export_product_package_to_stl(
data: ProductPackageInput,
data: ProductInput,
output_path: str | Path,
*,
linear_deflection: float = 0.1,
Expand Down
8 changes: 6 additions & 2 deletions src/simplecadapi/runtime/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,21 @@ def state_dir(config: NotebookConfig) -> Path:


@contextmanager
def exclusive(config: NotebookConfig) -> Iterator[None]:
def exclusive(config: NotebookConfig, *, enabled: bool = True) -> Iterator[None]:
"""Hold the notebook's state directory against other processes.

Worker processes may run the same notebook at once (two notebooks that
use a third), and a run prunes the cache directories it did not use. The
lock is between processes only: a run of a notebook this process is
already running (a ``use`` cycle, an editor next to a headless run) goes
on without it, as it always did. Without ``fcntl`` (Windows) it is a
no-op.
no-op. With ``enabled=False`` (a run that keeps no state: nothing to
prune, nothing to protect) it is a no-op too, and creates nothing.
"""

if not enabled:
yield
return
directory = state_dir(config)
key = str(directory)
with _held_lock:
Expand Down
7 changes: 5 additions & 2 deletions src/simplecadapi/runtime/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def run() -> NotebookRun:
# Errors still come through. "marimo" is the logger every marimo
# module shares (lazy.py's record shows its file name, "lazy", which
# is not the logger's name).
with suppress_warnings_logs("marimo"), exclusive(config):
with suppress_warnings_logs("marimo"), exclusive(config, enabled=cache):
return _run(config, values, cache=cache, through=through, product=product)

# marimo keeps its runtime context per thread, and a notebook run needs
Expand Down Expand Up @@ -322,7 +322,10 @@ def _run(
# Cells restored from the cache ran no ``use`` this time; the files they
# depend on are among those the previous run recorded.
recorded = frozenset(used) | (previous if restored else frozenset())
write_dependencies(config, recorded)
if cache:
# The record belongs to the cache: its entries are keyed on the files it lists, so a run that keeps no entries must
# not move it (and a run without the cache must work where the source directory is read-only, issue #89).
write_dependencies(config, recorded)
dependencies = module_files | recorded
if cache and cell_cache is not None:
# A cell that runs a child notebook depends on the record just written:
Expand Down
17 changes: 14 additions & 3 deletions src/simplecadapi/translator/package_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
from ..artifacts.part_definition import PartDefinition
from ..product.packages import (
ProductPackage,
build_product_package,
read_product_package,
validate_product_package,
)
from simplecadapi._cpp import OperationGraph

Definition = PartDefinition | AssemblyDefinition
ProductPackageInput = ProductPackage | bytes | bytearray | memoryview | str | Path
# What an exporter takes besides a package: the result of a build (``@scad.part`` / ``@scad.assemble``) or its definition.
ProductInput = ProductPackageInput | Definition | Any


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -70,15 +73,22 @@ def model_payload(self) -> dict[str, Any]:


def read_product_package_translation_units(
data: ProductPackageInput,
data: ProductInput,
) -> tuple[ProductPackage, tuple[ProductPackageTranslationUnit, ...]]:
"""Return a validated package and dependency-first definition units."""
"""Return a validated package and dependency-first definition units.

*data* is a package, the bytes or path of one, or (issue #89) the result of a build or a definition: a script that
only wants a STEP file need not write a ``.scadpkg`` first. That package is built in memory without the scene
projection (tessellated meshes), which no exporter reads and which took most of the time of a default capture.
"""

if isinstance(data, ProductPackage):
validate_product_package(data)
package = data
else:
elif isinstance(data, (bytes, bytearray, memoryview, str, Path)):
package = read_product_package(data)
else:
package = build_product_package(data, include_scene=False)
ordered: list[Definition] = []
visited: set[tuple[str, str]] = set()

Expand Down Expand Up @@ -122,5 +132,6 @@ def visit(definition: Definition) -> None:
__all__ = [
"ProductPackageInput",
"ProductPackageTranslationUnit",
"ProductInput",
"read_product_package_translation_units",
]
28 changes: 28 additions & 0 deletions test/test_notebook_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import importlib.metadata
import io
import json
import os
import pickle
import re
import sys
Expand Down Expand Up @@ -326,6 +327,33 @@ def test_without_the_cache_nothing_is_stored(self) -> None:
self.assertFalse((state_dir(run.config) / "cache").exists())
self.assertEqual(self.statuses(run_notebook(self.bracket)), ["ran"] * 4)

def test_without_the_cache_the_source_directory_is_left_alone(self) -> None:
"""Issue #89: a run that keeps no state must not create the state directory (``__marimo__``) next to the source,
so a notebook can run where the source directory is read-only (a container's ``-v src:/src:ro``)."""

run_notebook(self.bracket, cache=False)
self.assertFalse((self.root / "__marimo__").exists())
self.assertFalse(state_dir(read_notebook_config(self.bracket)).exists())

@unittest.skipUnless(
os.name == "posix" and hasattr(os, "geteuid") and os.geteuid() != 0, "needs a non-root POSIX user to make a directory read-only"
)
def test_without_the_cache_a_read_only_source_directory_runs(self) -> None:
directories = [self.root, self.root / "lib"]
modes = [directory.stat().st_mode for directory in directories]
for directory in directories:
directory.chmod(0o555)
try:
run = run_notebook(self.bracket, cache=False)
finally:
for directory, mode in zip(directories, modes):
directory.chmod(mode)
self.assertEqual(run.definition.definition_id, "bracket")

def test_with_the_cache_the_dependency_record_is_still_written(self) -> None:
run = run_notebook(self.bracket)
self.assertTrue((state_dir(run.config) / "deps.json").is_file())

def test_overrides_skip_the_defining_cell(self) -> None:
plain = run_notebook(self.bracket)
wide = run_notebook(self.bracket, overrides={"width": 30.0})
Expand Down
41 changes: 39 additions & 2 deletions test/test_product_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def build_root() -> scad.Assembly:
return scad.build_product_package(build_root())


def _build_cylinder_package():
def _build_cylinder_result():
@scad.part(
id="cylinder",
project_root=Path(__file__).parent,
Expand All @@ -156,7 +156,11 @@ def build_cylinder() -> scad.Part:
body = scad.make_cylinder_rsolid(radius=4.0, height=8.0)
return scad.make_part_rpart("cylinder", body)

return scad.build_product_package(build_cylinder())
return build_cylinder()


def _build_cylinder_package():
return scad.build_product_package(_build_cylinder_result())


class TestProductExporter(unittest.TestCase):
Expand All @@ -182,6 +186,39 @@ def test_exporters_are_available_only_from_exporter_namespace(self):
self.assertFalse(hasattr(scad, "export_obj"))
self.assertFalse(hasattr(scad.translator, "ap242_translator"))

def test_a_build_result_exports_in_one_step_without_a_scene(self):
"""Issue #89: a script that only wants a file need not capture a ``.scadpkg`` first, nor pay for the scene
(the tessellated projection no exporter reads)."""

from unittest import mock

from simplecadapi.product import packages

result = _build_cylinder_result()
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
with mock.patch.object(packages, "_scene_projection", side_effect=AssertionError("the scene was built")):
step = scad.exporter.export_product_package_to_step(result, root / "one.step")
stl = scad.exporter.export_product_package_to_stl(result, root / "one.stl")
self.assertEqual(step.definition_ids, ("cylinder",))
self.assertGreater((root / "one.step").stat().st_size, 0)
self.assertGreater((root / "one.stl").stat().st_size, 0)
self.assertEqual(stl.output_path, (root / "one.stl").resolve())

def test_a_definition_exports_like_its_build_result(self):
result = _build_cylinder_result()
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
from_result = scad.exporter.export_product_package_to_step(result, root / "a.step")
from_definition = scad.exporter.export_product_package_to_step(result.definition, root / "b.step")
self.assertEqual(from_result.definition_ids, from_definition.definition_ids)
self.assertEqual(from_result.metadata_item_count, from_definition.metadata_item_count)

def test_a_value_that_is_no_product_is_rejected(self):
with tempfile.TemporaryDirectory() as tmp_dir:
with self.assertRaises(TypeError):
scad.exporter.export_product_package_to_step(object(), Path(tmp_dir) / "x.step")

def test_nested_package_preserves_ap242_product_structure_and_placements(self):
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
Expand Down
Loading