Yes. I’ll treat your MacBook as the compute/CAD backend and your iPhone SSH session as the control surface from here on.
Start by creating a durable project workspace on the Mac.
~/Documents/camera-grip/
mkdir -p ~/Documents/camera-grip/{input,scripts,output,blender,freecad}
cd ~/Documents/camera-grip
pwd
find . -maxdepth 1 -type d | sortPut your scan in input/. If it is currently in Downloads:
cp ~/Downloads/'Camera Right Grip parts.gltf' \
~/Documents/camera-grip/input/Now install the useful Mac-side Python stack:
python3 -m pip install --upgrade pip
python3 -m pip install trimesh numpy scipy scikit-imageCheck Blender/OpenSCAD/FreeCAD availability:
which blender || true
which openscad || true
which FreeCADCmd || which freecadcmd || trueOn macOS, Blender may not be in $PATH, so also check:
ls /Applications/Blender.app/Contents/MacOS/Blender 2>/dev/null
ls /Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD 2>/dev/null
ls /Applications/FreeCAD.app/Contents/Resources/bin/FreeCADCmd 2>/dev/nullNow create the first useful script directly from your SSH session.
~/Documents/camera-grip/scripts/inspect_scan.py
cat > ~/Documents/camera-grip/scripts/inspect_scan.py <<'PY'
import trimesh
from pathlib import Path
src = Path.home() / "Documents/camera-grip/input/Camera Right Grip parts.gltf"
scene = trimesh.load(src, force="scene")
print("Scene geometries:", len(scene.geometry))
for name, geom in scene.geometry.items():
if isinstance(geom, trimesh.Trimesh):
print()
print("NAME:", name)
print("vertices:", len(geom.vertices))
print("faces:", len(geom.faces))
print("watertight:", geom.is_watertight)
print("winding consistent:", geom.is_winding_consistent)
print("bounds:")
print(geom.bounds)
print("extents:")
print(geom.extents)
PYRun it:
cd ~/Documents/camera-grip
python3 scripts/inspect_scan.pyNext, create a cleanup script that keeps the dominant mesh component and removes junk without inventing geometry.
~/Documents/camera-grip/scripts/clean_scan.py
cat > ~/Documents/camera-grip/scripts/clean_scan.py <<'PY'
import trimesh
from pathlib import Path
root = Path.home() / "Documents/camera-grip"
src = root / "input/Camera Right Grip parts.gltf"
dst = root / "output/Camera_Right_Grip_CLEAN_REFERENCE.glb"
scene = trimesh.load(src, force="scene")
meshes = [
g for g in scene.geometry.values()
if isinstance(g, trimesh.Trimesh)
]
mesh = max(meshes, key=lambda m: len(m.faces)).copy()
parts = mesh.split(only_watertight=False)
mesh = max(parts, key=lambda m: len(m.faces)).copy()
mesh.remove_unreferenced_vertices()
mesh.merge_vertices()
trimesh.repair.fix_normals(mesh, multibody=False)
out = trimesh.Scene()
out.add_geometry(
mesh,
node_name="Grip_CLEAN_REFERENCE",
geom_name="Grip_CLEAN_REFERENCE"
)
out.export(dst)
print("Wrote:", dst)
print("vertices:", len(mesh.vertices))
print("faces:", len(mesh.faces))
print("watertight:", mesh.is_watertight)
print("extents:", mesh.extents)
PYRun:
python3 scripts/clean_scan.pyThat output should immediately appear here:
~/Documents/camera-grip/output/
and therefore in your iOS Files workflow if ~/Documents is the folder you already expose/sync.
Now let's add a Blender command wrapper so you don't have to remember the ugly macOS app path.
~/Documents/camera-grip/run-blender
cat > ~/Documents/camera-grip/run-blender <<'SH'
#!/bin/bash
set -e
BLENDER="/Applications/Blender.app/Contents/MacOS/Blender"
if [ ! -x "$BLENDER" ]; then
echo "Blender not found at:"
echo "$BLENDER"
exit 1
fi
exec "$BLENDER" "$@"
SH
chmod +x ~/Documents/camera-grip/run-blenderTest:
~/Documents/camera-grip/run-blender --versionDo the same for OpenSCAD:
~/Documents/camera-grip/run-openscad
cat > ~/Documents/camera-grip/run-openscad <<'SH'
#!/bin/bash
set -e
OPENSCAD="/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"
if [ ! -x "$OPENSCAD" ]; then
echo "OpenSCAD not found at:"
echo "$OPENSCAD"
exit 1
fi
exec "$OPENSCAD" "$@"
SH
chmod +x ~/Documents/camera-grip/run-openscadTest:
~/Documents/camera-grip/run-openscad --versionAnd FreeCAD:
~/Documents/camera-grip/run-freecad
cat > ~/Documents/camera-grip/run-freecad <<'SH'
#!/bin/bash
set -e
FREECAD="/Applications/FreeCAD.app/Contents/Resources/bin/FreeCADCmd"
if [ ! -x "$FREECAD" ]; then
echo "FreeCADCmd not found at:"
echo "$FREECAD"
exit 1
fi
exec "$FREECAD" "$@"
SH
chmod +x ~/Documents/camera-grip/run-freecadTest:
~/Documents/camera-grip/run-freecad --versionThen add a Makefile so the whole thing becomes iPhone-friendly.
~/Documents/camera-grip/Makefile
cat > ~/Documents/camera-grip/Makefile <<'MAKE'
PYTHON=python3
inspect:
$(PYTHON) scripts/inspect_scan.py
clean:
$(PYTHON) scripts/clean_scan.py
files:
find output -maxdepth 1 -type f -print | sort
all: inspect clean files
MAKENow from your phone, this becomes:
cd ~/Documents/camera-grip
make alland later we'll grow it into:
make clean
make manifold
make sections
make blender
make freecad
make stepThe next move I recommend is not another blind manifold conversion. The useful next script should extract a series of cross-sections from the cleaned grip and save them as DXF/SVG/CSV-style profile data so we can rebuild a much cleaner lofted CAD body in FreeCAD.
So after you run:
cd ~/Documents/camera-grip
make allpaste me the output from:
python3 scripts/inspect_scan.pyand:
ls -lh outputThen I’ll give you the next exact CLI files for section extraction → FreeCAD loft → STEP.
Yep. From here I’d rather give you large, reusable pipeline scripts than dozens of tiny commands.
The next pipeline will take your original scan and do this entirely from your iPhone SSH session:
GLTF scan → clean dominant geometry → PCA-align conceptually → extract 16 ergonomic cross-sections → fit closed concave profiles → normalize profile direction to prevent loft twisting → FreeCAD B-spline sections → loft into a real B-rep solid → save FCStd + STEP
That gets us much closer to the “real faces and edges” object you want.
Paste this whole thing into your SSH terminal.
~/Documents/camera-grip/bootstrap_pipeline.sh
cat > ~/Documents/camera-grip/bootstrap_pipeline.sh <<'BOOTSTRAP'
#!/bin/bash
set -euo pipefail
ROOT="$HOME/Documents/camera-grip"
mkdir -p \
"$ROOT/input" \
"$ROOT/output" \
"$ROOT/scripts" \
"$ROOT/freecad" \
"$ROOT/blender"
cd "$ROOT"
echo
echo "============================================"
echo " Camera Grip CAD Pipeline Bootstrap"
echo "============================================"
echo
# ------------------------------------------------------------
# Python environment
# ------------------------------------------------------------
if [ ! -d ".venv" ]; then
python3 -m venv .venv
fi
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install \
numpy \
scipy \
trimesh \
shapely
# ------------------------------------------------------------
# Main geometry / section extraction script
# ------------------------------------------------------------
cat > scripts/grip_pipeline.py <<'PY'
#!/usr/bin/env python3
"""
Camera Grip Scan -> CAD Section Generator
Purpose
-------
Do NOT blindly convert every STL triangle into a CAD face.
Instead:
1. Load the scanned ergonomic grip.
2. Keep the dominant connected object.
3. Establish its principal coordinate frame using PCA.
4. Slice it into ergonomic stations.
5. At each station, collect nearby scan points.
6. Compute a concave 2D perimeter.
7. Resample every perimeter to the same number of points.
8. Align neighboring profiles to prevent loft twist.
9. Write a clean sections JSON file for FreeCAD.
The resulting section curves become the foundation of a much cleaner
B-rep grip body.
This approach intentionally separates:
SCAN GEOMETRY
↓
ERGONOMIC PROFILES
↓
CAD SURFACES
instead of attempting:
40,000 scan triangles
↓
40,000 CAD faces
which is not useful for mechanical design.
"""
from pathlib import Path
import json
import math
import sys
import numpy as np
import trimesh
from scipy.spatial.transform import Rotation
try:
from shapely.geometry import MultiPoint, Polygon
from shapely import concave_hull
HAVE_SHAPELY_CONCAVE = True
except ImportError:
from shapely.geometry import MultiPoint, Polygon
HAVE_SHAPELY_CONCAVE = False
# ============================================================
# PATHS
# ============================================================
ROOT = Path.home() / "Documents/camera-grip"
INPUT = ROOT / "input/Camera Right Grip parts.gltf"
OUTPUT = ROOT / "output"
CLEAN_MESH = OUTPUT / "Camera_Right_Grip_CLEAN_REFERENCE.glb"
SECTIONS_JSON = OUTPUT / "Camera_Right_Grip_SECTIONS.json"
SECTIONS_OBJ = OUTPUT / "Camera_Right_Grip_SECTIONS.obj"
# ============================================================
# TUNING PARAMETERS
# ============================================================
# Number of CAD cross-sections along the grip.
SECTION_COUNT = 16
# Number of perimeter points on every section.
#
# More points:
# closer scan matching
# more complex CAD
#
# Fewer points:
# smoother simpler CAD
#
PROFILE_POINTS = 32
# Ignore extreme ends of scan where cross sections often collapse.
END_MARGIN = 0.035
# Width of point band sampled around each section.
#
# Fraction of overall grip length.
SLICE_HALF_WIDTH = 0.025
# Concave hull parameter.
#
# Shapely:
# 0 = maximally concave
# 1 = convex hull
#
CONCAVITY_RATIO = 0.35
# Minimum vertices required for a useful section.
MIN_SLICE_POINTS = 40
# If the initial slice is sparse, expand it this many times.
MAX_SLICE_EXPANSIONS = 5
# ============================================================
# UTILITY
# ============================================================
def banner(text):
print()
print("=" * 68)
print(text)
print("=" * 68)
def signed_area(points):
x = points[:, 0]
y = points[:, 1]
return 0.5 * np.sum(
x * np.roll(y, -1)
-
y * np.roll(x, -1)
)
def resample_closed_polygon(points, count):
"""
Resample closed polygon perimeter by physical distance.
Produces identical point count for every section, which is
important for a stable CAD loft.
"""
points = np.asarray(points, dtype=float)
if np.linalg.norm(points[0] - points[-1]) > 1e-12:
points = np.vstack([points, points[0]])
segment = np.diff(points, axis=0)
lengths = np.linalg.norm(segment, axis=1)
total = lengths.sum()
if total <= 0:
raise RuntimeError("Degenerate polygon encountered.")
cumulative = np.concatenate([
[0.0],
np.cumsum(lengths)
])
samples = np.linspace(
0,
total,
count,
endpoint=False
)
result = []
for distance in samples:
index = np.searchsorted(
cumulative,
distance,
side="right"
) - 1
index = min(
index,
len(lengths) - 1
)
local_length = lengths[index]
if local_length == 0:
t = 0
else:
t = (
distance
-
cumulative[index]
) / local_length
point = (
points[index]
+
t * segment[index]
)
result.append(point)
return np.asarray(result)
def best_circular_alignment(previous, current):
"""
Find the rotational index offset that best lines up two
equal-length closed profiles.
Prevents the loft from corkscrewing.
"""
prev = previous - previous.mean(axis=0)
cur = current - current.mean(axis=0)
best_shift = 0
best_error = float("inf")
for shift in range(len(current)):
shifted = np.roll(
cur,
shift,
axis=0
)
error = np.mean(
np.sum(
(prev - shifted) ** 2,
axis=1
)
)
if error < best_error:
best_error = error
best_shift = shift
return np.roll(
current,
best_shift,
axis=0
)
# ============================================================
# LOAD SCAN
# ============================================================
def load_scan(path):
banner("LOAD SCAN")
if not path.exists():
print("ERROR:")
print()
print("Could not find:")
print(path)
print()
print("Put the GLTF here or edit INPUT in grip_pipeline.py.")
sys.exit(1)
scene = trimesh.load(
path,
force="scene"
)
geometries = [
g.copy()
for g in scene.geometry.values()
if isinstance(
g,
trimesh.Trimesh
)
]
if not geometries:
raise RuntimeError(
"No mesh geometry found."
)
mesh = max(
geometries,
key=lambda m: len(m.faces)
)
print(
"Imported geometries:",
len(geometries)
)
print(
"Selected vertices:",
len(mesh.vertices)
)
print(
"Selected faces:",
len(mesh.faces)
)
return mesh
# ============================================================
# CLEAN SCAN
# ============================================================
def clean_scan(mesh):
banner("CLEAN DOMINANT SCAN COMPONENT")
components = mesh.split(
only_watertight=False
)
print(
"Connected components:",
len(components)
)
mesh = max(
components,
key=lambda m: len(m.faces)
).copy()
mesh.remove_unreferenced_vertices()
mesh.merge_vertices()
trimesh.repair.fix_normals(
mesh,
multibody=False
)
print(
"Clean vertices:",
len(mesh.vertices)
)
print(
"Clean faces:",
len(mesh.faces)
)
print(
"Watertight:",
mesh.is_watertight
)
print(
"Bounds:"
)
print(
mesh.bounds
)
print(
"Extents:",
mesh.extents
)
OUTPUT.mkdir(
parents=True,
exist_ok=True
)
scene = trimesh.Scene()
scene.add_geometry(
mesh,
node_name="Grip_SCAN_REFERENCE"
)
scene.export(
CLEAN_MESH
)
print()
print(
"Saved:",
CLEAN_MESH
)
return mesh
# ============================================================
# PCA FRAME
# ============================================================
def calculate_principal_frame(mesh):
banner("CALCULATE PRINCIPAL GRIP FRAME")
vertices = np.asarray(
mesh.vertices
)
center = vertices.mean(axis=0)
centered = vertices - center
covariance = np.cov(
centered.T
)
values, vectors = np.linalg.eigh(
covariance
)
order = np.argsort(
values
)[::-1]
values = values[order]
vectors = vectors[:, order]
# Longest principal direction.
axis = vectors[:, 0]
transverse_x = vectors[:, 1]
transverse_y = vectors[:, 2]
# Ensure a right-handed coordinate frame.
if np.dot(
np.cross(
axis,
transverse_x
),
transverse_y
) < 0:
transverse_y *= -1
coordinates = centered @ vectors
axial = coordinates[:, 0]
minimum = axial.min()
maximum = axial.max()
length = maximum - minimum
print(
"Principal eigenvalues:",
values
)
print(
"Grip principal axis:",
axis
)
print(
"Principal length:",
length
)
return {
"center": center,
"axis": axis,
"u": transverse_x,
"v": transverse_y,
"min": minimum,
"max": maximum,
"length": length
}
# ============================================================
# SECTION PERIMETER
# ============================================================
def make_concave_profile(points):
"""
points = Nx2 projected slice points
"""
cloud = MultiPoint(
[tuple(p) for p in points]
)
polygon = None
if HAVE_SHAPELY_CONCAVE:
try:
polygon = concave_hull(
cloud,
ratio=CONCAVITY_RATIO,
allow_holes=False
)
except Exception:
polygon = None
if (
polygon is None
or
polygon.is_empty
or
polygon.geom_type != "Polygon"
):
polygon = cloud.convex_hull
if polygon.geom_type != "Polygon":
raise RuntimeError(
"Could not construct polygon from section."
)
exterior = np.asarray(
polygon.exterior.coords
)[:, :2]
if len(exterior) < 4:
raise RuntimeError(
"Section polygon has too few points."
)
return exterior
# ============================================================
# EXTRACT SECTIONS
# ============================================================
def extract_sections(mesh, frame):
banner("EXTRACT ERGONOMIC CROSS-SECTIONS")
vertices = np.asarray(
mesh.vertices
)
centered = (
vertices
-
frame["center"]
)
axial = centered @ frame["axis"]
ucoord = centered @ frame["u"]
vcoord = centered @ frame["v"]
low = (
frame["min"]
+
frame["length"]
*
END_MARGIN
)
high = (
frame["max"]
-
frame["length"]
*
END_MARGIN
)
stations = np.linspace(
low,
high,
SECTION_COUNT
)
sections = []
previous_profile = None
for section_index, station in enumerate(stations):
half_width = (
frame["length"]
*
SLICE_HALF_WIDTH
)
indices = None
for expansion in range(
MAX_SLICE_EXPANSIONS
):
mask = (
np.abs(
axial - station
)
<= half_width
)
candidate = np.nonzero(
mask
)[0]
if len(candidate) >= MIN_SLICE_POINTS:
indices = candidate
break
half_width *= 1.5
if indices is None:
print(
f"Skipping section {section_index}: "
"insufficient points."
)
continue
points2d = np.column_stack([
ucoord[indices],
vcoord[indices]
])
perimeter = make_concave_profile(
points2d
)
profile = resample_closed_polygon(
perimeter,
PROFILE_POINTS
)
# Standardize winding.
if signed_area(profile) < 0:
profile = profile[::-1]
if previous_profile is not None:
profile = best_circular_alignment(
previous_profile,
profile
)
previous_profile = profile.copy()
# Convert from local section coordinates back to original 3D.
points3d = (
frame["center"]
+
station
*
frame["axis"]
+
profile[:, 0, None]
*
frame["u"]
+
profile[:, 1, None]
*
frame["v"]
)
section = {
"index":
section_index,
"station":
float(station),
"sample_count":
int(len(indices)),
"points":
points3d.tolist()
}
sections.append(
section
)
print(
f"Section {section_index:02d}"
f" | scan points {len(indices):4d}"
f" | perimeter points {len(profile):2d}"
)
if len(sections) < 4:
raise RuntimeError(
"Too few valid sections for a CAD loft."
)
return sections
# ============================================================
# WRITE JSON
# ============================================================
def write_json(frame, sections):
banner("WRITE CAD SECTION DATA")
data = {
"schema":
"cdaprod.camera-grip.sections.v1",
"source":
str(INPUT),
"units":
"source_scan_units",
"principal_length":
float(frame["length"]),
"frame": {
"center":
frame["center"].tolist(),
"axis":
frame["axis"].tolist(),
"u":
frame["u"].tolist(),
"v":
frame["v"].tolist()
},
"profile_points":
PROFILE_POINTS,
"sections":
sections
}
with open(
SECTIONS_JSON,
"w"
) as f:
json.dump(
data,
f,
indent=2
)
print(
"Saved:",
SECTIONS_JSON
)
# ============================================================
# OBJ SECTION PREVIEW
# ============================================================
def write_obj_preview(sections):
"""
Writes simple line loops.
Blender can import this to visually inspect section placement.
"""
with open(
SECTIONS_OBJ,
"w"
) as f:
vertex_offset = 1
for section in sections:
points = np.asarray(
section["points"]
)
f.write(
f"\no section_{section['index']:02d}\n"
)
for point in points:
f.write(
"v "
f"{point[0]} "
f"{point[1]} "
f"{point[2]}\n"
)
indices = list(
range(
vertex_offset,
vertex_offset + len(points)
)
)
indices.append(
indices[0]
)
f.write(
"l "
+
" ".join(
str(i)
for i in indices
)
+
"\n"
)
vertex_offset += len(points)
print(
"Saved:",
SECTIONS_OBJ
)
# ============================================================
# MAIN
# ============================================================
def main():
mesh = load_scan(
INPUT
)
mesh = clean_scan(
mesh
)
frame = calculate_principal_frame(
mesh
)
sections = extract_sections(
mesh,
frame
)
write_json(
frame,
sections
)
write_obj_preview(
sections
)
banner("SCAN SECTION EXTRACTION COMPLETE")
print(
"Sections:",
len(sections)
)
print(
"Points per section:",
PROFILE_POINTS
)
print()
print(
"Next:"
)
print(
"FreeCAD will turn these profiles into B-spline wires "
"and loft them into a real CAD solid."
)
if __name__ == "__main__":
main()
PY
chmod +x scripts/grip_pipeline.py
# ------------------------------------------------------------
# FreeCAD reconstruction script
# ------------------------------------------------------------
cat > freecad/build_grip.py <<'PY'
"""
Camera Grip CAD reconstruction.
Runs inside FreeCADCmd.
Reads:
output/Camera_Right_Grip_SECTIONS.json
Creates:
output/Camera_Right_Grip_RECONSTRUCTED.FCStd
output/Camera_Right_Grip_RECONSTRUCTED.step
The resulting grip is a true OpenCascade B-rep loft.
Each cross section becomes ONE closed periodic B-spline edge.
That means the grip is constructed from a manageable number of
smooth CAD faces rather than tens of thousands of STL triangles.
"""
import FreeCAD as App
import Part
import json
import os
from pathlib import Path
# ============================================================
# PATHS
# ============================================================
ROOT = Path.home() / "Documents/camera-grip"
INPUT = (
ROOT
/
"output/Camera_Right_Grip_SECTIONS.json"
)
FCSTD_OUTPUT = (
ROOT
/
"output/Camera_Right_Grip_RECONSTRUCTED.FCStd"
)
STEP_OUTPUT = (
ROOT
/
"output/Camera_Right_Grip_RECONSTRUCTED.step"
)
# ============================================================
# OPTIONAL REAL-WORLD SCALE CALIBRATION
# ============================================================
"""
If you know the physical overall grip length, run:
GRIP_LENGTH_MM=125 make step
Replace 125 with the actual caliper measurement.
If GRIP_LENGTH_MM is not supplied, source coordinate values are
preserved.
"""
TARGET_LENGTH_MM = os.environ.get(
"GRIP_LENGTH_MM"
)
# ============================================================
# LOAD DATA
# ============================================================
print()
print("=" * 70)
print("FREECAD GRIP RECONSTRUCTION")
print("=" * 70)
print()
with open(
INPUT,
"r"
) as f:
data = json.load(
f
)
source_length = float(
data["principal_length"]
)
# ============================================================
# SCALE
# ============================================================
if TARGET_LENGTH_MM:
target_length = float(
TARGET_LENGTH_MM
)
scale = (
target_length
/
source_length
)
print(
"SOURCE PRINCIPAL LENGTH:",
source_length
)
print(
"TARGET LENGTH MM:",
target_length
)
print(
"SCALE FACTOR:",
scale
)
else:
scale = 1.0
print(
"No GRIP_LENGTH_MM supplied."
)
print(
"Preserving source numeric scale."
)
# ============================================================
# DOCUMENT
# ============================================================
doc = App.newDocument(
"Camera_Right_Grip"
)
section_group = doc.addObject(
"App::DocumentObjectGroup",
"CrossSections"
)
section_group.Label = (
"Ergonomic Cross Sections"
)
# ============================================================
# CREATE CLOSED BSPLINE WIRES
# ============================================================
wires = []
for section in data["sections"]:
index = int(
section["index"]
)
points = []
for xyz in section["points"]:
points.append(
App.Vector(
float(xyz[0]) * scale,
float(xyz[1]) * scale,
float(xyz[2]) * scale
)
)
curve = Part.BSplineCurve()
try:
curve.interpolate(
Points=points,
PeriodicFlag=True
)
except TypeError:
# Compatibility fallback for FreeCAD builds where the
# keyword signature differs.
curve.interpolate(
points,
True
)
edge = curve.toShape()
wire = Part.Wire(
[edge]
)
obj = doc.addObject(
"Part::Feature",
f"Section_{index:02d}"
)
obj.Label = (
f"Grip Section {index:02d}"
)
obj.Shape = wire
section_group.addObject(
obj
)
wires.append(
wire
)
doc.recompute()
# ============================================================
# LOFT
# ============================================================
print()
print(
"Creating B-rep loft from",
len(wires),
"closed B-spline sections..."
)
loft_shape = Part.makeLoft(
wires,
True, # solid
False # ruled
)
# ============================================================
# REFINE
# ============================================================
try:
loft_shape = (
loft_shape.removeSplitter()
)
except Exception as exc:
print(
"Refine warning:",
exc
)
# ============================================================
# ADD RESULT
# ============================================================
grip = doc.addObject(
"PartDesign::Feature",
"GripEnvelope"
)
grip.Label = (
"Grip Ergonomic CAD Envelope"
)
grip.Shape = loft_shape
# Hide section construction geometry.
for obj in section_group.Group:
obj.Visibility = False
doc.recompute()
# ============================================================
# VALIDITY REPORT
# ============================================================
shape = grip.Shape
print()
print("=" * 70)
print("RESULT")
print("=" * 70)
print(
"Shape type:",
shape.ShapeType
)
print(
"Solids:",
len(shape.Solids)
)
print(
"Shells:",
len(shape.Shells)
)
print(
"Faces:",
len(shape.Faces)
)
print(
"Edges:",
len(shape.Edges)
)
print(
"Vertices:",
len(shape.Vertexes)
)
print(
"Volume:",
shape.Volume
)
print(
"Valid:",
shape.isValid()
)
# ============================================================
# SAVE FREECAD
# ============================================================
doc.saveAs(
str(
FCSTD_OUTPUT
)
)
print()
print(
"Saved FreeCAD document:"
)
print(
FCSTD_OUTPUT
)
# ============================================================
# EXPORT STEP
# ============================================================
Part.export(
[grip],
str(
STEP_OUTPUT
)
)
print()
print(
"Saved STEP:"
)
print(
STEP_OUTPUT
)
print()
print("=" * 70)
print("DONE")
print("=" * 70)
PY
# ------------------------------------------------------------
# FreeCAD wrapper
# ------------------------------------------------------------
cat > run-freecad <<'SH'
#!/bin/bash
set -e
OPTIONS=(
"/Applications/FreeCAD.app/Contents/Resources/bin/FreeCADCmd"
"/Applications/FreeCAD.app/Contents/MacOS/FreeCADCmd"
)
for BIN in "${OPTIONS[@]}"
do
if [ -x "$BIN" ]; then
exec "$BIN" "$@"
fi
done
echo "Could not find FreeCADCmd."
echo
echo "Checked:"
printf ' %s\n' "${OPTIONS[@]}"
exit 1
SH
chmod +x run-freecad
# ------------------------------------------------------------
# OpenSCAD wrapper
# ------------------------------------------------------------
cat > run-openscad <<'SH'
#!/bin/bash
set -e
BIN="/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"
if [ ! -x "$BIN" ]; then
echo "OpenSCAD not found:"
echo "$BIN"
exit 1
fi
exec "$BIN" "$@"
SH
chmod +x run-openscad
# ------------------------------------------------------------
# Blender wrapper
# ------------------------------------------------------------
cat > run-blender <<'SH'
#!/bin/bash
set -e
BIN="/Applications/Blender.app/Contents/MacOS/Blender"
if [ ! -x "$BIN" ]; then
echo "Blender not found:"
echo "$BIN"
exit 1
fi
exec "$BIN" "$@"
SH
chmod +x run-blender
# ------------------------------------------------------------
# Makefile
# ------------------------------------------------------------
cat > Makefile <<'MAKE'
ROOT := $(HOME)/Documents/camera-grip
PYTHON := $(ROOT)/.venv/bin/python
.PHONY: help inspect sections step calibrated files all clean
help:
@echo
@echo "Camera Grip CAD Pipeline"
@echo
@echo "make sections"
@echo " clean scan and extract CAD cross sections"
@echo
@echo "make step"
@echo " build FreeCAD B-rep + STEP"
@echo
@echo "GRIP_LENGTH_MM=125 make step"
@echo " build STEP calibrated to 125 mm grip length"
@echo
@echo "make all"
@echo " sections + STEP"
@echo
@echo "make files"
@echo " show generated artifacts"
@echo
inspect:
$(PYTHON) scripts/grip_pipeline.py
sections:
$(PYTHON) scripts/grip_pipeline.py
step:
./run-freecad freecad/build_grip.py
all: sections step files
files:
@echo
@echo "=== GENERATED FILES ==="
@find output -maxdepth 1 -type f -print0 \
| xargs -0 ls -lh
clean:
rm -f output/Camera_Right_Grip_CLEAN_REFERENCE.glb
rm -f output/Camera_Right_Grip_SECTIONS.json
rm -f output/Camera_Right_Grip_SECTIONS.obj
rm -f output/Camera_Right_Grip_RECONSTRUCTED.FCStd
rm -f output/Camera_Right_Grip_RECONSTRUCTED.step
MAKE
echo
echo "============================================"
echo " BOOTSTRAP COMPLETE"
echo "============================================"
echo
echo "Project:"
echo "$ROOT"
echo
echo "Next:"
echo
echo " cd \"$ROOT\""
echo " make sections"
echo
BOOTSTRAP
chmod +x ~/Documents/camera-grip/bootstrap_pipeline.sh
~/Documents/camera-grip/bootstrap_pipeline.shThen make sure your original scan exists here:
~/Documents/camera-grip/input/Camera Right Grip parts.gltf
ls -lh \
~/Documents/camera-grip/input/'Camera Right Grip parts.gltf'If it's still in Downloads:
cp \
~/Downloads/'Camera Right Grip parts.gltf' \
~/Documents/camera-grip/input/Now run the entire scan-analysis/section-generation stage:
cd ~/Documents/camera-grip
make sectionsYou should end up with:
output/
├── Camera_Right_Grip_CLEAN_REFERENCE.glb
├── Camera_Right_Grip_SECTIONS.json
└── Camera_Right_Grip_SECTIONS.obj
The .obj is particularly useful because you can throw it into Blender and visually see the 16 actual section loops we're proposing for the CAD reconstruction.
Then build the actual FreeCAD solid:
make stepIf everything behaves, FreeCAD should report something resembling:
Shape type: Solid
Solids: 1
Faces: ...
Edges: ...
Volume: ...
Valid: True
and you'll get:
output/
├── Camera_Right_Grip_RECONSTRUCTED.FCStd
└── Camera_Right_Grip_RECONSTRUCTED.step
Those are the interesting files.
The FCStd gives you a real FreeCAD document with:
Camera_Right_Grip
├── CrossSections
│ ├── Grip Section 00
│ ├── Grip Section 01
│ ├── ...
│ └── Grip Section 15
│
└── Grip Ergonomic CAD Envelope
The grip envelope is an OpenCascade B-rep, not an STL mesh.
So now when you click it, you'll have real things such as:
Face1
Face2
Edge1
Edge2
...
that FreeCAD can use for Part Booleans, DatumPlanes, sketches, SubShapeBinders, measurements, etc.
Don't use the earlier blind ×1000 assumption anymore.
Measure the actual physical grip from the topmost to bottommost extent with your calipers. Suppose it turns out to be:
127.4 mm
Then simply run:
cd ~/Documents/camera-grip
GRIP_LENGTH_MM=127.4 make stepThe FreeCAD reconstruction will automatically calculate:
real length / scanned principal length
and scale all XYZ coordinates uniformly before creating the CAD geometry.
That gives us a far more defensible dimensional reference.
Once this pipeline produces a valid STEP, the next big script I'd build is the fun one:
CAD grip envelope + switch STEP + your measured chopped perfboards → automatically create button stations, board clearance solids, switch keepouts, wire exits and Boolean pockets.
At that point your iPhone workflow really does become essentially:
ssh macbook
cd ~/Documents/camera-grip
GRIP_LENGTH_MM=... make allthen open the resulting STEP/FCStd through Files.