Wall Module Schema
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