Wall Module Schema: Difference between revisions

From Open Source Ecology
Jump to navigation Jump to search
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

  1. Create schema
  2. Create compiler.
  3. 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

  1. wall_module_compiler.py
  2. FreeCAD "compiler" for OSE-style wall modules: wall / window / door
  3. Input: JSON (recommended) or YAML (if PyYAML is available in your FreeCAD python)
  4. 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
  1. ----------------------------
  2. Utilities
  3. ----------------------------

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]
  1. ----------------------------
  2. Core framing assumptions (v0)
  3. ----------------------------

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": []}},

}

  1. Coordinate system:
  2. X = module width
  3. Y = wall thickness direction (Face A at y=0; Face B at y=5.5)
  4. Z = height (bottom at z=0)
  5. Framing occupies y in [0, 5.5]. Sheathing on Face A is placed at y = -OSB_thk .. 0.
  1. ----------------------------
  2. FreeCAD object builders
  3. ----------------------------

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
  1. ----------------------------
  2. Panelization for OSB (v0)
  3. ----------------------------

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
  1. ----------------------------
  2. Opening computation helpers
  3. ----------------------------

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"])
  1. ----------------------------
  2. Compiler: spec -> FreeCAD model
  3. ----------------------------

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
  1. ----------------------------
  2. CLI entrypoint for freecadcmd
  3. ----------------------------

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

  1. Install FreeCAD (GUI and command-line support).
  2. Create a working folder, e.g.:
    • ~/ose_wall_compiler/
  3. 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)

  1. In your working folder, create a JSON file such as WM_WALL_48x120.json.
  2. 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

  1. 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

  1. 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

  1. Open a terminal in the working folder.
  2. Run FreeCAD in command-line mode (freecadcmd) to compile the JSON into an .FCStd file:

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

  1. Some FreeCAD installations may fail TechDraw in headless mode.
  2. 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)

  1. Launch FreeCAD.
  2. File → Open → select the generated .fcstd.
  3. Inspect the model tree:
    • WallModule
    • Framing → plates / studs / openings
    • Sheathing → OSB panels

Step 7: Export geometry for fabrication workflows (optional)

  1. Export 2D/3D formats as needed:
    • Select objects (or the WallModule group)
    • File → Export
    • Choose format:
      • STEP (.step) for interchange
      • STL for printing (if needed)
      • DXF for 2D cutting (typically from TechDraw or Draft)

Troubleshooting

  1. Command not found: freecadcmd
    • On some systems it is named FreeCADCmd.
    • Try: FreeCADCmd wall_module_compiler.py ...
  2. JSON parse errors
    • Validate commas and braces; JSON must be strict.
  3. No TechDraw page appears
    • Use --no-drawings; geometry output is still correct.
    • Or generate drawings from within the FreeCAD GUI.

Output Contract (What You Get)

  1. A deterministic FreeCAD model file: *.FCStd
  2. Model contains real solids:
    • Plates, studs, king/jack studs, headers, sills, cripples
    • OSB sheathing panels sized per the sourcing rules
  3. Optional: TechDraw page(s), depending on your FreeCAD setup