Skip to content

Instantly share code, notes, and snippets.

@Cdaprod
Last active September 11, 2026 00:05
Show Gist options
  • Select an option

  • Save Cdaprod/a8754a19d27381122c7b2e5b15369afb to your computer and use it in GitHub Desktop.

Select an option

Save Cdaprod/a8754a19d27381122c7b2e5b15369afb to your computer and use it in GitHub Desktop.
Using iOS and terminal for parametric modeling.

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 | sort

Put 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-image

Check Blender/OpenSCAD/FreeCAD availability:

which blender || true
which openscad || true
which FreeCADCmd || which freecadcmd || true

On 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/null

Now 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)
PY

Run it:

cd ~/Documents/camera-grip
python3 scripts/inspect_scan.py

Next, 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)
PY

Run:

python3 scripts/clean_scan.py

That 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-blender

Test:

~/Documents/camera-grip/run-blender --version

Do 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-openscad

Test:

~/Documents/camera-grip/run-openscad --version

And 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-freecad

Test:

~/Documents/camera-grip/run-freecad --version

Then 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
MAKE

Now from your phone, this becomes:

cd ~/Documents/camera-grip
make all

and later we'll grow it into:

make clean
make manifold
make sections
make blender
make freecad
make step

The 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 all

paste me the output from:

python3 scripts/inspect_scan.py

and:

ls -lh output

Then I’ll give you the next exact CLI files for section extraction → FreeCAD loft → STEP.

Part 2

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.sh

Then 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 sections

You 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 step

If 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.

And I built scale calibration into it

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 step

The 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 all

then open the resulting STEP/FCStd through Files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment