Wall Module Schema: Difference between revisions
No edit summary |
|||
| Line 13: | Line 13: | ||
Once you accept that, the next obvious step is: | Once you accept that, the next obvious step is: | ||
=Schema= | |||
schema_version: "wall_module_v0.1" | |||
units: | |||
length: "in" # internal canonical unit | |||
display: "ft-in" | |||
module: | |||
id: "WM-EX-001" | |||
type: "wall_panel" | |||
nominal_thickness_in: 5.5 # 2x6 actual | |||
width_in: 48 # user-selectable: 24..96 recommended | |||
height_in: 120 # user-selectable: >= 96 recommended | |||
stud_spacing_oc_in: 24 | |||
framing_lumber: "2x6" | |||
plates: | |||
top_plate_count: 2 # typical double top | |||
bottom_plate_count: 1 | |||
sheathing: | |||
side: "A" # sheathing on one face, A or B | |||
material: "OSB" | |||
thickness_in: 0.4375 # 7/16 | |||
sourcing_policy: | |||
preferred_sheet_width_in: 48 | |||
preferred_sheet_heights_in: [96, 108, 120] # 8', 9', 10' | |||
rule: | |||
# described in English below; compiler implements deterministically | |||
strategy: "single_sheet_if_possible_else_96_plus_rip" | |||
openings: | |||
# zero or more | |||
- id: "W1" | |||
type: "window" | |||
centered_in_module: false # user controls placement if not centered | |||
rough_opening: | |||
width_in: 36 | |||
height_in: 48 | |||
sill_height_from_bottom_in: 36 | |||
x_from_left_in: 6 # left edge of RO from left plate | |||
header: | |||
type: "flush_built" | |||
members: 2 | |||
lumber: "2x12" | |||
bias_to_face: "A" # "A" face per your requirement | |||
fastener_schedule: "nail-laminate-default" | |||
framing_rules: | |||
trimmers_each_side: 1 # jack studs each side | |||
king_studs_each_side: 1 | |||
cripple_above_header: "auto_to_top_plate" | |||
cripple_below_sill: "auto_to_bottom_plate" | |||
- id: "D1" | |||
type: "door" | |||
centered_in_module: true # door centered for single door modules | |||
allowed_module_widths_in: [48] # enforce unless double door | |||
rough_opening: | |||
width_in: 36 | |||
height_in: 80 | |||
x_from_left_in: null # null means compiler centers it | |||
header: | |||
type: "flush_built" | |||
members: 2 | |||
lumber: "2x12" | |||
bias_to_face: "A" | |||
fastener_schedule: "nail-laminate-default" | |||
framing_rules: | |||
trimmers_each_side: 1 | |||
king_studs_each_side: 1 | |||
cripple_above_header: "auto_to_top_plate" | |||
threshold_detail: "bottom_plate_removed_under_RO" # common door framing | |||
spacers: "auto_if_needed" | |||
manufacturing: | |||
cut_optimization: | |||
allow_splices: false # framing members are full-length unless constrained | |||
waste_priority: "medium" | |||
naming: | |||
face_A_definition: "sheathing_face" | |||
tolerances: | |||
framing_length_in: 0.0625 # +/- 1/16 typical cut tolerance target | |||
outputs: | |||
generate: | |||
- "framing_elevation_face_A" | |||
- "framing_elevation_face_B" | |||
- "osb_cut_plan" | |||
- "cut_list" | |||
- "bom" | |||
- "assembly_notes" | |||
=Compiler= | |||
# wall_module_compiler.py | |||
# FreeCAD "compiler" for OSE-style wall modules: wall / window / door | |||
# Input: JSON (recommended) or YAML (if PyYAML is available in your FreeCAD python) | |||
# Output: FCStd model; optional TechDraw page(s) | |||
import os, sys, json, math, traceback | |||
try: | |||
import FreeCAD as App | |||
import Part | |||
except Exception as e: | |||
raise RuntimeError("Run this inside FreeCAD or with freecadcmd. Import FreeCAD failed.") from e | |||
# ---------------------------- | |||
# Utilities | |||
# ---------------------------- | |||
INCH = 25.4 # FreeCAD internal units are mm; we accept inches in schema and convert to mm. | |||
def in_to_mm(x_in: float) -> float: | |||
return float(x_in) * INCH | |||
def mm(x_in: float) -> float: | |||
return in_to_mm(x_in) | |||
def v(x_in, y_in, z_in): | |||
return App.Vector(mm(x_in), mm(y_in), mm(z_in)) | |||
def clamp(a, lo, hi): | |||
return max(lo, min(hi, a)) | |||
def safe_name(s: str) -> str: | |||
# FreeCAD object names are picky | |||
out = [] | |||
for ch in s: | |||
if ch.isalnum() or ch in "_": | |||
out.append(ch) | |||
else: | |||
out.append("_") | |||
return "".join(out) | |||
def load_spec(path: str) -> dict: | |||
# Prefer JSON; support YAML if available. | |||
with open(path, "r", encoding="utf-8") as f: | |||
txt = f.read() | |||
# Detect by extension, but also allow JSON content in .yaml etc. | |||
ext = os.path.splitext(path)[1].lower() | |||
if ext in [".json"]: | |||
return json.loads(txt) | |||
# Try JSON first | |||
try: | |||
return json.loads(txt) | |||
except Exception: | |||
pass | |||
# Try YAML if available | |||
try: | |||
import yaml # type: ignore | |||
return yaml.safe_load(txt) | |||
except Exception as e: | |||
raise RuntimeError( | |||
"Could not parse spec. Use JSON, or install PyYAML into FreeCAD python for YAML support." | |||
) from e | |||
def ensure_list(x): | |||
if x is None: | |||
return [] | |||
return x if isinstance(x, list) else [x] | |||
# ---------------------------- | |||
# Core framing assumptions (v0) | |||
# ---------------------------- | |||
ACTUAL_2X_THICK = 1.5 # inches | |||
ACTUAL_2X6_DEPTH = 5.5 # inches | |||
ACTUAL_2X12_DEPTH = 11.25 # inches | |||
DEFAULTS = { | |||
"stud_spacing_oc_in": 24.0, | |||
"framing_lumber": "2x6", | |||
"plates": {"top_plate_count": 2, "bottom_plate_count": 1}, | |||
"sheathing": { | |||
"side": "A", | |||
"material": "OSB", | |||
"thickness_in": 7.0/16.0, | |||
"sourcing_policy": { | |||
"preferred_sheet_width_in": 48.0, | |||
"preferred_sheet_heights_in": [96.0, 108.0, 120.0], | |||
"rule": {"strategy": "single_sheet_if_possible_else_96_plus_rip"}, | |||
}, | |||
}, | |||
"openings": [], | |||
"manufacturing": {"outputs": {"generate": []}}, | |||
} | |||
# Coordinate system: | |||
# X = module width | |||
# Y = wall thickness direction (Face A at y=0; Face B at y=5.5) | |||
# Z = height (bottom at z=0) | |||
# | |||
# Framing occupies y in [0, 5.5]. Sheathing on Face A is placed at y = -OSB_thk .. 0. | |||
# ---------------------------- | |||
# FreeCAD object builders | |||
# ---------------------------- | |||
def make_box(doc, name, lx_in, ly_in, lz_in, base_x_in, base_y_in, base_z_in, group=None, color=None): | |||
obj = doc.addObject("Part::Box", safe_name(name)) | |||
obj.Length = mm(lx_in) | |||
obj.Width = mm(ly_in) | |||
obj.Height = mm(lz_in) | |||
obj.Placement.Base = v(base_x_in, base_y_in, base_z_in) | |||
if color is not None and hasattr(obj, "ViewObject"): | |||
obj.ViewObject.ShapeColor = color | |||
if group is not None: | |||
group.addObject(obj) | |||
return obj | |||
def make_group(doc, name, parent=None): | |||
grp = doc.addObject("App::DocumentObjectGroup", safe_name(name)) | |||
if parent is not None: | |||
parent.addObject(grp) | |||
return grp | |||
# ---------------------------- | |||
# Panelization for OSB (v0) | |||
# ---------------------------- | |||
def osb_panels_for(width_in, height_in, policy: dict): | |||
# Returns list of panels: each is dict {w,h,x,y,z} in inches in module coordinates, | |||
# placed on Face A, with y handled by caller. | |||
# Strategy: | |||
# - Prefer single 108 if height<=108; else single 120 if <=120; else 96 + remainder strips. | |||
# - Width: if width<=48: one panel; if width<=96: two panels (48 + remainder). | |||
pref_heights = policy.get("preferred_sheet_heights_in", [96.0, 108.0, 120.0]) | |||
def choose_height(h): | |||
if h <= 108.0: | |||
return 108.0 | |||
if h <= 120.0: | |||
return 120.0 | |||
return 96.0 | |||
base_sheet_h = choose_height(height_in) | |||
panels = [] | |||
# Height segmentation | |||
if height_in <= base_sheet_h: | |||
segs = [(height_in, 0.0)] | |||
else: | |||
# use 96 as base then remainder | |||
base = 96.0 | |||
rem = height_in - base | |||
segs = [(base, 0.0), (rem, base)] | |||
# Width segmentation | |||
if width_in <= 48.0: | |||
wsegs = [(width_in, 0.0)] | |||
elif width_in <= 96.0: | |||
wsegs = [(48.0, 0.0), (width_in - 48.0, 48.0)] | |||
else: | |||
# v0: allow tiling in 48" increments | |||
n_full = int(width_in // 48.0) | |||
rem = width_in - 48.0 * n_full | |||
x0 = 0.0 | |||
wsegs = [] | |||
for _ in range(n_full): | |||
wsegs.append((48.0, x0)) | |||
x0 += 48.0 | |||
if rem > 1e-6: | |||
wsegs.append((rem, x0)) | |||
for (h, z0) in segs: | |||
for (w, x0) in wsegs: | |||
panels.append({"w": w, "h": h, "x": x0, "z": z0}) | |||
return panels | |||
# ---------------------------- | |||
# Opening computation helpers | |||
# ---------------------------- | |||
def opening_rect(opening: dict, module_w_in: float): | |||
ro = opening.get("rough_opening", {}) | |||
ro_w = float(ro.get("width_in", 0.0)) | |||
ro_h = float(ro.get("height_in", 0.0)) | |||
typ = opening.get("type", "").lower() | |||
centered = bool(opening.get("centered_in_module", False)) | |||
if typ == "door" and centered: | |||
x0 = (module_w_in - ro_w) / 2.0 | |||
else: | |||
x0 = opening.get("x_from_left_in", 0.0) | |||
if x0 is None: | |||
x0 = (module_w_in - ro_w) / 2.0 | |||
if typ == "window": | |||
y0 = float(opening.get("sill_height_from_bottom_in", 0.0)) | |||
else: | |||
# door RO height measured from bottom | |||
y0 = 0.0 | |||
return { | |||
"x0": float(x0), | |||
"x1": float(x0) + ro_w, | |||
"z0": float(y0), | |||
"z1": float(y0) + ro_h, | |||
"ro_w": ro_w, | |||
"ro_h": ro_h, | |||
} | |||
def stud_centers(width_in: float, oc_in: float): | |||
# Convention: end studs at 0.75" and width-0.75"; interior at 0.75 + n*oc | |||
# This yields clean oc centerline logic. | |||
centers = [] | |||
left = 0.75 | |||
right = width_in - 0.75 | |||
centers.append(left) | |||
n = 1 | |||
while True: | |||
c = left + n * oc_in | |||
if c >= right - 1e-6: | |||
break | |||
centers.append(c) | |||
n += 1 | |||
if right - left > 1e-6: | |||
centers.append(right) | |||
# Deduplicate (if narrow panels) | |||
out = [] | |||
for c in centers: | |||
if not out or abs(out[-1] - c) > 1e-6: | |||
out.append(c) | |||
return out | |||
def overlaps_opening(stud_center_x, stud_thick_in, orect): | |||
# stud spans [cx-0.75, cx+0.75] | |||
sx0 = stud_center_x - stud_thick_in/2.0 | |||
sx1 = stud_center_x + stud_thick_in/2.0 | |||
# opening spans [x0, x1] | |||
return not (sx1 <= orect["x0"] or sx0 >= orect["x1"]) | |||
# ---------------------------- | |||
# Compiler: spec -> FreeCAD model | |||
# ---------------------------- | |||
def compile_wall_module(spec: dict, out_fcstd: str, make_drawings: bool = True): | |||
# Merge defaults shallowly | |||
module = spec.get("module", {}) | |||
plates = (module.get("plates") or spec.get("plates") or DEFAULTS["plates"]) | |||
sheathing = (module.get("sheathing") or spec.get("sheathing") or DEFAULTS["sheathing"]) | |||
openings = ensure_list(spec.get("openings", module.get("openings", DEFAULTS["openings"]))) | |||
W = float(module.get("width_in", 48.0)) | |||
H = float(module.get("height_in", 120.0)) | |||
oc = float(module.get("stud_spacing_oc_in", spec.get("stud_spacing_oc_in", DEFAULTS["stud_spacing_oc_in"]))) | |||
top_n = int(plates.get("top_plate_count", 2)) | |||
bot_n = int(plates.get("bottom_plate_count", 1)) | |||
osb_thk = float(sheathing.get("thickness_in", 7.0/16.0)) | |||
osb_policy = (sheathing.get("sourcing_policy") or DEFAULTS["sheathing"]["sourcing_policy"]) | |||
wall_thk = ACTUAL_2X6_DEPTH | |||
stud_thk = ACTUAL_2X_THICK | |||
# Plate thickness = 1.5 each | |||
stud_len = H - stud_thk * (top_n + bot_n) | |||
if stud_len <= 0: | |||
raise ValueError("Invalid height: plates consume all height.") | |||
doc = App.newDocument(safe_name(module.get("id", "WallModule"))) | |||
root = make_group(doc, "WallModule") | |||
grp_framing = make_group(doc, "Framing", root) | |||
grp_plates = make_group(doc, "Plates", grp_framing) | |||
grp_studs = make_group(doc, "Studs", grp_framing) | |||
grp_openings = make_group(doc, "Openings", grp_framing) | |||
grp_sheath = make_group(doc, "Sheathing", root) | |||
# ---- Plates (door bottom plate is split if a centered door exists) | |||
door_openings = [o for o in openings if (o.get("type","").lower()=="door")] | |||
door_rects = [] | |||
for d in door_openings: | |||
door_rects.append(opening_rect(d, W)) | |||
# Bottom plates | |||
for i in range(bot_n): | |||
z0 = i * stud_thk | |||
if door_rects: | |||
# v0: split around first door only | |||
dr = door_rects[0] | |||
# left segment | |||
if dr["x0"] > 0: | |||
make_box(doc, f"BP_{i}_L", dr["x0"], wall_thk, stud_thk, 0, 0, z0, grp_plates) | |||
# right segment | |||
if dr["x1"] < W: | |||
make_box(doc, f"BP_{i}_R", W - dr["x1"], wall_thk, stud_thk, dr["x1"], 0, z0, grp_plates) | |||
else: | |||
make_box(doc, f"BP_{i}", W, wall_thk, stud_thk, 0, 0, z0, grp_plates) | |||
# Top plates | |||
for i in range(top_n): | |||
z0 = H - stud_thk * (top_n - i) | |||
make_box(doc, f"TP_{i}", W, wall_thk, stud_thk, 0, 0, z0, grp_plates) | |||
# ---- Regular studs (skip where openings require replacement) | |||
# Precompute opening rects | |||
orects = [] | |||
for o in openings: | |||
orects.append((o, opening_rect(o, W))) | |||
centers = stud_centers(W, oc) | |||
def stud_x_from_center(cx): | |||
return cx - stud_thk/2.0 | |||
for idx, cx in enumerate(centers): | |||
# Skip studs that overlap any opening RO region (in x) | |||
skip = False | |||
for _, r in orects: | |||
if overlaps_opening(cx, stud_thk, r): | |||
skip = True | |||
break | |||
if skip: | |||
continue | |||
x0 = stud_x_from_center(cx) | |||
z0 = stud_thk * bot_n | |||
make_box(doc, f"STUD_{idx:02d}", stud_thk, wall_thk, stud_len, x0, 0, z0, grp_studs) | |||
# ---- Openings: king/jack/header/sill/cripples | |||
for o, r in orects: | |||
oid = safe_name(o.get("id", "O")) | |||
typ = o.get("type", "").lower() | |||
og = make_group(doc, f"{oid}_{typ.upper()}", grp_openings) | |||
# Define header geometry (2x12 x2 flush-built, biased to Face A) | |||
header_thk_y = 2.0 * stud_thk # 3.0" | |||
header_h_z = ACTUAL_2X12_DEPTH # 11.25" | |||
header_len_x = r["ro_w"] + 2.0 * stud_thk # bearing on jacks (v0) | |||
header_x0 = r["x0"] - stud_thk | |||
header_y0 = 0.0 # biased to Face A | |||
header_z0 = r["z1"] + stud_thk # top of RO + 1.5" (v0 convention) | |||
# Kings: full height studs at sides (outside jacks) | |||
z_stud0 = stud_thk * bot_n | |||
king_len = stud_len | |||
# left king at x0 - 1.5 | |||
make_box(doc, f"{oid}_KING_L", stud_thk, wall_thk, king_len, | |||
r["x0"] - 2.0*stud_thk, 0, z_stud0, og) | |||
# right king at x1 | |||
make_box(doc, f"{oid}_KING_R", stud_thk, wall_thk, king_len, | |||
r["x1"] + stud_thk, 0, z_stud0, og) | |||
# Jacks: from bottom plates up to header bearing | |||
jack_len = (header_z0 - z_stud0) | |||
jack_len = max(0.0, jack_len) | |||
make_box(doc, f"{oid}_JACK_L", stud_thk, wall_thk, jack_len, | |||
r["x0"] - stud_thk, 0, z_stud0, og) | |||
make_box(doc, f"{oid}_JACK_R", stud_thk, wall_thk, jack_len, | |||
r["x1"], 0, z_stud0, og) | |||
# Header | |||
make_box(doc, f"{oid}_HEADER_2PLY_2X12", header_len_x, header_thk_y, header_h_z, | |||
header_x0, header_y0, header_z0, og) | |||
# Cripples above header to top plates underside | |||
top_plate_underside = H - stud_thk * top_n | |||
header_top = header_z0 + header_h_z | |||
cripple_zone = top_plate_underside - header_top | |||
if cripple_zone > 0.5: | |||
# Place cripples on 24" grid inside opening span (between kings) | |||
cxs = stud_centers(header_x0 + header_len_x, oc) # quick hack; we will filter by range | |||
for j, cx in enumerate(centers): | |||
if cx <= (r["x0"] - stud_thk) or cx >= (r["x1"] + stud_thk): | |||
continue | |||
# Avoid jacks/king exact locations | |||
if abs(cx - (r["x0"] - 0.75)) < 1.0 or abs(cx - (r["x1"] + 0.75)) < 1.0: | |||
continue | |||
x0 = stud_x_from_center(cx) | |||
make_box(doc, f"{oid}_CRIP_A_{j:02d}", stud_thk, wall_thk, cripple_zone, | |||
x0, 0, header_top, og) | |||
if typ == "window": | |||
# Sill at z = z0 + 1.5 (v0). Use 2x6 member. | |||
sill_z0 = r["z0"] + stud_thk | |||
sill_len_x = r["ro_w"] | |||
sill_x0 = r["x0"] | |||
make_box(doc, f"{oid}_SILL_2X6", sill_len_x, wall_thk, stud_thk, | |||
sill_x0, 0, sill_z0, og) | |||
# Cripples below sill down to bottom plate top | |||
bottom_plate_top = stud_thk * bot_n | |||
below_zone = sill_z0 - bottom_plate_top | |||
if below_zone > 0.5: | |||
for j, cx in enumerate(centers): | |||
if cx <= r["x0"] or cx >= r["x1"]: | |||
continue | |||
x0 = stud_x_from_center(cx) | |||
make_box(doc, f"{oid}_CRIP_B_{j:02d}", stud_thk, wall_thk, below_zone, | |||
x0, 0, bottom_plate_top, og) | |||
# Doors: v0 assumes bottom plate split already; nothing further required here. | |||
# ---- Sheathing panels (Face A, y negative) | |||
panels = osb_panels_for(W, H, osb_policy) | |||
for k, p in enumerate(panels): | |||
make_box(doc, f"OSB_{k:02d}", p["w"], osb_thk, p["h"], | |||
p["x"], -osb_thk, p["z"], grp_sheath) | |||
doc.recompute() | |||
# ---- Optional TechDraw drawings (best-effort) | |||
if make_drawings: | |||
try: | |||
import TechDraw # noqa: F401 | |||
# Create a basic page; template path depends on installation. | |||
# Try a couple common template names; if not found, still create page without template. | |||
page = doc.addObject("TechDraw::DrawPage", "A_WallModule") | |||
templ = None | |||
# Common locations (varies by OS and FreeCAD version); this is best-effort. | |||
candidates = [] | |||
if hasattr(App, "getResourceDir"): | |||
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A4_LandscapeTD.svg")) | |||
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A4_Landscape.svg")) | |||
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A3_LandscapeTD.svg")) | |||
for c in candidates: | |||
if c and os.path.exists(c): | |||
templ = doc.addObject("TechDraw::DrawSVGTemplate", "Template") | |||
templ.Template = c | |||
page.Template = templ | |||
break | |||
# Add a view of the whole module (root group) | |||
view = doc.addObject("TechDraw::DrawViewPart", "View_Framing") | |||
view.Source = [root] | |||
page.addView(view) | |||
doc.recompute() | |||
except Exception: | |||
# Drawings are optional; geometry is primary. | |||
pass | |||
# Save | |||
doc.saveAs(out_fcstd) | |||
return out_fcstd | |||
# ---------------------------- | |||
# CLI entrypoint for freecadcmd | |||
# ---------------------------- | |||
def main(argv): | |||
if len(argv) < 3: | |||
print("Usage:") | |||
print(" freecadcmd wall_module_compiler.py <spec.json> <out.fcstd> [--no-drawings]") | |||
return 2 | |||
spec_path = argv[1] | |||
out_path = argv[2] | |||
make_drawings = True | |||
if len(argv) >= 4 and argv[3].strip() == "--no-drawings": | |||
make_drawings = False | |||
spec = load_spec(spec_path) | |||
compile_wall_module(spec, out_path, make_drawings=make_drawings) | |||
print(f"Wrote: {out_path}") | |||
return 0 | |||
if __name__ == "__main__": | |||
try: | |||
sys.exit(main(sys.argv)) | |||
except Exception: | |||
traceback.print_exc() | |||
sys.exit(1) | |||
= Generate a Wall Module CAD File in FreeCAD (Schema → Compiler → .FCStd) = | |||
== Prerequisites == | |||
# Install FreeCAD (GUI and command-line support). | |||
# Create a working folder, e.g.: | |||
#* <code>~/ose_wall_compiler/</code> | |||
# Save these files into that folder: | |||
#* <code>wall_module_compiler.py</code> (the compiler script) | |||
#* One or more module spec files, e.g. <code>WM_WALL_48x120.json</code>, <code>WM_WINDOW_48x120.json</code>, <code>WM_DOOR_48x96.json</code> | |||
== Step 1: Create a module specification file (JSON) == | |||
# In your working folder, create a JSON file such as <code>WM_WALL_48x120.json</code>. | |||
# Paste a minimal wall module spec: | |||
<pre> | |||
{ | |||
"schema_version": "wall_module_v0.1", | |||
"module": { | |||
"id": "WM_WALL_48x120", | |||
"type": "wall_panel", | |||
"width_in": 48, | |||
"height_in": 120, | |||
"stud_spacing_oc_in": 24, | |||
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 }, | |||
"sheathing": { "thickness_in": 0.4375 } | |||
}, | |||
"openings": [] | |||
} | |||
</pre> | |||
== Step 2: (Optional) Create a window module spec == | |||
# Create <code>WM_WINDOW_48x120.json</code>: | |||
<pre> | |||
{ | |||
"schema_version": "wall_module_v0.1", | |||
"module": { | |||
"id": "WM_WINDOW_48x120", | |||
"type": "wall_panel", | |||
"width_in": 48, | |||
"height_in": 120, | |||
"stud_spacing_oc_in": 24, | |||
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 }, | |||
"sheathing": { "thickness_in": 0.4375 } | |||
}, | |||
"openings": [ | |||
{ | |||
"id": "W1", | |||
"type": "window", | |||
"rough_opening": { "width_in": 36, "height_in": 48 }, | |||
"sill_height_from_bottom_in": 36, | |||
"x_from_left_in": 6, | |||
"header": { "type": "flush_built", "members": 2, "lumber": "2x12", "bias_to_face": "A" } | |||
} | |||
] | |||
} | |||
</pre> | |||
== Step 3: (Optional) Create a door module spec == | |||
# Create <code>WM_DOOR_48x96.json</code>: | |||
<pre> | |||
{ | |||
"schema_version": "wall_module_v0.1", | |||
"module": { | |||
"id": "WM_DOOR_48x96", | |||
"type": "wall_panel", | |||
"width_in": 48, | |||
"height_in": 96, | |||
"stud_spacing_oc_in": 24, | |||
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 }, | |||
"sheathing": { "thickness_in": 0.4375 } | |||
}, | |||
"openings": [ | |||
{ | |||
"id": "D1", | |||
"type": "door", | |||
"centered_in_module": true, | |||
"rough_opening": { "width_in": 36, "height_in": 80 }, | |||
"header": { "type": "flush_built", "members": 2, "lumber": "2x12", "bias_to_face": "A" } | |||
} | |||
] | |||
} | |||
</pre> | |||
== Step 4: Run the compiler (headless) to generate the FreeCAD file == | |||
# Open a terminal in the working folder. | |||
# Run FreeCAD in command-line mode (<code>freecadcmd</code>) to compile the JSON into an <code>.FCStd</code> file: | |||
=== Wall module === | |||
<pre> | |||
freecadcmd wall_module_compiler.py WM_WALL_48x120.json WM_WALL_48x120.fcstd | |||
</pre> | |||
=== Window module === | |||
<pre> | |||
freecadcmd wall_module_compiler.py WM_WINDOW_48x120.json WM_WINDOW_48x120.fcstd | |||
</pre> | |||
=== Door module === | |||
<pre> | |||
freecadcmd wall_module_compiler.py WM_DOOR_48x96.json WM_DOOR_48x96.fcstd | |||
</pre> | |||
== Step 5: If TechDraw causes issues, disable drawings == | |||
# Some FreeCAD installations may fail TechDraw in headless mode. | |||
# Re-run with drawings disabled: | |||
<pre> | |||
freecadcmd wall_module_compiler.py WM_WINDOW_48x120.json WM_WINDOW_48x120.fcstd --no-drawings | |||
</pre> | |||
== Step 6: Open the generated CAD file in FreeCAD (GUI) == | |||
# Launch FreeCAD. | |||
# File → Open → select the generated <code>.fcstd</code>. | |||
# Inspect the model tree: | |||
#* <code>WallModule</code> | |||
#* <code>Framing</code> → plates / studs / openings | |||
#* <code>Sheathing</code> → OSB panels | |||
== Step 7: Export geometry for fabrication workflows (optional) == | |||
# Export 2D/3D formats as needed: | |||
#* Select objects (or the <code>WallModule</code> group) | |||
#* File → Export | |||
#* Choose format: | |||
#** STEP (.step) for interchange | |||
#** STL for printing (if needed) | |||
#** DXF for 2D cutting (typically from TechDraw or Draft) | |||
== Troubleshooting == | |||
# <b>Command not found: freecadcmd</b> | |||
#* On some systems it is named <code>FreeCADCmd</code>. | |||
#* Try: <code>FreeCADCmd wall_module_compiler.py ...</code> | |||
# <b>JSON parse errors</b> | |||
#* Validate commas and braces; JSON must be strict. | |||
# <b>No TechDraw page appears</b> | |||
#* Use <code>--no-drawings</code>; geometry output is still correct. | |||
#* Or generate drawings from within the FreeCAD GUI. | |||
== Output Contract (What You Get) == | |||
# A deterministic FreeCAD model file: <code>*.FCStd</code> | |||
# Model contains real solids: | |||
#* Plates, studs, king/jack studs, headers, sills, cripples | |||
#* OSB sheathing panels sized per the sourcing rules | |||
# Optional: TechDraw page(s), depending on your FreeCAD setup | |||
Revision as of 03:57, 22 January 2026
https://chatgpt.com/share/69706dec-54ac-8010-a171-74eb64954e9a
Steps
- Create schema
- Create compiler.
- Compile. This generated CAD in FreeCAD
Real Takeaway
You are no longer “designing wall modules.”
You are designing a wall-module language.
FreeCAD is just the rendering backend.
Once you accept that, the next obvious step is:
Schema
schema_version: "wall_module_v0.1"
units:
length: "in" # internal canonical unit display: "ft-in"
module:
id: "WM-EX-001"
type: "wall_panel"
nominal_thickness_in: 5.5 # 2x6 actual
width_in: 48 # user-selectable: 24..96 recommended
height_in: 120 # user-selectable: >= 96 recommended
stud_spacing_oc_in: 24
framing_lumber: "2x6"
plates:
top_plate_count: 2 # typical double top
bottom_plate_count: 1
sheathing:
side: "A" # sheathing on one face, A or B
material: "OSB"
thickness_in: 0.4375 # 7/16
sourcing_policy:
preferred_sheet_width_in: 48
preferred_sheet_heights_in: [96, 108, 120] # 8', 9', 10'
rule:
# described in English below; compiler implements deterministically
strategy: "single_sheet_if_possible_else_96_plus_rip"
openings:
# zero or more
- id: "W1"
type: "window"
centered_in_module: false # user controls placement if not centered
rough_opening:
width_in: 36
height_in: 48
sill_height_from_bottom_in: 36
x_from_left_in: 6 # left edge of RO from left plate
header:
type: "flush_built"
members: 2
lumber: "2x12"
bias_to_face: "A" # "A" face per your requirement
fastener_schedule: "nail-laminate-default"
framing_rules:
trimmers_each_side: 1 # jack studs each side
king_studs_each_side: 1
cripple_above_header: "auto_to_top_plate"
cripple_below_sill: "auto_to_bottom_plate"
- id: "D1"
type: "door"
centered_in_module: true # door centered for single door modules
allowed_module_widths_in: [48] # enforce unless double door
rough_opening:
width_in: 36
height_in: 80
x_from_left_in: null # null means compiler centers it
header:
type: "flush_built"
members: 2
lumber: "2x12"
bias_to_face: "A"
fastener_schedule: "nail-laminate-default"
framing_rules:
trimmers_each_side: 1
king_studs_each_side: 1
cripple_above_header: "auto_to_top_plate"
threshold_detail: "bottom_plate_removed_under_RO" # common door framing
spacers: "auto_if_needed"
manufacturing:
cut_optimization:
allow_splices: false # framing members are full-length unless constrained
waste_priority: "medium"
naming:
face_A_definition: "sheathing_face"
tolerances:
framing_length_in: 0.0625 # +/- 1/16 typical cut tolerance target
outputs:
generate:
- "framing_elevation_face_A"
- "framing_elevation_face_B"
- "osb_cut_plan"
- "cut_list"
- "bom"
- "assembly_notes"
Compiler
- wall_module_compiler.py
- FreeCAD "compiler" for OSE-style wall modules: wall / window / door
- Input: JSON (recommended) or YAML (if PyYAML is available in your FreeCAD python)
- Output: FCStd model; optional TechDraw page(s)
import os, sys, json, math, traceback
try:
import FreeCAD as App import Part
except Exception as e:
raise RuntimeError("Run this inside FreeCAD or with freecadcmd. Import FreeCAD failed.") from e
- ----------------------------
- Utilities
- ----------------------------
INCH = 25.4 # FreeCAD internal units are mm; we accept inches in schema and convert to mm.
def in_to_mm(x_in: float) -> float:
return float(x_in) * INCH
def mm(x_in: float) -> float:
return in_to_mm(x_in)
def v(x_in, y_in, z_in):
return App.Vector(mm(x_in), mm(y_in), mm(z_in))
def clamp(a, lo, hi):
return max(lo, min(hi, a))
def safe_name(s: str) -> str:
# FreeCAD object names are picky
out = []
for ch in s:
if ch.isalnum() or ch in "_":
out.append(ch)
else:
out.append("_")
return "".join(out)
def load_spec(path: str) -> dict:
# Prefer JSON; support YAML if available.
with open(path, "r", encoding="utf-8") as f:
txt = f.read()
# Detect by extension, but also allow JSON content in .yaml etc.
ext = os.path.splitext(path)[1].lower()
if ext in [".json"]:
return json.loads(txt)
# Try JSON first
try:
return json.loads(txt)
except Exception:
pass
# Try YAML if available
try:
import yaml # type: ignore
return yaml.safe_load(txt)
except Exception as e:
raise RuntimeError(
"Could not parse spec. Use JSON, or install PyYAML into FreeCAD python for YAML support."
) from e
def ensure_list(x):
if x is None:
return []
return x if isinstance(x, list) else [x]
- ----------------------------
- Core framing assumptions (v0)
- ----------------------------
ACTUAL_2X_THICK = 1.5 # inches ACTUAL_2X6_DEPTH = 5.5 # inches ACTUAL_2X12_DEPTH = 11.25 # inches
DEFAULTS = {
"stud_spacing_oc_in": 24.0,
"framing_lumber": "2x6",
"plates": {"top_plate_count": 2, "bottom_plate_count": 1},
"sheathing": {
"side": "A",
"material": "OSB",
"thickness_in": 7.0/16.0,
"sourcing_policy": {
"preferred_sheet_width_in": 48.0,
"preferred_sheet_heights_in": [96.0, 108.0, 120.0],
"rule": {"strategy": "single_sheet_if_possible_else_96_plus_rip"},
},
},
"openings": [],
"manufacturing": {"outputs": {"generate": []}},
}
- Coordinate system:
- X = module width
- Y = wall thickness direction (Face A at y=0; Face B at y=5.5)
- Z = height (bottom at z=0)
- Framing occupies y in [0, 5.5]. Sheathing on Face A is placed at y = -OSB_thk .. 0.
- ----------------------------
- FreeCAD object builders
- ----------------------------
def make_box(doc, name, lx_in, ly_in, lz_in, base_x_in, base_y_in, base_z_in, group=None, color=None):
obj = doc.addObject("Part::Box", safe_name(name))
obj.Length = mm(lx_in)
obj.Width = mm(ly_in)
obj.Height = mm(lz_in)
obj.Placement.Base = v(base_x_in, base_y_in, base_z_in)
if color is not None and hasattr(obj, "ViewObject"):
obj.ViewObject.ShapeColor = color
if group is not None:
group.addObject(obj)
return obj
def make_group(doc, name, parent=None):
grp = doc.addObject("App::DocumentObjectGroup", safe_name(name))
if parent is not None:
parent.addObject(grp)
return grp
- ----------------------------
- Panelization for OSB (v0)
- ----------------------------
def osb_panels_for(width_in, height_in, policy: dict):
# Returns list of panels: each is dict {w,h,x,y,z} in inches in module coordinates,
# placed on Face A, with y handled by caller.
# Strategy:
# - Prefer single 108 if height<=108; else single 120 if <=120; else 96 + remainder strips.
# - Width: if width<=48: one panel; if width<=96: two panels (48 + remainder).
pref_heights = policy.get("preferred_sheet_heights_in", [96.0, 108.0, 120.0])
def choose_height(h):
if h <= 108.0:
return 108.0
if h <= 120.0:
return 120.0
return 96.0
base_sheet_h = choose_height(height_in) panels = []
# Height segmentation
if height_in <= base_sheet_h:
segs = [(height_in, 0.0)]
else:
# use 96 as base then remainder
base = 96.0
rem = height_in - base
segs = [(base, 0.0), (rem, base)]
# Width segmentation
if width_in <= 48.0:
wsegs = [(width_in, 0.0)]
elif width_in <= 96.0:
wsegs = [(48.0, 0.0), (width_in - 48.0, 48.0)]
else:
# v0: allow tiling in 48" increments
n_full = int(width_in // 48.0)
rem = width_in - 48.0 * n_full
x0 = 0.0
wsegs = []
for _ in range(n_full):
wsegs.append((48.0, x0))
x0 += 48.0
if rem > 1e-6:
wsegs.append((rem, x0))
for (h, z0) in segs:
for (w, x0) in wsegs:
panels.append({"w": w, "h": h, "x": x0, "z": z0})
return panels
- ----------------------------
- Opening computation helpers
- ----------------------------
def opening_rect(opening: dict, module_w_in: float):
ro = opening.get("rough_opening", {})
ro_w = float(ro.get("width_in", 0.0))
ro_h = float(ro.get("height_in", 0.0))
typ = opening.get("type", "").lower()
centered = bool(opening.get("centered_in_module", False))
if typ == "door" and centered:
x0 = (module_w_in - ro_w) / 2.0
else:
x0 = opening.get("x_from_left_in", 0.0)
if x0 is None:
x0 = (module_w_in - ro_w) / 2.0
if typ == "window":
y0 = float(opening.get("sill_height_from_bottom_in", 0.0))
else:
# door RO height measured from bottom
y0 = 0.0
return {
"x0": float(x0),
"x1": float(x0) + ro_w,
"z0": float(y0),
"z1": float(y0) + ro_h,
"ro_w": ro_w,
"ro_h": ro_h,
}
def stud_centers(width_in: float, oc_in: float):
# Convention: end studs at 0.75" and width-0.75"; interior at 0.75 + n*oc
# This yields clean oc centerline logic.
centers = []
left = 0.75
right = width_in - 0.75
centers.append(left)
n = 1
while True:
c = left + n * oc_in
if c >= right - 1e-6:
break
centers.append(c)
n += 1
if right - left > 1e-6:
centers.append(right)
# Deduplicate (if narrow panels)
out = []
for c in centers:
if not out or abs(out[-1] - c) > 1e-6:
out.append(c)
return out
def overlaps_opening(stud_center_x, stud_thick_in, orect):
# stud spans [cx-0.75, cx+0.75] sx0 = stud_center_x - stud_thick_in/2.0 sx1 = stud_center_x + stud_thick_in/2.0 # opening spans [x0, x1] return not (sx1 <= orect["x0"] or sx0 >= orect["x1"])
- ----------------------------
- Compiler: spec -> FreeCAD model
- ----------------------------
def compile_wall_module(spec: dict, out_fcstd: str, make_drawings: bool = True):
# Merge defaults shallowly
module = spec.get("module", {})
plates = (module.get("plates") or spec.get("plates") or DEFAULTS["plates"])
sheathing = (module.get("sheathing") or spec.get("sheathing") or DEFAULTS["sheathing"])
openings = ensure_list(spec.get("openings", module.get("openings", DEFAULTS["openings"])))
W = float(module.get("width_in", 48.0))
H = float(module.get("height_in", 120.0))
oc = float(module.get("stud_spacing_oc_in", spec.get("stud_spacing_oc_in", DEFAULTS["stud_spacing_oc_in"])))
top_n = int(plates.get("top_plate_count", 2))
bot_n = int(plates.get("bottom_plate_count", 1))
osb_thk = float(sheathing.get("thickness_in", 7.0/16.0))
osb_policy = (sheathing.get("sourcing_policy") or DEFAULTS["sheathing"]["sourcing_policy"])
wall_thk = ACTUAL_2X6_DEPTH stud_thk = ACTUAL_2X_THICK
# Plate thickness = 1.5 each
stud_len = H - stud_thk * (top_n + bot_n)
if stud_len <= 0:
raise ValueError("Invalid height: plates consume all height.")
doc = App.newDocument(safe_name(module.get("id", "WallModule")))
root = make_group(doc, "WallModule") grp_framing = make_group(doc, "Framing", root) grp_plates = make_group(doc, "Plates", grp_framing) grp_studs = make_group(doc, "Studs", grp_framing) grp_openings = make_group(doc, "Openings", grp_framing) grp_sheath = make_group(doc, "Sheathing", root)
# ---- Plates (door bottom plate is split if a centered door exists)
door_openings = [o for o in openings if (o.get("type","").lower()=="door")]
door_rects = []
for d in door_openings:
door_rects.append(opening_rect(d, W))
# Bottom plates
for i in range(bot_n):
z0 = i * stud_thk
if door_rects:
# v0: split around first door only
dr = door_rects[0]
# left segment
if dr["x0"] > 0:
make_box(doc, f"BP_{i}_L", dr["x0"], wall_thk, stud_thk, 0, 0, z0, grp_plates)
# right segment
if dr["x1"] < W:
make_box(doc, f"BP_{i}_R", W - dr["x1"], wall_thk, stud_thk, dr["x1"], 0, z0, grp_plates)
else:
make_box(doc, f"BP_{i}", W, wall_thk, stud_thk, 0, 0, z0, grp_plates)
# Top plates
for i in range(top_n):
z0 = H - stud_thk * (top_n - i)
make_box(doc, f"TP_{i}", W, wall_thk, stud_thk, 0, 0, z0, grp_plates)
# ---- Regular studs (skip where openings require replacement)
# Precompute opening rects
orects = []
for o in openings:
orects.append((o, opening_rect(o, W)))
centers = stud_centers(W, oc)
def stud_x_from_center(cx):
return cx - stud_thk/2.0
for idx, cx in enumerate(centers):
# Skip studs that overlap any opening RO region (in x)
skip = False
for _, r in orects:
if overlaps_opening(cx, stud_thk, r):
skip = True
break
if skip:
continue
x0 = stud_x_from_center(cx)
z0 = stud_thk * bot_n
make_box(doc, f"STUD_{idx:02d}", stud_thk, wall_thk, stud_len, x0, 0, z0, grp_studs)
# ---- Openings: king/jack/header/sill/cripples
for o, r in orects:
oid = safe_name(o.get("id", "O"))
typ = o.get("type", "").lower()
og = make_group(doc, f"{oid}_{typ.upper()}", grp_openings)
# Define header geometry (2x12 x2 flush-built, biased to Face A)
header_thk_y = 2.0 * stud_thk # 3.0"
header_h_z = ACTUAL_2X12_DEPTH # 11.25"
header_len_x = r["ro_w"] + 2.0 * stud_thk # bearing on jacks (v0)
header_x0 = r["x0"] - stud_thk
header_y0 = 0.0 # biased to Face A
header_z0 = r["z1"] + stud_thk # top of RO + 1.5" (v0 convention)
# Kings: full height studs at sides (outside jacks)
z_stud0 = stud_thk * bot_n
king_len = stud_len
# left king at x0 - 1.5
make_box(doc, f"{oid}_KING_L", stud_thk, wall_thk, king_len,
r["x0"] - 2.0*stud_thk, 0, z_stud0, og)
# right king at x1
make_box(doc, f"{oid}_KING_R", stud_thk, wall_thk, king_len,
r["x1"] + stud_thk, 0, z_stud0, og)
# Jacks: from bottom plates up to header bearing
jack_len = (header_z0 - z_stud0)
jack_len = max(0.0, jack_len)
make_box(doc, f"{oid}_JACK_L", stud_thk, wall_thk, jack_len,
r["x0"] - stud_thk, 0, z_stud0, og)
make_box(doc, f"{oid}_JACK_R", stud_thk, wall_thk, jack_len,
r["x1"], 0, z_stud0, og)
# Header
make_box(doc, f"{oid}_HEADER_2PLY_2X12", header_len_x, header_thk_y, header_h_z,
header_x0, header_y0, header_z0, og)
# Cripples above header to top plates underside
top_plate_underside = H - stud_thk * top_n
header_top = header_z0 + header_h_z
cripple_zone = top_plate_underside - header_top
if cripple_zone > 0.5:
# Place cripples on 24" grid inside opening span (between kings)
cxs = stud_centers(header_x0 + header_len_x, oc) # quick hack; we will filter by range
for j, cx in enumerate(centers):
if cx <= (r["x0"] - stud_thk) or cx >= (r["x1"] + stud_thk):
continue
# Avoid jacks/king exact locations
if abs(cx - (r["x0"] - 0.75)) < 1.0 or abs(cx - (r["x1"] + 0.75)) < 1.0:
continue
x0 = stud_x_from_center(cx)
make_box(doc, f"{oid}_CRIP_A_{j:02d}", stud_thk, wall_thk, cripple_zone,
x0, 0, header_top, og)
if typ == "window":
# Sill at z = z0 + 1.5 (v0). Use 2x6 member.
sill_z0 = r["z0"] + stud_thk
sill_len_x = r["ro_w"]
sill_x0 = r["x0"]
make_box(doc, f"{oid}_SILL_2X6", sill_len_x, wall_thk, stud_thk,
sill_x0, 0, sill_z0, og)
# Cripples below sill down to bottom plate top
bottom_plate_top = stud_thk * bot_n
below_zone = sill_z0 - bottom_plate_top
if below_zone > 0.5:
for j, cx in enumerate(centers):
if cx <= r["x0"] or cx >= r["x1"]:
continue
x0 = stud_x_from_center(cx)
make_box(doc, f"{oid}_CRIP_B_{j:02d}", stud_thk, wall_thk, below_zone,
x0, 0, bottom_plate_top, og)
# Doors: v0 assumes bottom plate split already; nothing further required here.
# ---- Sheathing panels (Face A, y negative)
panels = osb_panels_for(W, H, osb_policy)
for k, p in enumerate(panels):
make_box(doc, f"OSB_{k:02d}", p["w"], osb_thk, p["h"],
p["x"], -osb_thk, p["z"], grp_sheath)
doc.recompute()
# ---- Optional TechDraw drawings (best-effort)
if make_drawings:
try:
import TechDraw # noqa: F401
# Create a basic page; template path depends on installation.
# Try a couple common template names; if not found, still create page without template.
page = doc.addObject("TechDraw::DrawPage", "A_WallModule")
templ = None
# Common locations (varies by OS and FreeCAD version); this is best-effort.
candidates = []
if hasattr(App, "getResourceDir"):
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A4_LandscapeTD.svg"))
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A4_Landscape.svg"))
candidates.append(os.path.join(App.getResourceDir(), "Mod", "TechDraw", "Templates", "A3_LandscapeTD.svg"))
for c in candidates:
if c and os.path.exists(c):
templ = doc.addObject("TechDraw::DrawSVGTemplate", "Template")
templ.Template = c
page.Template = templ
break
# Add a view of the whole module (root group)
view = doc.addObject("TechDraw::DrawViewPart", "View_Framing")
view.Source = [root]
page.addView(view)
doc.recompute()
except Exception:
# Drawings are optional; geometry is primary.
pass
# Save doc.saveAs(out_fcstd) return out_fcstd
- ----------------------------
- CLI entrypoint for freecadcmd
- ----------------------------
def main(argv):
if len(argv) < 3:
print("Usage:")
print(" freecadcmd wall_module_compiler.py <spec.json> <out.fcstd> [--no-drawings]")
return 2
spec_path = argv[1]
out_path = argv[2]
make_drawings = True
if len(argv) >= 4 and argv[3].strip() == "--no-drawings":
make_drawings = False
spec = load_spec(spec_path)
compile_wall_module(spec, out_path, make_drawings=make_drawings)
print(f"Wrote: {out_path}")
return 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv))
except Exception:
traceback.print_exc()
sys.exit(1)
Generate a Wall Module CAD File in FreeCAD (Schema → Compiler → .FCStd)
Prerequisites
- Install FreeCAD (GUI and command-line support).
- Create a working folder, e.g.:
~/ose_wall_compiler/
- Save these files into that folder:
wall_module_compiler.py(the compiler script)- One or more module spec files, e.g.
WM_WALL_48x120.json,WM_WINDOW_48x120.json,WM_DOOR_48x96.json
Step 1: Create a module specification file (JSON)
- In your working folder, create a JSON file such as
WM_WALL_48x120.json. - Paste a minimal wall module spec:
{
"schema_version": "wall_module_v0.1",
"module": {
"id": "WM_WALL_48x120",
"type": "wall_panel",
"width_in": 48,
"height_in": 120,
"stud_spacing_oc_in": 24,
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 },
"sheathing": { "thickness_in": 0.4375 }
},
"openings": []
}
Step 2: (Optional) Create a window module spec
- Create
WM_WINDOW_48x120.json:
{
"schema_version": "wall_module_v0.1",
"module": {
"id": "WM_WINDOW_48x120",
"type": "wall_panel",
"width_in": 48,
"height_in": 120,
"stud_spacing_oc_in": 24,
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 },
"sheathing": { "thickness_in": 0.4375 }
},
"openings": [
{
"id": "W1",
"type": "window",
"rough_opening": { "width_in": 36, "height_in": 48 },
"sill_height_from_bottom_in": 36,
"x_from_left_in": 6,
"header": { "type": "flush_built", "members": 2, "lumber": "2x12", "bias_to_face": "A" }
}
]
}
Step 3: (Optional) Create a door module spec
- Create
WM_DOOR_48x96.json:
{
"schema_version": "wall_module_v0.1",
"module": {
"id": "WM_DOOR_48x96",
"type": "wall_panel",
"width_in": 48,
"height_in": 96,
"stud_spacing_oc_in": 24,
"plates": { "top_plate_count": 2, "bottom_plate_count": 1 },
"sheathing": { "thickness_in": 0.4375 }
},
"openings": [
{
"id": "D1",
"type": "door",
"centered_in_module": true,
"rough_opening": { "width_in": 36, "height_in": 80 },
"header": { "type": "flush_built", "members": 2, "lumber": "2x12", "bias_to_face": "A" }
}
]
}
Step 4: Run the compiler (headless) to generate the FreeCAD file
- Open a terminal in the working folder.
- Run FreeCAD in command-line mode (
freecadcmd) to compile the JSON into an.FCStdfile:
Wall module
freecadcmd wall_module_compiler.py WM_WALL_48x120.json WM_WALL_48x120.fcstd
Window module
freecadcmd wall_module_compiler.py WM_WINDOW_48x120.json WM_WINDOW_48x120.fcstd
Door module
freecadcmd wall_module_compiler.py WM_DOOR_48x96.json WM_DOOR_48x96.fcstd
Step 5: If TechDraw causes issues, disable drawings
- Some FreeCAD installations may fail TechDraw in headless mode.
- Re-run with drawings disabled:
freecadcmd wall_module_compiler.py WM_WINDOW_48x120.json WM_WINDOW_48x120.fcstd --no-drawings
Step 6: Open the generated CAD file in FreeCAD (GUI)
- Launch FreeCAD.
- File → Open → select the generated
.fcstd. - Inspect the model tree:
WallModuleFraming→ plates / studs / openingsSheathing→ OSB panels
Step 7: Export geometry for fabrication workflows (optional)
- Export 2D/3D formats as needed:
- Select objects (or the
WallModulegroup) - File → Export
- Choose format:
- STEP (.step) for interchange
- STL for printing (if needed)
- DXF for 2D cutting (typically from TechDraw or Draft)
- Select objects (or the
Troubleshooting
- Command not found: freecadcmd
- On some systems it is named
FreeCADCmd. - Try:
FreeCADCmd wall_module_compiler.py ...
- On some systems it is named
- JSON parse errors
- Validate commas and braces; JSON must be strict.
- No TechDraw page appears
- Use
--no-drawings; geometry output is still correct. - Or generate drawings from within the FreeCAD GUI.
- Use
Output Contract (What You Get)
- A deterministic FreeCAD model file:
*.FCStd - Model contains real solids:
- Plates, studs, king/jack studs, headers, sills, cripples
- OSB sheathing panels sized per the sourcing rules
- Optional: TechDraw page(s), depending on your FreeCAD setup