Generate wall library.py

From Open Source Ecology
Revision as of 01:51, 11 March 2026 by Marcin (talk | contribs) (Created page with "#!/usr/bin/env python3 from pathlib import Path from typing import Any import math import sys import yaml import FreeCAD import Part OUTPUT_DIR = Path("cad_library") def load_yaml(path: Path) -> Any: with path.open("r", encoding="utf-8") as f: return yaml.safe_load(f) def nominal_to_actual_lumber(nominal: str) -> tuple[float, float]: mapping = { "2x2": (1.5, 1.5), "2x3": (1.5, 2.5), "2x4": (1.5, 3.5), "2x6": (1.5,...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search
  1. !/usr/bin/env python3

from pathlib import Path from typing import Any import math import sys

import yaml

import FreeCAD import Part


OUTPUT_DIR = Path("cad_library")


def load_yaml(path: Path) -> Any:

   with path.open("r", encoding="utf-8") as f:
       return yaml.safe_load(f)


def nominal_to_actual_lumber(nominal: str) -> tuple[float, float]:

   mapping = {
       "2x2": (1.5, 1.5),
       "2x3": (1.5, 2.5),
       "2x4": (1.5, 3.5),
       "2x6": (1.5, 5.5),
       "2x8": (1.5, 7.25),
       "2x10": (1.5, 9.25),
       "2x12": (1.5, 11.25),
   }
   if nominal not in mapping:
       raise ValueError(f"Unsupported nominal lumber size: {nominal}")
   return mapping[nominal]


def ft_to_in(value_ft: float) -> float:

   return value_ft * 12.0


def make_box(x_len: float, y_len: float, z_len: float, x: float, y: float, z: float):

   shape = Part.makeBox(x_len, y_len, z_len)
   shape.translate(FreeCAD.Vector(x, y, z))
   return shape


def derive_stud_positions(width_in: float, stud_thickness_in: float, spacing_oc_in: float) -> list[float]:

   """
   Returns x positions of stud left faces.
   Includes left end stud and right end stud.
   Intermediate studs placed at requested on-center spacing where possible.
   """
   positions = [0.0]
   current_left_face = spacing_oc_in
   right_end_left_face = width_in - stud_thickness_in
   while current_left_face + stud_thickness_in <= right_end_left_face - 1e-6:
       positions.append(current_left_face)
       current_left_face += spacing_oc_in
   if right_end_left_face > 0.0 and (not math.isclose(positions[-1], right_end_left_face, abs_tol=1e-6)):
       positions.append(right_end_left_face)
   # Deduplicate while preserving order
   deduped = []
   for p in positions:
       if not deduped or not math.isclose(deduped[-1], p, abs_tol=1e-6):
           deduped.append(p)
   return deduped


def build_wall(instance: dict[str, Any]) -> FreeCAD.Document:

   instance_id = instance["id"]
   params = instance["parameters"]
   width_ft = float(params["nominal_width_ft"])
   height_ft = float(params["nominal_height_ft"])
   stud_nominal = str(params["stud_lumber_nominal"])
   spacing_oc_in = float(params["stud_spacing_oc_in"])
   osb_thickness_in = float(params["osb_thickness_in"])
   exterior_face = str(params["exterior_face"])
   stud_thickness_in, stud_depth_in = nominal_to_actual_lumber(stud_nominal)
   width_in = ft_to_in(width_ft)
   framed_height_in = ft_to_in(height_ft)
   plate_thickness_in = stud_thickness_in
   stud_length_in = framed_height_in - 2.0 * plate_thickness_in
   if stud_length_in <= 0:
       raise ValueError(f"{instance_id}: derived stud length is non-positive")
   doc = FreeCAD.newDocument(instance_id)
   shapes = []
   # Coordinate convention:
   # X = wall width
   # Y = wall thickness (interior -> exterior)
   # Z = wall height
   #
   # Framing occupies Y = 0 to stud_depth_in
   # OSB sits on exterior side.
   #
   # exterior_face is metadata for now; geometry is built with exterior on +Y.
   # Bottom plate
   bottom_plate = make_box(
       width_in,
       stud_depth_in,
       plate_thickness_in,
       0.0,
       0.0,
       0.0,
   )
   shapes.append(("bottom_plate", bottom_plate))
   # Top plate
   top_plate = make_box(
       width_in,
       stud_depth_in,
       plate_thickness_in,
       0.0,
       0.0,
       framed_height_in - plate_thickness_in,
   )
   shapes.append(("top_plate", top_plate))
   # Studs
   stud_positions = derive_stud_positions(width_in, stud_thickness_in, spacing_oc_in)
   for i, x in enumerate(stud_positions):
       stud = make_box(
           stud_thickness_in,
           stud_depth_in,
           stud_length_in,
           x,
           0.0,
           plate_thickness_in,
       )
       shapes.append((f"stud_{i+1}", stud))
   # OSB on exterior side (+Y)
   osb = make_box(
       width_in,
       osb_thickness_in,
       framed_height_in,
       0.0,
       stud_depth_in,
       0.0,
   )
   shapes.append(("osb_exterior", osb))
   for name, shape in shapes:
       obj = doc.addObject("Part::Feature", name)
       obj.Shape = shape
   # Add simple metadata object
   meta = doc.addObject("App::FeaturePython", "metadata")
   meta.Label = f"{instance_id} | exterior={exterior_face}"
   doc.recompute()
   return doc


def save_document(doc: FreeCAD.Document, out_path: Path) -> None:

   out_path.parent.mkdir(parents=True, exist_ok=True)
   doc.saveAs(str(out_path))


def main() -> int:

   if len(sys.argv) != 2:
       print("Usage: python generate_wall_library.py <instances.yaml>")
       return 2
   instances_path = Path(sys.argv[1])
   if not instances_path.exists():
       print(f"Error: instances file not found: {instances_path}")
       return 2
   try:
       instances_doc = load_yaml(instances_path)
   except Exception as e:
       print(f"Error loading instances file: {e}")
       return 2
   if not isinstance(instances_doc, dict) or "instances" not in instances_doc:
       print("Instances file must contain top-level key 'instances'")
       return 1
   instances = instances_doc["instances"]
   if not isinstance(instances, list):
       print("'instances' must be a list")
       return 1
   OUTPUT_DIR.mkdir(exist_ok=True)
   for instance in instances:
       instance_id = instance["id"]
       print(f"Generating {instance_id}...")
       doc = build_wall(instance)
       out_path = OUTPUT_DIR / f"{instance_id}.FCStd"
       save_document(doc, out_path)
       print(f"  Saved: {out_path}")
   print(f"Generated {len(instances)} wall module(s) in {OUTPUT_DIR}")
   return 0


if __name__ == "__main__":

   raise SystemExit(main())