Publish 0.4.21 from develop to main

Source ref: develop
Source commit: 1ad217cfec
Target branch: main
Previous target: 02ecd59dd1
Release base: 02ecd59dd1
Previous source: 290ecd21a0

Included commits since previous source:
1ad217cf Merge pull request #306 from earthtojake/release/0.4.21
0409d80a Release 0.4.21
0cbb1b01 Merge pull request #265 from knewnothing-git/add-dfam-check-skill
757591ac fix(dfam-check): carry p05_mm through per_body
f666a97d fix(dfam-check): guard degenerate meshes, cover advertised formats
cb6f984b fix(dfam-check): address maintainer review on #265
a63a4325 test(dfam-check): verify measurements against known-by-construction parts
27f92ee9 feat(dfam-check): measure meshes against per-process DfAM limits
This commit is contained in:
github-actions[bot]
2026-08-21 03:07:38 +00:00
40 changed files with 604 additions and 42 deletions
+2 -2
View File
@@ -10,13 +10,13 @@
"description": "CAD, robotics, fabrication, and local review plugins."
},
"description": "CAD, robotics, fabrication, and local review plugins.",
"version": "0.4.20",
"version": "0.4.21",
"plugins": [
{
"name": "cad",
"source": "./",
"description": "CAD, robotics, fabrication, and local review skills.",
"version": "0.4.20",
"version": "0.4.21",
"author": {
"name": "earthtojake"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cad",
"version": "0.4.20",
"version": "0.4.21",
"description": "CAD, robotics, fabrication, and local review skills.",
"author": {
"name": "earthtojake",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cad",
"version": "0.4.20",
"version": "0.4.21",
"description": "CAD, robotics, fabrication, and local review skills packaged for agent plugin installs.",
"author": {
"name": "earthtojake",
+1
View File
@@ -76,6 +76,7 @@ robot description files, simulation, and local review.
| SRDF | Adds MoveIt planning groups, end effectors, poses, and collision rules to a URDF. | [skills/srdf](skills/srdf/SKILL.md) |
| SDF | Creates simulator models and worlds with frames, physics, sensors, and lights. | [skills/sdf](skills/sdf/SKILL.md) |
| SendCutSend | Checks DXF and STEP files before upload to SendCutSend. | [skills/sendcutsend](skills/sendcutsend/SKILL.md) |
| DfAM Check | Measures mesh printability per process: wall thickness, overhangs, support volume, and build orientation. | [skills/dfam-check](skills/dfam-check/SKILL.md) |
| G-code | Slices supported mesh files into validated, printer-profiled FDM `.gcode` with real slicer CLIs. | [skills/gcode](skills/gcode/SKILL.md) |
| Bambu Labs | Dry-runs, uploads, and cautiously starts local Bambu Lab print jobs from validated `.gcode`. | [skills/bambu-labs](skills/bambu-labs/SKILL.md) |
| Implicit CAD | Creates browser-native implicit CAD models using GLSL signed-distance fields and CAD Viewer raymarch rendering. Experimental. | [skills/implicit-cad](skills/implicit-cad/SKILL.md) |
+1 -1
View File
@@ -1 +1 @@
0.4.20
0.4.21
+1 -1
View File
@@ -1 +1 @@
cadgen==0.4.20
cadgen==0.4.21
+1 -1
View File
@@ -26,7 +26,7 @@
root.style.colorScheme = mode;
})();
</script>
<script type="module" crossorigin src="/assets/index-uox6WXtn.js"></script>
<script type="module" crossorigin src="/assets/index-CtivV2cL.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-7jnMmQ9s.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-KJd670JQ.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-icons-cUeeSVuj.js">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "moveit2_server"
version = "0.4.20"
version = "0.4.21"
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
requires-python = ">=3.12"
dependencies = [
@@ -2,7 +2,7 @@
"name": "cad-viewer-runtime",
"private": true,
"type": "module",
"version": "0.4.20",
"version": "0.4.21",
"scripts": {
"start": "node scripts/start-viewer.mjs",
"serve": "python3 -m server_py.server",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
@@ -1,12 +1,12 @@
{
"name": "cadjs",
"version": "0.4.20",
"version": "0.4.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.4.20",
"version": "0.4.21",
"dependencies": {
"gifenc": "^1.0.3",
"implicitjs": "file:../implicitjs",
@@ -16,7 +16,7 @@
}
},
"../implicitjs": {
"version": "0.4.20",
"version": "0.4.21",
"dependencies": {
"gifenc": "^1.0.3",
"playwright": "^1.52.0",
@@ -1,6 +1,6 @@
{
"name": "cadjs",
"version": "0.4.20",
"version": "0.4.21",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
@@ -1,12 +1,12 @@
{
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"dependencies": {
"gifenc": "^1.0.3",
"playwright": "^1.52.0",
@@ -1,6 +1,6 @@
{
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"type": "module",
"description": "Standalone browser-native implicit CAD runtime, renderer, mesh sampler, and exporters.",
"exports": {
@@ -1 +1 @@
cadgen==0.4.20
cadgen==0.4.21
+1 -1
View File
@@ -1,2 +1,2 @@
cadgen==0.4.20
cadgen==0.4.21
playwright
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Thompson Labs LLC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+95
View File
@@ -0,0 +1,95 @@
---
name: dfam-check
description: Measure mesh files against Design for Additive Manufacturing (DfAM) rules and report printability findings per process (FDM, SLS, SLA/DLP, metal PBF, MJF). Use when the user asks whether a part is printable, wants overhang/wall-thickness/support analysis of an `.stl`, `.obj`, `.ply`, or `.3mf` mesh, wants a build-orientation recommendation, or wants DfAM redesign guidance before slicing with `$gcode` or regenerating geometry with `$cad`.
---
# DfAM Check
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill to produce conservative, evidence-backed DfAM reports for mesh
files before slicing or printing. It measures geometry facts locally and
compares them against per-process design limits; it never slices, uploads, or
starts print jobs.
## Geometry Inspection
Use `scripts/dfam_tool.py` in the active project Python environment for all
geometry facts (requires `trimesh`, `numpy`, `rtree`). The tool is fact-only:
it reports measurements and never emits pass/fail or readiness statuses.
Comparisons and verdicts belong to this workflow. Do not estimate wall
thickness, overhang angles, or support volume by eye or from renders when the
tool can measure them.
```bash
python scripts/dfam_tool.py measure part.stl --angle-limit 45
python scripts/dfam_tool.py orientations part.stl --angle-limit 45
```
Set `--angle-limit` to the target process's self-supporting angle from
`references/process-limits.md` before measuring, and re-run when the target
process changes: the aggregate support-area facts are binned against it.
STEP/STP input is boundary-representation CAD, not a mesh. When the `$cad`
skill is installed, export an STL sidecar with it first, then measure the STL
here. Report that remediation instead of attempting raw STEP parsing.
## Workflow
1. Collect print intent: target process, material, layer height, and any
machine or material datasheet the user can provide. If the process is
unknown, measure once with the default 45° limit, then present findings
per candidate process rather than guessing a single verdict.
2. Read `references/process-limits.md` and select the limit column for the
target process. A user-provided machine/material datasheet overrides the
defaults; cite whichever source is used for every comparison.
3. Run `measure` on the exact upload file. Do not inspect only a generator
script, source CAD model, or console summary of the file.
4. Run `orientations` when the process requires supports and the measured
support area is nonzero. Report any candidate that materially reduces
support area, with its build-height tradeoff.
5. Compare each measured fact to the cited limit and report findings with
restrained status labels:
- `✅ pass`: the measured fact satisfies the cited limit.
- `❌ fail`: a measured fact directly violates the cited limit.
- `❓ need more info`: missing process context, unmeasured geometry,
sampling too sparse to trust, or tool limitations.
6. Order findings by severity: watertightness first (blocks slicing for
every process), then wall thickness, then overhangs/supports, then
orientation and cost signals.
## Comparison
Compare only trustworthy pairs of evidence.
- Cite the limit source (process-limits table row, or the user's datasheet
field) and the measured fact (JSON field path) for every finding.
- Treat `p05_mm` below the wall-thickness limit as a violation even when
`min_mm` alone could be a sampling outlier; report both values.
- On an assembly, `wall_thickness` reports `body_count` and a `per_body`
breakdown. Attribute a violation to the body it belongs to; a thin figure
pooled across bodies is not a finding against the part as a whole.
- Do not apply support-angle findings to powder processes (SLS, MJF); the
relevant powder-process check is trapped-volume powder escape, which this
tool does not yet measure — report that as `❓ need more info` when
enclosed cavities are likely.
- Do not silently rescale geometry. `scale.units_suspect` is measured from
the bounding-box diagonal: when it is `true`, the source is probably in
meters or inches, every down-facing face reads as resting on the plate, and
overhang and support figures of 0.0 mean nothing. Report a unit/scale
finding and ask the user to confirm units before comparing anything against
a material limit.
- Support-volume ratios are coarse upper bounds; report them as cost
signals, not hard failures, unless the user has set an explicit budget.
## Redesign Handoff
For every `❌ fail`, include a concrete, plain-language redesign instruction
with target numbers (for example "thicken the wall at [12.4, 3.0, 8.1] from
0.6 mm to ≥1.2 mm" or "chamfer the overhang at [23.3, 10.0, 52.0] to ≥45°").
When the `$cad` skill is installed, offer to apply the redesign instructions
with it and re-measure the regenerated geometry here, repeating until no
`❌ fail` findings remain. When `$cad-viewer` is installed, hand the measured
file path(s) to it so the user can inspect the findings visually.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "DfAM Check"
short_description: "Measure meshes against DfAM printability rules"
default_prompt: "Use $dfam-check to measure this mesh against DfAM rules for my target process."
@@ -0,0 +1,58 @@
# DfAM Process Limits
Design-rule limits per additive process, for comparison against measured
facts from `scripts/dfam_tool.py`. Values are conservative defaults from
published process design guides and consistent with ISO/ASTM 52910
(general DfAM guidance) and ISO/ASTM 52911-1 (laser powder bed fusion of
metals). Machine-, material-, and parameter-specific datasheets override
these defaults when the user provides them — cite whichever source is used.
| Limit | FDM/FFF | SLS (PA12) | SLA/DLP | PBF-LB metal (SLM/DMLS) | MJF |
| --- | --- | --- | --- | --- | --- |
| Min supported wall (mm) | 1.2 | 0.7 | 0.5 | 0.4 | 0.5 |
| Min unsupported wall (mm) | 1.6 | 0.7 | 1.0 | 0.5 | 0.5 |
| Self-supporting angle (deg from horizontal) | 45 | n/a (powder supports) | 30 | 45 | n/a (powder supports) |
| Min hole diameter (mm) | 2.0 | 1.5 | 0.5 | 1.5 | 1.0 |
| Min positive feature (mm) | 0.8 | 0.8 | 0.2 | 0.4 | 0.5 |
| Max unsupported bridge (mm) | 10 | n/a | 5 | 2 | n/a |
Sources: Hubs FDM/SLS/SLA/metal design guides, Formlabs design guides,
HP MJF design guidelines, EOS design rules; ISO/ASTM 52910 §6 for the
category structure (feature limits §6.5, support structures §6.7).
## Interpretation notes
- **Wall thickness facts** come from ray-cast sampling, so `min_mm` is a
sampled minimum, not an exhaustive one. Treat `p05_mm` below the limit as
a strong violation signal even when `min_mm` alone might be an outlier.
- **Overhang facts** exclude faces resting on the build plate. For powder
processes (SLS, MJF) the surrounding powder supports all geometry:
support-area findings do not apply, but trapped-powder escape holes
become the relevant check instead.
- **Support volume** is a prism upper-bound estimate for cost and
post-processing effort, not a slicer-accurate figure. Ratios above
~30% of part volume usually justify reorientation or redesign for
support-requiring processes.
- **Watertightness** (`mesh.watertight: false`) blocks slicing for every
process and should be reported before any other finding.
- **Orientation candidates** are the six axis-aligned rotations only.
A candidate reaching materially lower support area than the current
orientation is a finding worth reporting with its build-height tradeoff.
## What the tool measures today
`dfam_tool.py` returns measured values for **min supported wall**, **min
unsupported wall** (both from the thickness field) and **self-supporting
angle** (from the overhang map). Those rows can be compared directly against
the limits above.
**Min hole diameter**, **min positive feature** and **max unsupported bridge**
have no measured counterpart yet. Treat them the way the trapped-powder gap is
treated: report them as not checked, rather than inferring them from a render,
the bounding box, or the triangle count. A limit with no measurement behind it
is not a finding.
Wall thickness is measured per connected body. An assembly reports a
`per_body` breakdown alongside the pooled figures, because a ray crossing a
mating clearance would otherwise record the fit gap as a wall.
+11
View File
@@ -0,0 +1,11 @@
trimesh
numpy
rtree
# trimesh treats these as optional extras, but this tool needs them on every
# run: scipy backs the connected-components call behind mesh.body_count and
# mesh.split, and networkx + lxml are required to load the .3mf that SKILL.md
# advertises. Without them the first measure of any mesh dies with a raw
# ModuleNotFoundError.
scipy
networkx
lxml
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""Fact-only DfAM geometry measurements for mesh files.
Reports measurements as JSON. It never emits pass/fail, verdicts, or
readiness statuses; comparisons against process limits belong to the
skill workflow using `references/process-limits.md`.
Requires: trimesh, numpy, rtree (pip install trimesh numpy rtree)
Usage:
python dfam_tool.py measure <mesh> [--samples 2000] [--angle-limit 45]
python dfam_tool.py orientations <mesh> [--angle-limit 45]
`--angle-limit` only parameterises which faces are *counted* in the
support-area aggregates; per-face angles are always reported so the
agent can re-bin against any process limit.
"""
from __future__ import annotations
import argparse
import json
import sys
import numpy as np
import trimesh
def _load(path: str) -> trimesh.Trimesh:
mesh = trimesh.load(path, force="mesh")
if isinstance(mesh, trimesh.Scene):
mesh = mesh.dump(concatenate=True)
return mesh
def _mesh_facts(mesh: trimesh.Trimesh) -> dict:
return {
"bbox_mm": [round(float(v), 2) for v in mesh.extents],
"volume_mm3": round(float(abs(mesh.volume)), 1) if mesh.is_volume else None,
"surface_area_mm2": round(float(mesh.area), 1),
"triangle_count": int(len(mesh.faces)),
"watertight": bool(mesh.is_watertight),
"euler_number": int(mesh.euler_number),
"body_count": int(mesh.body_count),
}
def _overhang_facts(mesh: trimesh.Trimesh, angle_limit: float) -> dict:
"""Face angles measured from horizontal: 0 = flat ceiling, 90 = vertical."""
normals = mesh.face_normals
areas = mesh.area_faces
centers = mesh.triangles_center
down = normals[:, 2] < -1e-6
surface_angle = 90.0 - np.degrees(np.arcsin(np.clip(-normals[:, 2], 0, 1)))
z_min = mesh.bounds[0][2]
on_plate = centers[:, 2] < (z_min + 0.1)
counted = down & ~on_plate & (surface_angle < angle_limit)
total_area = float(areas.sum())
# angle histogram of down-facing, off-plate faces (10° bins)
off_plate_down = down & ~on_plate
hist = {}
if off_plate_down.any():
bins = np.arange(0, 100, 10)
idx = np.digitize(surface_angle[off_plate_down], bins) - 1
for b in range(len(bins) - 1):
area = float(areas[off_plate_down][idx == b].sum())
if area > 0:
hist[f"{bins[b]}-{bins[b+1]}deg"] = round(area, 2)
worst = []
if counted.any():
w_idx = np.where(counted)[0]
order = w_idx[np.argsort(-areas[w_idx])][:8]
worst = [
{
"location_xyz": [round(float(v), 2) for v in centers[i]],
"surface_angle_deg": round(float(surface_angle[i]), 1),
"area_mm2": round(float(areas[i]), 2),
}
for i in order
]
return {
"angle_limit_used_deg": angle_limit,
"down_facing_area_below_limit_mm2": round(float(areas[counted].sum()), 2),
"down_facing_area_below_limit_pct": round(
100 * float(areas[counted].sum()) / total_area, 1) if total_area else 0.0,
"face_count_below_limit": int(counted.sum()),
"down_facing_angle_histogram_mm2": hist,
"largest_faces_below_limit": worst,
}
def _wall_facts(mesh: trimesh.Trimesh, samples: int, seed: int = 42) -> dict:
"""Ray-cast thickness field, measured one connected body at a time.
Cast against a whole assembly, a ray leaving one body can cross a mating
clearance and land on its neighbour, which records the fit gap as a wall.
A tight-clearance assembly then reports a wall-thickness violation that no
single part actually has. Splitting first makes that impossible, because
each body is only ever measured against itself.
"""
bodies = mesh.split(only_watertight=False)
if len(bodies) <= 1:
facts = _wall_facts_single(mesh, samples, seed)
facts.pop("_thickness", None)
facts.pop("_origins", None)
return facts
areas = np.array([float(b.area) for b in bodies])
if not np.isfinite(areas).all() or areas.sum() <= 0.0:
return {
"samples": 0,
"note": "no positive face area; mesh is degenerate, thickness not measured",
}
# Split the sample budget by surface area so a large body is not measured
# at the same resolution as a small one, with a floor so small bodies are
# still sampled at all.
share = areas / areas.sum()
pooled: list = []
pooled_origins: list = []
per_body: list = []
for i, (body, frac) in enumerate(zip(bodies, share)):
budget = max(int(round(samples * frac)), 64)
facts = _wall_facts_single(body, budget, seed + i)
per_body.append({
"body": i,
"min_mm": facts.get("min_mm"),
"p05_mm": facts.get("p05_mm"),
"median_mm": facts.get("median_mm"),
"samples_valid": facts.get("samples_valid", 0),
})
if "_thickness" in facts:
pooled.append(facts["_thickness"])
pooled_origins.append(facts["_origins"])
if not pooled:
return {
"error": "no valid thickness samples",
"body_count": len(bodies),
"per_body": per_body,
}
thickness = np.concatenate(pooled)
origins = np.concatenate(pooled_origins)
thin_idx = np.argsort(thickness)[:8]
return {
"body_count": len(bodies),
"measured_per_body": True,
"samples_valid": int(len(thickness)),
"min_mm": round(float(thickness.min()), 3),
"p05_mm": round(float(np.percentile(thickness, 5)), 3),
"p25_mm": round(float(np.percentile(thickness, 25)), 3),
"median_mm": round(float(np.median(thickness)), 3),
"max_mm": round(float(thickness.max()), 3),
"per_body": per_body,
"thinnest_samples": [
{
"location_xyz": [round(float(v), 2) for v in origins[i]],
"thickness_mm": round(float(thickness[i]), 3),
}
for i in thin_idx
],
}
def _wall_facts_single(mesh: trimesh.Trimesh, samples: int, seed: int = 42) -> dict:
"""Ray-cast thickness field for ONE connected body."""
rng = np.random.default_rng(seed)
n = min(samples, max(len(mesh.faces), 1))
# Area weighting needs a positive total. A mesh of only degenerate faces
# has none, and is not something a thickness field can describe - say so
# rather than dividing by zero inside rng.choice.
total_area = float(mesh.area_faces.sum())
if not np.isfinite(total_area) or total_area <= 0.0:
return {
"samples": 0,
"note": "no positive face area; mesh is degenerate, thickness not measured",
}
face_idx = rng.choice(len(mesh.faces), size=n,
p=mesh.area_faces / total_area)
origins = mesh.triangles_center[face_idx]
directions = -mesh.face_normals[face_idx]
origins = origins + directions * 1e-4
locations, ray_ids, _ = mesh.ray.intersects_location(
ray_origins=origins, ray_directions=directions, multiple_hits=False)
if len(ray_ids) == 0:
return {"error": "ray casting produced no hits", "samples_requested": n}
thickness = np.linalg.norm(locations - origins[ray_ids], axis=1)
diag = float(np.linalg.norm(mesh.extents))
valid = (thickness > 1e-3) & (thickness < diag)
thickness = thickness[valid]
if len(thickness) == 0:
return {"error": "no valid thickness samples", "samples_requested": n}
hit_origins = origins[ray_ids][valid]
thin_idx = np.argsort(thickness)[:8]
return {
"samples_valid": int(len(thickness)),
"min_mm": round(float(thickness.min()), 3),
"p05_mm": round(float(np.percentile(thickness, 5)), 3),
"p25_mm": round(float(np.percentile(thickness, 25)), 3),
"median_mm": round(float(np.median(thickness)), 3),
"max_mm": round(float(thickness.max()), 3),
"thinnest_samples": [
{
"location_xyz": [round(float(v), 2) for v in hit_origins[i]],
"thickness_mm": round(float(thickness[i]), 3),
}
for i in thin_idx
],
# Underscore keys are internal: _wall_facts pools them across bodies
# and strips them before anything is printed. They are numpy arrays
# and would not survive json.dumps.
"_thickness": thickness,
"_origins": hit_origins,
}
def _support_volume_facts(mesh: trimesh.Trimesh, angle_limit: float) -> dict:
"""Prism estimate of volume under faces below the given angle."""
m = mesh.copy()
m.apply_translation([0, 0, -m.bounds[0][2]])
normals = m.face_normals
areas = m.area_faces
centers = m.triangles_center
down = normals[:, 2] < -1e-6
surface_angle = 90.0 - np.degrees(np.arcsin(np.clip(-normals[:, 2], 0, 1)))
on_plate = centers[:, 2] < 0.1
needs = down & ~on_plate & (surface_angle < angle_limit)
proj_area = areas[needs] * np.abs(normals[needs, 2])
support_vol = float((proj_area * centers[needs, 2]).sum())
part_vol = float(abs(m.volume)) if m.is_volume else float(m.convex_hull.volume)
return {
"angle_limit_used_deg": angle_limit,
"estimated_support_volume_mm3": round(support_vol, 1),
"part_volume_mm3": round(part_vol, 1),
"support_to_part_ratio_pct": round(
100 * support_vol / part_vol, 1) if part_vol else 0.0,
"method": "prism from face centroid to build plate; coarse upper-bound estimate",
}
def _orientation_facts(mesh: trimesh.Trimesh, angle_limit: float) -> dict:
"""Support area + build height for 6 axis-aligned candidate orientations."""
rotations = {
"current_plus_z": np.eye(4),
"flip_180_x": trimesh.transformations.rotation_matrix(np.pi, [1, 0, 0]),
"rot_plus_90_x": trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0]),
"rot_minus_90_x": trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0]),
"rot_plus_90_y": trimesh.transformations.rotation_matrix(np.pi / 2, [0, 1, 0]),
"rot_minus_90_y": trimesh.transformations.rotation_matrix(-np.pi / 2, [0, 1, 0]),
}
out = []
for name, T in rotations.items():
m = mesh.copy()
m.apply_transform(T)
ov = _overhang_facts(m, angle_limit)
out.append({
"orientation": name,
"support_area_mm2": ov["down_facing_area_below_limit_mm2"],
"support_area_pct": ov["down_facing_area_below_limit_pct"],
"build_height_mm": round(float(m.extents[2]), 2),
})
return {
"angle_limit_used_deg": angle_limit,
# Percentages are of total surface area, which is rotation-invariant.
# That makes them comparable between candidates but not a measure of
# plate coverage - rank on support_area_mm2, read pct as a signal.
"pct_denominator": "total surface area",
"candidates": out,
}
def _safe(fn, *args) -> dict:
"""Run one fact family, degrading to an error field instead of a traceback.
measure assembles every family before printing, so one throwing family
used to cost the user the facts that did compute: a planar mesh dies in
convex_hull and took the whole report with it. Each family now fails on
its own and the rest still reach the caller as JSON.
"""
try:
return fn(*args)
except Exception as exc: # noqa: BLE001 - report it, never propagate
detail = f"{type(exc).__name__}: {exc}".splitlines()[0]
return {"error": detail[:300]}
def _scale_hint(mesh: trimesh.Trimesh) -> dict:
"""Flag meshes whose units are probably not millimetres.
A meters-scale export measures a bbox like 0.05 x 0.02 x 0.04, which sits
under the 0.1 mm on-plate tolerance: every down-facing face reads as
resting on the plate, so overhangs and support both come back 0.0 and the
part looks like a flawless print. Give the workflow something measured to
branch on rather than asking the agent to eyeball the bounding box.
"""
diag = float(np.linalg.norm(mesh.extents))
suspect = bool(np.isfinite(diag) and diag < 1.0)
return {
"bbox_diagonal_mm": round(diag, 4),
"units_suspect": suspect,
"note": (
"bbox diagonal under 1 mm; source is probably in meters or inches. "
"Rescale to millimetres before trusting overhang, support or "
"thickness numbers."
) if suspect else "bbox consistent with millimetre units",
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
sub = ap.add_subparsers(dest="command", required=True)
m = sub.add_parser("measure", help="full measurement set for one mesh")
m.add_argument("mesh")
m.add_argument("--samples", type=int, default=2000)
m.add_argument("--angle-limit", type=float, default=45.0)
o = sub.add_parser("orientations", help="candidate orientation measurements")
o.add_argument("mesh")
o.add_argument("--angle-limit", type=float, default=45.0)
args = ap.parse_args()
try:
mesh = _load(args.mesh)
except Exception as e:
print(json.dumps({"error": f"failed to load mesh: {e}"}))
return 1
if args.command == "measure":
report = {
"file": args.mesh,
"mesh": _safe(_mesh_facts, mesh),
"scale": _safe(_scale_hint, mesh),
"overhangs": _safe(_overhang_facts, mesh, args.angle_limit),
"wall_thickness": _safe(_wall_facts, mesh, args.samples),
"support_volume": _safe(_support_volume_facts, mesh, args.angle_limit),
}
else:
report = {
"file": args.mesh,
"scale": _safe(_scale_hint, mesh),
"orientations": _safe(_orientation_facts, mesh, args.angle_limit),
}
print(json.dumps(report, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -1,3 +1,3 @@
cadgen==0.4.20
cadgen==0.4.21
ezdxf
shapely
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
@@ -1,12 +1,12 @@
{
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"dependencies": {
"gifenc": "^1.0.3",
"playwright": "^1.52.0",
@@ -1,6 +1,6 @@
{
"name": "implicitjs",
"version": "0.4.20",
"version": "0.4.21",
"type": "module",
"description": "Standalone browser-native implicit CAD runtime, renderer, mesh sampler, and exporters.",
"exports": {
+1 -1
View File
@@ -1,2 +1,2 @@
cadgen==0.4.20
cadgen==0.4.21
playwright
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,2 +1,2 @@
cadgen==0.4.20
cadgen==0.4.21
playwright
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,2 +1,2 @@
cadgen==0.4.20
cadgen==0.4.21
playwright
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cadgen"
version = "0.4.20"
version = "0.4.21"
description = "STEP-first CAD artifact generation runtime: build123d STEP/GLB/topology generation, validation, and inspection for CAD agent skills."
readme = "README.md"
license = "MIT"