Skip to content

Instantly share code, notes, and snippets.

@vjeranc
Created September 29, 2025 07:23
Show Gist options
  • Save vjeranc/c65f8d02833d5c8bb8b9b1e7a855cef1 to your computer and use it in GitHub Desktop.
Save vjeranc/c65f8d02833d5c8bb8b9b1e7a855cef1 to your computer and use it in GitHub Desktop.
FreeCAD suitcase push button with filled full shapes for durability, edge cylinders deep enough to catch the metal rods
#!/usr/bin/env python3
"""
Can fully paste into FreeCAD Python console.
FreeCAD script to create a mirrored solid rectangular box with stepped protrusions:
- Main box: 45mm x 15mm x 18.5mm (solid, created by mirroring 22.5mm half)
- Protrusions: 40mm wide x 7mm tall on each side (125mm total width)
- First 28mm: 15mm deep (full depth)
- Last 12mm: 7mm deep (centered)
- Main cylinders: 12mm outer diameter, 8mm inner, 2mm wall thickness
- Tip cylinders: 5mm outer diameter, 3mm inner, 12mm tall (extends 5mm below)
- Junction dents: 4mm wide x 15mm deep x 2mm tall dent at protrusion-box interface
- Text imprint: "Sara" in Iosevka SS06 Heavy Extended font, 8mm height, 0.5mm deep on top center
- Main box: Completely solid (no hollow interior)
- Blue color, perfect symmetry through mirroring
"""
import FreeCAD as App
import Part
from FreeCAD import Vector
def create_solid_box():
"""Create a solid rectangular box with protrusions and cylinders."""
# Create new document
doc = App.newDocument("SolidBox")
# Dimensions (half width for mirroring)
width = 22.5 # mm (will be mirrored to 45mm total)
depth = 15.0 # mm
height = 18.5 # mm
# Create solid main box (no hollowing needed - much simpler!)
main_box = Part.makeBox(width, depth, height, Vector(0, 0, 0))
# Create side protrusion with two sections:
# - First 28mm: full 15mm depth
# - Last 12mm: 7mm depth (centered)
protrusion_width = 40.0 # extends outward from left side (x-axis)
protrusion_depth = depth # full depth to match main box (15mm)
protrusion_height = 7.0 # along z-axis (height)
# Section 1: Full depth section (28mm wide, 15mm deep)
section1_width = 28.0 # 28mm from main box edge
protrusion_section1 = Part.makeBox(
section1_width,
protrusion_depth, # full 15mm depth
protrusion_height,
Vector(-section1_width, 0, 0), # Start at x=-28, full depth
)
# Section 2: Reduced depth section (12mm wide, 7mm deep, centered)
section2_width = protrusion_width - section1_width # 12mm
section2_depth = 7.0 # reduced depth
section2_offset = (protrusion_depth - section2_depth) / 2 # center the 7mm section
protrusion_section2 = Part.makeBox(
section2_width,
section2_depth,
protrusion_height,
Vector(-protrusion_width, section2_offset, 0), # Start at x=-40, centered in depth
)
# Combine both sections
protrusion = protrusion_section1.fuse(protrusion_section2)
# Create cylinder that intersects with protrusion
# Cylinder is 12mm diameter, 7mm tall, with 8mm inner diameter (2mm wall thickness)
# Positioned at 8-20mm mark (14mm from protrusion edge), will be mirrored
cylinder_outer_radius = 6.0 # 12mm diameter / 2
cylinder_inner_radius = 4.0 # 8mm diameter / 2
cylinder_height = protrusion_height # 7mm tall (same as protrusion)
# Create hole cutter (8mm diameter cylinder) to cut hole through protrusion
# Position cylinder 8mm from beginning of protrusion, centered on the full 15mm depth
# Protrusion starts at x=-40, cylinder left edge at x=-32, center at x=-26
cylinder_x_position = -protrusion_width + 20 + cylinder_outer_radius # 8mm + 6mm = 14mm from protrusion start
hole_cutter = Part.makeCylinder(
cylinder_inner_radius,
cylinder_height,
Vector(cylinder_x_position, depth / 2, 0), # Centered on full 15mm depth
Vector(0, 0, 1), # Cylinder axis along z-direction
)
# Cut hole through the protrusion first
protrusion = protrusion.cut(hole_cutter)
# Create hollow cylinder (12mm outer, 8mm inner)
cylinder_outer = Part.makeCylinder(
cylinder_outer_radius,
cylinder_height,
Vector(cylinder_x_position, depth / 2, 0), # Same position as hole
Vector(0, 0, 1), # Cylinder axis along z-direction (hollow top to bottom)
)
cylinder_inner = Part.makeCylinder(
cylinder_inner_radius,
cylinder_height,
Vector(cylinder_x_position, depth / 2, 0), # Same center position
Vector(0, 0, 1), # Cylinder axis along z-direction
)
cylinder = cylinder_outer.cut(cylinder_inner)
# Fuse the hollow cylinder with the holey protrusion
protrusion = protrusion.fuse(cylinder)
# Create tip cylinder at the very end of the protrusion
# 5mm diameter, 12mm tall (7mm above + 5mm below protrusion)
tip_outer_radius = 2.5 # 5mm diameter / 2
tip_inner_radius = 1.5 # 3mm diameter / 2
tip_total_height = 7 + 5 # 12mm total (7mm + 5mm extension below)
tip_hole_depth = 7 + 4 # 11mm deep hole from bottom
# Position tip cylinder 2.5mm inward from the tip (so it doesn't extend beyond protrusion)
tip_x_position = -protrusion_width + tip_outer_radius # x=-37.5 (2.5mm inward from tip)
tip_y_position = section2_offset + section2_depth / 2 # center of the 7mm deep section
tip_z_start = -5.0 # starts 5mm below protrusion (protrusion is at z=0)
# Create outer tip cylinder
tip_cylinder_outer = Part.makeCylinder(
tip_outer_radius,
tip_total_height,
Vector(tip_x_position, tip_y_position, tip_z_start),
Vector(0, 0, 1), # Vertical cylinder
)
# Create inner hole cylinder (3mm diameter, 11mm deep from bottom)
tip_cylinder_inner = Part.makeCylinder(
tip_inner_radius,
tip_hole_depth, # 11mm deep from bottom
Vector(tip_x_position, tip_y_position, tip_z_start),
Vector(0, 0, 1), # Vertical cylinder
)
# Cut hole in tip cylinder
tip_cylinder = tip_cylinder_outer.cut(tip_cylinder_inner)
# Add tip cylinder to protrusion
protrusion = protrusion.fuse(tip_cylinder)
# Combine solid main box with protrusion
half_shape = main_box.fuse(protrusion)
# Create rectangular dent at the top of protrusion where it meets main box
# 2mm deep dent, 5mm wide, 15mm deep (full depth)
hole_width = 4.0 # 4mm wide extending into protrusion (along x-axis)
hole_depth = 15.0 # full depth (along y-axis)
hole_height = 2.0 # 2mm deep dent from top (along z-axis)
# Position dent at the junction between main box and protrusion
# Starting from main box edge (x=0) extending 5mm into protrusion
# From the top of protrusion down 2mm (z = 7mm down to z = 5mm)
hole_cutter = Part.makeBox(
hole_width,
hole_depth,
hole_height,
Vector(-hole_width, 0, protrusion_height - hole_height), # x=-5 to 0, y=0 to 15, z=5 to 7
)
# Cut the dent from the combined shape
half_shape = half_shape.cut(hole_cutter)
# Create mirrored copy to get full 45mm width
# Mirror across the right edge (x=22.5mm plane) to create the other half
mirrored_half = half_shape.mirror(Vector(width, 0, 0), Vector(1, 0, 0))
# Combine both halves to create continuous 45mm wide solid box
final_shape = half_shape.fuse(mirrored_half)
# Refine the shape to create a single unified solid (fix broken surfaces)
try:
# For compound objects, we need to fuse all the parts first
if hasattr(final_shape, "ShapeType") and final_shape.ShapeType == "Compound":
# Extract all solids from the compound and fuse them
solids = []
for shape in final_shape.SubShapes:
if shape.ShapeType == "Solid":
solids.append(shape)
if len(solids) > 1:
# Fuse all solids into one
unified_solid = solids[0]
for solid in solids[1:]:
unified_solid = unified_solid.fuse(solid)
final_shape = unified_solid
elif len(solids) == 1:
final_shape = solids[0]
# Now try to refine the unified shape
if hasattr(final_shape, "removeSplitter"):
final_shape = final_shape.removeSplitter()
if hasattr(final_shape, "refine"):
final_shape = final_shape.refine()
print("Shape successfully unified into a single solid")
except Exception as e:
print(f"Shape refinement failed: {e}")
print("Continuing with current shape")
def add_fillet():
# Add filleting to smooth sharp edges BEFORE text cutting (simpler geometry)
print("Adding fillets to smooth sharp edges...")
try:
fillet_radius = 1.0 # 1mm radius - more aggressive since geometry is simpler
edges = final_shape.Edges
print(f"Found {len(edges)} edges in final shape")
# Strategy: Fillet suitable edges individually (most reliable approach)
suitable_edges = []
for edge in edges:
edge_length = edge.Length
# Fillet edges longer than 5mm (avoid tiny features)
if edge_length > 5.0:
suitable_edges.append((edge, edge_length))
# Sort by length - longest edges first (safest to most complex)
suitable_edges.sort(key=lambda x: x[1], reverse=True)
print(f"Found {len(suitable_edges)} edges for filleting (>5mm long)")
if len(suitable_edges) > 0:
# Individual edge filleting - try all edges
filleted_count = 0
failed_count = 0
current_shape = final_shape
for i, (edge, length) in enumerate(suitable_edges):
try:
current_shape = current_shape.makeFillet(fillet_radius, [edge])
filleted_count += 1
if filleted_count <= 10: # Show first 10 for progress
print(f"✅ Filleted edge {i + 1}: {length:.1f}mm")
elif filleted_count % 10 == 0: # Every 10th after that
print(f"✅ Progress: {filleted_count} edges filleted...")
except Exception:
failed_count += 1
if failed_count <= 3: # Only show first few failures
print(f"⚠️ Edge {i + 1} failed (length: {length:.1f}mm)")
continue
if filleted_count > 0:
final_shape = current_shape
print(f"🎯 Filleting complete: {filleted_count} edges filleted, {failed_count} failed")
print(f"✅ Smooth edges applied with {fillet_radius}mm radius")
else:
print("⚠️ No edges could be filleted - keeping sharp edges")
else:
print("No suitable edges found for filleting")
except Exception as e:
print(f"Filleting failed: {e}")
print("Continuing with sharp edges - shape is still valid")
# Create text "Sara" to imprint on the main box
try:
import os
import Draft
# Text specifications
text_string = "Sara"
text_size = 8.0 # 8mm text height
text_depth = 1.5 # 1.5mm deep cut (1mm into surface + 0.5mm above for clean cut)
# Create 3D text shape using the newer make_text function
# Try to find the Iosevka SS06 font file path on macOS
font_paths = [
# Your specific Heavy Extended font file
"/Users/" + os.environ.get("USER", "user") + "/Downloads/Iosevka-SS06-Heavy-Extended-148.ttf",
# Look for other DfontSplitter extracted Heavy Extended files
"/Users/" + os.environ.get("USER", "user") + "/Downloads/IosevkaSS06-HeavyExtended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Downloads/Iosevka-SS06-Heavy-Extended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Desktop/IosevkaSS06-HeavyExtended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Desktop/Iosevka-SS06-Heavy-Extended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Library/Fonts/IosevkaSS06-HeavyExtended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Library/Fonts/Iosevka-SS06-Heavy-Extended.ttf",
"/Users/" + os.environ.get("USER", "user") + "/Library/Fonts/Iosevka SS06 Heavy Extended.ttf",
# Then try the .ttc collection (will use default style - probably Regular)
"/Users/" + os.environ.get("USER", "user") + "/Library/Fonts IosevkaSS06.ttc",
"/Users/" + os.environ.get("USER", "user") + "/Library/Fonts/IosevkaSS06.ttc",
# System font fallbacks that are bold
"/System/Library/Fonts/Arial Black.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Arial Bold.ttf",
]
# Find first available font
font_file = "/System/Library/Fonts/Helvetica.ttc" # Default fallback
for path in font_paths:
if os.path.exists(path):
font_file = path
break
# Determine if we found a bold/heavy variant
font_name = os.path.basename(font_file)
is_heavy = "Heavy" in font_name or "Black" in font_name or "Bold" in font_name
print(f"Using font file: {font_file}")
print(f"Font appears to be heavy/bold: {is_heavy}")
# Use the correct FreeCAD API for ShapeString
text_obj = Draft.make_shapestring(
String=text_string,
FontFile=font_file,
Size=text_size,
Tracking=0.0,
)
# Position the text (will be adjusted later during cutting)
text_obj.Placement.Base = Vector(0, 0, 0)
doc.recompute() # Generate the shape
# Check if we got a valid shape
if hasattr(text_obj, "Shape") and text_obj.Shape and text_obj.Shape.Wires:
# Create faces from text wires, preserving holes in letters like "a", "e", "o", "p"
print(f"Found {len(text_obj.Shape.Wires)} wires in text shape")
# Group wires by letter - outer boundaries and inner holes
text_faces = []
# For complex text with holes, we need to create faces properly
# First try to get faces directly if they exist (ShapeString should provide proper faces)
if hasattr(text_obj.Shape, "Faces") and text_obj.Shape.Faces:
print("Using existing faces from text shape (preserves holes)")
text_faces = text_obj.Shape.Faces
print(f"Found {len(text_faces)} faces with holes preserved")
else:
# Fallback: Create faces from wires, but this may not preserve holes correctly
print("Creating faces from wires (may lose holes in letters like 'a', 'e', 'o', 'p')...")
closed_wires = [w for w in text_obj.Shape.Wires if w.isClosed()]
print(f"Found {len(closed_wires)} closed wires")
# Create faces from wires (this approach may fill holes)
for i, wire in enumerate(closed_wires):
try:
face = Part.Face(wire)
text_faces.append(face)
print(f"Created face {i + 1} with area {abs(face.Area):.2f}")
except Exception as e:
print(f"Failed to create face from wire {i + 1}: {e}")
if len(text_faces) > 1:
print("⚠️ Multiple faces detected - holes in letters may be filled")
print(" ShapeString should provide proper faces with holes preserved")
if text_faces:
# Combine all letter faces
text_compound = Part.makeCompound(text_faces)
# Create 3D text cutter that extends ABOVE the top surface to cut DOWN
text_3d = text_compound.extrude(Vector(0, 0, text_depth))
print(f"Text 3D bounding box before positioning: {text_3d.BoundBox}")
# Position text at the center of the FULL 45mm box
text_center_x = 22.5 # Dead center of the 45mm wide box
text_center_y = 7.5 # Dead center of the 15mm deep box
# Position the cutter to START BELOW the surface and extend ABOVE it
# This ensures the cutter intersects with the top surface for cutting
cut_depth = 1.0 # Actual depth to cut into surface (1mm)
text_z_start = height - cut_depth # Start 1mm below surface (z=17.5)
text_z_end = text_z_start + text_depth # End at z=19.0
print(f"Main box height: {height}mm (top surface at z={height})")
print(f"Text cutter: z={text_z_start} to z={text_z_end} (should overlap with box)")
text_3d.translate(
Vector(
text_center_x - text_3d.BoundBox.XLength / 2,
text_center_y - text_3d.BoundBox.YLength / 2,
text_z_start, # Start 2mm below surface and extend upward
),
)
print(f"Text 3D bounding box after positioning: {text_3d.BoundBox}")
print(
f"Text positioned at: x={text_center_x - text_3d.BoundBox.XLength / 2:.1f}, y={text_center_y - text_3d.BoundBox.YLength / 2:.1f}, z={text_z_start}",
)
# Cut text into the main box (imprint from the top)
print("Attempting to cut text into main box...")
original_volume = final_shape.Volume if hasattr(final_shape, "Volume") else 0
final_shape = final_shape.cut(text_3d)
new_volume = final_shape.Volume if hasattr(final_shape, "Volume") else 0
volume_diff = original_volume - new_volume
print(f"Volume removed by text cutting: {volume_diff:.2f} mm³")
if volume_diff > 0:
print(f"✅ Text 'Sara' successfully cut into surface: {cut_depth}mm deep using {font_name}")
print(f"Bold/Heavy font detected: {is_heavy}")
else:
print("⚠️ WARNING: No volume was removed - text may not be intersecting with the box!")
print(" Check positioning - text cutter should overlap with main box")
if not is_heavy:
print("⚠️ WARNING: Using regular weight font - may appear thin!")
print(" For Heavy Extended, try: FontBook > Iosevka SS06 > Export Heavy Extended style")
# Clean up the temporary text object
doc.removeObject(text_obj.Name)
# Unify the shape again after text cutting to ensure single solid
print("Unifying shape after text cutting...")
try:
if hasattr(final_shape, "removeSplitter"):
final_shape = final_shape.removeSplitter()
if hasattr(final_shape, "refine"):
final_shape = final_shape.refine()
print("Shape successfully unified after text cutting")
except Exception as e:
print(f"Shape unification after text cutting failed: {e}")
print("Continuing with current shape")
except Exception as e:
print(f"Text imprinting failed: {e}")
print("\n💡 To get Heavy Extended style from your .ttc file:")
print(" 1. Install 'DfontSplitter' from Mac App Store (free)")
print(" 2. Drag IosevkaSS06.ttc into DfontSplitter")
print(" 3. Find IosevkaSS06-HeavyExtended.ttf in output")
print(" 4. Update font_paths in script with that .ttf path")
print("\n📝 Font Book can't export from .ttc - need DfontSplitter or FontForge")
print("For now, continuing without text - you can add manually in FreeCAD GUI")
# Create the box object in FreeCAD
box_obj = doc.addObject("Part::Feature", "SolidBoxWithProtrusions")
box_obj.Shape = final_shape
# Set the color to blue
if hasattr(box_obj, "ViewObject"):
box_obj.ViewObject.ShapeColor = (0.0, 0.4, 1.0) # Blue color (RGB)
box_obj.ViewObject.Transparency = 0
# Recompute the document
doc.recompute()
print(
"Unified solid box with stepped protrusions, tip cylinders, junction dents, and text imprint created successfully!",
)
print(f"Total dimensions: {width * 2}mm x {depth}mm x {height}mm")
print(f"Total width with protrusions: {protrusion_width + width * 2 + protrusion_width}mm")
print(f"Each protrusion: {protrusion_width}mm wide x {protrusion_height}mm tall")
print(f" - First 28mm: {protrusion_depth}mm deep (full depth)")
print(f" - Last 12mm: {section2_depth}mm deep (centered)")
print("Protrusion structure: 8mm solid + 12mm cylinder + 20mm solid")
print("Main cylinder: 12mm outer diameter, 8mm inner diameter, 2mm wall thickness")
print(f"Tip cylinder: {tip_outer_radius * 2}mm outer diameter, {tip_inner_radius * 2}mm inner diameter")
print(f" - Height: {tip_total_height}mm ({protrusion_height}mm + {tip_total_height - protrusion_height}mm below)")
print(f" - Hole depth: {tip_hole_depth}mm from bottom")
print(f"Junction dent: {hole_width}mm wide x {hole_depth}mm deep x {hole_height}mm tall (2mm dent from top)")
print("Text imprint: 'Sara' in Iosevka SS06 Heavy Extended font, 8mm height, 0.5mm deep on main box top")
print("Main box: Completely solid (no hollow interior)")
print("Perfect symmetry achieved through mirroring!")
return box_obj
if __name__ == "__main__":
# Run this script in FreeCAD
if "FreeCAD" in globals() or "App" in dir():
# Create mirrored solid box with protrusions (125mm total width)
box = create_solid_box()
else:
print("This script should be run within FreeCAD!")
print("Open FreeCAD, then run: exec(open('/path/to/tent.py').read())")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment