Generate wall library.py
Jump to navigation
Jump to search
- !/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())