feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user