313 lines
14 KiB
Python
313 lines
14 KiB
Python
"""Show how source code maps to the serializable operation tree.
|
|
|
|
Run from the repository root with:
|
|
uv run python examples/07_serialization_operation_tree.py
|
|
|
|
This example intentionally keeps the geometry simple. Its main purpose is to
|
|
show that user-facing calls such as `make_box_rsolid()` and
|
|
`helical_sweep_rsolid()` are lowered into the canonical, replayable operation
|
|
nodes stored in `model.json`.
|
|
|
|
Generated files:
|
|
examples/out/serialization_operation_tree.model.json
|
|
examples/out/serialization_operation_tree.summary.md
|
|
examples/out/serialization_operation_tree.step
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
from textwrap import dedent
|
|
|
|
import simplecadapi as scad
|
|
from simplecadapi import ql as Q
|
|
|
|
|
|
OUT = Path("examples/out")
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
MODEL_JSON_PATH = OUT / "serialization_operation_tree.model.json"
|
|
SUMMARY_PATH = OUT / "serialization_operation_tree.summary.md"
|
|
STEP_PATH = OUT / "serialization_operation_tree.step"
|
|
|
|
|
|
def source_step(name: str):
|
|
"""Print a readable marker while building the recorded model."""
|
|
print(f"SOURCE STEP: {name}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Expression parameters: model JSON stores numeric snapshots in node.params and
|
|
# expression references in node.param_exprs / expression_graph.
|
|
# ---------------------------------------------------------------------------
|
|
plate_w = scad.var("plate_w", 36.0, comment="main plate width")
|
|
plate_h = scad.var("plate_h", 18.0, comment="main plate height")
|
|
plate_t = scad.var("plate_t", 3.0, comment="main plate thickness")
|
|
hole_r = scad.var("hole_r", 2.2, comment="through-hole radius")
|
|
rib_t = scad.var("rib_t", 1.6, comment="rib thickness")
|
|
fillet_r = scad.var("fillet_r", 0.45, comment="small edge fillet radius")
|
|
|
|
|
|
with scad.GraphSession() as session:
|
|
# Basic construction and primitive lowering:
|
|
# make_box_rsolid -> rectangle face -> four line edges -> wire -> face -> extrude
|
|
source_step("01 make_box_rsolid(expr dimensions) -> lowered profile + extrude")
|
|
plate = scad.make_box_rsolid(plate_w, plate_h, plate_t)
|
|
plate = scad.apply_tag(plate, "demo.main_plate")
|
|
|
|
# make_cylinder_rsolid is also serializable via lowering:
|
|
# circle edge -> wire -> face -> extrude
|
|
source_step("02 make_cylinder_rsolid(expr radius) -> lowered circle face + extrude")
|
|
hole = scad.make_cylinder_rsolid(
|
|
hole_r,
|
|
plate_t + 2.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
)
|
|
drilled_plate = scad.cut_rsolid(plate, hole)
|
|
|
|
# Core wire/profile API: point, line, circle, arc, spline, helix, wire construction,
|
|
# face construction. These are kept small and placed away from the plate so
|
|
# they are easy to inspect in the graph without making the shape complicated.
|
|
source_step("03 make_point_rvertex")
|
|
marker_point = scad.make_point_rvertex(-18.0, -9.0, 6.0)
|
|
|
|
source_step("04 explicit edges + make_wire_from_edges_rwire + make_face_from_wire_rface")
|
|
e1 = scad.make_line_redge((-8.0, 0.0, plate_t), (-6.0, 0.0, plate_t))
|
|
e2 = scad.make_three_point_arc_redge(
|
|
(-6.0, 0.0, plate_t), (-5.0, 1.0, plate_t), (-4.0, 0.0, plate_t)
|
|
)
|
|
e3 = scad.make_angle_arc_redge(
|
|
(-3.0, 0.0, plate_t), 1.0, 3.14159, 0.0, normal=(0.0, 0.0, 1.0)
|
|
)
|
|
spline_fit = scad.fit_cubic_bspline_control_points(
|
|
[(-2.0, 0.0, plate_t), (-1.0, 0.8, plate_t), (0.0, 0.0, plate_t)],
|
|
tolerance=0.01,
|
|
)
|
|
e4 = scad.make_spline_redge(
|
|
control_points=spline_fit.control_points,
|
|
knots=spline_fit.unique_knots,
|
|
multiplicities=spline_fit.multiplicities,
|
|
)
|
|
# The four edges above are intentionally separate leaf examples. A valid
|
|
# `make_wire_from_edges_rwire` example follows with a closed triangle.
|
|
|
|
# A closed profile built explicitly from lines, then converted to a face.
|
|
tri_a = scad.make_line_redge((8.0, -2.0, plate_t), (11.0, -2.0, plate_t))
|
|
tri_b = scad.make_line_redge((11.0, -2.0, plate_t), (9.5, 1.0, plate_t))
|
|
tri_c = scad.make_line_redge((9.5, 1.0, plate_t), (8.0, -2.0, plate_t))
|
|
triangle_wire = scad.make_wire_from_edges_rwire([tri_a, tri_b, tri_c])
|
|
triangle_face = scad.make_face_from_wire_rface(triangle_wire)
|
|
triangle_boss = scad.extrude_rsolid(triangle_face, (0.0, 0.0, 1.0), rib_t)
|
|
|
|
# Convenience wire/face builders are included too; inside GraphSession they
|
|
# lower to the same canonical low-level edge/wire/face operations.
|
|
source_step("05 convenience wires/faces -> lowered canonical edge/wire/face nodes")
|
|
rectangle_wire = scad.make_rectangle_rwire(4.0, 2.0, center=(-13.0, 0.0, plate_t))
|
|
rectangle_face = scad.make_rectangle_rface(4.0, 2.0, center=(-13.0, 4.0, plate_t))
|
|
circle_wire = scad.make_circle_rwire((13.0, 4.0, plate_t), 1.0)
|
|
circle_face = scad.make_circle_rface((13.0, 0.0, plate_t), 1.0)
|
|
segment_wire = scad.make_segment_rwire((-13.0, -4.0, plate_t), (-9.0, -4.0, plate_t))
|
|
polyline_wire = scad.make_polyline_rwire(
|
|
[(-3.0, -5.0, plate_t), (-1.0, -4.0, plate_t), (1.0, -5.0, plate_t)]
|
|
)
|
|
arc_wire = scad.make_three_point_arc_rwire(
|
|
(3.0, -5.0, plate_t), (4.0, -4.0, plate_t), (5.0, -5.0, plate_t)
|
|
)
|
|
angle_arc_wire = scad.make_angle_arc_rwire((7.0, -5.0, plate_t), 1.0, 0.0, 1.57)
|
|
wire_spline_fit = scad.fit_cubic_bspline_control_points(
|
|
[(9.0, -5.0, plate_t), (10.0, -4.0, plate_t), (11.0, -5.0, plate_t)],
|
|
tolerance=0.01,
|
|
)
|
|
spline_wire = scad.make_spline_rwire(
|
|
control_points=wire_spline_fit.control_points,
|
|
knots=wire_spline_fit.unique_knots,
|
|
multiplicities=wire_spline_fit.multiplicities,
|
|
)
|
|
|
|
# Basic solid constructors that lower to replayable core operations.
|
|
source_step("06 make_sphere_rsolid, make_cone_rsolid")
|
|
sphere = scad.make_sphere_rsolid(1.0, center=(-7.0, 6.0, plate_t + 1.0))
|
|
cone = scad.make_cone_rsolid(
|
|
1.2,
|
|
2.0,
|
|
top_radius=0.4,
|
|
bottom_face_center=(-3.0, 6.0, plate_t),
|
|
)
|
|
# Feature operations.
|
|
source_step("07 revolve_rsolid, loft_rsolid, sweep_rsolid")
|
|
revolve_profile = scad.make_polyline_rwire(
|
|
[(0.5, 0.0, 0.0), (1.2, 0.0, 0.0), (1.0, 0.0, 1.6), (0.5, 0.0, 1.6)],
|
|
closed=True,
|
|
)
|
|
revolved_pin = scad.revolve_rsolid(
|
|
revolve_profile,
|
|
axis=(0.0, 0.0, 1.0),
|
|
angle=360.0,
|
|
origin=(0.0, 0.0, 0.0),
|
|
)
|
|
revolved_pin = scad.translate_shape(revolved_pin, (4.0, 6.0, plate_t))
|
|
|
|
loft_a = scad.make_rectangle_rwire(1.8, 1.2, center=(8.0, 6.0, plate_t))
|
|
loft_b = scad.make_rectangle_rwire(1.0, 0.8, center=(8.0, 6.0, plate_t + 2.0))
|
|
lofted_post = scad.loft_rsolid([loft_a, loft_b], ruled=True)
|
|
|
|
sweep_profile = scad.make_circle_rface((12.0, 6.0, plate_t), 0.35, normal=(1.0, 0.0, 0.0))
|
|
sweep_path = scad.make_polyline_rwire(
|
|
[(12.0, 6.0, plate_t), (14.0, 6.0, plate_t + 1.0), (15.5, 7.0, plate_t + 1.5)]
|
|
)
|
|
swept_pipe = scad.sweep_rsolid(sweep_profile, sweep_path, is_frenet=False)
|
|
|
|
# Composite operation: helical_sweep_rsolid is serialized as helix + face + sweep,
|
|
# not as a dedicated `helical_sweep` graph node.
|
|
source_step("08 helical_sweep_rsolid macro -> make_helix_redge + wire + face + sweep")
|
|
thread_profile = scad.make_rectangle_rwire(0.25, 0.18, center=(0.0, 0.0, 0.0))
|
|
helical_thread = scad.helical_sweep_rsolid(
|
|
thread_profile,
|
|
pitch=0.7,
|
|
height=2.2,
|
|
radius=0.9,
|
|
center=(13.0, -6.0, plate_t),
|
|
)
|
|
|
|
# Transforms and patterns. Pattern helpers serialize as explicit translate /
|
|
# rotate nodes instead of `linear_pattern` / `radial_pattern` macro nodes.
|
|
source_step("09 translate_shape, rotate_shape, mirror_shape")
|
|
rib = scad.make_box_rsolid(rib_t, plate_h * 0.55, plate_t * 1.4)
|
|
rib = scad.translate_shape(rib, (-plate_w / 4.0, 0.0, plate_t))
|
|
rib = scad.rotate_shape(rib, 0.0) # zero-angle shortcut, intentionally not recorded
|
|
rib_copy = scad.mirror_shape(rib, plane_origin=(0.0, 0.0, 0.0), plane_normal=(1.0, 0.0, 0.0))
|
|
|
|
source_step("10 linear_pattern_rsolidlist and radial_pattern_rsolidlist macro lowering")
|
|
lug_seed = scad.make_box_rsolid(1.2, 1.2, 1.0, bottom_face_center=(-12.0, -7.0, plate_t))
|
|
linear_lugs = scad.linear_pattern_rsolidlist(lug_seed, (1.0, 0.0, 0.0), count=3, spacing=3.0)
|
|
spoke_seed = scad.make_box_rsolid(0.8, 2.0, 0.8, bottom_face_center=(0.0, 5.2, plate_t))
|
|
radial_spokes = scad.radial_pattern_rsolidlist(
|
|
spoke_seed,
|
|
center=(0.0, 0.0, plate_t),
|
|
axis=(0.0, 0.0, 1.0),
|
|
count=4,
|
|
total_rotation_angle=360.0,
|
|
)
|
|
|
|
# Boolean operations. Boolean union must produce one connected solid, so
|
|
# this tiny demo uses overlapping boxes instead of trying to merge every
|
|
# separate showcase solid above.
|
|
source_step("11 union_rsolid, intersect_rsolid, cut_rsolid")
|
|
union_a = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-4.0, -7.0, 0.0))
|
|
union_b = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-2.5, -7.0, 0.0))
|
|
union_demo = scad.union_rsolid(union_a, union_b)
|
|
|
|
overlap_a = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(12.0, -2.0, plate_t))
|
|
overlap_b = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(13.0, -2.0, plate_t))
|
|
intersection_demo = scad.intersect_rsolid(overlap_a, overlap_b)
|
|
|
|
# Detail operations use QL selectors so the graph contains stable, serializable
|
|
# selection hints rather than Python object identity from source code.
|
|
source_step("12 fillet_rsolid, chamfer_rsolid, shell_rsolid with serializable selectors")
|
|
vertical_edges = Q.edges().where(Q.curve_type("line")).take(4)
|
|
final = scad.fillet_rsolid(union_demo, vertical_edges, fillet_r)
|
|
|
|
chamfer_box = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(2.0, -7.0, 0.0))
|
|
top_outer_edges = Q.edges().order_by(Q.center_axis("z"), desc=True).take(4)
|
|
chamfer_demo = scad.chamfer_rsolid(chamfer_box, top_outer_edges, 0.15)
|
|
|
|
# Keep shell separate so the demo includes shell without making the main part
|
|
# fragile. It remains a replayable leaf in model.json.
|
|
shell_box = scad.make_box_rsolid(4.0, 3.0, 2.0, bottom_face_center=(18.0, -7.0, 0.0))
|
|
top_face = Q.faces().order_by(Q.center_axis("z"), desc=True).take(1).exactly(1)
|
|
shell_demo = scad.shell_rsolid(shell_box, top_face, 0.25)
|
|
|
|
# Export the canonical model JSON and inspect how the graph maps back to source.
|
|
model_json = scad.export_model_json(session)
|
|
payload = json.loads(model_json)
|
|
MODEL_JSON_PATH.write_text(model_json, encoding="utf-8")
|
|
|
|
# Replay from model JSON to prove that the stored operation tree is sufficient.
|
|
rebuilt = scad.replay_model_json(model_json)
|
|
scad.export_step(rebuilt, str(STEP_PATH))
|
|
|
|
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
|
op_counts = Counter(ops)
|
|
expr_nodes = payload["expression_graph"]["nodes"]
|
|
nodes_with_exprs = [
|
|
node for node in payload["graph"]["nodes"] if node.get("param_exprs")
|
|
]
|
|
|
|
# Build a compact source-to-graph explanation. This file is easier to read than
|
|
# the full JSON and is meant to be opened side by side with this Python source.
|
|
summary = dedent(
|
|
f"""
|
|
# Serialization Operation Tree Example
|
|
|
|
Source file: `examples/07_serialization_operation_tree.py`
|
|
|
|
Generated model JSON: `{MODEL_JSON_PATH}`
|
|
Generated STEP replay output: `{STEP_PATH}`
|
|
|
|
## What to compare
|
|
|
|
1. Read the `SOURCE STEP` comments / print output in the Python source.
|
|
2. Open the JSON and inspect `graph.nodes[*].op`, `params`, `param_exprs`, and `inputs`.
|
|
3. Notice that convenience API calls are lowered to canonical replayable operations.
|
|
|
|
## Basic counts
|
|
|
|
- graph nodes: `{len(payload['graph']['nodes'])}`
|
|
- graph edges: `{len(payload['graph']['edges'])}`
|
|
- leaf ids: `{len(payload['leaf_ids'])}` -> `{payload['leaf_ids']}`
|
|
- expression graph nodes: `{len(expr_nodes)}`
|
|
- operation nodes with `param_exprs`: `{len(nodes_with_exprs)}`
|
|
- replayed outputs: `{len(rebuilt)}`
|
|
|
|
## Canonical operation set observed
|
|
|
|
"""
|
|
).lstrip()
|
|
|
|
for op, count in sorted(op_counts.items()):
|
|
summary += f"- `{op}`: {count}\n"
|
|
|
|
summary += dedent(
|
|
"""
|
|
|
|
## Important source-code to graph mappings
|
|
|
|
- `make_box_rsolid(...)` does **not** appear as `make_box` in model JSON.
|
|
It lowers to `make_line_redge` + `make_wire_from_edges_rwire` +
|
|
`make_face_from_wire_rface` + `make_extrude_rsolid`.
|
|
- `make_cylinder_rsolid(...)` lowers to a circle face plus `make_extrude_rsolid`.
|
|
- `make_sphere_rsolid(...)` and `make_cone_rsolid(...)` lower to revolve chains.
|
|
- `make_rectangle_rwire`, `make_circle_rwire`, `make_polyline_rwire`, and
|
|
single-arc/spline/helix wire helpers lower to edge + wire operations.
|
|
- `linear_pattern_rsolidlist(...)` lowers to explicit `make_translate_rshape`
|
|
nodes.
|
|
- `radial_pattern_rsolidlist(...)` lowers to explicit `make_rotate_rshape`
|
|
nodes.
|
|
- `helical_sweep_rsolid(...)` lowers to helix + face + `make_sweep_rsolid`;
|
|
there is no `helical_sweep` node.
|
|
- Expression values are snapshotted into `params`; the symbolic links live in
|
|
`param_exprs` and the top-level `expression_graph`.
|
|
|
|
## Nodes that reference expressions
|
|
|
|
"""
|
|
)
|
|
|
|
for node in nodes_with_exprs[:40]:
|
|
summary += f"- `{node['node_id']}` `{node['op']}` param_exprs={json.dumps(node['param_exprs'], sort_keys=True)}\n"
|
|
|
|
if len(nodes_with_exprs) > 40:
|
|
summary += f"- ... {len(nodes_with_exprs) - 40} more expression-backed nodes\n"
|
|
|
|
SUMMARY_PATH.write_text(summary, encoding="utf-8")
|
|
|
|
print("wrote", MODEL_JSON_PATH)
|
|
print("wrote", SUMMARY_PATH)
|
|
print("wrote", STEP_PATH)
|
|
print("graph_nodes", len(payload["graph"]["nodes"]))
|
|
print("expression_nodes", len(expr_nodes))
|
|
print("leaf_ids", payload["leaf_ids"])
|
|
print("replayed_outputs", len(rebuilt))
|
|
print("observed_ops", ", ".join(sorted(op_counts)))
|