Publish 0.4.21 from develop to main
Source ref: develop Source commit:1ad217cfecTarget branch: main Previous target:02ecd59dd1Release base:02ecd59dd1Previous source:290ecd21a0Included commits since previous source:1ad217cfMerge pull request #306 from earthtojake/release/0.4.210409d80aRelease 0.4.210cbb1b01Merge pull request #265 from knewnothing-git/add-dfam-check-skill757591acfix(dfam-check): carry p05_mm through per_bodyf666a97dfix(dfam-check): guard degenerate meshes, cover advertised formatscb6f984bfix(dfam-check): address maintainer review on #265a63a4325test(dfam-check): verify measurements against known-by-construction parts27f92ee9feat(dfam-check): measure meshes against per-process DfAM limits
This commit is contained in:
@@ -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,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,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",
|
||||
|
||||
@@ -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 @@
|
||||
cadgen==0.4.20
|
||||
cadgen==0.4.21
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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,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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,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,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,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,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"
|
||||
|
||||
Reference in New Issue
Block a user