Created
September 29, 2025 07:23
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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