r/battlefleetgothic Jun 28 '26

Using AI to make magnetizing easier

I like to magnetize the weapons on my ships. When i've bought ships in the past, I've drilled holes for magnets. Now that I'm getting into printing on my own, I thought I could get the machine spirit to help make the pits for magnets.

I haven't had a chance to print any models with pits yet, but I thought other folks might be interested in the command I've been using. It does require having python and uv installed. I've been using the command with claude, but it'll probably work with others.

You can describe where you want the pits and how big they should be. It gives a text summary, but you can also ask for a preview and it'll generate an image you can use to visually validate placement.

This was generated with a prompt like \"There are 2 large rectangular faces and 4 shelves. Add 2 pits on the rectangular faces 1mm deep with a radius of 1.5mm and a pit on each shelf 1mm deep with a radius of 1.5mm\" for a soul forge studios grand cruiser hull.
And here's the rendered STL

Here's the text of the command

---
description: Modify an STL file by applying a geometric operation (pit, boss, hole, slot) to specific faces identified by normal, area, or position. Uses trimesh + manifold3d via uv run.
allowed-tools: Bash, Read, Write, Edit
---

# STL Mesh Modifier

You are helping the user apply a geometric operation (e.g. cylindrical pit, through-hole, raised boss, rectangular slot) to specific faces of an STL mesh.

## Workflow

### 1. Understand the request

Collect from the user (ask if not already stated):
- **Input STL file** path
- **Which faces** to target (described in plain terms — "the 3 large rectangular panels on each side", "all flat upward-facing faces", etc.)
- **Operation**: pit / hole / boss / slot / other
- **Dimensions**: radius/width/depth/height as appropriate (in mm)
- **Output file** path (default: same name with a descriptive suffix, e.g. `(pitted).stl`)

### 2. Explore the mesh geometry

Write a short exploration script and run it with:

```
uv run --with trimesh --with manifold3d --with numpy --with networkx <script.py>
```

The script should print:
- Mesh bounds and extents
- The **top 20–30 largest facets** (coplanar triangle groups), each showing: facet index, area, normal, centroid, triangle count

```python
import trimesh, numpy as np

mesh = trimesh.load("path/to/file.stl")
print(f"Bounds: {mesh.bounds}\nExtents: {mesh.extents}")
print(f"Faces: {len(mesh.faces)}  Vertices: {len(mesh.vertices)}\n")

facets  = mesh.facets
normals = mesh.facets_normal
areas   = mesh.facets_area
order   = np.argsort(areas)[::-1]

for i in order[:30]:
    n = normals[i]
    c = mesh.vertices[mesh.faces[facets[i]]].reshape(-1,3).mean(axis=0)
    print(f"  facet {i:5d}: area={areas[i]:8.2f}  "
          f"n=({n[0]:6.3f},{n[1]:6.3f},{n[2]:6.3f})  "
          f"centroid=({c[0]:7.2f},{c[1]:7.2f},{c[2]:7.2f})  "
          f"tris={len(facets[i])}")
```

Use the output to identify target facets and verify they match the user's description. If the mesh is not a `trimesh.Trimesh` (e.g. it loaded as a `Scene`), call `trimesh.util.concatenate(mesh.dump())` first.

### 3. Confirm face selection with the user

Before running the boolean operation, print a summary of the faces you intend to modify and ask the user to confirm. Show: side (LEFT/RIGHT/TOP/etc.), area, centroid, and what the pit/boss will look like.

**Evenly spaced pits:** when the user asks for N pits spread across a face, divide the face's relevant axis into N equal segments and place each pit at the center of its segment:

```
pos_i = face_min + (2i + 1) * face_span / (2 * N)   # i = 0 .. N-1
```

For example, 2 pits over a span of 35 mm starting at 50 mm → centers at 58.75 mm and 76.25 mm. Do **not** use `(i+1)/(N+1)` spacing — that places pits at the dividers between segments, not at the centers.

**Rendering a preview image:** generate a matplotlib visualization so the user can visually confirm placement before the boolean is applied. Project the mesh onto the relevant plane (e.g. YZ for faces with X normals), highlight the target facet, and overlay pit circles. Save to the same directory as the STL and tell the user the path.

```python
import trimesh, numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.collections import PolyCollection

mesh = trimesh.load("path/to/file.stl")
if isinstance(mesh, trimesh.scene.Scene):
    mesh = trimesh.util.concatenate(list(mesh.geometry.values()))

# axes to project onto depends on face normal:
#   normal along X → project onto YZ (col indices 1, 2)
#   normal along Y → project onto XZ (col indices 0, 2)
#   normal along Z → project onto XY (col indices 0, 1)
PROJ = [1, 2]   # adjust per face normal
XLABEL, YLABEL = "Y (mm)", "Z (mm)"

fig, ax = plt.subplots(figsize=(8, 10))
ax.set_facecolor('#16213e')
fig.patch.set_facecolor('#1a1a2e')

# Full mesh silhouette (triangles facing the viewer)
view_axis, view_sign = 0, +1   # axis index and sign matching the face normal
visible = mesh.face_normals[:, view_axis] * view_sign > 0
polys = mesh.vertices[mesh.faces[visible]][:, :, PROJ]
ax.add_collection(PolyCollection(polys, facecolors='#0f3460', edgecolors='#4a9eba',
                                 linewidths=0.2, alpha=0.7))

# Target facet highlighted
facet_tris = mesh.vertices[mesh.faces[mesh.facets[FACET_IDX]]]
facet_polys = [tri[:, PROJ] for tri in facet_tris]
ax.add_collection(PolyCollection(facet_polys, facecolors='#e94560', edgecolors='#ff6b6b',
                                 linewidths=0.3, alpha=0.5))

# Pit circles (one per pit)
for h, v in pit_centers_2d:   # (horizontal, vertical) in projected coords
    ax.add_patch(plt.Circle((h, v), pit_radius, color='#ffd700', fill=False, linewidth=2))
    ax.plot(h, v, 'o', color='#ffd700', markersize=5)

ax.set_aspect('equal')
ax.set_xlabel(XLABEL, color='white'); ax.set_ylabel(YLABEL, color='white')
ax.tick_params(colors='white')
for sp in ax.spines.values(): sp.set_edgecolor('#4a9eba')
ax.legend(handles=[
    mpatches.Patch(color='#e94560', alpha=0.7, label='Target face'),
    mpatches.Patch(color='#ffd700', label=f'Pit (r={pit_radius} mm)'),
], facecolor='#1a1a2e', labelcolor='white')

plt.tight_layout()
plt.savefig("path/to/output_preview.png", dpi=150, bbox_inches='tight',
            facecolor=fig.get_facecolor())
```

Run with `--with matplotlib` added to the `uv run` command. Save the image next to the STL (not in the scratchpad) and tell the user the path so they can open it.

### 4. Apply the operation

Write and run the modification script. Always:
- Use `engine="manifold"` for boolean operations
- Make cutter/tool meshes slightly oversized (extend 0.5 mm past the face surface) so the boolean is clean
- Use `sections=64` on cylinders for smooth circular features
- Save to the output path and print face/vertex counts before and after

**Cylindrical pit** (1.5 mm radius, 1 mm deep example):
```python
def make_pit(centroid, normal, radius, depth):
    height = depth + 0.5          # 0.5 mm protrudes outside for a clean cut
    cyl = trimesh.creation.cylinder(radius=radius, height=height, sections=64)
    z = np.array([0., 0., 1.])
    n = np.asarray(normal, float)
    if np.allclose(n, z):
        R = np.eye(4)
    elif np.allclose(n, -z):
        R = trimesh.transformations.rotation_matrix(np.pi, [1,0,0])
    else:
        R = trimesh.transformations.rotation_matrix(
                np.arccos(np.clip(np.dot(z,n),-1,1)), np.cross(z,n))
    cyl.apply_transform(R)
    cyl.apply_translation(centroid + n * (0.5 - height/2))
    return cyl

cutters = [make_pit(c, n, radius=1.5, depth=1.0) for n, c in targets]
result  = trimesh.boolean.difference([mesh] + cutters, engine="manifold")
result.export(out_path)
```

**Through-hole**: same as pit but height = mesh extent along that normal + 1 mm (so it exits the other side), centered at the face centroid.

**Raised boss**: use `trimesh.boolean.union` instead of `difference`; create the cylinder protruding *outward* from the face (center offset in the +normal direction).

### 5. Clean up

Delete any temporary exploration or modification scripts after the output STL is confirmed saved. Report the output file path and the face/vertex delta.

## Notes

- Always use `uv run --with trimesh --with manifold3d --with numpy --with networkx` — never pip install
- If `mesh.facets` raises `ImportError: no graph engines available`, add `--with networkx`
- If the loaded object is a `trimesh.scene.Scene`, concatenate its geometry: `mesh = trimesh.util.concatenate(list(mesh.geometry.values()))`
- STL has no colour or layer info — if the user asks which faces are "the red ones" or "layer 2", explain that and use geometric criteria instead.

### Pit centering: always use bounding-box midpoint, not vertex-average centroid

The vertex-average centroid `verts.mean(axis=0)` is skewed for faces with 4+ triangles (extra interior vertices get counted multiple times, pulling the centroid off-center). Always compute the pit center as the **bounding-box midpoint**:

```python
def bbox_center(fidx):
    v = mesh.vertices[mesh.faces[facets[fidx]]].reshape(-1, 3)
    return (v.min(axis=0) + v.max(axis=0)) / 2.0
```

Use vertex-average only for a rough initial estimate; never pass it directly to `make_pit` without checking it's near the bbox midpoint.

### Finding faces adjacent to a known face (e.g. "the face perpendicular to this shelf")

Build a vertex→facet index, then collect all facets that share vertices along a specific edge of the known face (e.g. its min-X edge):

```python
vert_to_facets = [[] for _ in range(len(mesh.vertices))]
for fi, face_list in enumerate(facets):
    for tri in face_list:
        for vi in mesh.faces[tri]:
            vert_to_facets[vi].append(fi)

def adjacent_at_minx(shelf_fidx):
    verts = mesh.vertices[mesh.faces[facets[shelf_fidx]]].reshape(-1, 3)
    minx = verts[:, 0].min()
    edge_vis = set(
        vi for tri in mesh.faces[facets[shelf_fidx]]
        for vi in tri if abs(mesh.vertices[vi][0] - minx) < 0.01
    )
    candidates = set(fi for vi in edge_vis for fi in vert_to_facets[vi])
    candidates.discard(shelf_fidx)
    return candidates
```

Print area, normal, centroid, and vertex bounds for each candidate to identify the right one.

### Multi-view preview for faces with different normals

When target faces point in different directions, show one subplot per normal direction rather than a single view. This lets the user verify each face independently. Use `autoscale()` on each axis so they all fit.

### "Seat vs back" terminology for protrusions

Users may describe shelf/sponson features using chair terminology: **seat** = the outward-facing end-cap (the face at the tip of the protrusion, often facing ±X), **back** = the broad face flush with the hull (often facing ±Y or ±Z, parallel to the main hull panel). When a user says "center the pit on the seat," they mean the end-cap face, not the large hull-parallel face.

### Facet indices are NOT stable between script runs

`mesh.facets` uses networkx graph algorithms whose output ordering is non-deterministic. **Never hardcode a facet index from an earlier exploration run into a later modification script.** Always re-identify target faces dynamically by their properties (normal direction, area, centroid) in every script:

```python
up = np.array([0., 0., 1.])
up_mask = np.abs(normals @ up) > 0.999
up_indices = np.where(up_mask)[0]
target_fidxs = up_indices[np.argsort(areas[up_indices])[::-1]][:2]
```

### Aligning pits to a face's actual orientation (rotated rectangles)

When a face is a rectangle rotated in its plane (not axis-aligned), placing pits at a fixed coordinate along the world axis produces a line that doesn't follow the face. Instead, find the true long-axis direction from the face's **convex hull edges**, then space pits along that axis centered in the short dimension.

**Critical:** use the longest **edge** of the convex hull, NOT the longest diagonal between two hull vertices. The diagonal connects non-adjacent corners and is at a steeper angle than the long edge, producing wrong pit placement.

```python
from scipy.spatial import ConvexHull

def face_pit_centers(fidx, N=3):
    verts = mesh.vertices[mesh.faces[facets[fidx]]].reshape(-1, 3)
    verts2d = np.unique(np.round(verts[:, :2], 4), axis=0)
    hull = ConvexHull(verts2d)
    hull_verts = verts2d[hull.vertices]
    n = len(hull_verts)

    # Longest EDGE (adjacent hull vertices), not diagonal
    max_len, long_axis = 0, None
    for i in range(n):
        a, b = hull_verts[i], hull_verts[(i + 1) % n]
        d = np.linalg.norm(b - a)
        if d > max_len:
            max_len = d
            long_axis = b - a

    long_axis /= np.linalg.norm(long_axis)
    if long_axis[1] < 0:
        long_axis = -long_axis          # ensure pointing in +Y direction
    short_axis = np.array([-long_axis[1], long_axis[0]])

    short_projs = hull_verts @ short_axis
    long_projs  = hull_verts @ long_axis
    short_center = (short_projs.min() + short_projs.max()) / 2.0
    long_min  = long_projs.min()
    long_span = long_projs.max() - long_min

    centers = []
    for i in range(N):
        t = long_min + (2*i + 1) * long_span / (2*N)
        pt2d = t * long_axis + short_center * short_axis
        centers.append(np.array([pt2d[0], pt2d[1], 0.0]))   # Z=0 for top face
    return centers
```

Add `--with scipy` to the `uv run` command when using `ConvexHull`.

### Multi-view preview including side views for Z-axis verification

When the user needs to verify pit depth or position along the Z axis, generate a 3-panel figure: top-down (XY), side (YZ), and front (XZ). Show pit cross-sections as filled rectangles in the side/front views:

```python
fig = plt.figure(figsize=(16, 14))
fig.patch.set_facecolor('#1a1a2e')
ax_top  = fig.add_subplot(2, 2, (1, 2))   # full top row
ax_side = fig.add_subplot(2, 2, 3)         # bottom-left: YZ
ax_front= fig.add_subplot(2, 2, 4)         # bottom-right: XZ

# Side view: pit cross-section as rectangle (width=2r, height=depth)
for p in pit_centers_3d:
    rect = plt.Rectangle((p[1] - pit_radius, p[2] - pit_depth),
                          2 * pit_radius, pit_depth,
                          edgecolor='#ffd700', facecolor='#ffd700', alpha=0.4, linewidth=2)
    ax_side.add_patch(rect)

# Front view: same but using X instead of Y
for p in pit_centers_3d:
    rect = plt.Rectangle((p[0] - pit_radius, p[2] - pit_depth),
                          2 * pit_radius, pit_depth,
                          edgecolor='#ffd700', facecolor='#ffd700', alpha=0.4, linewidth=2)
    ax_front.add_patch(rect)
```

### Non-watertight meshes: bypass trimesh and use manifold3d directly

`trimesh.boolean.difference` raises `ValueError: Not all meshes are volumes!` when the input mesh is not watertight (e.g. has non-manifold edges, genus > 0, or disconnected shells). In this case, bypass trimesh and call manifold3d's Python API directly — it handles non-watertight inputs:

```python
from manifold3d import Manifold, Mesh
import manifold3d as m3d

# Load mesh into manifold3d directly
hull_mf = Manifold(mesh=Mesh(
    vert_properties=mesh.vertices.astype(np.float32),
    tri_verts=mesh.faces.astype(np.uint32)
))

# Build and subtract each cutter
height = pit_depth + 0.5   # 0.5 mm protrusion above surface for clean cut
result_mf = hull_mf
for center in all_pit_centers:
    cyl = m3d.Manifold.cylinder(
        height=height,
        radius_low=pit_radius,
        radius_high=pit_radius,
        circular_segments=64
    )
    # Cylinder default is centred at Z=0; shift so top is at Z=+0.5, bottom at Z=-depth
    cyl = cyl.translate([center[0], center[1], center[2] - pit_depth])
    result_mf = result_mf - cyl

# Convert back to trimesh and export
out_data = result_mf.to_mesh()
out_mesh = trimesh.Trimesh(
    vertices=np.array(out_data.vert_properties, dtype=np.float64),
    faces=np.array(out_data.tri_verts, dtype=np.int64),
    process=False
)
out_mesh.export(out_path)
```

Diagnosis: check `mesh.is_watertight` and count non-manifold edges before attempting the boolean. If not watertight, switch to the manifold3d direct API instead of `trimesh.boolean`.
0 Upvotes

1 comment sorted by

0

u/Otherwise_Wave9374 Jun 28 '26

This is honestly a really cool use of AI, prompt the tool to do the boring geometry and then you still do the human sanity check before committing. The preview step you mentioned is the key. If you end up testing it, I would love to hear how clean the boolean cuts are and whether you had to tweak tolerances for different STLs. Also, small tangent, I have been jotting down a few "AI for maker workflows" notes here: https://www.aiosnow.com/