feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
+16 -8
View File
@@ -62,7 +62,7 @@ SimpleWorkplane ← local modeling context
- **Shape-first API**: users work with `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`, not graph nodes.
- **Functional modeling style**: public operations return new geometry values, e.g. `make_box_rsolid(...)`, `cut_rsolid(...)`, `fillet_rsolid(...)`.
- **OCP-native runtime**: geometry construction, topology traversal, properties, booleans, transforms, and export use OCP/OpenCascade helpers.
- **Replayable graph workflows**: `GraphSession` can record a canonical low-level operation graph and `export_model_json()` can serialize it for `replay_model_json()`.
- **Replayable graph workflows**: `@scad.model` owns one `GraphSession` and returns a `ModelResult`; `@scad.requires_session` composes child builders, and `scad.capture_result()` selects canonical output nodes for replay and export.
- **Tags and metadata**: tags are useful for lightweight semantics; structured numeric facts should be stored in metadata such as `metadata["geo"]`.
- **Indexed topology access**: use plural methods such as `get_edges()` and `get_faces()` for enumeration, and pass an index to the same getter, such as `get_edges(index)` or `get_faces(index)`, for intentional indexed picks that should become graph selection nodes.
@@ -74,11 +74,14 @@ import simplecadapi as scad
with scad.SimpleWorkplane(origin=(0, 0, 0)):
box = scad.make_box_rsolid(width=5, height=3, depth=2)
scad.apply_tag(box, "role.bracket")
scad.apply_tag(shape=box, tag="role.bracket")
box.set_metadata("material", "6061-T6")
box.auto_tag_faces("box")
top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(face)]
top_faces = [
face for face in box.get_faces()
if "face.top" in scad.list_tags(shape=face)
]
print(len(top_faces))
```
@@ -87,13 +90,18 @@ print(len(top_faces))
```python
import simplecadapi as scad
with scad.GraphSession() as session:
body = scad.make_box_rsolid(10, 10, 4)
hole = scad.make_cylinder_rsolid(1.5, 8, bottom_face_center=(0, 0, -2))
@scad.model(graph_id="drilled_block")
def build_model():
body = scad.make_box_rsolid(width=10, height=10, depth=4)
hole = scad.make_cylinder_rsolid(
radius=1.5, height=8, bottom_face_center=(0, 0, -2)
)
part = scad.cut_rsolid(body, hole)
scad.capture_result(value=part)
return part
payload = scad.export_model_json(session)
rebuilt = scad.replay_model_json(payload)
result = build_model()
rebuilt = result.replay()
print(len(rebuilt))
```
@@ -0,0 +1,256 @@
# Dimension Tolerance Chains
SimpleCADAPI can attach units and manufacturing tolerances to declared dimension
variables, infer dimensions through the expression DAG, propagate source
variation, and verify derived dimensions against design requirements.
This feature is separate from sketch-solver residual tolerances, boolean fuzzy tolerances, mesh resolution, and geometric fitting tolerances. A dimension tolerance describes permitted manufacturing variation around a nominal design value; it never changes a CAD operation's numerical robustness settings.
## Declare Source Dimensions
Every variable that participates in a tolerance chain must declare a tolerance:
```python
import simplecadapi as scad
width = scad.var("width", 10.0, unit="mm", tolerance=0.1)
shaft = scad.var(
"shaft",
0.315,
unit="in",
tolerance=(-0.05, 0.0),
tolerance_unit="mm",
)
```
A scalar tolerance is symmetric. `tolerance=0.1` means `-0.1/+0.1`
around the nominal value. A two-value sequence contains signed
`(lower_deviation, upper_deviation)` values. The lower deviation must be at most
zero, the upper deviation must be at least zero, and all values must be finite.
`tolerance_unit` defaults to `unit`. It may differ from the nominal unit, but the
dimensions must match. Geometry and tolerance propagation use canonical
millimeters for length and degrees for angle. Declaration-space values remain on
the `Var` for display and serialization.
The same values can be represented explicitly:
```python
tolerance = scad.DimensionTolerance(
lower_deviation=-0.05,
upper_deviation=0.0,
)
shaft = scad.var("shaft", 8.0, unit="mm", tolerance=tolerance)
```
Tolerance identity follows `expr_id`, not the human-readable variable name. Two variables with the same name remain separate tolerance sources.
## Propagate A Chain
```python
housing = scad.var("housing", 100.0, unit="mm", tolerance=0.15)
bearing = scad.var(
"bearing", 2.0, unit="cm", tolerance=(-0.04, 0.05), tolerance_unit="mm"
)
spacer = scad.var("spacer", 79.4, unit="mm", tolerance=0.05)
clearance = housing - bearing - spacer
result = scad.analyze_tolerance(clearance, method="worst_case")
print(result.nominal)
print(result.lower_bound, result.upper_bound)
print(result.lower_deviation, result.upper_deviation)
for contribution in result.contributions:
print(contribution.variable_name, contribution.lower_deviation, contribution.upper_deviation)
```
`ToleranceAnalysis` contains absolute bounds, deviations from nominal, inferred
`dimension`, canonical `unit`, and one contribution record per source variable.
Each contribution reports its nominal and source tolerance in canonical units.
The unit system validates the expression before propagation. This permits
physically meaningful nonlinear chains such as:
```python
width = scad.var("width", 30.0, unit="mm", tolerance=0.1)
height = scad.var("height", 40.0, unit="mm", tolerance=0.2)
diagonal = scad.sqrt(width**2 + height**2)
analysis = scad.analyze_tolerance(diagonal)
assert analysis.dimension == scad.LENGTH
assert analysis.unit == scad.MM
```
Area and volume expressions can be inferred and analyzed. Persisted manufacturing
requirements currently accept final Length and Angle results only.
## Propagation Methods
### Worst Case
`method="worst_case"` is the default and the safety-oriented validation method.
- Affine chains preserve variable identity and combine coefficients exactly. Repeated use is not treated as an independent source, so `x - x` has zero propagated tolerance.
- Nonlinear chains use conservative interval propagation.
- Multiplication and division consider all endpoint sign combinations.
- Integer, negative, fractional, and varying powers validate their mathematical domains.
- `sin` and `cos` include interior extrema; `tan` rejects intervals crossing a discontinuity.
- `sqrt`, `asin`, and `acos` validate the entire input interval.
- Division rejects denominator intervals containing zero.
- `atan2` rejects tolerance regions containing its undefined origin.
Conservative interval propagation can intentionally overestimate a strongly correlated nonlinear expression. It must not underestimate a safety bound.
### RSS
`method="rss"` uses first-order analytic sensitivities and root-sum-square combination:
```python
result = scad.analyze_tolerance(clearance, method="rss")
```
Distinct variables are assumed independent. Repeated occurrences of the same variable are merged before RSS, so `x - x` still has zero sensitivity and `x + x` has twice the sensitivity of `x`.
RSS validates the full declared tolerance interval before calculating the first-order estimate. A nominal point cannot hide a division singularity, trigonometric discontinuity, or invalid function domain elsewhere in the source range.
Covariance matrices, correlation groups, probability distributions, and Monte Carlo analysis are not represented by the current API. Use `worst_case` when the independence assumption is unavailable or when a guaranteed envelope is required.
## Check One Requirement
`check_tolerance()` returns a result without raising when the derived tolerance exceeds the permitted deviations:
```python
check = scad.check_tolerance(
clearance,
tolerance=(-0.25, 0.24),
method="worst_case",
name="axial_clearance",
tolerance_unit="mm",
)
print(check.passed)
print(check.lower_margin, check.upper_margin)
```
A non-negative lower and upper margin means the requirement passes, subject to a
small floating-point comparison epsilon. Requirement deviations are converted
from `tolerance_unit` to the target's canonical unit before comparison.
## Session Requirements And Automatic Validation
Use `GraphSession.require_tolerance()` for design requirements that must travel with the model:
```python
with scad.GraphSession() as session:
body = scad.make_box_rsolid(housing, 10.0, 10.0)
session.require_tolerance(
clearance,
(-0.25, 0.24),
method="worst_case",
name="axial_clearance",
requirement_id="req.axial_clearance",
tolerance_unit="mm",
)
report = session.validate_tolerances(raise_on_failure=True)
model_json = scad.export_model_json(session)
```
Automatic validation occurs when:
1. `validate_tolerances(raise_on_failure=True)` is called.
2. A session or model JSON payload is exported.
3. A model JSON payload is imported or replayed.
4. A model is translated to FreeCAD through the model importer.
A failed requirement raises `ToleranceValidationError` at the tolerance layer. Model import, export, and replay expose it through the existing structured `SimpleCADError` harness where applicable.
Declaring a requirement validates that the chain is complete and mathematically defined, but it still records a failing requirement so callers can inspect its margins. Export and replay are the enforcement boundaries.
## Serialization
Variable tolerances are stored on variable nodes in `expression_graph`:
```json
{
"expr_id": "var_width",
"kind": "var",
"name": "width",
"default": 1.0,
"unit": "in",
"tolerance": {
"lower_deviation": -0.1,
"upper_deviation": 0.1
},
"tolerance_unit": "mm"
}
```
Design requirements and their latest validation evidence are stored in the top-level `tolerance_graph`:
```json
{
"requirements": [
{
"requirement_id": "req.axial_clearance",
"target_expr_id": "expr_clearance",
"tolerance": {
"lower_deviation": -0.25,
"upper_deviation": 0.24
},
"method": "worst_case",
"name": "axial_clearance",
"tolerance_unit": "mm",
"target_dimension": {
"length": 1,
"angle": 0
}
}
],
"validation": {
"passed": true,
"checks": []
}
}
```
Validation evidence is recomputed during import; serialized evidence is not
trusted as an authority. The target expression dimension is inferred again and
must match `target_dimension`; `tolerance_unit` must have the same dimension.
Payloads created before units or `tolerance_graph` existed remain valid as legacy
unitless expressions and import with an empty tolerance graph when it is absent.
Nominal geometry replay still uses the numeric snapshots in operation-node `params`. Tolerance validation does not sample or regenerate worst-case geometry.
## FreeCAD Translation
FreeCAD translation keeps the full tolerance graph as document metadata. The
`SimpleCADExpressions` spreadsheet stores lower/upper deviations in columns E/F,
nominal unit in G, tolerance unit in H, and inferred dimension in I. Spreadsheet
values and formulas use canonical CAD values so inch/radian declarations remain
consistent with operation-node snapshots. The translator preserves tolerance
intent but does not convert it into FreeCAD geometric-tolerance objects or
statistical solvers.
## Failure Conditions
Tolerance analysis fails explicitly for:
- a source variable without a declared tolerance
- non-finite nominal values or deviations
- malformed signed lower/upper deviations
- unknown, malformed, non-finite, incompatible, overflowing, or underflowing units
- addition/subtraction or `atan2` with incompatible dimensions
- invalid powers, square roots, or trigonometric dimensions
- mixing unit-declared and legacy variables in one expression
- a requirement unit or persisted target dimension that disagrees with the target
- an Area, Volume, Dimensionless, or compound-dimension requirement target
- duplicate requirement IDs
- unknown or dangling expression references
- malformed, cyclic, duplicate-ID, or unsupported expression nodes
- an undefined expression anywhere in the declared tolerance interval
- an RSS derivative at a non-differentiable nominal point
- an unsupported propagation method
See [Physical Units And Dimension Inference](physical-units.md) for the complete
unit registry, dimension algebra, custom-unit payload, and legacy behavior.
@@ -117,7 +117,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可
```json
{
"schema_version": "2.0",
"producer_version": "2.0.1b1",
"producer_version": "2.0.2",
"capabilities": {
"selection_ref_strategies": true,
"geo_select_nodes": true,
@@ -128,7 +128,8 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可
"topology_delta_summary": false,
"assembly_graph": false,
"scalar_field_graph": false,
"expression_graph": true
"expression_graph": true,
"dimension_tolerances": true
},
"graph_id": "graph_xxxxxxxx",
"nodes": [...],
@@ -163,6 +164,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可
| `assembly_graph` | `bool` | 当前 graph JSON 本身不承载 assembly graph |
| `scalar_field_graph` | `bool` | 当前为 `false`;SDF / scalar field graph 暂时不在支持范围内 |
| `expression_graph` | `bool` | session/model payload 支持 expression graph |
| `dimension_tolerances` | `bool` | session/model payload supports variable tolerances and a tolerance requirement graph |
## 5. Operation Node Schema
@@ -334,7 +336,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可
- `make_*_face` 与 `make_face_from_wire` -> `Sketch`
- `make_*_edge` / `make_*_wire` -> `Profile`
- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature`
- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_twisted_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature`
## 7. Topology Delta Schema
@@ -667,7 +669,13 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w
"expr_id": "var_119b16e4",
"kind": "var",
"name": "r",
"default": 2.0
"default": 2.0,
"unit": "mm",
"tolerance": {
"lower_deviation": -0.1,
"upper_deviation": 0.2
},
"tolerance_unit": "mm"
}
]
}
@@ -692,7 +700,13 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w
"expr_id": "var_xxx",
"kind": "var",
"name": "radius",
"default": 2.0
"default": 2.0,
"unit": "mm",
"tolerance": {
"lower_deviation": -0.05,
"upper_deviation": 0.1
},
"tolerance_unit": "mm"
}
```
@@ -722,6 +736,82 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w
- `cos`
- `tan`
- `sqrt`
- `acos`
- `asin`
- `atan`
- `atan2`
### 10.3 Unit And Dimension Semantics
Variable nodes may include:
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `unit` | `string | unit object` | no | nominal declaration unit |
| `tolerance` | `object` | no | signed deviations in `tolerance_unit` |
| `tolerance_unit` | `string | unit object` | no | source tolerance unit; defaults to `unit` in Python declarations |
Built-in units serialize as symbols such as `mm`, `in`, `deg`, or `rad`. Custom
units serialize in full:
```json
{
"symbol": "thou",
"dimension": {"length": 1, "angle": 0},
"scale_to_canonical": 0.0254
}
```
Dimensions contain required integer `length` and `angle` exponents. Canonical
numeric values used by operation-node `params` and tolerance analysis are `mm`,
`mm^2`, `mm^3`, `deg`, or `1` for the named dimensions.
Import rebuilds the complete DAG and reruns dimension inference. Addition and
subtraction require matching dimensions; multiplication/division combine
exponents; dimensioned powers require supported constant exponents; square root
requires even exponents; and trigonometric operations enforce Angle/Dimensionless
inputs. A graph cannot mix unit-declared variables with legacy variables lacking
units. Unitless legacy graphs remain accepted.
### 10.4 Dimension Tolerance Graph
Variable `tolerance` values are signed deviations from `default`. A scalar source dimension must use `lower_deviation <= 0 <= upper_deviation`.
Session/model payloads may include a sibling `tolerance_graph`:
```json
{
"requirements": [
{
"requirement_id": "req.clearance",
"target_expr_id": "expr_clearance",
"tolerance": {
"lower_deviation": -0.2,
"upper_deviation": 0.3
},
"method": "worst_case",
"name": "clearance",
"tolerance_unit": "mm",
"target_dimension": {
"length": 1,
"angle": 0
}
}
],
"validation": {
"passed": true,
"checks": []
}
}
```
Supported methods are `worst_case` and `rss`. Unit-aware requirements must target
Length or Angle. `tolerance_unit` must match the inferred target dimension and is
converted to the canonical unit before comparison. Importers recompute validation
from `expression_graph`, compare the inferred result to `target_dimension`, and do
not trust serialized `validation` evidence. Missing `tolerance_graph` is treated as
an empty graph for backward compatibility. Legacy requirements may omit both unit
fields when their target expression is unitless.
## 11. Frame Graph Schema
@@ -765,6 +855,7 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w
"canonical_contract": {...},
"graph": {...},
"expression_graph": {...},
"tolerance_graph": {...},
"frame_graph": {...},
"geometry_registry": [...],
"semantic_entity_registry": [...],
@@ -784,6 +875,7 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w
| `graph` | `graph object` | yes | canonical low-level graph and only source of truth |
| `leaf_ids` | `array<string>` | yes | explicit result set for multi-output graph replay/export |
| `expression_graph` | `object` | yes | expression DAG |
| `tolerance_graph` | `object` | no | dimension-chain requirements and validation evidence; defaults to an empty graph |
| `frame_graph` | `object` | yes | frame snapshots |
| `geometry_registry` | `array<object>` | yes | output geometry registry |
| `semantic_entity_registry` | `array<object>` | yes | semantic entity registry |
@@ -936,6 +1028,7 @@ New canonical profile nodes use the `make_*_r*` names listed in `canonical_contr
- `make_revolve_rsolid`
- `make_loft_rsolid`
- `make_sweep_rsolid`
- `make_twisted_sweep_rsolid`
- `make_translate_rshape`
- `make_rotate_rshape`
- `make_mirror_rshape`
@@ -1296,6 +1389,23 @@ Important:
| --- | --- | --- |
| `is_frenet` | `bool` | sweep orientation mode |
#### `make_twisted_sweep_rsolid`
- Inputs: 1 profile `Face`
- Outputs: 1 `Solid`
- Params:
| Key | Type | Meaning |
| --- | --- | --- |
| `axis` | 3-number array | sweep and rotation-axis direction in caller coordinates |
| `origin` | 3-number array | sweep start and a point on the rotation axis |
| `distance` | positive number | axial sweep distance |
| `twist_angle` | number | signed total rotation in degrees |
| `guide_radius` | positive number | internal auxiliary-spine radius |
Replay rebuilds the auxiliary spine deterministically inside the operation. It
does not infer section counts or lower to transient loft nodes.
### 14.6 Boolean Ops
#### `make_union_rsolid`
+202
View File
@@ -0,0 +1,202 @@
# Physical Units And Dimension Inference
SimpleCADAPI attaches physical meaning at `Var` declarations and infers the
dimension of every derived scalar expression. This catches invalid formulas before
they reach geometry, tolerance analysis, model export, replay, or FreeCAD
translation.
## Canonical CAD Units
Declaration values are preserved on each `Var`, but evaluation converts them to a
single CAD coordinate system:
| Dimension | Canonical unit |
| --- | --- |
| Dimensionless | `1` |
| Length | `mm` |
| Area | `mm^2` |
| Volume | `mm^3` |
| Angle | `deg` |
Degrees are canonical because existing SimpleCAD rotation and angular APIs use
degrees. Trigonometric evaluation converts to radians internally and converts
inverse-trigonometric results back to degrees.
```python
import math
import simplecadapi as scad
width = scad.var("width", 1.0, unit="in")
angle = scad.var("angle", math.pi / 2, unit="rad")
assert width.default == 1.0
assert width.evaluate() == 25.4
assert angle.evaluate() == 90.0
assert math.isclose(scad.sin(angle).evaluate(), 1.0)
```
Bindings use the variable's declaration unit. `width.evaluate({"width": 2.0})`
therefore returns `50.8` millimeters for an inch-declared variable.
## Declaring Units And Tolerances
```python
width = scad.var(
"width",
1.0,
unit="in",
tolerance=(-0.1, 0.2),
tolerance_unit="mm",
)
```
- `default` is in `unit`.
- `tolerance` is in `tolerance_unit`.
- `tolerance_unit` defaults to `unit` when a tolerance is present.
- Nominal and tolerance units may differ, but their dimensions must match.
- A `tolerance_unit` requires both `unit` and `tolerance`.
- Values must be finite and representable after canonical conversion.
`width.default` and `width.tolerance` preserve declaration-space values.
`width.canonical_default` and `width.canonical_tolerance` expose values used by
geometry and tolerance propagation.
## Built-In Units
| Dimension | Symbols |
| --- | --- |
| Dimensionless | `1`, `%` |
| Length | `mm`, `cm`, `m`, `in`, `ft` |
| Area | `mm^2`, `cm^2`, `m^2`, `in^2`, `ft^2` |
| Volume | `mm^3`, `cm^3`, `m^3`, `in^3`, `ft^3` |
| Angle | `deg`, `rad` |
Common singular/plural names are accepted by `get_unit()`, including
`millimeters`, `inches`, `feet`, `degrees`, `radians`, `square feet`, and
`cubic inches`. `ml` aliases `cm^3`.
Use constants such as `MM`, `INCH`, `DEGREE`, `RADIAN`, `LENGTH`, and `ANGLE`, or
resolve strings:
```python
assert scad.get_unit("inch") == scad.INCH
assert scad.convert_value(1.0, "in", "mm") == 25.4
assert math.isclose(scad.convert_value(180.0, "deg", "rad"), math.pi)
```
Incompatible conversion raises `UnitValidationError`.
## Dimension Algebra
`Dimension` stores integer exponents for length and angle. Named dimensions are:
- `DIMENSIONLESS = Dimension()`
- `LENGTH = Dimension(length=1)`
- `AREA = Dimension(length=2)`
- `VOLUME = Dimension(length=3)`
- `ANGLE = Dimension(angle=1)`
`infer_dimension(expression)` applies these rules:
| Operation | Rule |
| --- | --- |
| `a + b`, `a - b` | dimensions must match |
| `a * b` | add dimension exponents |
| `a / b` | subtract dimension exponents |
| `a ** n` | multiply exponents by constant integer `n` |
| `sqrt(a)` or `a ** 0.5` | every exponent must be even |
| unary `-a`, `abs(a)` | preserve dimension |
| `sin`, `cos`, `tan` | input must be Angle; result is Dimensionless |
| `asin`, `acos`, `atan` | input must be Dimensionless; result is Angle |
| `atan2(y, x)` | inputs must have the same dimension; result is Angle |
Arbitrary and varying powers are permitted for dimensionless bases. A dimensioned
base requires a constant integer exponent, except `0.5` is accepted when all base
exponents are even.
```python
width = scad.var("width", 30.0, unit="mm")
height = scad.var("height", 40.0, unit="mm")
area = width * height
diagonal = scad.sqrt(width**2 + height**2)
assert scad.infer_dimension(area) == scad.AREA
assert scad.infer_dimension(diagonal) == scad.LENGTH
assert diagonal.evaluate() == 50.0
```
## Numeric Constants
Numeric literals are dimensionless coefficients in multiplication and division.
For addition and subtraction, a literal adopts the other operand's dimension as a
contextual offset:
```python
length = scad.var("length", 10.0, unit="mm")
assert scad.infer_dimension(length * 2.0) == scad.LENGTH
assert scad.infer_dimension(length + 2.0) == scad.LENGTH
```
The literal is already expressed in the canonical result unit. `length + 2.0`
therefore means two millimeters, not two units of `length.unit`. Prefer explicit
variables when declaration-unit intent must be retained.
## Legacy Unitless Expressions
Variables without `unit` retain the previous behavior:
- `infer_dimension()` returns `None`.
- Trigonometric inputs and results use radians.
- Existing arbitrary expression and tolerance behavior remains available.
- A legacy variable cannot be mixed with a unit-declared variable in one
expression because no safe physical meaning can be inferred.
Pure numeric constant expressions infer `Dimensionless`.
## Custom Units
Custom linear-scale units use the same canonical system:
```python
thou = scad.Unit("thou", scad.LENGTH, 0.0254)
width = scad.var("width", 1000.0, unit=thou)
assert width.evaluate() == 25.4
```
Built-in units serialize as symbols. Custom units serialize a definition:
```json
{
"symbol": "thou",
"dimension": {"length": 1, "angle": 0},
"scale_to_canonical": 0.0254
}
```
Units are scale-only. Offset units such as Celsius/Fahrenheit are not represented.
## Validation Boundaries
Unit and dimension validation runs when:
1. A `Var`, `Dimension`, or `Unit` is created.
2. An expression is directly evaluated.
3. An expression is registered or imported through `ExpressionGraph`.
4. A tolerance is analyzed or a requirement is declared.
5. Session/model JSON is imported, exported, replayed, or translated.
Malformed units, cyclic graphs, duplicate expression IDs, mixed legacy/typed
variables, incompatible dimensions, invalid roots, and invalid trigonometric inputs
are rejected before graph mutation or geometry replay.
## Manufacturing Requirement Scope
Area, volume, inverse length, and other compound dimensions can be inferred and
analyzed. Manufacturing requirements created by `check_tolerance()` or
`GraphSession.require_tolerance()` currently accept final `Length` and `Angle`
results only. This prevents an area or volume variation from being presented as a
linear dimension requirement without explicit engineering semantics.
See [Dimension Tolerance Chains](dimension-tolerance-chains.md) for propagation,
RSS assumptions, enforcement boundaries, and serialized requirement fields.
+30 -12
View File
@@ -10,14 +10,19 @@ The long-form schema reference remains [`../operation_graph_json_spec.md`](../op
import json
import simplecadapi as scad
with scad.GraphSession() as session:
body = scad.make_box_rsolid(10, 6, 2)
hole = scad.make_cylinder_rsolid(1, 4, bottom_face_center=(0, 0, -1))
@scad.model(graph_id="drilled_block")
def build_model():
body = scad.make_box_rsolid(width=10, height=6, depth=2)
hole = scad.make_cylinder_rsolid(
radius=1, height=4, bottom_face_center=(0, 0, -1)
)
result = scad.cut_rsolid(body, hole)
scad.capture_result(value=result)
return result
model_json = scad.export_model_json(session)
payload = json.loads(model_json)
rebuilt = scad.replay_model_json(model_json)
model = build_model()
payload = json.loads(model.model_json)
rebuilt = model.replay()
```
Inspect these fields:
@@ -29,6 +34,17 @@ Inspect these fields:
- `node["inputs"]`: upstream node ids used by replay.
- `payload["leaf_ids"]`: explicit final result node ids.
- `payload["expression_graph"]`: expression DAG used by expression-backed parameters.
- `payload["tolerance_graph"]`: dimension-chain requirements and validation evidence.
For new top-level models, `ModelResult.model_json` is the preferred artifact
accessor. Use `@scad.requires_session` for reusable builders and
`scad.capture_result(...)` when the final output should not be inferred from
all graph leaves. If a model invocation also needs durable CAD/viewer files,
pass `export_dir=...` to `@scad.model`; its captured geometry/product values
then produce one self-contained `<graph_id>.scene.zip`. It embeds
`model/model.json`, mapped project-relative Python sources, and the evaluated
render/selection assets. It does not create adjacent model/session JSON, STEP,
STL, or FCStd files. No files are written when `export_dir` is omitted.
## Important rule: source API is not always graph API
@@ -52,11 +68,13 @@ Many user-facing functions are convenience APIs. During an active `GraphSession`
- [Primitive and profile operations](primitives-and-profiles.md)
- [Features, booleans, transforms, patterns, and selectors](features-booleans-transforms.md)
- [Expressions and replay behavior](expressions-and-replay.md)
- [Physical units and dimension inference](../physical-units.md)
- [Dimension tolerance chains](../dimension-tolerance-chains.md)
## Example
## Examples
See [`../../../examples/07_serialization_operation_tree.py`](../../../examples/07_serialization_operation_tree.py). It intentionally exercises every canonical core operation and writes:
- `examples/out/serialization_operation_tree.model.json`
- `examples/out/serialization_operation_tree.summary.md`
- `examples/out/serialization_operation_tree.step`
The retained examples use the same model/session contract. See
[`../../../examples/08_constrained_sketch.py`](../../../examples/08_constrained_sketch.py)
for sketch promotion and replay, and
[`../../../examples/10_part_assembly.py`](../../../examples/10_part_assembly.py)
for product hierarchy and automatic artifact export.
@@ -15,14 +15,15 @@ This lets consumers choose between:
```python
import simplecadapi as scad
width = scad.var("width", 24.0, comment="plate width")
height = scad.var("height", 12.0, comment="plate height")
thickness = scad.var("thickness", 4.0, comment="plate thickness")
width = scad.var("width", 24.0, unit="mm", comment="plate width", tolerance=0.1)
height = scad.var("height", 12.0, unit="mm", comment="plate height", tolerance=0.1)
thickness = scad.var("thickness", 4.0, unit="mm", comment="plate thickness", tolerance=(-0.05, 0.1))
with scad.GraphSession() as session:
plate = scad.make_box_rsolid(width, height, thickness)
rib = scad.make_box_rsolid(width / 4.0, height, thickness * 2.0)
part = scad.union_rsolid(plate, rib)
session.require_tolerance(width + height, 0.2, tolerance_unit="mm", name="plate_envelope")
model_json = scad.export_model_json(session)
```
@@ -48,7 +49,9 @@ A node with expression-backed params may look like:
}
```
`params.distance` is the evaluated snapshot. `param_exprs.distance` says the value came from expression node `var_thickness`.
`params.distance` is the evaluated canonical snapshot. Unit-aware lengths are
stored in millimeters and angles in degrees. `param_exprs.distance` says the value
came from expression node `var_thickness` and preserves its declaration metadata.
For tuple/list params, `param_exprs` mirrors the shape of the parameter and uses `null` where no expression is present:
@@ -78,12 +81,24 @@ Consumers that want parameterization should:
Consumers that only want geometry can ignore `param_exprs` and `expression_graph`.
Variable nodes may contain `unit`, `tolerance`, and `tolerance_unit`. Registered
units use string symbols; custom units use `{symbol, dimension,
scale_to_canonical}` objects. Import reconstructs the expression graph and reruns
dimension inference rather than trusting external dimension claims.
Session/model payloads store derived-dimension requirements in `tolerance_graph`.
See [Physical Units](../physical-units.md) and [Dimension Tolerance
Chains](../dimension-tolerance-chains.md) for inference, propagation, and
validation semantics.
## Replay policy in current implementation
`replay_model_json(model_json)` currently uses the canonical low-level `graph` and the numeric values in `node.params`.
That means replay is deterministic with respect to the exported snapshot. It does not currently re-solve expressions with changed variable values.
Replay does validate stored tolerance requirements before rebuilding the nominal geometry. A failing tolerance chain blocks replay, but passing bounds do not cause replay to sample or regenerate limit geometry.
In practical terms:
```python
@@ -122,6 +122,40 @@ Replay effect:
2. Replay path wire from input 1.
3. Call `sweep_rsolid(profile, path, is_frenet=...)`.
## Twisted Sweep
Source:
```python
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
solid = scad.twisted_sweep_rsolid(
profile=profile,
distance=8.0,
twist_angle=30.0,
)
```
Serialized node:
```json
{
"op": "make_twisted_sweep_rsolid",
"params": {
"axis": [0.0, 0.0, 1.0],
"origin": [0.0, 0.0, 0.0],
"distance": 8.0,
"twist_angle": 30.0,
"guide_radius": 1.0
},
"inputs": ["node_for_profile_face"],
"output_count": 1
}
```
Replay reconstructs the continuous auxiliary-spine rotation law from the
recorded parameters and invokes `twisted_sweep_rsolid(...)`. No sampled loft
sections are stored or inferred.
## Helical sweep macro lowering
Source:
+34 -12
View File
@@ -2,21 +2,24 @@
## Overview
`TaggedMixin` is the internal tag and metadata storage mixin used by `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`. It owns the shared `_tags`, `_metadata`, and `_runtime` stores for topology wrappers.
`TaggedMixin` is the internal semantic binding and metadata mixin used by topology wrappers. Canonical tag ownership is source-preserving `TagBinding` data. `_tags` is only an effective-scope compatibility cache; user code must not treat it as writable truth.
User code should not call member tag mutators. The public tag API is functional:
- `apply_tag(shape, tag)` attaches one normalized tag.
- `list_tags(shape)` returns tags in deterministic sorted order.
- `apply_tag_rselection(scope, targets, tag, ...)` returns an independent semantic view and exposes explicit propagation policies.
- `list_tags(shape, scope=...)` returns tags in deterministic sorted order.
- `explain_tag(shape, tag, scope=...)` preserves binding and producer evidence.
- `select_faces_by_tag(...)`, `select_edges_by_tag(...)`, and QL predicates such as `ql.tag("role.*")` provide selection/query helpers.
## Tagging Mental Model
- Tags are normalized lowercase dot-separated semantic tokens.
- Examples: `role.mounting_surface`, `anchor.datum.primary`, `group.fasteners`, `face.top`, `edge.boundary`, `wire.outer`, `solid.boolean.cut`.
- `apply_tag(shape, tag)` does not expose propagation controls.
- The standard policy propagates `role.*`, `anchor.*`, `group.*`, and a few legacy bare semantic tags downward.
- Topology-specific tags such as `face.*`, `edge.*`, `wire.*`, `vertex.*`, and `solid.*` stay local.
- New user assignments default to local topology propagation regardless of prefix.
- Downward inheritance is explicit and computed dynamically; bindings are not copied into every child.
- `effective` means local plus inherited and does not include lineage.
- `lineage` requires complete topology history and only follows derivations allowed by the binding policy.
- Numeric dimensions, measurements, and rich descriptive payloads belong in metadata, not tags.
- Geometry builders store structured geometry facts under `metadata["geo"]`.
@@ -34,16 +37,29 @@ top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(fa
print(len(top_faces))
```
## Propagation Example
## Explicit Propagation Example
```python
import simplecadapi as scad
body = scad.make_box_rsolid(10, 10, 2)
scad.apply_tag(body, "role.mounting_plate")
body = scad.make_box_rsolid(width=10, height=10, depth=2)
tagged = scad.apply_tag_rselection(
scope=body,
targets=[body],
tag="role.mounting_plate",
topology_propagation=scad.TopologyPropagation.DOWNWARD,
)
face_hits = [face for face in body.get_faces() if "role.mounting_plate" in scad.list_tags(face)]
edge_hits = [edge for edge in body.get_edges() if "role.mounting_plate" in scad.list_tags(edge)]
face_hits = scad.select_faces_by_tag(
solid=tagged,
tag="role.mounting_plate",
scope=scad.TagScope.INHERITED,
)
edge_hits = scad.select_edges_by_tag(
shape=tagged,
tag="role.mounting_plate",
scope=scad.TagScope.INHERITED,
)
print(len(face_hits), len(edge_hits))
```
@@ -55,7 +71,13 @@ Primitives and modeling operations may attach normalized tags automatically:
- Primitive tags such as `geom.primitive.box`, `geom.primitive.cylinder`, and `geom.primitive.sphere`.
- Face tags from `auto_tag_faces(...)`, such as `face.top`, `face.bottom`, `face.side`, and `face.surface`.
- Wire tags such as `wire.outer` and `wire.inner`.
- Operation/tracking tags such as `solid.boolean.cut`, `op.cut.modified`, or `op.extrude.generated`.
- Operation-level categorical tags such as `solid.boolean.cut` may remain local annotations.
Operation events and source roles are not tags. Proven `preserved`, `modified`,
or `generated` events and `body`/`tool` origins live in typed
`metadata["track"]`. Query them with `ql.operation_event(...)` and
`ql.origin_role(...)`. Missing correspondence remains `coverage="partial"` and
`status="unknown"`; it is never promoted to `generated` by default.
## Metadata Methods
@@ -84,7 +106,7 @@ scad.apply_tag(body, "role.mounting_plate")
body.auto_tag_faces("box")
top_faces = Q.select(body.get_faces()).where(Q.tag("face.top")).all()
role_faces = Q.select(body.get_faces()).where(Q.tag("role.*")).all()
role_faces = Q.select(body.get_faces()).where(Q.tag("role.*", scope="effective")).all()
print(len(top_faces), len(role_faces))
```