Personalize Elixihub behavior for this browser. Changes save automatically.
Workspace Preferences
Control how the component browser, assembly viewer, and keyboard editing behave.
Stored in browser
Help & Guide
ElixiCAD uses AI agents to generate parametric 3D CAD models from plain-English descriptions.
What is ElixiCAD?
ElixiCAD lets you describe a 3D object in natural language and have AI design and model every individual component, then assemble them into a complete CAD file you can download or send to a 3D printer.
Creating a project — step by step
1
Click New Project
Give your project a name and a brief description. Include key dimensions, materials, and intended use.
2
Choose an AI provider and generation profile
The settings gear (⌥1) lets you choose between Claude, Gemini, and others. Balanced is a good starting point.
3
Wait for the design brief and component decomposition
The system writes a design brief and spec, then creates an AI agent for each component. This takes 30–90 seconds.
4
Review and approve components
When generation completes you will see a 3D preview. Click Approve to accept, or add feedback to request changes.
5
Create the assembly
Navigate to the Assembly tab and click Create Assembly once all components are approved.
6
Download or 3D print
Use Download for a tar archive of all STEP, STL, and FCStd files.
Tips for better results
✓Include dimensions.
Vague descriptions produce inconsistent results. Specify mm or cm.
✓Describe how parts connect.
The assembly agent uses this to position parts correctly.
✓Keep projects focused.
5–12 components works well. Very large assemblies take longer and have more placement errors.
✓Use feedback to iterate.
Describe specific issues rather than asking for a full regeneration.
Navigation (⌥ = Option/Alt key)
⌥ (hold)
Show shortcut numbers on the right nav
⌥ + 1
Open Settings
⌥ + 2
Open Skills
⌥ + 3
Open Standard Parts
⌥ + 4
Go to Test Lab
⌥ + 5
Open Help
⌥ + 6
Sign In / Sign Out
Global
Ctrl+S
Open Settings
Escape
Close any open modal
Ctrl+M
Toggle measurement mode (3D viewer)
3D Viewer
Ctrl+Shift+1
Plan view
Ctrl+Shift+2
Elevation view
Ctrl+Shift+3
End view
Ctrl+Shift+4
Isometric view
Component Board
← → ↑ ↓
Navigate component cards
Enter
Open the focused component
Assembly View
Escape
Clear the selected component
Ctrl+H
Toggle visibility of selected component
Ctrl+L
Toggle the component legend
Delete
Back to project
Assembly Edit Mode
← →
Move component along X axis
↑ ↓
Move component along Y axis
Ctrl+↑ / Ctrl+↓
Move component along Z axis
X / Y / Z
Rotate around that axis
Standard Parts
Reusable catalog components available for project generation.
Fasteners
Anchoring & Mounting
Hinges & Motion
Latching & Locking
Structural & Joining
Woodworking
Metal Fabrication
Other
Loading preview…
Skills
Reusable project guidance that can be attached to new projects.
# Structural Insulated Panel (SIP) Building Skill
This skill defines how to design and model SIP-based buildings in FreeCAD using the direct modeling approach. It covers panel specifications, connections, roofs, foundations, siding, and openings — all in metric units.
**Status note (2026-07-05):** this file predates the assembly-layer placement
redesign. Two conventions below are superseded and must NOT be followed for
building-graph SIP projects:
- **Do NOT call `.orient("Y")`** on a component-generation wall/partition
script. Pipeline scripts are expanded through `Wall.construct()`, which
always builds panels span-along-local-X regardless of compass direction
(`Wall._orientation` is stored but never read by `_WallConstructor`); the
assembly Placer applies the correct world rotation for every wall.
`.orient("Y")` only affects the separate, non-pipeline `.build()` whole-wall
shape and would double-rotate the wall if combined with the Placer's own
yaw. `.orient()` remains valid Python API for a standalone one-off
`.build()` script with no Placer.
- **Do NOT set `feature.Placement` in a wall/partition/roof-slope/foundation
component script.** The assembly Placer owns ALL world position and
rotation; see `sip_walls_elixifree.md` and `docs/design/SIP_ROOF_STANDARDS.md`
for the current convention (`.build()`/`.construct()` in the local/default
frame, no `feature.Placement`, no `.orient()`).
For current pipeline behaviour, prefer `sip_walls_elixifree.md`,
`sip_roofs.md`, `sip_partition_walls.md`, and `sip_foundations.md`.
---
## When to Use This Skill
Use this skill when the project involves SIP construction: residential buildings, cabins, garden rooms, studios, or any structure where walls and roof are built from composite foam-core panels. If a component description mentions "SIP", "structural insulated panel", "wall panel", "roof panel", "spline", or "sill plate" in a building context, apply this skill.
---
## ⚠ Non-Negotiable Construction Rules
These rules apply to every SIP component without exception. Violating them produces a structurally incorrect or unbuilable model.
1. **Walls are always multiple panels — never a single box.**
Every wall MUST be built using the panel loop (individual 1200mm-wide SIP panels with block splines between them). A wall modelled as one `Part.makeBox` is wrong regardless of wall length.
2. **Every wall component generates its own bottom plate.**
The wall component (not the foundation) creates a 45×90mm PT timber bottom plate at Z=0 as the very first solid. The SIP panels sit on top of it at Z=90. A wall whose panels start at Z=0 is missing its bottom plate.
3. **Openings described in the brief MUST be modelled with a full-depth timber buck AND a unit.**
If the brief mentions a window or door on a wall, that wall component MUST: (a) use `make_panel_zone` to stop SIP panels at the king stud face — never cut a void from a continuous wall, (b) add full-depth king studs (`KING_D = TOTAL_THICKNESS`) + full-depth trimmer studs + LVL header + window sill, and (c) add a separate feature for the glazing or door unit. King studs that are only 90mm deep are wrong — they leave the SIP foam unsupported and create a structural gap.
4. **NEVER generate a void component. Void components are banned.**
A component whose `agent_id` or `role` contains "void", "subtraction", or "boolean tool" MUST NOT appear in any SIP manifest. There is no such thing as a void agent in a SIP building. Openings are always handled inside the wall agent that contains them — a wall with a window is one agent, not a wall agent plus a void agent. If you are about to generate a component named "East Window Void", "Door Void", or similar, stop and instead add the opening to the description of the wall agent it belongs to.
5. **Flat roofs MUST have a drainage fall of 2–5°, created by tapered top plates — never by tapered insulation or a tapered deck.**
The structural SIP deck is ALWAYS a flat panel and never tapers. The fall is
borne by a **tapered top plate** on the fall-run (eave) walls: a wedge with a
level underside and a top face cut to the fall angle. The flat deck rests on
this wedge and so sits tilted at the fall — its top surface sheds water. The
walls themselves stay level-topped; the taper lives entirely in the plate.
This is the SAME Fig 1.4.7 angled eave-plate mechanism used for pitched
eaves, at a shallower angle (≤5°). **You do NOT author the tapered plate in
the component script — the constructability layer adds it automatically** to
the fall-run eave walls of every flat roof. Your job is only to build the
flat `RoofPanel` deck and the level-topped walls. Never model tapered
insulation, a tapered deck, `INSUL_MIN`, or `FALL_RATIO` — the fall is
structural, not an insulation build-up. Scupper openings MUST still be cut
through the parapet at the low side of the fall — a flat roof with no
scuppers behind a parapet will flood.
**Roof edge treatment — parapet vs. cantilevered eave.** A flat roof edge is finished EITHER with a parapet upstand (default) OR, when an overhang is specified, with a **cantilevered eave**. The two are mutually exclusive *per edge* — a parapet sits at the wall line; an overhang projects past it.
When the brief or the imported "Measured Building Analysis" specifies a **roof overhang** on an edge, that edge MUST be built as a cantilevered eave, NOT a parapet:
- **Extend the roof SIP deck/panel itself** beyond the wall line by `OVERHANG` on that edge (the same panel runs longer — this is a cantilever, NOT a separate bolt-on component). `ROOF_DECK_LENGTH = span + OVERHANG_eave_1 + OVERHANG_eave_2`.
- Add a vertical **fascia** board closing the projecting panel end (full panel thickness deep, e.g. 25 × panel-thickness × deck-width) at the outer edge.
- Add a horizontal **soffit** board closing the underside of the overhang (from wall outer face to fascia inner face).
- Drain the overhanging edge with a **gutter at the eave** instead of scuppers through a parapet (overhanging edges have no parapet to scupper).
- **Cantilever limit:** an unsupported roof-SIP cantilever is ≤ ~1/3 of the panel span. If `OVERHANG > span / 3`, add an LVL/I-joist outrigger or double spline at the bearing wall to carry it — do not cantilever an unsupported panel past this limit.
Edges WITHOUT a specified overhang keep the parapet + scupper detail from the rule above.
6. **Foundation slab extends beyond the wall footprint on all sides.**
SLAB_W = BUILDING_WIDTH + 2 × SLAB_OVERHANG (minimum 200mm). A slab the same size as the wall footprint leaves no bearing margin.
7. **Any panel span exceeding 2440mm MUST be split into multiple sections with LVL bearers.**
Standard SIP stock sheets are 2440mm long. A panel component with a span dimension > 2440mm cannot be sourced or built. Split the span into n equal sections where n = ceil(span / 2440), and model an LVL bearer (45mm wide × panel core depth) at every splice point. This applies to roofs AND walls.
8. **Every SIP building MUST include fastener agents for sole plate anchors, roof hurricane ties, and corner post bases.**
Fasteners are structural — they are not cosmetic additions. A manifest without fastener agents is incomplete. See the MANDATORY: Fasteners section for required connections, naming conventions, and FreeCAD code patterns.
9. **Sole plates and top plates MUST use CORE_THICKNESS as their depth, offset by FACE_THICKNESS.**
The timber plate slots into the routed groove in the SIP foam. The OSB skins overhang on both sides. A plate as wide as TOTAL_THICKNESS cannot enter the groove — it is 11mm too wide on each face.
**Formula:** plate depth = `CORE_THICKNESS`, Y-offset = `FACE_THICKNESS`
```python
# CORRECT — plate fits in the foam channel
bp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, BOTTOM_PLATE_H,
Vector(0, FACE_THICKNESS, 0))
tp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, TOP_PLATE_H,
Vector(0, FACE_THICKNESS, BOTTOM_PLATE_H + PANEL_HEIGHT))
# WRONG — plate is full SIP thickness, cannot slot into panel
# bp = Part.makeBox(WALL_LENGTH, TOTAL_THICKNESS, BOTTOM_PLATE_H, Vector(0, 0, 0))
```
**Applies to:** bottom plate (sole plate), double top plate, and any sill plate or rim plate that slots into a SIP panel edge.
9. **Gable end walls MUST include the full triangular gable section above eave height, and BUTT between the eave walls' inner faces.**
For a duo-pitch (gable) roof with the ridge running along BUILDING_WIDTH (X axis), the EAST and WEST walls are gable end walls. Their top is not flat — it follows the roof pitch. Each gable end wall component MUST include: (a) the rectangular SIP wall section from Z=0 to Z=`gable_rake_end_h`, AND (b) a triangular prism from `gable_rake_end_h` up to `underside_ridge_z` (both from `Elixihub.Concept.RoofGeometry` — includes the +90mm eaves-joint lift; see `docs/design/SIP_ROOF_STANDARDS.md` §2.5). A gable wall that stops at eave height leaves an open triangular gap — unenclosed, weather-exposed, and structurally wrong. **The gable's span is `BUILDING_DEPTH - 2*PANEL_THICKNESS`, NOT the full `BUILDING_DEPTH`** — the eave (south/north) walls run through the corners outer-to-outer and the gable end walls butt between their inner faces (corner rule, no exception regardless of relative wall lengths).
10. **Pitched roof slope scripts MUST NOT set `feature.Placement`.** Use `PitchedRoofSlope(...).build()` and `result.add_to_doc("Body")` — stop there. The assembly layer (Placer) applies pitch rotation from the design spec. Setting Placement in the script fights the assembly layer and produces doubled or wrong rotations.
11. **No component-generation script sets `feature.Placement`, and no wall/partition script calls `.orient()`.**
The assembly Placer owns ALL world position and rotation for every component. `Wall.build()`/`Wall.construct()` place geometry in a default local frame; the Placer applies the correct world position and yaw for every wall regardless of compass direction — `.orient("Y")` only affects a separate, non-pipeline whole-wall `.build()` shape and double-rotates a wall if combined with the Placer's own yaw. Required pattern:
```python
result = Wall(span=..., height=..., stock=...).build()
result.add_to_doc("Body")
# No feature.Placement, no .orient() — the Placer handles world position and rotation
```
12. **Flat roof deck bottom face MUST be at `Z = WALL_HEIGHT`, never at Z = 0.**
`ROOF_Z = WALL_HEIGHT` always. A flat roof placed at Z=0 is on the ground, not the roof. Do NOT model the deck as a single `Part.makeBox(BUILDING_WIDTH, BUILDING_DEPTH, THICKNESS)` — use the panel loop from the Flat Roof Code section. The panel loop positions correctly: `Vector(PANEL_THICKNESS, y, ROOF_Z)`.
---
## MANDATORY: Panel Span Splitting
Standard SIP stock is 2440mm × 1220mm. The 1200mm width rule (side-by-side panels) is already required. The **span direction** (perpendicular to the panel seams) has the same 2440mm limit and is equally non-negotiable.
**When the rule fires:** Whenever the span dimension of any panel — wall height, roof run, or any other — exceeds 2440mm.
**Non-negotiable:** A panel longer than 2440mm cannot be ordered from standard stock. The design cannot be built. Split every time, without exception.
### The Split Algorithm
```python
import math
MAX_SPAN_MM = 2440
def span_sections(total_span):
"""Return list of equal section lengths, each ≤ 2440mm."""
n = math.ceil(total_span / MAX_SPAN_MM)
section = total_span / n # float — equal sections
return [section] * n # n sections, all under 2440mm
```
Use **equal sections** (not 2440 + remainder). Equal sections avoid a tiny sliver panel and produce a cleaner, more buildable result.
### Roof Span Splice (flat roof, most common case)
Roof panels run in the SPAN direction. For a building with INNER_WIDTH > 2440mm:
```python
import math, FreeCAD, Part
from FreeCAD import Vector
# === PARAMETERS ===
BUILDING_WIDTH = 3000 # outer face to outer face (X)
PANEL_THICKNESS = 122 # wall SIP total thickness
CORE_THICKNESS = 100 # foam core depth
FACE_THICKNESS = 11 # OSB skin
ROOF_PANEL_W = 1200 # panel width (Y direction, side-by-side)
LVL_W = 45 # LVL bearer width (in span direction)
MAX_SPAN = 2440 # stock sheet max span
INNER_WIDTH = BUILDING_WIDTH - 2 * PANEL_THICKNESS # 2756mm example → 2512mm
# How many span sections?
n_span = math.ceil(INNER_WIDTH / MAX_SPAN) # 2512/2440 → ceil(1.03) = 2
SECTION_SPAN = INNER_WIDTH / n_span # 2512 / 2 = 1256mm (fits easily)
# → 2 sections of 1256mm, 1 LVL bearer between them
# Roof panel loop (width direction, Y) × span sections (X direction)
# Build in span sections first, then tile in the width direction
roof_parts = []
for i_width, pw in enumerate(panel_widths): # panel_widths = 1200mm buckets in Y
y = PANEL_THICKNESS + sum(panel_widths[:i_width])
x = PANEL_THICKNESS
for i_span in range(n_span):
# Panel section
sx = SECTION_SPAN
f1 = Part.makeBox(sx, pw, FACE_THICKNESS, Vector(x, y, ROOF_Z))
co = Part.makeBox(sx, pw, CORE_THICKNESS, Vector(x, y, ROOF_Z + FACE_THICKNESS))
f2 = Part.makeBox(sx, pw, FACE_THICKNESS, Vector(x, y, ROOF_Z + FACE_THICKNESS + CORE_THICKNESS))
roof_parts.append(f1.fuse(co).fuse(f2))
# LVL span bearer between this section and the next
if i_span < n_span - 1:
bearer = Part.makeBox(
LVL_W, pw, CORE_THICKNESS,
Vector(x + sx - LVL_W / 2, y, ROOF_Z + FACE_THICKNESS)
)
roof_parts.append(bearer)
x += sx
```
**Worked examples:**
| Building width | INNER_WIDTH | n sections | Section span | LVL bearers |
|---------------|-------------|------------|--------------|-------------|
| 2m (INNER~1756) | 1756mm | 1 | 1756mm | 0 — no split needed |
| 3m (INNER~2512) | 2512mm | 2 | 1256mm | 1 mid-span bearer |
| 4m (INNER~3756) | 3756mm | 2 | 1878mm | 1 mid-span bearer |
| 5m (INNER~4756) | 4756mm | 2 | 2378mm | 1 mid-span bearer |
| 6m (INNER~5756) | 5756mm | 3 | 1919mm | 2 bearers at 1/3 and 2/3 span |
### Wall Height Splice (if wall height > 2440mm)
Standard wall heights (2400mm, 2700mm, 3000mm) already come from manufacturer. Heights up to 2700mm fit within a single panel. At 3000mm the panel height is exactly at the 2440mm limit — use a 2440mm lower course + 560mm upper course:
```python
WALL_HEIGHT = 3000 # total panel height needed
if WALL_HEIGHT <= 2440:
course_heights = [WALL_HEIGHT] # single course
else:
n_courses = math.ceil(WALL_HEIGHT / 2440)
section_h = WALL_HEIGHT / n_courses
course_heights = [section_h] * n_courses
# For each course, build the panel loop. Between courses, add a horizontal LVL spline:
# LVL_SPLINE = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, 45,
# Vector(0, FACE_THICKNESS, z_splice - 22.5))
```
### Mono-Pitch / Gable Roof Panel Span
The same algorithm applies. The "span" is measured along the slope (rafter direction). For a mono-pitch:
```python
SLOPE_LENGTH = BUILDING_DEPTH / math.cos(PITCH_RAD) # slant length along roof surface
n_span = math.ceil(SLOPE_LENGTH / MAX_SPAN)
section_span = SLOPE_LENGTH / n_span
```
---
## SIP Panel Specifications (Metric)
All dimensions in millimetres. OSB facing is 11mm each side.
| Name | Total Thickness | Core (EPS) | Approx. R-Value |
|--------------|-----------------|------------|-----------------|
| SIP-100 | 122mm | 100mm | R-15 |
| SIP-150 | 172mm | 150mm | R-23 |
| SIP-200 | 222mm | 200mm | R-30 |
| SIP-250 | 272mm | 250mm | R-38 |
| SIP-300 | 322mm | 300mm | R-45 |
**Standard panel widths:** 1200mm (preferred) or 1220mm
**Standard panel heights:** 2400mm, 2700mm, 3000mm (custom heights available)
**OSB face thickness:** 11mm each side
Wall panels orient with height vertical (Z-axis). Roof panels orient with height along slope.
---
## Spline Types and Geometry
Splines join adjacent panels at vertical (wall) and lateral (roof) edges.
### Surface Spline
Thin OSB/plywood strip inserted into a routed groove in the panel foam face.
- Width: 45mm
- Thickness: 18mm
- Height: matches panel height
- Sits centred in the panel thickness, recessed 15mm into each panel face
```python
SPLINE_WIDTH = 45
SPLINE_THICKNESS = 18
SPLINE_HEIGHT = PANEL_HEIGHT
spline = Part.makeBox(SPLINE_THICKNESS, SPLINE_WIDTH, SPLINE_HEIGHT,
Vector(joint_x - SPLINE_THICKNESS / 2,
(TOTAL_THICKNESS - SPLINE_WIDTH) / 2,
0))
```
### Block Spline (most common)
Solid timber friction-fitted between panel foam cores at a joint.
- 45 × 90mm for SIP-100/SIP-150 walls
- 45 × 140mm for SIP-200+ walls
- Height: matches panel height
```python
SPLINE_W = 45
SPLINE_D = 90 # or 140 for thicker panels
spline = Part.makeBox(SPLINE_W, SPLINE_D, PANEL_HEIGHT,
Vector(joint_x,
(TOTAL_THICKNESS - SPLINE_D) / 2,
BOTTOM_PLATE_H))
```
### LVL Spline
Engineered lumber for structural or high-load joints. Same geometry as block spline but specified as LVL material. Size to load — minimum 45 × 90mm.
### Double Block Spline (corners)
Two block splines side by side at corner junctions. Used where extra structural capacity is needed at L-corners and wall-to-roof junctions.
---
## Wall Assembly — ALWAYS use `Wall` builder
**Do not write the three-layer panel loop manually.** Use the `Wall` builder — it handles panel segmentation, block splines, sole plate, and top plate internally.
```
┌─────────────────────────────────────┐ ← double top plate Z = BOTTOM_PLATE_H + PANEL_HEIGHT
│ Panel │Sp│ Panel │Sp│ Panel │ ← SIP panels Z = BOTTOM_PLATE_H (= 90)
└─────────────────────────────────────┘ ← PT bottom plate Z = 0
══════════════════════════════════════ ← slab top Z = 0
```
**Wall height** = BOTTOM_PLATE_H + PANEL_HEIGHT + TOP_PLATE_H = 90 + panel_height + 180mm
```python
from elixifree.domains.sip import Wall
from FreeCAD import Placement, Rotation, Vector
result = Wall(span=WALL_LENGTH, height=PANEL_HEIGHT, stock=STOCK).build()
result.add_to_doc("Body")
# Do NOT set feature.Placement and do NOT call .orient("Y") for a component-generation
# script — see the status note at the top of this file. The assembly Placer owns all
# world position and rotation for every wall regardless of compass direction.
```
**Superseded — for historical reference only.** The `.orient("Y")` /
`feature.Placement` convention below was replaced by the Placer-owns-everything
model; do not follow it for new component scripts:
~~Assembly positions (inner-face-at-origin convention):~~
~~- South wall: `Vector(0, 0, 0)`~~
~~- North wall: `Vector(0, BUILDING_DEPTH, 0)` — NOT `BUILDING_DEPTH - PANEL_THICKNESS`~~
~~- West wall `.orient("Y")`: `Vector(0, 0, 0)`~~
~~- East wall `.orient("Y")`: `Vector(BUILDING_WIDTH, 0, 0)` — NOT `BUILDING_WIDTH - PANEL_THICKNESS`~~
See `sip_walls_elixifree.md` for the current `Wall` API and pipeline convention (`.gable()`, `.opening()`, `.inner_groove()`; `.orient()` is standalone-`.build()`-only, not for pipeline scripts).
---
## Corner Conditions
### L-Corner (external corner)
One wall panel face butts into the exterior face of the perpendicular wall's end panel. A double block spline fills the interior gap. The corner panel sits flush with the exterior face.
```
Plan view (top down):
┌──────────────────┐
│ Wall A panels │
│ ├──┐
│ │ │ Wall B
└──────────────────┘ │ panels
↑ │
double spline here │
```
- Wall A runs full length to the outer face
- Wall B's end panel butts to Wall A's exterior OSB face
- A double block spline (2× 45×90mm) fills the corner pocket on Wall B's end
### T-Corner (interior partition meeting exterior wall)
Interior wall panel butts into the face of the exterior wall panel. Single block spline at junction. Interior wall bottom plate runs to exterior wall face.
---
## Wall Openings — Windows and Doors
### Construction Principle — Full-Depth Timber Buck
In SIP construction, every window and door opening is framed with a **full-depth timber buck**: a structural timber frame whose depth equals the full wall thickness (`TOTAL_THICKNESS`). The SIP panels stop at the face of the king stud and butt flush against solid timber across the entire wall section. There is no EPS foam in the framing zone — it is entirely replaced by engineered timber.
```
Plan view (top-down cross-section through wall at opening):
←─── SIP panel (left) ───→ ←── FRAME_ZONE_W ──────────────→ ←── SIP panel (right) ───→
[OSB][ EPS core ][OSB] [KNG][TRM][ clear void ][TRM][KNG] [OSB][ EPS core ][OSB]
←── TOTAL_THICKNESS ──→ ←──── TOTAL_THICKNESS each member ────→ ←── TOTAL_THICKNESS ──→
```
This is why king studs must be **45 × TOTAL_THICKNESS** in cross-section — not 45 × 90mm. A 90mm-deep king stud leaves the SIP panel foam unsupported on either side, creates a thermal bridge gap, and provides no bearing surface for the panel OSB faces.
### Opening Coordinate System
`OPENING_X` is always **the left face of the left king stud** — the X position where SIP panels stop.
```
X: 0 ─────── [panels] ──── OPENING_X
├── king_left (KING_W = 45mm wide)
├── trimmer_left (TRIMMER_W = 45mm wide)
├── [clear void] (CLEAR_W wide)
├── trimmer_right
├── king_right
OPENING_X + FRAME_ZONE_W ──── [panels] ──── WALL_LENGTH
```
### Sizing Calculations
```python
KING_W = 45 # king stud face width (X)
KING_D = TOTAL_THICKNESS # king stud FULL WALL DEPTH (Y) — not 90mm
TRIMMER_W = 45 # trimmer stud face width (X)
TRIMMER_D = TOTAL_THICKNESS # trimmer stud full depth (Y)
SILL_H = 90 # sill plate height for windows (Z)
CLEAR_W = FRAME_W # clear glazing/door width inside frame
CLEAR_H = FRAME_H # clear glazing/door height inside frame
RO_W = CLEAR_W + 2 * TRIMMER_W # rough opening width (trimmer outer faces)
HEADER_SPAN = RO_W + 2 * KING_W # king-to-king span = total framing width
FRAME_ZONE_W = HEADER_SPAN # X width replaced by framing (no SIP panels here)
HEADER_DEPTH = max(150, RO_W // 10) # LVL header depth (Z); min 150mm
# OPENING_X must produce a clean left-panel width: ideally a multiple of 1200mm
# or leave a remainder panel ≥ 300mm. Align to nearest panel seam where possible.
OPENING_X = 1200 # example: one full 1200mm panel to the left, then the frame
# Vertical positions
if IS_DOOR:
CLEAR_BASE_Z = BOTTOM_PLATE_H # door clear void starts at bottom plate top
TRIMMER_H = CLEAR_H # trimmers height = clear door height
else:
CLEAR_BASE_Z = BOTTOM_PLATE_H + SILL_H # window void starts above sill
TRIMMER_H = SILL_H + CLEAR_H # trimmers from bottom plate to header
HEADER_BASE_Z = BOTTOM_PLATE_H + TRIMMER_H # Z of header underside
```
### Framing Member Summary
| Member | Width (X) | Depth (Y) | Height (Z) | Notes |
|---|---|---|---|---|
| King stud | 45mm | **TOTAL_THICKNESS** | PANEL_HEIGHT | Full height, full depth — primary load path |
| Trimmer stud | 45mm | **TOTAL_THICKNESS** | TRIMMER_H | Bears on bottom plate; carries header |
| LVL header | HEADER_SPAN | **TOTAL_THICKNESS** | HEADER_DEPTH | Spans king-to-king; min depth 150mm |
| Window sill | CLEAR_W | **TOTAL_THICKNESS** | SILL_H | Windows only; sits on trimmer tops at base of void |
| Cripple SIP | CLEAR_W | TOTAL_THICKNESS | cripple_h | Short SIP section above header to top plate |
### Panel Zone Helper and Opening Layout
Before building panels, divide the wall into solid SIP zones and framing zones. Panels must never be placed where the frame is — they stop at the king stud face.
```python
def make_panel_zone(x_start, zone_length):
"""Build SIP panels + block splines filling zone_length starting at x_start."""
zone_parts = []
if zone_length <= 0:
return zone_parts
x = x_start
full = int(zone_length) // 1200
rem = int(zone_length) % 1200
widths = [1200] * full + ([rem] if rem > 0 else [])
for i, pw in enumerate(widths):
f1 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, 0, BOTTOM_PLATE_H))
co = Part.makeBox(pw, CORE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS, BOTTOM_PLATE_H))
f2 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS+CORE_THICKNESS, BOTTOM_PLATE_H))
zone_parts.extend([f1, co, f2])
x += pw
if i < len(widths) - 1:
sp = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x - 22, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
zone_parts.append(sp)
return zone_parts
# Single opening — panel zones on each side
parts.extend(make_panel_zone(0, OPENING_X)) # left of frame
parts.extend(make_panel_zone(OPENING_X + FRAME_ZONE_W, # right of frame
WALL_LENGTH - OPENING_X - FRAME_ZONE_W))
```
### Complete Wall-with-Opening Code
```python
import FreeCAD, Part
from FreeCAD import Vector
# === PARAMETERS ===
WALL_LENGTH = 4800
PANEL_HEIGHT = 2700
CORE_THICKNESS = 150
FACE_THICKNESS = 11
TOTAL_THICKNESS = CORE_THICKNESS + 2 * FACE_THICKNESS # 172mm for SIP-150
BOTTOM_PLATE_H = 90
TOP_PLATE_H = 180
IS_DOOR = False
FRAME_W = 1000 # clear opening width (inside frame rebates)
FRAME_H = 1200 # clear opening height
KING_W = 45
KING_D = TOTAL_THICKNESS # FULL WALL DEPTH
TRIMMER_W = 45
TRIMMER_D = TOTAL_THICKNESS # FULL WALL DEPTH
SILL_H = 90
CLEAR_W = FRAME_W
CLEAR_H = FRAME_H
RO_W = CLEAR_W + 2 * TRIMMER_W
HEADER_SPAN = RO_W + 2 * KING_W
FRAME_ZONE_W = HEADER_SPAN
HEADER_DEPTH = max(150, RO_W // 10)
# OPENING_X = left face of left king stud (where left panels end)
# Align to a panel seam: 1200, 2400, 3600, etc.
OPENING_X = 1200
if IS_DOOR:
CLEAR_BASE_Z = BOTTOM_PLATE_H
TRIMMER_H = CLEAR_H
else:
CLEAR_BASE_Z = BOTTOM_PLATE_H + SILL_H
TRIMMER_H = SILL_H + CLEAR_H
HEADER_BASE_Z = BOTTOM_PLATE_H + TRIMMER_H
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("WallWithOpening")
parts = []
# === STEP 1: Bottom plate — full wall length (unbroken, even under opening) ===
# CORE_THICKNESS depth only — slots into the SIP foam channel, offset by FACE_THICKNESS.
bp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, BOTTOM_PLATE_H, Vector(0, FACE_THICKNESS, 0))
parts.append(bp)
# === STEP 2: SIP panel zones — stop at king stud faces ===
def make_panel_zone(x_start, zone_length):
zone_parts = []
if zone_length <= 0:
return zone_parts
x = x_start
full = int(zone_length) // 1200
rem = int(zone_length) % 1200
widths = [1200] * full + ([rem] if rem > 0 else [])
for i, pw in enumerate(widths):
f1 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, 0, BOTTOM_PLATE_H))
co = Part.makeBox(pw, CORE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS, BOTTOM_PLATE_H))
f2 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS + CORE_THICKNESS, BOTTOM_PLATE_H))
zone_parts.extend([f1, co, f2])
x += pw
if i < len(widths) - 1:
sp = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x - 22, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
zone_parts.append(sp)
return zone_parts
parts.extend(make_panel_zone(0, OPENING_X))
parts.extend(make_panel_zone(OPENING_X + FRAME_ZONE_W,
WALL_LENGTH - OPENING_X - FRAME_ZONE_W))
# === STEP 3: Timber buck framing (full TOTAL_THICKNESS depth throughout) ===
# King studs — full wall depth, full panel height, one each side
king_left = Part.makeBox(KING_W, KING_D, PANEL_HEIGHT,
Vector(OPENING_X, 0, BOTTOM_PLATE_H))
king_right = Part.makeBox(KING_W, KING_D, PANEL_HEIGHT,
Vector(OPENING_X + FRAME_ZONE_W - KING_W, 0, BOTTOM_PLATE_H))
parts.extend([king_left, king_right])
# Trimmer studs — full wall depth, height from bottom plate to header underside
trimmer_left = Part.makeBox(TRIMMER_W, TRIMMER_D, TRIMMER_H,
Vector(OPENING_X + KING_W, 0, BOTTOM_PLATE_H))
trimmer_right = Part.makeBox(TRIMMER_W, TRIMMER_D, TRIMMER_H,
Vector(OPENING_X + FRAME_ZONE_W - KING_W - TRIMMER_W, 0, BOTTOM_PLATE_H))
parts.extend([trimmer_left, trimmer_right])
# LVL header — full wall depth, spans king-to-king
header = Part.makeBox(HEADER_SPAN, TOTAL_THICKNESS, HEADER_DEPTH,
Vector(OPENING_X, 0, HEADER_BASE_Z))
parts.append(header)
if not IS_DOOR:
# Window sill — full wall depth, sits on trimmers at base of clear void
sill = Part.makeBox(CLEAR_W, TOTAL_THICKNESS, SILL_H,
Vector(OPENING_X + KING_W + TRIMMER_W, 0, BOTTOM_PLATE_H))
parts.append(sill)
# Cripple SIP panels above header — short panels between header top and top plate
cripple_h = PANEL_HEIGHT - TRIMMER_H - HEADER_DEPTH
if cripple_h > 40:
cx = OPENING_X + KING_W + TRIMMER_W
cz = HEADER_BASE_Z + HEADER_DEPTH
cp_f1 = Part.makeBox(CLEAR_W, FACE_THICKNESS, cripple_h, Vector(cx, 0, cz))
cp_co = Part.makeBox(CLEAR_W, CORE_THICKNESS, cripple_h, Vector(cx, FACE_THICKNESS, cz))
cp_f2 = Part.makeBox(CLEAR_W, FACE_THICKNESS, cripple_h, Vector(cx, FACE_THICKNESS + CORE_THICKNESS, cz))
parts.extend([cp_f1, cp_co, cp_f2])
# === STEP 4: Double top plate — full wall length (unbroken) ===
# CORE_THICKNESS depth only — slots into the SIP foam channel, offset by FACE_THICKNESS.
tp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, TOP_PLATE_H,
Vector(0, FACE_THICKNESS, BOTTOM_PLATE_H + PANEL_HEIGHT))
parts.append(tp)
# === STEP 5: Fuse everything into one wall solid ===
wall = parts[0]
for p in parts[1:]:
wall = wall.fuse(p)
feature = doc.addObject("Part::Feature", "WallWithOpening")
feature.Shape = wall
# === STEP 6: Window or door unit — separate feature, not fused to wall ===
# The clear void between inner trimmer faces is the visible opening.
# Add the unit as a separate Part::Feature so it can be coloured/selected independently.
UNIT_FRAME_T = 65 # window or door frame depth (sits centred in wall depth)
if IS_DOOR:
DOOR_LEAF_T = 44
door_frame = Part.makeBox(CLEAR_W, UNIT_FRAME_T, CLEAR_H,
Vector(OPENING_X + KING_W + TRIMMER_W,
(TOTAL_THICKNESS - UNIT_FRAME_T) / 2,
CLEAR_BASE_Z))
door_leaf = Part.makeBox(CLEAR_W - 10, DOOR_LEAF_T, CLEAR_H - 15,
Vector(OPENING_X + KING_W + TRIMMER_W + 5,
(TOTAL_THICKNESS - DOOR_LEAF_T) / 2,
CLEAR_BASE_Z + 10))
unit = door_frame.fuse(door_leaf)
else:
GLASS_T = 28
win_frame = Part.makeBox(CLEAR_W, UNIT_FRAME_T, CLEAR_H,
Vector(OPENING_X + KING_W + TRIMMER_W,
(TOTAL_THICKNESS - UNIT_FRAME_T) / 2,
CLEAR_BASE_Z))
glazing = Part.makeBox(CLEAR_W - 40, GLASS_T, CLEAR_H - 40,
Vector(OPENING_X + KING_W + TRIMMER_W + 20,
(TOTAL_THICKNESS - GLASS_T) / 2,
CLEAR_BASE_Z + 20))
unit = win_frame.fuse(glazing)
unit_obj = doc.addObject("Part::Feature", "DoorUnit" if IS_DOOR else "WindowUnit")
unit_obj.Shape = unit
doc.recompute()
if FreeCAD.GuiUp:
FreeCAD.Gui.ActiveDocument.ActiveView.fitAll()
```
### Multiple Openings in One Wall
Plan all `OPENING_X` positions before building panels. Panels fill every gap between framing zones.
```python
# Define all openings as (opening_x, frame_zone_w, is_door, clear_w, clear_h)
# OPENING_X values must be sorted and non-overlapping
openings = [
{"x": 1200, "fzw": DOOR_FRAME_ZONE_W, "is_door": True, "cw": 900, "ch": 2100},
{"x": 3000, "fzw": WIN_FRAME_ZONE_W, "is_door": False, "cw": 1000, "ch": 1200},
]
# Build panel zones around all opening zones
solid_zones = []
prev_end = 0
for o in sorted(openings, key=lambda o: o["x"]):
if o["x"] > prev_end:
solid_zones.append((prev_end, o["x"] - prev_end))
prev_end = o["x"] + o["fzw"]
if prev_end < WALL_LENGTH:
solid_zones.append((prev_end, WALL_LENGTH - prev_end))
for x_start, zone_len in solid_zones:
parts.extend(make_panel_zone(x_start, zone_len))
# Add framing for each opening
for o in openings:
ox = o["x"]
fzw = o["fzw"]
# ... king studs, trimmers, header, sill per opening using ox as OPENING_X
```
### OPENING_X Alignment Guide
Choose `OPENING_X` so the panel zones on each side are buildable widths (≥ 300mm, ideally multiples of 1200mm):
| Left zone target | OPENING_X | Left zone actual | Notes |
|---|---|---|---|
| 1 full panel | 1200 | 1200mm | Clean seam |
| 2 full panels | 2400 | 2400mm | Clean seam |
| 1.5 panels | 1800 | 600mm + 1200mm | 600mm remainder — acceptable |
| Centred in 4800mm wall, 1090mm frame | 1855 | 1855mm = 1200+655 | 655mm remainder — acceptable |
---
## Pitched Roof Slopes — ALWAYS use `PitchedRoofSlope`
**Do not write manual bevel geometry.** The `PitchedRoofSlope` builder handles ridge-bevel and eave-bevel cuts internally. Manual geometry produces arithmetic errors and bypasses the constructability pipeline (which segments into 1220mm-wide buildable panels via `.construct()`).
```python
from elixifree.domains.sip import PitchedRoofSlope
# === PARAMETERS — read every value from the goal string ===
BUILDING_WIDTH = 6000 # ridge direction (X)
HALF_SPAN = 2000 # = BUILDING_DEPTH/2 (to centreline; NO +PT)
PITCH_DEG = 20
STOCK = "SIP-150"
slope = PitchedRoofSlope(
ridge_length=BUILDING_WIDTH,
half_span=HALF_SPAN,
pitch_degrees=PITCH_DEG,
stock=STOCK,
is_south=True, # True = south/front slope; False = north/back slope
)
result = slope.build()
result.add_to_doc("Body")
# DO NOT set feature.Placement — the assembly layer (Placer) applies pitch rotation
```
**`half_span` = `BUILDING_DEPTH/2`** (to the centreline) — never add `PANEL_THICKNESS`. The eave is placed at the wall outer face by the assembly layer, so both undersides meet on the centreline. Adding `+ PT` clashes the two slopes by `2×PT` at the ridge. See `docs/design/SIP_ROOF_STANDARDS.md`.
**North slope:** change `is_south=False`. Same geometry, different assembly rotation applied by Placer.
### Gable End Walls — use `Wall.gable()`
East and west gable end walls must have a triangular section above eave height. Use `.gable()` — do not hand-code the triangular prism:
```python
from elixifree.domains.sip import Wall
# Gable end walls BUTT between the eave walls' inner faces (corner rule, no exception) —
# span is NOT the full BUILDING_DEPTH.
GABLE_SPAN = BUILDING_DEPTH - 2 * PANEL_THICKNESS
result = (Wall(span=GABLE_SPAN, height=EAVE_HEIGHT, stock=STOCK)
.gable(ridge_height=RIDGE_Z)
.build()) # default local frame — do NOT call .orient("Y"); Placer sets world rotation
result.add_to_doc("Body")
# Do NOT set feature.Placement — the assembly layer (Placer) owns world position
```
See `sip_roofs.md` for flat roof, ridge board, and span-splitting details.
### Flat Roof
A flat roof uses horizontal SIP panels resting directly on the wall top plates. Use SIP-200 or SIP-250 for flat roofs — the additional insulation compensates for the reduced stack effect and improves thermal performance at the cold deck.
**Drainage fall is not optional — a truly flat surface ponds water and will fail.** The 2–5° fall is created by a **tapered top plate** (a level-underside, sloped-top wedge) on the fall-run eave walls; the flat SIP deck rests on it and sits tilted at the fall. The deck itself is never cut into a taper, and there is NO tapered insulation. Wall-height coordination is preserved because the **walls stay level-topped** — only the plate is wedged. The membrane follows the tilted deck; it does not add its own taper.
**Fall angle:**
- **2°** — minimum design fall. Ensures drainage even after some deflection occurs in service.
- **5°** — upper end of the design range for a flat roof; beyond this the roof reads as a shallow mono-pitch, not a flat roof.
**Use a fall angle in the 2–5° range in generated models, borne entirely by the tapered top plate — never by tilting the deck or tapering the insulation.**
#### Flat Roof Elements
1. **SIP roof panels** — horizontal, spanning the shorter building dimension. Multiple 1200mm-wide panels joined by splines, same panel-loop pattern as walls. The deck is ALWAYS a flat, uniform-thickness panel — it never tapers. The 2–5° fall comes from the tapered top plate underneath it (constructability layer), not from cutting the deck.
2. **Insulation board** — a flat, uniform-thickness layer on top of the deck. It never tapers and never carries the fall — the fall is structural (tapered top plate), not an insulation build-up.
3. **Waterproof membrane** — EPDM or TPO, 3mm shell on top of the insulation. It follows the tilted deck's slope as a uniform-thickness layer; it is never itself cut into a wedge.
4. **Parapet walls** — short SIP-100 panels extending above roof level on all four sides. The interior face of the parapet is lined with the membrane upstand.
5. **Scupper outlets through the parapet** — rectangular holes cut through the parapet panel at the low side of the fall. These are the only way water can leave a parapeted roof. Without scuppers the roof will flood.
6. **Overflow scuppers** — a second set of scupper holes 50mm higher than the primary, as a backup if the primary blocks.
7. **Downpipe stubs** — short cylinder representing the external downpipe at each primary scupper.
8. **Flat roof bearer (optional)** — for spans > 3000mm, a mid-span LVL bearer under the panels reduces deflection.
#### Parapet vs. Open Eave
| Detail | Parapet | Open Eave / Fascia |
|--------------|----------------------------------|--------------------------------|
| Look | Modern, clean — walls continue past roof | Reveals roof edge from below |
| Water | Contained; internal drainage | Drains freely at edge |
| Model | Extend wall panels up by PARAPET_H above roof | Fascia board at panel end |
| Default | **Use this for modern flat roofs** | Use for agricultural/industrial |
**NON-NEGOTIABLE flat roof placement rules:**
1. The SIP roof deck bottom face sits at `Z = WALL_HEIGHT` (top of eave walls). A flat roof placed at `Z = 0` is on the ground — not the roof.
2. The roof deck spans `INNER_WIDTH × INNER_DEPTH` (between wall inner faces). Do NOT make it `BUILDING_WIDTH × BUILDING_DEPTH`.
3. `ROOF_Z = WALL_HEIGHT` always. Derive everything from this.
4. Do NOT model the roof as a single `Part.makeBox` — use the panel loop below so panel joints and splines are visible.
5. The deck and insulation are flat, uniform-thickness layers — NEVER a polygon-face wedge. The 2–5° drainage fall comes from the tapered top plate under the deck (constructability layer), not from tapering the deck or insulation.
#### Flat Roof Code
```python
import FreeCAD, Part, math
from FreeCAD import Vector
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("FlatRoof")
# === PARAMETERS ===
BUILDING_WIDTH = 3000 # outer face to outer face (X)
BUILDING_DEPTH = 2000 # outer face to outer face (Y)
WALL_HEIGHT = 2970 # BOTTOM_PLATE_H + PANEL_HEIGHT + TOP_PLATE_H
PANEL_THICKNESS = 172 # wall SIP total thickness
ROOF_CORE = 200 # SIP-200 for flat roofs
ROOF_FACE = 11
ROOF_THICKNESS = ROOF_CORE + 2 * ROOF_FACE # 222mm
ROOF_PANEL_W = 1200
INSUL_T = 50 # insulation thickness (mm) — flat, uniform, NEVER tapered
MEMBRANE_T = 3 # EPDM/TPO membrane — flat, uniform, follows the deck's tilt
PARAPET_H = 300 # parapet height above ROOF_Z
PARAPET_T = 122 # SIP-100 parapet panels
COPING_T = 3 # aluminium coping
# === DRAINAGE GEOMETRY ===
# The 2–5° drainage fall is NOT modelled here. It is borne by a tapered top
# plate on the fall-run eave walls (level underside, sloped top face) that the
# constructability layer adds automatically — the SAME Fig 1.4.7 angled
# eave-plate mechanism used for pitched eaves, at a shallower angle. This
# script builds the deck, insulation, and membrane as flat, level, uniform-
# thickness layers; the tapered plate underneath is what tilts them to fall.
# Fall direction (for scupper placement only): low side at Y = PANEL_THICKNESS
# (front/south) — scuppers go on this side.
INNER_WIDTH = BUILDING_WIDTH - 2 * PANEL_THICKNESS
INNER_DEPTH = BUILDING_DEPTH - 2 * PANEL_THICKNESS
ROOF_Z = WALL_HEIGHT # bottom face of SIP roof panels (level; plate tilts it)
insul_z_base = ROOF_Z + ROOF_THICKNESS # top of roof deck = base of insulation
# === STEP 1: SIP Roof Deck (multiple panels — same loop as walls) ===
full_panels = INNER_DEPTH // ROOF_PANEL_W
remainder = INNER_DEPTH % ROOF_PANEL_W
panel_widths_roof = [ROOF_PANEL_W] * full_panels + ([remainder] if remainder > 0 else [])
roof_parts = []
y = PANEL_THICKNESS
for i, pw in enumerate(panel_widths_roof):
f1 = Part.makeBox(INNER_WIDTH, pw, ROOF_FACE,
Vector(PANEL_THICKNESS, y, ROOF_Z))
co = Part.makeBox(INNER_WIDTH, pw, ROOF_CORE,
Vector(PANEL_THICKNESS, y, ROOF_Z + ROOF_FACE))
f2 = Part.makeBox(INNER_WIDTH, pw, ROOF_FACE,
Vector(PANEL_THICKNESS, y, ROOF_Z + ROOF_FACE + ROOF_CORE))
roof_parts.append(f1.fuse(co).fuse(f2))
if i < len(panel_widths_roof) - 1:
sp = Part.makeBox(INNER_WIDTH, 45, 90,
Vector(PANEL_THICKNESS, y + pw - 22.5,
ROOF_Z + (ROOF_THICKNESS - 90) / 2))
roof_parts.append(sp)
y += pw
roof_deck = roof_parts[0]
for p in roof_parts[1:]:
roof_deck = roof_deck.fuse(p)
# === STEP 2: Insulation — flat, uniform-thickness box. NEVER a wedge/taper. ===
# The 2-5 deg fall is carried entirely by the tapered top plate under the deck
# (constructability layer); the insulation itself is always uniform thickness.
insulation = Part.makeBox(
INNER_WIDTH, INNER_DEPTH, INSUL_T,
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z_base)
)
# === STEP 3: Membrane — flat, uniform-thickness shell on top of the insulation. ===
# NEVER a wedge — it follows the tilted deck as a uniform layer, it does not add its own taper.
membrane = Part.makeBox(
INNER_WIDTH, INNER_DEPTH, MEMBRANE_T,
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z_base + INSUL_T)
)
# === STEP 4: Parapet walls (four sides) ===
PARAPET_Z = ROOF_Z # parapets start at same Z as roof panels (wall top)
PARAPET_TOP_Z = PARAPET_Z + PARAPET_H
par_s = Part.makeBox(BUILDING_WIDTH, PARAPET_T, PARAPET_H,
Vector(0, 0, PARAPET_Z))
par_n = Part.makeBox(BUILDING_WIDTH, PARAPET_T, PARAPET_H,
Vector(0, BUILDING_DEPTH - PARAPET_T, PARAPET_Z))
par_w = Part.makeBox(PARAPET_T, BUILDING_DEPTH - 2 * PARAPET_T, PARAPET_H,
Vector(0, PARAPET_T, PARAPET_Z))
par_e = Part.makeBox(PARAPET_T, BUILDING_DEPTH - 2 * PARAPET_T, PARAPET_H,
Vector(BUILDING_WIDTH - PARAPET_T, PARAPET_T, PARAPET_Z))
# === STEP 5: Scuppers through south parapet at low side — MANDATORY ===
# Primary scupper: centred on south parapet, at membrane level
SCUPPER_W = 100 # opening width
SCUPPER_H = 75 # opening height
SCUPPER_Z = insul_z_base + INSUL_T - 10 # just below membrane surface (low side of the fall)
scupper_primary = Part.makeBox(
SCUPPER_W, PARAPET_T, SCUPPER_H,
Vector(BUILDING_WIDTH / 2 - SCUPPER_W / 2, 0, SCUPPER_Z)
)
par_s = par_s.cut(scupper_primary)
# Overflow scupper: offset to one side, 50mm higher (backup if primary blocks)
scupper_overflow = Part.makeBox(
SCUPPER_W, PARAPET_T, SCUPPER_H,
Vector(BUILDING_WIDTH / 4 - SCUPPER_W / 2, 0, SCUPPER_Z + 50)
)
par_s = par_s.cut(scupper_overflow)
# === STEP 6: Downpipe stub at primary scupper (exterior face) ===
DOWNPIPE_R = 50 # 100mm dia downpipe
downpipe = Part.makeCylinder(
DOWNPIPE_R, 400,
Vector(BUILDING_WIDTH / 2, -400, SCUPPER_Z + SCUPPER_H / 2 - DOWNPIPE_R)
)
# === STEP 7: Aluminium coping cap on parapet top ===
cope_s = Part.makeBox(BUILDING_WIDTH, PARAPET_T + 50, COPING_T, Vector(0, -25, PARAPET_TOP_Z))
cope_n = Part.makeBox(BUILDING_WIDTH, PARAPET_T + 50, COPING_T, Vector(0, BUILDING_DEPTH - PARAPET_T - 25, PARAPET_TOP_Z))
cope_w = Part.makeBox(PARAPET_T + 50, BUILDING_DEPTH, COPING_T, Vector(-25, 0, PARAPET_TOP_Z))
cope_e = Part.makeBox(PARAPET_T + 50, BUILDING_DEPTH, COPING_T, Vector(BUILDING_WIDTH - PARAPET_T - 25, 0, PARAPET_TOP_Z))
# === ADD TO DOCUMENT ===
for shape, name in [
(roof_deck, "RoofDeck"),
(insulation, "Insulation"),
(membrane, "Membrane"),
(par_s, "ParapetSouth"),
(par_n, "ParapetNorth"),
(par_w, "ParapetWest"),
(par_e, "ParapetEast"),
(downpipe, "Downpipe"),
(cope_s, "CopingSouth"),
(cope_n, "CopingNorth"),
(cope_w, "CopingWest"),
(cope_e, "CopingEast"),
]:
obj = doc.addObject("Part::Feature", name)
obj.Shape = shape
doc.recompute()
if FreeCAD.GuiUp:
FreeCAD.Gui.ActiveDocument.ActiveView.fitAll()
```
---
### Roof Eave Blocking
At the eave, the panel foam end is exposed. Solid timber blocking closes the gap and provides a nailing surface for fascia.
```python
EAVE_BLOCK_H = TOTAL_THICKNESS
eave_block = Part.makeBox(BUILDING_DEPTH, EAVE_BLOCK_H, 90,
Vector(0, 0, EAVE_HEIGHT - 90))
```
---
## Building Assembly — Coordinate System
**This section is the primary reference for placing all components correctly. Errors in assembly positioning are the most common source of misaligned 3D models.**
### Reference Origin
```
Origin (0, 0, 0) = the SW corner of the building footprint at Z = 0 (top of slab / floor level).
X-axis = East (along building width)
Y-axis = North (along building depth)
Z-axis = Up
```
### Slab and Footprint
The slab extends `SLAB_OVERHANG` beyond the building footprint on all four sides. The building footprint is the outer face of the wall panels.
```python
BUILDING_WIDTH = 3000 # outer face to outer face (X)
BUILDING_DEPTH = 2000 # outer face to outer face (Y)
PANEL_THICKNESS = 172 # TOTAL_THICKNESS
SLAB_OVERHANG = 200 # slab extends this far past wall face on all sides
SLAB_T = 125
SLAB_W = BUILDING_WIDTH + 2 * SLAB_OVERHANG
SLAB_D = BUILDING_DEPTH + 2 * SLAB_OVERHANG
# Slab origin: SW corner of slab at slab bottom
slab_origin = Vector(-SLAB_OVERHANG, -SLAB_OVERHANG, -SLAB_T)
slab = Part.makeBox(SLAB_W, SLAB_D, SLAB_T, slab_origin)
# Slab top surface is at Z = 0
```
### Wall Positions
For a duo-pitch gable roof with the ridge running along BUILDING_WIDTH (X):
- **EAVE walls** — south (Y=0) and north (Y=BUILDING_DEPTH): rectangular, height = EAVE_HEIGHT. The ridge runs above their centrelines.
- **GABLE END walls** — east (X=BUILDING_WIDTH) and west (X=0): triangular top. These walls are perpendicular to the ridge and must include the gable triangle section above EAVE_HEIGHT (see rule 9 and the Gable End Wall section above).
**Wall inner-face-at-origin convention:** `Wall.build()` shifts geometry so the INNER face is at local Y=0 and the OUTER face is at Y=−PT. The assembly `position` is placed at the inner face world coordinate.
```
orient("X") default: X=[0, span], Y=[-PT, 0], Z=[0, height] ← inner face at Y=0
orient("Y"): X=[0, PT], Y=[0, span], Z=[0, height] ← inner face at X=0
```
**Assembly position formula:** `pos = outer_face_world_pos + PT`
| Wall | Outer face world pos | Assembly position (inner face) |
|------|---------------------|-------------------------------|
| South (orient X) | Y=0 | pos=(0, PT, 0) |
| North (orient X) | Y=BUILDING_DEPTH | pos=(0, BUILDING_DEPTH+PT, 0) |
| West (orient Y) | X=0 | pos=(0, PT, 0) — span starts at south inner face |
| East (orient Y) | X=BUILDING_WIDTH | pos=(BUILDING_WIDTH-PT, PT, 0) — same Y offset |
**Corner rule (locked, no exception): eave walls run through outer-to-outer; gable end walls BUTT between the eave inner faces.** Gable end walls (east/west) span `BUILDING_DEPTH - 2*PANEL_THICKNESS` — **NOT** the full `BUILDING_DEPTH` and **NOT** `BUILDING_DEPTH - PT`. The roof does NOT bear on the gable rake (it bears on the eave walls via the angled eave top plate, Fig 1.4.7); the gable's `.gable()` triangle is non-bearing infill, so it no longer needs to reach the roof slope underside at the corners. A full-depth gable span overlaps the eave wall bodies at both corners by PT — that overlap is the bug this rule corrects, not an intentional feature. This holds regardless of which wall pair is longer.
```python
PT = PANEL_THICKNESS # e.g. 142 for SIP-120
GABLE_SPAN = BUILDING_DEPTH - 2 * PT # butt-adjusted — passed to Wall(span=GABLE_SPAN, ...)
```
Every wall/partition/roof-slope/foundation component script builds in its
default local frame and calls only `result.add_to_doc("Body")` — **no**
`feature.Placement`, **no** `.orient()`. The assembly Placer computes and
applies the correct world position AND rotation for every component
deterministically; see the status note at the top of this file and
`sip_walls_elixifree.md` for the current pipeline convention.
```python
from elixifree.domains.sip import Wall
result = Wall(span=BUILDING_WIDTH, height=WALL_HEIGHT, stock=STOCK).build()
result.add_to_doc("Body")
# No feature.Placement, no .orient() — the Placer owns world position and rotation
```
### Foundation Position
The Foundation builder's local origin is the **top face** — the strip geometry extends DOWN from local Z=0 to Z=−STRIP_DEPTH internally. Set `position.z = 0` to land the top face at world Z=0 (floor level):
```
position = { "x": -PT_WALL, "y": -PT_WALL, "z": 0 }
dimensions = { "length": BUILDING_WIDTH + 2*PT_WALL, "width": BUILDING_DEPTH + 2*PT_WALL, "height": STRIP_DEPTH }
```
- `position.z = 0` — MANDATORY. Using `z = -STRIP_DEPTH` DOUBLES the internal offset and buries the foundation top at world Z=−STRIP_DEPTH, leaving the walls floating above it.
- `position.x = -PT_WALL`, `position.y = -PT_WALL` — the strip extends PT_WALL beyond the building footprint on all sides so it underlies the full SIP wall thickness.
### Interior Partition Walls
Interior partitions are timber stud walls (100mm thick), not SIP panels. They always sit at `position.z = 0` (on the floor slab).
**East-west partition** (running along X, dividing north-south):
```
position.y = PT_WALL + offset_from_south_inner_face # distance from south inner face
position.x = PT_WALL # start at west inner face
span = BUILDING_WIDTH - 2*PT_WALL # between east and west inner faces
```
**North-south partition** (running along Y, dividing east-west):
```
position.x = PT_WALL + offset_from_west_inner_face # distance from west inner face
position.y = PT_WALL # start at south inner face
span = BUILDING_DEPTH - 2*PT_WALL # between south and north inner faces
```
**NEVER** place a partition at `x=0` or `y=0` — those coordinates are inside the exterior wall. The inner face of the south/north walls is at `y=PT_WALL`; the inner face of the east/west walls is at `x=PT_WALL` and `x=BUILDING_WIDTH-PT_WALL`.
Example for a building 9000×7200mm, PT=142mm, with a bedroom partition 2800mm from the south inner face:
```
position = { "x": 142, "y": 142 + 2800, "z": 0 } → y = 2942
span = 9000 - 2*142 = 8716mm
```
### Roof Slope Positions (Gable Roof, Ridge Along X)
**The assembly layer (Placer) owns roof slope placement and rotation — you do not compute pivot offsets.** The slope is built flat with `half_span = BUILDING_DEPTH/2` (to the centreline). The Placer rotates it about its local eave origin (Y=0) by `roll = +pitch` (north slope: `yaw = 180` first) and snaps the eave to the wall **outer** face:
```
south slope: position = (0, 0, EAVE_HEIGHT) # outer face y=0
north slope: position = (0, BUILDING_DEPTH, EAVE_HEIGHT) # outer face y=depth
```
Because `half_span = BUILDING_DEPTH/2` and the eave sits at the outer face, both slope undersides meet exactly at the centreline `y = BUILDING_DEPTH/2`. The Placer applies the eave snap deterministically, so even if the design spec emits an inner-face `y` the slopes still close at the ridge. See `docs/design/SIP_ROOF_STANDARDS.md` §2.4.
**Do NOT emit an inner-face roof position** (e.g. `y = PT_WALL + PT_PANEL·sin(pitch)`). That was the old, wrong convention — it pushes each underside `PT` past the centreline and clashes the slopes by `2×PT`.
**Ridge symmetry check:** `south_pos_y + slope_len·cos(pitch)` and `north_pos_y − slope_len·cos(pitch)` must both equal `BUILDING_DEPTH/2`.
### Quick Assembly Checklist
Before generating any component, confirm these values are consistent across all agents:
| Variable | Where defined | Consumed by |
|------------------------|---------------|------------------------------|
| `PANEL_THICKNESS` | foundation | all walls, roof positioning |
| `BUILDING_WIDTH` | foundation | all walls, roof span |
| `BUILDING_DEPTH` | foundation | all walls, roof span |
| `WALL_HEIGHT` | any wall | roof eave height |
| `EAVE_HEIGHT` | derived | both roof slopes |
| `RIDGE_Z` | derived | ridge beam |
| `SLAB_OVERHANG` | foundation | slab dimensions only |
---
## Siding / External Cladding
Siding attaches to the exterior OSB face via battens. The foam core must never be the structural anchor for fixings.
### Layer Order (outside in)
1. Cladding (timber, fibre cement, or metal profile)
2. Battens — 50 × 25mm timber, screwed into OSB at 600mm centres
3. House wrap (Tyvek or similar) — model as a 3mm shell or omit for clarity
4. OSB exterior face (part of SIP panel)
### Batten Geometry
```python
BATTEN_W = 50
BATTEN_D = 25
BATTEN_SPACING = 600 # centre to centre
WALL_HEIGHT_TOTAL = BOTTOM_PLATE_H + PANEL_HEIGHT + TOP_PLATE_H
batten_count = WALL_LENGTH // BATTEN_SPACING
battens = []
for i in range(batten_count + 1):
x = min(i * BATTEN_SPACING, WALL_LENGTH - BATTEN_W)
b = Part.makeBox(BATTEN_W, BATTEN_D, WALL_HEIGHT_TOTAL,
Vector(x, -BATTEN_D, 0)) # sits proud of exterior OSB face
battens.append(b)
```
### Cladding Types
**Timber weatherboard** — 150–200mm wide boards, 25–30mm thick, horizontal lapped:
```python
BOARD_W = 175
BOARD_T = 25
LAP = 40 # overlap
exposed = BOARD_W - LAP
board_count = int(math.ceil(WALL_HEIGHT_TOTAL / exposed))
for i in range(board_count):
z = i * exposed
board = Part.makeBox(WALL_LENGTH, BOARD_T, BOARD_W,
Vector(0, -(BATTEN_D + BOARD_T), z))
# add to parts
```
**Fibre cement planks** — 200–300mm wide, 8–10mm thick, horizontal direct-fix or on battens. Same pattern as timber weatherboard with BOARD_T = 9.
**Metal profile (corrugated or standing seam)** — 0.7mm TCT steel, modelled as a flat shell for structural purposes:
```python
cladding_shell = Part.makeBox(WALL_LENGTH, 1, WALL_HEIGHT_TOTAL,
Vector(0, -(BATTEN_D + 1), 0))
```
---
## Foundations
See `sip_foundations.md` for full foundation code. Summary:
- **Slab-on-grade** — no ElixiFree builder; write raw `Part` geometry (slab + edge beams + anchor bolts). `SLAB_OVERHANG` ≥ 200mm; slab top = Z=0.
- **Strip foundation** — use `Foundation(..., type="strip_foundation").build()`. Do NOT write raw `Part.makeBox` fuse code.
- **Screw pile** — no ElixiFree builder; write raw `Part` geometry.
---
## Ventilation
SIP panels are extremely airtight — there is negligible incidental air leakage through the structure. Every SIP building **requires deliberately designed ventilation**. Without it, CO₂ accumulates, moisture condenses inside the panel structure, and the building becomes unhealthy. Ventilation is not optional; it is a building system component that must be modelled.
### Strategy by Building Size
| Floor area | Strategy | Minimum provisions |
|-------------------|-----------------------------------|---------------------------------------------------------|
| < 15 m² (garden room, studio) | **Passive through-wall vents** | 2 × 100mm wall vents (inlet low + exhaust high, opposite walls) + trickle vents in window frames |
| 15–50 m² (cabin, annexe) | **Passive + extract fan** | 4 × 100mm vents, 1 × bathroom/kitchen extract fan |
| > 50 m² (house) | **MVHR unit + ductwork** | Mechanical ventilation with heat recovery, supply + extract to each room |
### Vent Sizing Calculation
```python
import math
# === BUILDING DIMENSIONS ===
FLOOR_AREA_M2 = (BUILDING_WIDTH / 1000) * (BUILDING_DEPTH / 1000)
CEILING_H_M = (BOTTOM_PLATE_H + PANEL_HEIGHT) / 1000 # floor-to-underside-of-structure
ROOM_VOLUME_M3 = FLOOR_AREA_M2 * CEILING_H_M
# Minimum air change rate:
# 0.5 ACH — habitable room with openable windows
# 1.0 ACH — habitable room with no opening windows (sealed glazing)
ACH = 0.5
MIN_FLOW_M3H = ROOM_VOLUME_M3 * ACH # m³/hr
MIN_FLOW_LS = MIN_FLOW_M3H / 3.6 # litres/sec
# Minimum vent free area (assuming ~1.5 m/s air velocity at vent)
VENT_VELOCITY = 1.5 # m/s
AREA_NEEDED_M2 = (MIN_FLOW_M3H / 3600) / VENT_VELOCITY
AREA_NEEDED_MM2 = AREA_NEEDED_M2 * 1e6 # mm²
# Standard round duct free areas
AREA_100MM = math.pi * 50**2 # ~7,854 mm²
AREA_125MM = math.pi * 62.5**2 # ~12,272 mm²
AREA_150MM = math.pi * 75**2 # ~17,671 mm²
# Number of 100mm vents required (rounded up, minimum 2: one inlet + one exhaust)
vents_needed = max(2, math.ceil(AREA_NEEDED_MM2 / AREA_100MM))
# Example: 3×2m room → 6 m², vol 16.2 m³, flow 8.1 m³/hr → 1.5 mm² → 1 duct → use 2 (inlet + exhaust)
```
### Through-Wall Vent (Passive — small buildings)
**Placement rules:**
- Inlet: low on the wall (200–300mm above finished floor), on the prevailing wind side or the wall most exposed to fresh air
- Exhaust: high on the opposite wall (200mm below the top plate / ceiling level)
- Never place inlet and exhaust on the same wall — air will short-circuit and not ventilate the space
- Minimum 2m horizontal separation between any inlet and exhaust
- Keep inlets away from external corners (dead-air zones)
- All duct penetrations go through the full OSB + foam + OSB wall depth; seal around duct with compressible acoustic foam
```python
import FreeCAD, Part, math
from FreeCAD import Vector
# === PARAMETERS ===
DUCT_RADIUS = 50 # 100mm diameter duct
COWL_W = 150 # external weather cowl width
COWL_H = 150 # external weather cowl height
COWL_D = 60 # external weather cowl depth (protrudes from wall face)
GRILLE_D = 10 # internal grille plate depth
# Inlet vent — low on south wall, centred
INLET_X = BUILDING_WIDTH / 2
INLET_Z = BOTTOM_PLATE_H + 250 # 250mm above finished floor
# Cut duct void through full wall thickness (south wall, Y = 0 to TOTAL_THICKNESS)
inlet_void = Part.makeCylinder(
DUCT_RADIUS, TOTAL_THICKNESS,
Vector(INLET_X, 0, INLET_Z),
Vector(0, 1, 0) # direction: through wall in +Y
)
south_wall = south_wall.cut(inlet_void)
# External weather cowl (louvred hood, sits proud of exterior OSB face)
ext_cowl = Part.makeBox(
COWL_W, COWL_D, COWL_H,
Vector(INLET_X - COWL_W / 2, -COWL_D, INLET_Z - COWL_H / 2)
)
# Internal grille plate (flush with interior OSB face)
int_grille = Part.makeBox(
COWL_W, GRILLE_D, COWL_H,
Vector(INLET_X - COWL_W / 2, TOTAL_THICKNESS, INLET_Z - COWL_H / 2)
)
# Exhaust vent — high on north wall, centred
EXHAUST_X = BUILDING_WIDTH / 2
EXHAUST_Z = BOTTOM_PLATE_H + PANEL_HEIGHT - 250 # 250mm below top of panels
exhaust_void = Part.makeCylinder(
DUCT_RADIUS, TOTAL_THICKNESS,
Vector(EXHAUST_X, BUILDING_DEPTH - TOTAL_THICKNESS, EXHAUST_Z),
Vector(0, 1, 0)
)
north_wall = north_wall.cut(exhaust_void)
ext_exhaust_cowl = Part.makeBox(
COWL_W, COWL_D, COWL_H,
Vector(EXHAUST_X - COWL_W / 2,
BUILDING_DEPTH,
EXHAUST_Z - COWL_H / 2)
)
int_exhaust_grille = Part.makeBox(
COWL_W, GRILLE_D, COWL_H,
Vector(EXHAUST_X - COWL_W / 2,
BUILDING_DEPTH - TOTAL_THICKNESS - GRILLE_D,
EXHAUST_Z - COWL_H / 2)
)
# Add to document
for shape, name in [
(ext_cowl, "VentInletCowl"),
(int_grille, "VentInletGrille"),
(ext_exhaust_cowl, "VentExhaustCowl"),
(int_exhaust_grille, "VentExhaustGrille"),
]:
obj = doc.addObject("Part::Feature", name)
obj.Shape = shape
```
### MVHR Unit (Mechanical Ventilation with Heat Recovery — buildings > 50 m²)
The MVHR unit is a box mounted on an internal wall or in a utility cupboard. It has four duct connections: fresh-air intake and stale-air exhaust penetrating one external wall, plus supply and extract ducts that distribute air through the building internally (omit internal ductwork at concept stage; model the wall penetrations only).
```python
# === MVHR PARAMETERS ===
MVHR_W = 600 # unit width
MVHR_H = 400 # unit height
MVHR_D = 300 # unit depth (projects from wall into room)
DUCT_R = 80 # 160mm diameter supply/extract ducts
# MVHR unit body — mounted on north interior wall, 300mm above floor
mvhr_unit = Part.makeBox(
MVHR_W, MVHR_D, MVHR_H,
Vector(PANEL_THICKNESS + 100,
BUILDING_DEPTH - PANEL_THICKNESS - MVHR_D,
BOTTOM_PLATE_H + 300)
)
# Fresh-air intake duct — penetrates north wall (low port)
intake_void = Part.makeCylinder(
DUCT_R, PANEL_THICKNESS,
Vector(PANEL_THICKNESS + 100 + MVHR_W * 0.25,
BUILDING_DEPTH - PANEL_THICKNESS,
BOTTOM_PLATE_H + 300 + DUCT_R),
Vector(0, 1, 0)
)
north_wall = north_wall.cut(intake_void)
# Stale-air exhaust duct — penetrates north wall (high port, beside intake)
exhaust_void = Part.makeCylinder(
DUCT_R, PANEL_THICKNESS,
Vector(PANEL_THICKNESS + 100 + MVHR_W * 0.75,
BUILDING_DEPTH - PANEL_THICKNESS,
BOTTOM_PLATE_H + 300 + MVHR_H - DUCT_R * 2),
Vector(0, 1, 0)
)
north_wall = north_wall.cut(exhaust_void)
# External duct terminals (short stubs proud of exterior wall face)
intake_terminal = Part.makeCylinder(
DUCT_R, 150,
Vector(PANEL_THICKNESS + 100 + MVHR_W * 0.25,
BUILDING_DEPTH,
BOTTOM_PLATE_H + 300 + DUCT_R),
Vector(0, 1, 0)
)
exhaust_terminal = Part.makeCylinder(
DUCT_R, 150,
Vector(PANEL_THICKNESS + 100 + MVHR_W * 0.75,
BUILDING_DEPTH,
BOTTOM_PLATE_H + 300 + MVHR_H - DUCT_R * 2),
Vector(0, 1, 0)
)
for shape, name in [
(mvhr_unit, "MVHRUnit"),
(intake_terminal, "MVHRIntakeTerminal"),
(exhaust_terminal, "MVHRExhaustTerminal"),
]:
obj = doc.addObject("Part::Feature", name)
obj.Shape = shape
```
### Roof Vent Terminal (Flat Roof — duct routed vertically)
When ductwork rises vertically through the roof rather than through a wall (e.g., MVHR exhaust on a flat roof), cut the penetration through the roof stack and add a weathered terminal cap.
```python
# === ROOF VENT TERMINAL ===
TERMINAL_R = 80 # 160mm duct
TERMINAL_H = 350 # stub height above roof membrane
CAP_R = 130 # cowl cap radius (wider than duct)
CAP_H = 50
# Position: above MVHR unit, clear of parapet shadow
TERM_X = PANEL_THICKNESS + 100 + MVHR_W * 0.75
TERM_Y = BUILDING_DEPTH - PANEL_THICKNESS - MVHR_D / 2
# Cut penetration through insulation and membrane
penetration_depth = INSUL_T + MEMBRANE_T # through insulation + membrane (both flat, uniform)
terminal_void = Part.makeCylinder(
TERMINAL_R, penetration_depth,
Vector(TERM_X, TERM_Y, insul_z_base)
)
insulation = insulation.cut(terminal_void)
membrane = membrane.cut(terminal_void)
# Duct stub above roof
duct_stub = Part.makeCylinder(
TERMINAL_R, TERMINAL_H,
Vector(TERM_X, TERM_Y, insul_z_base + penetration_depth)
)
# Cowl cap (mushroom-head rain guard)
cowl_cap = Part.makeCylinder(
CAP_R, CAP_H,
Vector(TERM_X, TERM_Y, insul_z_base + penetration_depth + TERMINAL_H)
)
for shape, name in [
(duct_stub, "RoofVentDuct"),
(cowl_cap, "RoofVentCowl"),
]:
obj = doc.addObject("Part::Feature", name)
obj.Shape = shape
```
### Ventilation Component in the Building Hierarchy
For small buildings (passive vents): include vent penetrations and cowls as part of the **wall component** that they penetrate.
For buildings with MVHR: add a dedicated **ventilation component** that depends on the wall components it penetrates.
```
walls (south, north, east, west)
└── ventilation (depends on: south_wall, north_wall — cuts penetrations and adds cowls/unit)
```
**Ventilation component provides:**
- `inlet_position` — {x, y, z} of duct centreline at inlet wall interior face
- `exhaust_position` — {x, y, z} of duct centreline at exhaust wall interior face
---
## MANDATORY: Part Code Embossing
Every component script that receives a `PART CODE:` instruction in its task MUST include the `_emboss_part_code` helper and call it on the final shape **before** `obj.Shape = <shape>`. The complete function template is provided verbatim in the task text — copy it exactly, do not rewrite or shorten it.
Key rules:
- Declare `PART_CODE = "<code>"` and `PRINT_SCALE = 50` in the `# === PARAMETERS ===` block.
- Replace `<final_shape>` in the two call sites at the bottom of the template with the actual variable name that holds your completed geometry (e.g. `result`, `base_plate`, `panel`).
- The function is wrapped in `try/except` — if embossing fails for any geometry reason, it returns the original shape silently. Generation will still succeed.
- Do not modify the matrix math, depth formulas, or wire-sorting logic.
## MANDATORY: Foundation Component
**Every SIP building manifest MUST include a `foundation` component.** No exceptions. The foundation is always generated — even when the user hasn't mentioned the foundation in the prompt. It is the root of the dependency tree and provides the floor level, building footprint dimensions, and panel_thickness to every wall agent.
**NON-NEGOTIABLE — agent id MUST be exactly `foundation`:**
The foundation agent id is always the literal string `foundation`. No other name is acceptable. The validator, dependency resolver, and all downstream agents depend on this exact string.
❌ FORBIDDEN agent ids — do not use any of these:
- `concrete_slab` — wrong: this is a material description, not the role id
- `slab` — wrong: too generic
- `strip_foundation` — wrong: describes the type, not the role
- `foundation_slab` — wrong: reversed
- `base_plate` / `base_plate_south` / `base_plate_north` — wrong: base plates are sole plates, not the foundation
- `foundation_front` / `foundation_south` / `foundation_strip_north` — wrong: never split the foundation into sides
✅ CORRECT: one agent, id = `foundation`, priority = 1, no dependencies.
**Rules:**
- Agent id: `foundation` (exactly this string — see forbidden list above)
- Priority: 1 (highest — no dependencies)
- Dependencies: none
- Foundation type defaults to `slab_on_grade` unless the design spec explicitly states otherwise
- Use `BUILDING_WIDTH` and `BUILDING_DEPTH` from the design spec `overall_dimensions` for the footprint (the slab extends SLAB_OVERHANG = 200mm beyond this on all sides)
- The foundation is modelled as a single flat piece: slab field + perimeter edge beams as one fused solid
- `assembly_placement.position` = `{x: -200, y: -200, z: -125}` for a 125mm slab with 200mm overhang (slab bottom is -125mm, SW corner of slab is at -200, -200)
- `bounding_box` = `{x: BUILDING_WIDTH + 400, y: BUILDING_DEPTH + 400, z: 125}` (slab field only; edge beam extends deeper)
**Goal string template:**
```
Create slab-on-grade foundation, BUILDINGWIDTHx1200mm slab overhang 200mm all sides, 125mm slab thickness, 600mm edge beam depth, 300mm edge beam width. Single fused solid. Z=0 is slab top.
```
(Replace BUILDINGWIDTH and BUILDINGDEPTH with actual mm values from the spec.)
**If the spec mentions a strip foundation:** use a SINGLE agent with id `foundation`, priority 1. The strip is a flat perimeter ring — one model, one component. Do NOT create four separate side agents. See the Strip Foundation section above for exact geometry and Python code.
**Wrong vs correct — strip foundation:**
```
❌ WRONG (split into sides):
{ "id": "foundation_front", "priority": 1, ... }
{ "id": "foundation_back", "priority": 1, ... }
{ "id": "foundation_left", "priority": 1, ... }
{ "id": "foundation_right", "priority": 1, ... }
✅ CORRECT (single perimeter ring):
{ "id": "foundation", "priority": 1, "dependencies": [], ... }
```
**If the spec mentions a screw pile foundation**, use the screw pile catalog entry; use agent id `foundation`.
## Component Agent Breakdown
For a rectangular SIP building, use this component hierarchy. Each component declares interfaces that the next tier depends on.
**Pitched roof — slab-on-grade foundation:**
```
foundation
├── south_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_south (depends on: south_wall.plate_length)
│ ├── top_plate_screws_south (depends on: south_wall.plate_length, south_wall.wall_top)
│ └── roof_hurricane_ties_south_eave (depends on: south_wall.wall_top, south_wall.plate_length)
├── north_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_north
│ ├── top_plate_screws_north
│ └── roof_hurricane_ties_north_eave
├── west_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
│ ├── sole_plate_anchor_bolts_west
│ └── top_plate_screws_west
└── east_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
├── sole_plate_anchor_bolts_east
├── top_plate_screws_east
├── roof_south_slope (depends on: south_wall.wall_top, east_wall.wall_top, west_wall.wall_top)
├── roof_north_slope (depends on: north_wall.wall_top, east_wall.wall_top, west_wall.wall_top)
└── ridge_beam (depends on: roof_south_slope.ridge_z, foundation.building_depth)
```
**Pitched roof — strip foundation (single foundation agent):**
```
foundation (priority 1, no deps — single flat perimeter strip ring, Z=0 is top/bearing face)
├── south_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_south
│ ├── top_plate_screws_south
│ └── roof_hurricane_ties_south_eave
├── north_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_north
│ ├── top_plate_screws_north
│ └── roof_hurricane_ties_north_eave
├── west_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
│ ├── sole_plate_anchor_bolts_west
│ └── top_plate_screws_west
└── east_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
├── sole_plate_anchor_bolts_east
├── top_plate_screws_east
├── roof_south_slope
├── roof_north_slope
└── ridge_beam
```
**Flat roof — slab-on-grade foundation:**
```
foundation
├── south_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_south
│ ├── top_plate_screws_south
│ └── roof_hurricane_ties_south_eave
├── north_wall (depends on: foundation.floor_level, foundation.building_width)
│ ├── sole_plate_anchor_bolts_north
│ ├── top_plate_screws_north
│ └── roof_hurricane_ties_north_eave
├── west_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
│ ├── sole_plate_anchor_bolts_west
│ └── top_plate_screws_west
└── east_wall (depends on: foundation.floor_level, foundation.building_depth, south_wall.panel_thickness)
├── sole_plate_anchor_bolts_east
├── top_plate_screws_east
├── flat_roof (depends on: south_wall.wall_top, building_width, building_depth)
│ └── parapets (depends on: flat_roof.roof_z, flat_roof.roof_thickness)
└── ventilation (depends on: south_wall or north_wall wall geometry)
```
**Add ventilation for all buildings; corner post bases when posts are present:**
```
walls
└── ventilation (depends on: the wall(s) it penetrates)
foundation (if corner posts present)
├── corner_post_base_sw (depends on: foundation, south_wall.panel_thickness)
├── corner_post_base_se
├── corner_post_base_nw
└── corner_post_base_ne
```
### Interface Definitions
**foundation** (slab-on-grade) provides:
- `floor_level` = 0 (Z coordinate of slab top — always 0 by convention)
- `building_width` — outer face to outer face (X)
- `building_depth` — outer face to outer face (Y)
- `panel_thickness` — TOTAL_THICKNESS of the chosen SIP spec
- `slab_overhang` — how far slab extends past wall faces
**strip foundation** — single `foundation` agent (flat perimeter ring). Provides `floor_level = 0` (top face is Z=0). Walls depend on `foundation.floor_level` and derive dimensions from the spec.
**wall** provides:
- `wall_top` — Z of top of double top plate = WALL_HEIGHT
- `wall_height` = BOTTOM_PLATE_H + PANEL_HEIGHT + TOP_PLATE_H
- `wall_length` — outer dimension of this wall face
- `panel_thickness` — passthrough
**roof slope** (pitched) provides:
- `ridge_z` — Z coordinate of ridge panel top edge
- `slope_length` — along-slope panel run length
- `pitch_deg`
**flat_roof** provides:
- `roof_z` = WALL_HEIGHT (Z of roof panel bottom)
- `roof_top_z` = WALL_HEIGHT + ROOF_THICKNESS + INSUL_T + MEMBRANE_T (flat, level layers — the 2–5° fall itself is applied by the tapered top plate under the deck, not reflected in this level-frame formula)
- `parapet_z` = `roof_z` (parapets start at wall top, same Z as roof panels)
**ventilation** provides:
- `inlet_position` — {x, y, z} of inlet duct centreline at interior wall face
- `exhaust_position` — {x, y, z} of exhaust duct centreline at interior wall face
---
## Building Prompt Patterns
When a user asks for a SIP building, decompose the prompt into these questions before generating components:
1. **Floor plan** — overall dimensions (length × width in mm)?
2. **Wall panel spec** — SIP-150 (default), SIP-200, etc.?
3. **Wall height** — floor-to-ceiling (typically 2400–2700mm); add 270mm for plates
4. **Roof type** — flat (modern default), mono-pitch, or duo-pitch gable? Pitch angle if pitched (default 20°)?
5. **Foundation type** — slab (default), strip+timber floor, or screw pile?
6. **Openings** — how many windows/doors, on which walls, approximate sizes?
7. **Siding** — timber weatherboard (default), fibre cement, or metal?
8. **Ventilation** — always include. < 15m²: passive through-wall vents; ≥ 50m²: MVHR unit.
**Roof type guidance:**
- "modern", "contemporary", "minimal", "flat roof", "studio", "garden room" → use **flat roof** with parapets
- "cabin", "traditional", "cottage", "gable", "pitched" → use **duo-pitch**
- "lean-to", "mono-pitch", "shed roof" → use **mono-pitch**
For a project brief like "a 6×9m SIP cabin with gable roof":
- Foundation: concrete slab, BUILDING_WIDTH=6000, BUILDING_DEPTH=9000, SLAB_OVERHANG=200
- 4 walls: gable end walls 6000mm full width; side walls 9000 - 2×172 = 8656mm
- Roof: duo-pitch, 20°, HALF_SPAN=4500mm, ridge_height=1638mm
- No siding component unless asked
- Openings added to specific walls when described
For a project brief like "a 3×4m modern SIP garden room":
- Foundation: concrete slab, BUILDING_WIDTH=3000, BUILDING_DEPTH=4000, SLAB_OVERHANG=200
- 4 walls: SIP-150, PANEL_HEIGHT=2700, WALL_HEIGHT=2970
- Roof: flat, SIP-200 deck, ROOF_THICKNESS=222, INSUL_T=50 (flat, uniform — fall borne by tapered top plate), PARAPET_H=300
- Windows and door on south wall
### MANDATORY: Design Spec `"roof"` Section
Every SIP design spec **MUST** include a top-level `"roof"` object with `roof_type_key` and `roof_structure_key`. Without it the profile validator will warn on every generation. Use the table below:
| Roof type | `roof_type_key` | `roof_structure_key` |
|-----------|-----------------|----------------------|
| Flat / warm deck | `"flat"` | `"roof_structure/sip_flat_warm_roof"` |
| Mono-pitch | `"mono_pitch"` | `"roof_structure/sip_mono_pitch_panels"` |
| Duo-pitch gable | `"duo_pitch"` | `"roof_structure/sip_simple_gable_panels"` |
Example for a duo-pitch gable:
```json
"roof": {
"roof_type_key": "duo_pitch",
"roof_structure_key": "roof_structure/sip_simple_gable_panels",
"pitch_degrees": 20,
"ridge_direction": "east_west"
}
```
This field is required in the spec JSON even when full roof component details appear separately in the `components` array.
---
## MANDATORY: Fasteners
Every SIP building manifest **MUST** include fastener agents. Fasteners are structural — they are not optional decoration. The following connections require mechanical fixing in every SIP model.
### Required Fastener Connections
| Connection | Fastener | Library Key | Spacing |
|---|---|---|---|
| Sole plate → foundation slab | M12 anchor bolt + washer + nut | `hardware/anchors/m12_anchor_bolt_200mm` | 600mm centres, ≤150mm from each end |
| Top/sole plate → panel OSB | Structural screw #10 × 150mm | `hardware/screws/structural_screw_10g_x_150mm` | 150mm centres |
| Spline → panel edge | Structural screw #10 × 150mm | `hardware/screws/structural_screw_10g_x_150mm` | 300mm centres |
| Corner post → sole plate | Post base 100mm | `hardware/framing/post_base_100mm` | 1 per post |
| Roof panel → wall top plate | Hurricane tie | `hardware/framing/hurricane_tie` | 600mm centres along each eave |
### Fastener Agent Strategy
**Arrays of fasteners** (anchor bolts along a plate, screws along a panel) are modelled as **`source_kind: "generated"`** agents. The agent generates an array of fastener primitives in FreeCAD, positioned at the correct spacing along the plate length.
**Single instances** (one post base per corner post) are **`source_kind: "library"`** agents referencing the library part directly.
### Manifest Naming Convention
```
sole_plate_anchor_bolts_south — anchor bolts for south wall sole plate
sole_plate_anchor_bolts_north
top_plate_screws_south — screws fixing south wall top plate to panels
top_plate_screws_north
roof_hurricane_ties_south_eave — hurricane ties at south eave
roof_hurricane_ties_north_eave
corner_post_base_sw — post base at SW corner (if corner posts present)
corner_post_base_se
corner_post_base_nw
corner_post_base_ne
```
### FreeCAD Code Pattern — Anchor Bolt Array
```python
import FreeCAD
import Part
from FreeCAD import Vector
# === PARAMETERS ===
PLATE_LENGTH = 3756 # from parent interface
PLATE_Y_OFFSET = 11 # FACE_THICKNESS — plate is offset from wall face
CORE_THICKNESS = 100 # CORE_THICKNESS of SIP
BOLT_DIA = 12
BOLT_SHAFT_LENGTH = 200
BOLT_EMBEDMENT = 150 # below slab top (Z=0 is slab top)
WASHER_DIA = 30
HEAD_H = 7.5
WASHER_T = 3.0
END_OFFSET = 150 # first bolt distance from plate end
SPACING = 600
import math
n_bolts = max(2, math.ceil((PLATE_LENGTH - 2 * END_OFFSET) / SPACING) + 1)
actual_spacing = (PLATE_LENGTH - 2 * END_OFFSET) / (n_bolts - 1) if n_bolts > 1 else 0
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("anchor_bolts")
# Bolt centred in plate depth: Y = PLATE_Y_OFFSET + CORE_THICKNESS/2
bolt_y = PLATE_Y_OFFSET + CORE_THICKNESS / 2
# Z origin for shaft: from -BOLT_EMBEDMENT to top of washer+nut
bolt_z_base = -BOLT_EMBEDMENT
solids = []
for i in range(n_bolts):
bx = END_OFFSET + i * actual_spacing
shaft = Part.makeCylinder(BOLT_DIA / 2, BOLT_SHAFT_LENGTH, Vector(bx, bolt_y, bolt_z_base))
# Hex head above shaft
import math as _math
haf = 19 / 2 / _math.cos(_math.pi / 6)
hex_pts = [Vector(bx + haf * _math.cos(_math.radians(60*k)), bolt_y + haf * _math.sin(_math.radians(60*k)),
bolt_z_base + BOLT_SHAFT_LENGTH) for k in range(6)]
hex_pts.append(hex_pts[0])
hw = Part.makePolygon(hex_pts)
hf = Part.Face(Part.Wire(hw.Edges))
head = hf.extrude(Vector(0, 0, HEAD_H))
washer = Part.makeCylinder(WASHER_DIA / 2, WASHER_T,
Vector(bx, bolt_y, bolt_z_base + BOLT_SHAFT_LENGTH))
washer_hole = Part.makeCylinder(BOLT_DIA / 2 + 0.5, WASHER_T,
Vector(bx, bolt_y, bolt_z_base + BOLT_SHAFT_LENGTH))
washer = washer.cut(washer_hole)
solids.extend([shaft, head, washer])
result = solids[0]
for s in solids[1:]:
result = result.fuse(s)
obj = doc.addObject("Part::Feature", "AnchorBolts")
obj.Shape = result
doc.recompute()
```
### FreeCAD Code Pattern — Hurricane Tie Array
```python
import FreeCAD
import Part
from FreeCAD import Vector
# === PARAMETERS ===
EAVE_LENGTH = 3756 # length of eave edge
SPACING = 600
STRAP_W = 38
STRAP_T = 2.5
VERT_LEG = 90 # up the roof panel edge
HORIZ_LEG = 50 # across the top plate
HOLE_DIA = 5
import math
n_ties = max(2, math.ceil(EAVE_LENGTH / SPACING))
actual_spacing = EAVE_LENGTH / n_ties
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("hurricane_ties")
solids = []
for i in range(n_ties):
tx = i * actual_spacing + actual_spacing / 2 - STRAP_W / 2
# Vertical leg
vert = Part.makeBox(STRAP_T, STRAP_W, VERT_LEG, Vector(tx, 0, 0))
# Horizontal leg (extends in Y across top plate)
horiz = Part.makeBox(HORIZ_LEG, STRAP_W, STRAP_T, Vector(tx + STRAP_T, 0, 0))
tie = vert.fuse(horiz)
solids.append(tie)
result = solids[0]
for s in solids[1:]:
result = result.fuse(s)
obj = doc.addObject("Part::Feature", "HurricaneTies")
obj.Shape = result
doc.recompute()
```
### Fastener Agent Dependencies
Fastener agents depend on their parent structural component:
```
foundation
├── south_wall
│ ├── sole_plate_anchor_bolts_south (depends on: south_wall.plate_length, foundation)
│ ├── top_plate_screws_south (depends on: south_wall.plate_length, south_wall.wall_top)
│ └── roof_hurricane_ties_south_eave (depends on: south_wall.wall_top, south_wall.plate_length)
└── ... (same for each wall)
```
### Assembly Placement for Fastener Agents
Anchor bolt arrays: `assembly_placement.position` = `{x: wall_x, y: wall_y, z: 0}` — same XY offset as the wall they belong to; Z=0 is slab top and the bolts embed downward.
Hurricane ties: `assembly_placement.position` = eave corner of the wall they tie to; Z = wall_top Z.
Post bases: `assembly_placement.position` = corner post position; Z = sole plate top face.
---
## Common Mistakes to Avoid
1. **Never use face references** — always position by coordinate offset
2. **Slab must be larger than building footprint** — SLAB_W = BUILDING_WIDTH + 2 × SLAB_OVERHANG (minimum 200mm each side); a slab equal to the footprint will leave walls hanging at the edge
3. **Panel foam must stay above ground** — check DPC_T + SILL_PLATE_H ≥ 150mm above finished ground
4. **Anchor bolts go into the concrete** — model with negative Z (embedment below slab top)
5. **Spline depth must match foam depth** — block spline D = CORE_THICKNESS (not TOTAL_THICKNESS)
6. **Roof panels must have ridge bevel cuts** — without bevel, the panels collide at the ridge and cannot mate flush; ridge_bevel_x = TOTAL_THICKNESS × tan(pitch_rad)
7. **Roof rotation pivot is the eave edge interior face** — rotate around (x=0, y=TOTAL_THICKNESS, z=EAVE_HEIGHT), not around (0, 0, 0)
8. **Double top plate = 180mm** — two 45×90 plates stacked; wall_top = BOTTOM_PLATE_H + PANEL_HEIGHT + 180
9. **Window/door openings require all four framing members** — king studs + trimmer studs + LVL header + sill plate (windows); missing king studs means the opening has no structural connection to the wall panels above
10. **Side walls fit between gable walls** — side wall length = BUILDING_DEPTH − 2 × PANEL_THICKNESS, offset from wall exterior by PANEL_THICKNESS
11. **Flat roof panels span the inner width, not the outer width** — roof panels sit between wall interior faces; X span = BUILDING_WIDTH − 2 × PANEL_THICKNESS, starting at X = PANEL_THICKNESS
12. **Flat roofs use SIP-200 or thicker** — SIP-150 is insufficient for a flat cold roof; specify ROOF_CORE ≥ 200mm
13. **Never tilt or taper flat roof SIP panels, and never taper the insulation to create drainage fall** — build the deck and insulation as flat, level, uniform-thickness layers; the 2–5° fall is borne structurally by a tapered top plate on the fall-run eave walls (constructability layer adds it automatically), the same Fig 1.4.7 eave-plate mechanism used for pitched eaves
14. **Parapets start at wall top Z, not at roof top Z** — PARAPET_Z = WALL_HEIGHT (same as ROOF_Z), so the parapet and roof panels begin at the same level and the parapet extends above the roof surface
15. **Insulation is always a flat box, never a wedge** — the drainage fall comes from the tapered top plate under the deck, not from tapering the insulation; the insulation stays uniform thickness
16. **Scuppers must be cut through the parapet** — flat roofs with parapets drain through rectangular holes in the parapet panel, not through the deck; the outlet cylinder in the deck alone does nothing if the parapet retains water
17. **Every wall component generates its own bottom plate** — the foundation component does not generate bottom plates. The `Wall` builder includes the bottom plate automatically. If writing manual geometry, start with `bp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, BOTTOM_PLATE_H, Vector(0, FACE_THICKNESS, 0))` at Z=0 — CORE_THICKNESS depth, offset by FACE_THICKNESS.
18. **Walls with openings must include the glazing/door unit** — after cutting the void and adding framing, add a box representing the window frame + glazing or door leaf; without this the opening renders as an empty hole
19. **Every SIP building must include a ventilation component** — SIP construction is airtight; omitting ventilation means the building has no fresh air path; always generate at minimum an inlet and exhaust vent even for small garden rooms
20. **Inlet and exhaust vents must be on opposite walls** — placing both on the same wall causes short-circuit airflow; no fresh air reaches the interior; inlet on south/windward wall, exhaust on north/leeward wall
21. **Vent ducts penetrate the full wall thickness** — cut the cylinder void through all three layers (OSB + EPS + OSB); a duct that only penetrates the OSB face is blocked by the foam core and delivers no airflow
22. **Scale vent count to floor area** — use `vents_needed = max(2, math.ceil(AREA_NEEDED_MM2 / AREA_100MM))`; a single 100mm vent is sufficient for rooms under ~10m², two are standard minimum; larger buildings need proportionally more or an MVHR unit
23. **King studs and trimmer studs must be TOTAL_THICKNESS deep** — `KING_D = TOTAL_THICKNESS`, not 90mm. A 90mm-deep king stud centred in the foam leaves a gap on each side; the SIP OSB faces have nothing to bear against and the frame is not tied through the wall depth
24. **Never build a continuous wall then cut a void for the opening** — plan the opening zone before building panels and use `make_panel_zone` to stop panels at the king stud face. Cutting a void after fusing removes material that was never there structurally; framing members added afterwards float inside the wall solid rather than replacing it
25. **OPENING_X is the left face of the left king stud** — not the left edge of the clear void. The clear void starts at `OPENING_X + KING_W + TRIMMER_W`. Using the void edge as OPENING_X shifts the entire frame inward and places panels inside the framing zone
26. **Bottom plate and top plate run the full wall length unbroken** — including under and over the opening. The frame bears on the bottom plate; the double top plate continues over the header distributing the load. A gap in the plate at the opening leaves the frame floating
27. **Window sill is the full wall depth** — `SILL_D = TOTAL_THICKNESS`, same as every other framing member. A narrower sill leaves the window unit unsupported on the inner or outer face and cannot be properly flashed or sealed
28. **Never use `INSUL_MIN` or `FALL_RATIO` — these no longer exist.** Insulation thickness is a single flat value (`INSUL_T`); the drainage fall is not an insulation parameter at all — it is set by the tapered top plate's fall angle (2–5°), applied by the constructability layer
29. **Never encode a "1:N" fall ratio in the roof script** — the fall is a 2–5° angle borne by the tapered top plate, not a deck or insulation slope computed in the component script. `FALL_HEIGHT`, `FALL_RATIO`, and similar formulas do not belong in flat-roof component code
30. **The membrane must be a flat box, never a wedge** — model it as a uniform-thickness layer on top of the insulation surface. It follows the deck's tilt (from the tapered top plate below) as a rigid body; it does not add its own taper
31. **Insulation must be a flat box, never a polygon extrude wedge** — `Part.makeBox(..., INSUL_T)` is correct; a trapezoid/wedge profile is the OLD (removed) pattern. The insulation is uniform thickness; the fall lives entirely in the tapered top plate beneath the deck
32. **Fastener agents must be included in every manifest** — sole plate anchor bolts, roof hurricane ties, and corner post bases are all mandatory. A manifest that lists only structural SIP components is incomplete; the assembly will be missing all mechanical connections.
33. **Anchor bolt Y position is plate centreline, not wall face** — bolt_y = FACE_THICKNESS + CORE_THICKNESS/2. Placing bolts at Y=0 or Y=TOTAL_THICKNESS/2 misses the plate and embeds into the OSB skin or slab overhang.
34. **Fastener arrays use `source_kind: "generated"`, not `"library"`** — arrays of bolts or screws along a plate are generated agents that produce parametric arrays; a library agent produces only a single instance.
SIP Construction (DEPRECATED)
Native
DEPRECATED — superseded by the ElixiFree-API SIP skills (sip_decomposition, sip_walls_elixifree, sip_roofs, sip_foundations, sip_openings). Retained for backward compatibility; do not attach to new projects.
~23013 tokens
# SIP Wall Construction Skill
**Status note (2026-07-05):** This skill predates the assembly-layer placement
redesign (see `sip_walls_elixifree.md` and `docs/design/SIP_ROOF_STANDARDS.md`).
For SIP component generation the pipeline is: `Wall.build()` in the script gets
rewritten to `Wall.construct(profile)` by the constructability layer, which
always builds panels span-along-local-X regardless of compass direction, and
the assembly Placer owns ALL world position AND rotation — do NOT set
`feature.Placement` in a component script, and do NOT call `.orient("Y")` (it
only affects the separate, non-pipeline `.build()` whole-wall shape and would
double-rotate an east/west wall against the Placer's own yaw). This file is not
in the current `component_generation` skill routing (`sip_walls_elixifree.md`
is) but remains selectable — treat the `.orient("Y")` and `feature.Placement`
instructions below as historical/manual-script reference only, not the current
pipeline convention.
## SIP Panel Stock Reference
All dimensions in millimetres. OSB facing is 11mm each side.
| Name | Total Thickness | Core (EPS) | Approx. R-Value |
|---------|----------------|------------|-----------------|
| SIP-100 | 122mm | 100mm | R-15 |
| SIP-150 | 172mm | 150mm | R-23 |
| SIP-200 | 222mm | 200mm | R-30 |
| SIP-250 | 272mm | 250mm | R-38 |
| SIP-300 | 322mm | 300mm | R-45 |
Stock name = core thickness. Never derive the name from total thickness.
**Standard panel widths:** 1200mm (preferred) or 1220mm
**Standard panel heights:** 2400mm, 2700mm, 3000mm (custom heights available)
---
## Wall Components — ALWAYS use `Wall` builder
**Do not write the three-layer panel loop manually.** The `Wall` builder handles panel segmentation, block splines, sole plate, and top plate internally. Manual loops produce subtle coordinate errors and bypass the constructability pipeline.
```python
from elixifree.domains.sip import Wall
from FreeCAD import Placement, Rotation, Vector
# === PARAMETERS — read every value from the goal string ===
SPAN = 6000 # wall length
HEIGHT = 2700 # panel height (NOT including plates)
STOCK = "SIP-150"
result = Wall(span=SPAN, height=HEIGHT, stock=STOCK).build()
feature = result.add_to_doc("Body")
# MANDATORY: set world position after add_to_doc()
feature.Placement = Placement(Vector(pos_x, pos_y, 0), Rotation())
```
**`feature.Placement` is mandatory on every wall script.** `Wall.build()` places geometry in local space with the inner face at the origin. Without a Placement the feature lands at world origin.
Assembly positions (inner-face-at-origin convention):
- South wall: `Vector(0, 0, 0)`
- **North wall: `Vector(0, BUILDING_DEPTH, 0)`** — NOT `BUILDING_DEPTH - PANEL_THICKNESS`
- West wall `.orient("Y")`: `Vector(0, 0, 0)`
- **East wall `.orient("Y")`: `Vector(BUILDING_WIDTH, 0, 0)`** — NOT `BUILDING_WIDTH - PANEL_THICKNESS`
### East/west walls — `.orient("Y")`
East and west walls span along Y. Use `.orient("Y")`:
```python
result = Wall(span=BUILDING_DEPTH, height=HEIGHT, stock=STOCK).orient("Y").build()
feature = result.add_to_doc("Body")
feature.Placement = Placement(Vector(BUILDING_WIDTH, 0, 0), Rotation())
```
### Gable end walls — `.gable(ridge_height)`
End walls of a gable building must have a triangular section above eave height. Use `.gable()` — do not hand-code the triangular prism:
```python
result = (Wall(span=BUILDING_DEPTH, height=EAVE_HEIGHT, stock=STOCK)
.gable(ridge_height=RIDGE_Z)
.orient("Y")
.build())
feature = result.add_to_doc("Body")
feature.Placement = Placement(Vector(BUILDING_WIDTH, 0, 0), Rotation())
```
- `height` = eave height (wall height at the corners)
- `ridge_height` = Z of the gable apex — always read from the goal string
- Cannot combine `.gable()` with `height_end`
### Sloped-top wall under mono-pitch roof — `height_end`
```python
result = Wall(span=SPAN, height=2440, stock=STOCK, height_end=2590).orient("Y").build()
```
### Wall with door or window
```python
result = (Wall(span=SPAN, height=HEIGHT, stock=STOCK)
.opening(x=1500, z=0, width=900, height=2100) # door: z=0
.opening(x=3000, z=900, width=1000, height=1200) # window: z=sill height
.build())
```
`.opening(x, z, width, height)` — x from left edge, z from bottom plate top (z=0 for doors). Chain for multiple openings.
### Inner face grooves (spline receivers)
```python
result = (Wall(span=SPAN, height=HEIGHT, stock=STOCK)
.inner_groove(x=61) # groove at left end (TOTAL_THICKNESS/2 from edge)
.inner_groove(x=SPAN-61) # groove at right end
.build())
```
---
## Fallback — manual geometry
Use raw `Part` calls only when the builder cannot express the geometry. Add a comment:
```python
# ElixiFree gap: <description of what the builder cannot handle>
```
### Manual panel geometry (reference only)
```python
import Part
from FreeCAD import Vector
CORE = 150; FACE = 11; TOTAL = CORE + 2*FACE # SIP-150
pw = 1200; ph = 2700
f1 = Part.makeBox(pw, FACE, ph, Vector(x, 0, BOTTOM_PLATE_H))
co = Part.makeBox(pw, CORE, ph, Vector(x, FACE, BOTTOM_PLATE_H))
f2 = Part.makeBox(pw, FACE, ph, Vector(x, FACE+CORE, BOTTOM_PLATE_H))
panel = f1.fuse(co).fuse(f2)
```
**Sole/top plate rule:** plate depth = `CORE_THICKNESS`, Y-offset = `FACE_THICKNESS`. The plate slots into the foam channel; OSB skins overhang on both faces. A plate as wide as `TOTAL_THICKNESS` is 22mm too wide and cannot enter the groove.
```python
# CORRECT
bp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, 90, Vector(0, FACE_THICKNESS, 0))
tp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, 180, Vector(0, FACE_THICKNESS, BOTTOM_PLATE_H + PANEL_HEIGHT))
# WRONG — too wide for the groove
# bp = Part.makeBox(WALL_LENGTH, TOTAL_THICKNESS, 90, Vector(0, 0, 0))
```
---
## Corner Conditions
### L-Corner (external corner)
- Wall A runs full length to the outer face
- Wall B's end panel butts to Wall A's exterior OSB face
- A double block spline (2× 45×90mm) fills the corner pocket on Wall B's end
### T-Corner (partition meeting exterior wall)
Interior wall panel butts into the face of the exterior wall panel. Single block spline at junction.
---
## Spline Types (reference)
| Type | Size | Use |
|------|------|-----|
| Block spline | 45×90mm (SIP-100/150), 45×140mm (SIP-200+) | Standard panel-to-panel joint |
| Surface spline | 18×45mm | Routed-groove joint, lower load |
| LVL spline | 45×90mm minimum, engineered | High-load or structural joints |
| Double block | 2× block side-by-side | L-corners, wall-to-roof junctions |
---
## Panel Height Splice (wall height > 2440mm)
Standard panel heights (2400mm, 2700mm) come from manufacturer. For custom heights above 2440mm split into equal courses:
```python
import math
WALL_HEIGHT = 3000
n_courses = math.ceil(WALL_HEIGHT / 2440)
section_h = WALL_HEIGHT / n_courses # equal sections, each ≤ 2440mm
```
Add a horizontal LVL spline (45×CORE_THICKNESS×45mm) at each course splice.
---
## ⚠ Non-Negotiable Wall Rules
1. **Always use the `Wall` builder.** Do not write the three-layer panel loop manually.
2. **Every wall script MUST set `feature.Placement`** after `result.add_to_doc("Body")`.
3. **Gable end walls MUST use `.gable(ridge_height)`.** A plain rectangular wall will not fit under a pitched roof.
4. **Sole/top plates use `CORE_THICKNESS` depth, offset by `FACE_THICKNESS`.** A full-thickness plate cannot enter the foam groove.
5. **Panel height > 2440mm must be split into equal courses with LVL splices.**
SIP Wall Construction (DEPRECATED)
Native
DEPRECATED — superseded by sip_walls_elixifree (ElixiFree API). Retained for backward compatibility; do not attach to new projects.
~1913 tokens
# ElixiFree Core Skill
Use the ElixiFree core library for all geometry. It provides clean, declarative
functions over FreeCAD's Part API. The library handles the FreeCAD details — you
supply dimensions and intent.
## Import
```python
from elixifree import (
box, cylinder, sphere, cone, wedge, torus, prism, fuse, cut, intersect, translate, mirror, rotate, fillet, chamfer, bbox, center_of, place, select_edges, select_faces, Profile, rect_profile, circle_profile, polygon_profile, slot_profile, extrude, revolve, loft, sweep, shell, hole, cbore_hole, csk_hole, linear_array, polar_array, grid_array
)
```
## Standalone Mechanical Object Pattern
For generic single component projects, create one user-facing component and model
details as features:
```python
from elixifree import box, cylinder, cut, fillet
# Create base solids, subtract holes/sockets, add ribs/bosses, then add the result to the document.
```
Use raw FreeCAD only when the current ElixiFree API cannot express the required
geometry, and mark the script with `# ElixiFree gap: <reason>`.
## Ending every script
```python
from elixifree import add_to_doc
add_to_doc(result, "Body")
```
This handles document setup, recompute, and fitAll. Do NOT write these manually.
## Primitives
```
box(w, h, d, at=(0,0,0)) -> Shape
cylinder(r, h, at=(0,0,0)) -> Shape
sphere(r, at=(0,0,0)) -> Shape
cone(r_base, r_top, h, at=(0,0,0)) -> Shape
torus(r_major, r_minor, at=(0,0,0)) -> Shape
prism(points, h, at=(0,0,0)) -> Shape 2D polygon extruded
wedge(dx, dy, dz, xmin, zmin, xmax, zmax, at) -> Shape
```
## Booleans and transforms
```
cut(base, *tools) -> Shape subtract tools from base
fuse(*shapes) -> Shape join two or more shapes
intersect(a, b) -> Shape common volume (raises if empty)
translate(shape, x=0, y=0, z=0) -> Shape move a copy
rotate(shape, axis, degrees, center) -> Shape rotate a copy; axis="X"|"Y"|"Z"
mirror(shape, plane="XZ") -> Shape mirror about "XY", "XZ", or "YZ"
```
## Edge/face operations
```
fillet(shape, radius, edges=None) -> Shape raises ValueError on failure
chamfer(shape, size, edges=None) -> Shape raises ValueError on failure
shell(shape, thickness, faces=None) -> Shape hollow out; opens faces= (default ">Z")
```
## Selectors (edges= and faces= arguments)
Use selector strings instead of geometry indices:
```
">Z" top face / top edges
"<Z" bottom face / bottom edges
">X", "<X" outermost/innermost in X
">Y", "<Y" outermost/innermost in Y
"|Z" edges parallel to Z (vertical)
"#Z" edges perpendicular to Z (horizontal)
"%CIRCLE" circular edges
"%PLANE" planar faces
"%CYLINDER" cylindrical faces
">Z[0]" topmost (Nth selector)
">Z AND %PLANE" combinations with AND / OR
```
## Profile and solid-of-profile
```
profile(points, arcs=None) -> Profile closed 2D cross-section
rect_profile(w, h) -> Profile
circle_profile(r) -> Profile
polygon_profile(n, r) -> Profile regular n-gon, circumradius r
slot_profile(length, r) -> Profile
extrude(p, length, plane="XY", at=None, symmetrical=False) -> Shape
revolve(p, axis="Z", angle=360, at=None) -> Shape
loft([p1, p2, ...], ruled=False) -> Shape
sweep(p, path) -> Shape
```
Position a profile: `p.at(x, y, z)` — returns a copy at world position.
## Holes
```
hole(shape, face, x, y, diameter, depth) -> Shape
cbore_hole(shape, face, x, y, diameter, depth, cbore_diameter, cbore_depth) -> Shape
csk_hole(shape, face, x, y, diameter, depth, csk_diameter, csk_angle=82) -> Shape
```
`face` is a selector string (e.g. `">Z"`). `depth` accepts a number or `"through"`.
`x`, `y` are offsets from the face centre in the face's local plane.
## Arrays
```
linear_array(shape, count, dx=0, dy=0, dz=0, fuse=True) -> Shape or list
polar_array(shape, count, axis="Z", angle=360, center, fuse=True)-> Shape or list
grid_array(shape, nx, ny, dx, dy, fuse=True) -> Shape or list
```
## Placement helpers
```
bbox(shape) -> dict xmin/xmax/ymin/ymax/zmin/zmax/xlen/ylen/zlen
center_of(shape) -> (cx, cy, cz)
place(shape, on, align="top", offset) -> Shape
select_edges(shape, selector) -> list[Edge]
select_faces(shape, selector) -> list[Face]
```
`place` align values: "top", "bottom", "left", "right", "front", "back".
## Rules
1. **Always use ElixiFree functions.** Do not write raw `Part.makeBox`, `Part.makeCylinder` etc.
2. **Use `add_to_doc(result, "Body")` at the end** — never write doc/recompute/Gui lines.
3. **`fillet` and `chamfer` raise `ValueError` on failure** — do not call them with extreme radii.
4. If ElixiFree cannot express the geometry, use raw `Part` as a fallback and add a comment:
`# ElixiFree gap: <description>`
ElixiFree Core (Generic CAD)
Native
ElixiFree core library API for generic CAD component generation
~1304 tokens
# SIP Partition Wall Skill — ElixiFree Builder API
Internal partition walls in a SIP building use **SIP-100 (122mm)** as the default stock.
They are thinner than external walls to maximise floor area and do **not** use corner
spline joints — they butt against external wall faces.
## Key rules
1. **Always use `stock="SIP-100"`** for partition walls — never SIP-150 or thicker.
2. **No `.corner_spline()`** — partition ends abut external wall inner faces, no spline protrusion.
3. **Do cut `.inner_groove()` into external walls** where the partition meets them.
4. **Door openings** follow the same rough opening rules as external walls: use `.opening(x, z, width, height)`.
5. **Orientation**: do NOT call `.orient("Y")` on a partition script, even for a front-to-back run. Pipeline scripts are expanded through `Wall.construct()`, which always builds span-along-local-X regardless of run direction; the partition's real-world yaw is derived from the building graph (`BuildingGraph.interior_wall_world_placement/2`) and applied by the assembly Placer's graph partition path (ratcheted by the `assembly.partition_orientation` validator). Calling `.orient("Y")` pre-rotates the shape a second time and double-rotates the wall against the Placer's own yaw.
6. **Span MUST come from the actual neighbours (`connects_to`), never from the raw building width/depth.**
A partition's declared box must fit INSIDE the inner envelope `[PT, BUILDING_WIDTH-PT] x
[PT, BUILDING_DEPTH-PT]`. `SipSpecNormalizer` derives/clamps this deterministically before
component generation, but the spec author should still get it right: span = distance between
the two inner faces (perimeter wall or other partition) the partition connects to.
**Worked example** (9000x7000mm building, PT=142, inner envelope Y in [142,6858]): a
north-south hallway partition running from a living/back partition at y=3942 to the north
wall inner face (y=6858) has **span = 6858 - 3942 = 2916mm** — correct, because it is derived
from the real neighbour distance. The observed field failure was the same partition declared
with **span=7000** (the raw `BUILDING_DEPTH`) positioned at y=4042: its far end would land at
y=11042, more than 4.2m outside the north wall inner face. Never size a partition to the
building's outer dimension — derive it from what it actually connects to, or (if that cannot
be derived) clamp it inside the inner envelope rather than stretching it.
## Partition wall — plain
```python
from elixifree.domains.sip import Wall
result = Wall(span=3000, height=2440, stock="SIP-100").build()
result.add_to_doc("Body")
```
## Partition wall — with internal door
```python
from elixifree.domains.sip import Wall
SPAN = 3000
DOOR_W = 800 # internal doors are narrower than external — typically 762–838mm
DOOR_H = 2040
DOOR_X = (SPAN - DOOR_W) / 2 # centred
result = (Wall(span=SPAN, height=2440, stock="SIP-100")
.opening(x=DOOR_X, z=0, width=DOOR_W, height=DOOR_H)
.build())
result.add_to_doc("Body")
```
## Partition wall — front-to-back run
A front-to-back partition (parallel to east/west walls) is still built in the
default local frame — do NOT call `.orient("Y")`. The Placer determines its
world yaw from the building graph (`BuildingGraph.interior_wall_world_placement/2`)
and applies the rotation.
```python
from elixifree.domains.sip import Wall
result = (Wall(span=4000, height=2440, stock="SIP-100")
.build()) # default local frame — do NOT call .orient("Y")
result.add_to_doc("Body")
```
## External wall grooves to receive a partition
When a partition wall meets an external wall, add an `.inner_groove()` at that position:
```python
from elixifree.domains.sip import Wall, sip_constants
c = sip_constants("SIP-100") # partition wall thickness = 122mm
# External south wall with grooves at 3000mm and 5000mm where partitions meet
result = (Wall(span=10000, height=2440, stock="SIP-100")
.inner_groove(x=3000) # left partition wall meet
.inner_groove(x=5000) # right partition wall meet
.build())
result.add_to_doc("Body")
```
## Internal door sizes (guidance)
| Room type | Typical clear width |
|-----------|-------------------|
| Bedroom | 762mm |
| Bathroom | 700mm |
| Kitchen | 838mm |
| Hallway | 838mm |
Standard internal door height: **2040mm** (not 2100mm as for external doors).
## Rules
1. **Never use `.corner_spline()`** on partition walls.
2. **Always `stock="SIP-100"`** — never thicker.
3. **End every script with `result.add_to_doc("Body")`** — never write doc/recompute/Gui lines.
4. If the geometry cannot be expressed with the builder, use raw `Part` and add a comment:
`# ElixiFree gap: <description>`
SIP Partition Walls (ElixiFree API)
Native
Internal partition wall rules: SIP-100 stock, no corner splines, internal door sizes, external wall groove receivers.
~1193 tokens
# SIP Wall Panel Skill — ElixiFree Builder API
Use the declarative builder API for all SIP components. The builder captures
your intent as parameters — no geometry code, no raw Part calls for standard components.
## Import
**Exact exported names — use these exactly, no variations:**
```python
from elixifree.domains.sip import Wall, RoofPanel, PitchedRoofSlope, FloorDeck, Foundation, sip_constants
```
- Wall panels → `Wall` (NOT `WallPanel`, `SIPWall`, `Panel`, or any other name)
- Flat roof panels → `RoofPanel` (flat roofs ONLY)
- Pitched roof slopes → `PitchedRoofSlope` (gable, duo-pitch, mono-pitch — full rules in the SIP Roof Construction skill)
- Floor/ceiling decks (inter-storey cassettes) → `FloorDeck` (NOT `Deck`, `FloorCassette`, `Cassette`, or any other name)
- Foundations → `Foundation`
**Do NOT add `import Part`, `import math`, or `from FreeCAD import Vector, Placement, Rotation` to ElixiFree scripts.** The builder handles all geometry internally; the assembly layer (Placer) owns all world positioning and rotation, including the roof pitch tilt.
## Ending every script
Use `result.add_to_doc("Body")` — this handles doc setup, recompute, and fitAll in one call.
Do NOT write `doc.addObject`, `doc.recompute()`, or `FreeCAD.Gui` lines manually.
## Stock constants — use `sip_constants()` only when you need derived dimensions
Only call `sip_constants()` when your script needs to do arithmetic with SIP dimensions (e.g., computing coordinates that depend on total thickness). Do NOT call it just to name a constant you won't use in geometry.
```python
from elixifree.domains.sip import sip_constants
c = sip_constants("SIP-100")
# c["face"]=11, c["core"]=100, c["total"]=122, c["groove_width"]=45, c["groove_depth"]=50
```
For simple walls with openings, you almost never need `sip_constants()` — the builder handles all internal dimensions.
## Wall — plain
`Wall(span, height, stock, height_end=None)` — parameters are `span`, `height`, `stock`, and optional `height_end`. There is NO `length`, `width`, or `depth` parameter.
Use `height_end` for gable/sloped-top walls: `height` is the wall height at X=0, `height_end` is the wall height at X=span. The builder cuts a wedge to produce the sloped top face.
```python
from elixifree.domains.sip import Wall
result = Wall(span=4000, height=2440, stock="SIP-100").build()
result.add_to_doc("Body")
```
## Reading key values from your goal
Your goal string contains the exact numeric values you need. Always extract them before writing any code:
- **Foundation**: read `strip depth`, `strip width`, `panel thickness`, `place at Vector(...)` — use them verbatim.
- **Gable end walls**: read `eave height` → use as `height=`, read `ridge height` → use as `ridge_height=`.
- **Roof slopes**: see the SIP Roof Construction skill (`sip_roofs.md`) — do not build them from this skill's rules.
If a value is missing from your goal, derive it from the bounding box dimensions provided in the prompt.
---
## Wall — gable end wall (duo-pitch/gable roof)
End walls of a gable building must have an inverted-V triangle cut from the top so they fit under the pitched roof. Use `.gable(ridge_height, apex_x=None)`.
**MANDATORY: every east/west gable end wall calls `.gable(ridge_height=...)`.**
A gable wall with no `.gable()` call builds a plain rectangular panel that will
not fit under the pitched roof — this has been observed in the field (one
gable wall regenerated with the call, its twin without it). If your component
is a gable end wall (east/west wall of a duo-pitch/gable building), `.gable()`
is never optional, even if the goal text seems ambiguous — the ridge height and
eave height values are always injected into the goal for exactly this reason.
**Your goal will say something like:**
> "Create east gable end wall SIP panel: span 4000mm, eave_height 2871.99mm, ridge height 4810.71mm …"
Extract `eave_height` → `height=`, `ridge height` → `ridge_height=`. Both values
are computed by `Elixihub.Concept.RoofGeometry` (Elixir side) from ONE formula —
never derive them yourself, always read the goal's exact numbers:
```
eave_height (rake end) = gable_rake_end_h = EAVE_HEIGHT + LIFT + PT * tan(θ)
ridge_height (apex) = underside_ridge_z = EAVE_HEIGHT + LIFT + (D/2) * tan(θ)
```
Worked reference example (EAVE_HEIGHT=2700, θ=30°, PT=142, D=7000, LIFT=90):
```
eave_height ≈ 2871.99
ridge_height ≈ 4810.71
```
```python
from elixifree.domains.sip import Wall
# Values read directly from goal: span=4000, eave_height=2871.99, ridge height=4810.71
SPAN = 4000
EAVE_HEIGHT = 2871.99
RIDGE_HEIGHT = 4810.71 # from goal: "ridge height 4810.71mm"
result = (Wall(span=SPAN, height=EAVE_HEIGHT, stock="SIP-100")
.gable(ridge_height=RIDGE_HEIGHT)
.build()) # default local frame — the Placer's yaw sets world orientation
result.add_to_doc("Body")
```
- `span` = the exact value in the goal. For a gable end wall this is `BUILDING_DEPTH - 2*PANEL_THICKNESS`: the eave (south/north) walls run through the corners outer-to-outer and the gable BUTTS between their inner faces, so it never spans the full building depth. Use the goal's number verbatim; do not "correct" it upward.
- `height` = the gable's OWN `eave_height` field (the rake-end height where the
rectangular body meets the triangle) — this is NOT the eave WALLS' plain
height; the gable wall is taller because of the roof pitch rise over PT
- `ridge_height` = Z of the gable apex — MUST come from the goal string
- `apex_x` defaults to `span/2` for a symmetric gable; set explicitly for asymmetric
**Cannot combine `.gable()` with `height_end`** — use one or the other.
## Wall — sloped top (gable wall under mono-pitch roof)
```python
from elixifree.domains.sip import Wall
# height at X=0 is 2440, height at X=span is 2590
result = Wall(span=3716, height=2440, stock="SIP-120", height_end=2590).build()
result.add_to_doc("Body")
```
## Wall — with door or window
```python
from elixifree.domains.sip import Wall
result = (Wall(span=4000, height=2440, stock="SIP-100")
.opening(x=1500, z=0, width=900, height=2100)
.build())
result.add_to_doc("Body")
```
`.opening(x, z, width, height)` — x from left edge, z from bottom (z=0 for door).
Chain multiple `.opening()` calls for multiple openings.
## Wall — orientation (east/west walls)
**`.orient("Y")` is for standalone `.build()` use only — pipeline-generated wall
scripts do NOT call it.** For SIP component-generation agents, every wall script
is expanded through `Wall.construct()` (the constructability layer rewrites
`.build()` → `.construct(profile)` before execution — see
`Elixihub.Constructability.Handlers.Sip`). `_WallConstructor` always builds
panels span-along-local-X, thickness-along-local-Y, **unconditionally** — it
never branches on `Wall._orientation` — because the assembly Placer applies the
wall's world rotation (yaw) externally and identically regardless of the wall's
compass direction. Calling `.orient("Y")` pre-rotates the shape a SECOND time
inside `_build_geometry()`; combined with the Placer's own yaw this double-rotates
an east/west wall. **Do not call `.orient("Y")` in component-generation scripts.**
Build every wall in its default local frame and let the Placer's yaw put it on
the correct world axis.
`.orient(axis)` still exists in the Python API and is legitimate for a
standalone script that calls `.build()` directly (outside the pipeline, e.g. a
one-off model with no Placer) and needs the whole assembled wall body pre-rotated
in local space. It is NOT part of the pipeline convention — do not add it to
scripts destined for constructability expansion.
```python
from elixifree.domains.sip import Wall
# Goal: "6000mm deep (Y direction) × 2700mm high × 142mm thick" → east/west wall
result = (Wall(span=6000, height=2440, stock="SIP-100")
.build()) # default local frame — do NOT call .orient("Y") here
result.add_to_doc("Body")
# Local BoundBox: X=6000 (span), Y=142 (thickness), Z=2440 (height)
# The Placer rotates this into the correct world orientation for an east/west wall.
```
## Wall — inner face grooves (spline receivers)
When a perpendicular wall's spline needs to slot into this wall's face, use `.inner_groove()`:
```python
from elixifree.domains.sip import Wall
result = (Wall(span=4000, height=2440, stock="SIP-100")
.inner_groove(x=61) # groove at left end (TOTAL_THICKNESS/2 from edge)
.inner_groove(x=3939) # groove at right end
.build())
result.add_to_doc("Body")
```
`.inner_groove(x, width=45, depth=50)` — x is distance from left edge to groove centre.
## Roof panel — FLAT ROOFS ONLY (`RoofPanel`)
**`RoofPanel` is for flat roofs only.** It builds a horizontal flat box regardless of any taper.
For pitched roofs (gable, duo-pitch, mono-pitch) use `PitchedRoofSlope` — full rules in the SIP Roof Construction skill (`sip_roofs.md`).
```python
from elixifree.domains.sip import RoofPanel
result = RoofPanel(span=4200, depth=3200, stock="SIP-150").build()
result.add_to_doc("Body")
```
## Floor / ceiling deck — `FloorDeck` (inter-storey cassettes)
**Any component described as a floor deck, ceiling deck, inter-storey deck, or floor
cassette uses `FloorDeck` — there is NO `elixifree.domains.floor`, `FloorCassette`, or
`Deck` module/class. Use only `from elixifree.domains.sip import FloorDeck`.**
`FloorDeck` extrudes the storey footprint polygon up to the SIP total thickness (like
`FlatRoof`, but it is the horizontal deck separating two storeys). It bears on the lower
storey's wall tops; the Placer lifts it to the correct Z. Do NOT set `feature.Placement`.
**Parameters:**
- `vertices` — the storey footprint as an ordered list of `[x, y]` points in mm
(closed loop; a rectangle is four corners). Read the footprint from your goal.
- `stock` — SIP stock key (e.g. `"SIP-200"`), default `"SIP-200"`.
```python
from elixifree.domains.sip import FloorDeck
# Rectangular footprint 6000 (X) x 4000 (Y), read from the goal
result = FloorDeck(
vertices=[[0, 0], [6000, 0], [6000, 4000], [0, 4000]],
stock="SIP-200",
).build()
result.add_to_doc("Body")
```
## Pitched roof slope — `PitchedRoofSlope` (covered by the SIP Roof Construction skill)
**NON-NEGOTIABLE: Any component described as a "pitched", "gable", "sloped", or "angled" roof panel MUST use `PitchedRoofSlope`. NEVER use `RoofPanel` for a pitched roof — it will always be flat.**
Roof slope construction rules (`ridge_length`, `half_span`, `pitch_degrees`,
`eave_overhang`, `rake_overhang` — with formulas and worked examples) live in the
**SIP Roof Construction** skill (`sip_roofs.md`), which is injected to agents
building roof components. This wall skill deliberately does not duplicate them —
an earlier copy here drifted stale (wrong `half_span` formula, missing overhang
parameters) and caused misbuilt roof panels (ISSUE-005).
## Foundation
**Slab** (solid flat plate):
```python
from elixifree.domains.sip import Foundation
result = Foundation(length=6000, width=4000, depth=150).build()
result.add_to_doc("Body")
```
**Strip / perimeter** (hollow rectangular ring — use when the description says "strip", "perimeter", "rectangular frame", or "continuous frame"):
**Your goal will say something like:**
> "Create strip perimeter foundation: 6000×4000mm footprint, strip depth 300mm, strip width 450mm, wall panel thickness 122mm. Total slab 6244×4244mm. Place at Vector(-122, -122, -300)."
Extract every value from the goal. The `Place at Vector(...)` in the goal is the mandatory `feature.Placement` offset — copy it verbatim.
```python
from elixifree.domains.sip import Foundation
from FreeCAD import Placement, Rotation, Vector
# === PARAMETERS — read directly from goal string ===
BUILDING_WIDTH = 6000 # "6000×4000mm footprint"
BUILDING_DEPTH = 4000
PANEL_THICKNESS = 122 # "wall panel thickness 122mm"
STRIP_D = 300 # "strip depth 300mm"
STRIP_W = 450 # "strip width 450mm"
result = Foundation(
length=BUILDING_WIDTH + 2 * PANEL_THICKNESS,
width=BUILDING_DEPTH + 2 * PANEL_THICKNESS,
depth=STRIP_D,
type="strip_foundation",
strip_w=STRIP_W,
).build()
feature = result.add_to_doc("Body")
# MANDATORY: offset from goal — "Place at Vector(-122, -122, -300)"
feature.Placement = Placement(Vector(-PANEL_THICKNESS, -PANEL_THICKNESS, -STRIP_D), Rotation())
```
`type="strip_foundation"` is required whenever the foundation is described as a perimeter ring rather than a solid slab. Default `strip_w` is 450mm if not specified.
## Stock values
| Name | Total thickness |
|---------|----------------|
| SIP-100 | 122mm |
| SIP-120 | 142mm |
| SIP-150 | 172mm |
| SIP-200 | 222mm (default)|
| SIP-250 | 272mm |
| SIP-300 | 322mm |
**CRITICAL — stock name rule:** Use the exact stock name from the design profile (`SIP-100`, `SIP-120`, etc.).
**NEVER derive a stock name from a thickness value.** A 142mm wall is `SIP-120`, not `SIP-142`.
The name is the core thickness, not the total panel thickness.
## Rules
1. **Always use a builder.** Do not call raw `Part.makeBox` for standard components.
2. **End every script with `result.add_to_doc("Body")`** — never write doc/recompute/Gui lines.
3. **Do not add plates, panel splits, or framing** — the assembly pipeline handles those.
4. If the builder cannot express the geometry, use raw `Part` as a fallback and add a
comment: `# ElixiFree gap: <description>` so it can be tracked for future development.
5. **Never call `.corner_spline()`.** Splines and locking plates are separate assembly parts created during constructability — they are NOT part of the wall panel component. Wall ends always have open edge grooves (built-in automatically); never add protruding splines to the wall body.
6. **Never pass a thickness integer as `stock`.** `stock` is always a string: `"SIP-120"`, `"SIP-100"`, etc.
7. **`build()` takes no arguments.** Call `result = Wall(...).build()` with no args, then `result.add_to_doc("Body")` on the next line. Never call `.build("Body")` or `.build(doc)`.
8. **Pitched roof slopes MUST use `PitchedRoofSlope`, not `RoofPanel`.** Any component described as "pitched", "gable", "sloped", "angled", or with a degree pitch value is a pitched roof — use `PitchedRoofSlope`. A script that uses `RoofPanel` for a pitched roof will render flat.
9. **`PitchedRoofSlope` scripts MUST NOT set `feature.Placement`.** The roof panel is modelled flat in the local frame. Call `result.add_to_doc("Body")` and stop — the assembly layer (Placer) applies pitch rotation from the design spec. Never set `feature.Placement` for world positioning in a roof slope script.
10. **Gable end walls MUST use `.gable(ridge_height)` — no exceptions, ever.** Any east/west end wall of a gable (duo-pitch) building must call `.gable(ridge_height=<ridge_z>)` so the top of the wall follows the roof pitch. The `ridge_height` and `height` (eave_height) are both computed by `Elixihub.Concept.RoofGeometry` and injected verbatim into the goal — read them from there, never guess or omit the call. A plain wall will be rectangular and will not fit under the pitched roof. Cannot combine with `height_end`.
11. **Angled eave top plate (Fig 1.4.7): polygon-profile extrude, NEVER `Part.makeWedge`.** When the goal asks the eave wall to carry the Fig 1.4.7 angled eave top plate, build the wall with `Wall(...)` first, then add the plate as an `# ElixiFree gap:` raw-Part fallback. `Part.makeWedge`'s argument order is not two box corners — miscalling it fails with "delta y of wedge too small". Build the raked plate from its YZ cross-section and extrude along X:
```python
# ElixiFree gap: Wall() has no builder for the Fig 1.4.7 angled eave top plate.
import Part, math
eave_base_z = HEIGHT + TOP_FLAT_PLATE_HEIGHT # flat top plate below
high_edge = LOW_EDGE_H + WIDTH * math.tan(math.radians(PITCH_DEG))
profile = Part.makePolygon([
Vector(0, 0, eave_base_z),
Vector(0, WIDTH, eave_base_z),
Vector(0, WIDTH, eave_base_z + LOW_EDGE_H), # low edge at outer face
Vector(0, 0, eave_base_z + high_edge), # high edge at inner face
Vector(0, 0, eave_base_z),
])
eave_plate = Part.Face(profile).extrude(Vector(LENGTH, 0, 0))
shape = shape.fuse(eave_plate)
```
SIP Wall Panels (ElixiFree API)
Native
API reference for elixifree.sip wall panel functions
~4083 tokens
# SIP Roof Skill
This skill covers SIP roof components: pitched slopes, flat roofs, gable end walls, ridge boards, and span-splitting rules. All dimensions in millimetres.
---
## SIP Panel Stock Reference
| Name | Total thickness | Core (EPS) |
|---------|----------------|------------|
| SIP-100 | 122mm | 100mm |
| SIP-150 | 172mm | 150mm |
| SIP-200 | 222mm | 200mm |
| SIP-250 | 272mm | 250mm |
| SIP-300 | 322mm | 300mm |
Stock name = core thickness. `SIP-150` is a 172mm total panel — never derive the name from total thickness.
---
## Roof type catalogue — pick exactly one
Four supported types (`roof.type` in the design spec). Anything else is a logged gap.
Full standard: `docs/design/SIP_ROOF_STANDARDS.md`.
| `roof.type` | Shape | Builder | Edge detail |
|----------------|-----------------------------|----------------------------------|---------------------------|
| `duo_pitch` | Two slopes, central ridge | `PitchedRoofSlope` ×2 + `RidgeBoard` | eave + rake overhang |
| `mono_pitch` | Single slope, high→low wall | `PitchedRoofSlope` ×1 | eave overhang at low edge |
| `flat_parapet` | Level deck, 1:20 fall | `RoofPanel` + parapet | parapet + scuppers |
| `flat_eaves` | Level deck, 1:20 fall | `RoofPanel` + cantilever eave | fascia + gutter, no parapet |
`flat_parapet` and `flat_eaves` differ ONLY in edge treatment. Pitched types share
the same `PitchedRoofSlope` builder and the same `half_span = BUILDING_DEPTH/2` rule;
`mono_pitch` uses a single slope and the high wall replaces the ridge board.
---
## Pitched Roof Slopes — ALWAYS use `PitchedRoofSlope`
**Do not write manual bevel geometry for pitched roofs.** The `PitchedRoofSlope` builder handles ridge-bevel and eave-bevel cuts internally, tested and frozen. Manual geometry produces subtle arithmetic errors and, critically, produces a monolithic panel that bypasses the constructability pipeline (which segments into 1220mm-wide buildable SIP panels via `.construct()`).
```python
from elixifree.domains.sip import PitchedRoofSlope
# === PARAMETERS — read every value directly from the goal string ===
# NON-NEGOTIABLE: ridge_length is ALWAYS the building width W exactly.
# NEVER add rake_overhang to it (ridge_length=W, NOT W + 2*rake). Goal text may
# mention a "total length" of W + 2*rake — that total is DERIVED by the builder
# from ridge_length + rake_overhang; feeding it back as ridge_length doubles the
# rake and pushes the slope past one gable end.
BUILDING_WIDTH = 6000 # "building width 6000mm" — ridge direction (X)
HALF_SPAN = 3000 # = BUILDING_DEPTH / 2 (to centreline) — see formula below
PITCH_DEG = 20 # "pitch 20deg"
STOCK = "SIP-150" # "stock SIP-150"
EAVE_OVERHANG = 400 # horizontal projection past the eave wall (50 = minimal clipped eave)
RAKE_OVERHANG = 400 # projection past the gable ends (50 = minimal)
slope = PitchedRoofSlope(
ridge_length=BUILDING_WIDTH,
half_span=HALF_SPAN,
pitch_degrees=PITCH_DEG,
stock=STOCK,
is_south=True, # True = south/front slope; False = north/back slope
eave_overhang=EAVE_OVERHANG,
rake_overhang=RAKE_OVERHANG,
)
result = slope.build()
result.add_to_doc("Body")
# DO NOT set feature.Placement — the assembly layer (Placer) applies pitch rotation
```
**Overhangs:** `eave_overhang` and `rake_overhang` extend the structural deck past the
walls (default 50mm = a tight closed eave). The `.construct()` pipeline adds the
**fascia** (vertical board at the panel tip), **soffit** (underside board), and
**eave/ridge blocking** automatically — you do not model them by hand. For a deep
soffit eave pass `eave_overhang=400` (or whatever the goal states); for a minimal
clipped eave leave the defaults.
**`half_span` formula:** `BUILDING_DEPTH / 2`
`half_span` is the horizontal run from the eave wall to the building **centreline**. Use `BUILDING_DEPTH / 2` — never add `PANEL_THICKNESS`. The slope is built to the centreline and its eave is placed at the wall **outer** face by the assembly layer, so both slopes' undersides meet exactly on the centreline. Adding `+ PANEL_THICKNESS` (an older, wrong rule) pushes each slope `PT` past the centreline and clashes the two slopes by `2×PT` at the ridge. See `docs/design/SIP_ROOF_STANDARDS.md` §2.
**North slope:** change `is_south=False`. Same geometry, same formula — do not copy south placement with a sign flip.
### What the builder does internally
- Computes `slope_length = half_span / cos(pitch_rad)`
- Builds three-layer OSB/EPS/OSB flat panel in local frame (X=ridge, Y=slope, Z=thickness)
- Ridge bevel: cuts triangle at Y=slope_length so the ridge face is plumb after rotation (`bevel_y = TT × tan(pitch)`)
- Eave bevel: cuts triangle at Y=0 so the panel bears cleanly on the angled-cut top plate (`eave_bv = TT / tan(pitch)`)
- Returns a flat `BuildResult.shape` — rotation to pitch angle is the assembly layer's job
**When the goal says the pitch is already included in the bounding box:** still call `PitchedRoofSlope` — the Placer reads the pitch from the design spec and tilts the flat panel into position.
### Mono-pitch (shed) roof — a single slope
A `mono_pitch` roof is **one** `PitchedRoofSlope` running from a high wall to a low
wall. There is no ridge and no opposing slope, so:
- The neutral platform can represent >5° through 45°. The SIP profile currently
treats >30° as representable but outside its verified construction range; keep
the geometry and surface the warning rather than flattening or substituting it.
- `half_span = BUILDING_DEPTH` (the slope spans the **full** depth, not half).
- There is no ridge beam; the high end bears on the **high wall** top plate.
- The two side walls are **sloped-top** walls — use `Wall(..., height_end=...)`, not
`.gable()`. `height` = low-wall eave height, `height_end` = high-wall height.
- The high and low level walls both receive the roof-bearing/eave top-plate detail;
raked side walls do not receive that plate.
- Eave overhang applies at the low edge; rake overhang at the gable sides.
```python
slope = PitchedRoofSlope(
ridge_length=BUILDING_WIDTH,
half_span=BUILDING_DEPTH, # full depth for mono-pitch (single slope)
pitch_degrees=PITCH_DEG,
stock=STOCK,
is_south=True,
eave_overhang=400,
)
```
---
## Gable End Walls — use `Wall.gable()`
For a duo-pitch (gable) roof the east and west walls must include the full triangular section above eave height. Use the `.gable()` modifier — do not hand-code the triangular prism.
Both numbers below come from `Elixihub.Concept.RoofGeometry` — the SipSpecNormalizer
writes them into the goal DETERMINISTICALLY (the LLM's own guess is discarded), so
always read them verbatim from the goal string rather than computing or guessing:
```
height (rake end / rectangular top) = gable_rake_end_h
= EAVE_HEIGHT + LIFT + PANEL_THICKNESS * tan(PITCH_DEG)
ridge_height (apex) = underside_ridge_z
= EAVE_HEIGHT + LIFT + (BUILDING_DEPTH / 2) * tan(PITCH_DEG)
```
Both are samples of the SAME roof-underside plane (`z = eave_bearing_z + y*tan(θ)`),
at `y = PANEL_THICKNESS` (rake end) and `y = BUILDING_DEPTH/2` (apex) respectively —
so a gable wall built with these two values has a rake edge exactly coplanar with
the roof slope undersides. Worked reference example (H_e=2700, θ=30°, PT=142, D=7000):
```
gable_rake_end_h = 2700 + 90 + 142 * tan(30°) ≈ 2871.99
underside_ridge_z = 2700 + 90 + 3500 * tan(30°) ≈ 4810.71
```
```python
from elixifree.domains.sip import Wall
# Values from goal: span=4000, eave_height=2871.99, ridge height=4810.71
result = (Wall(span=4000, height=2871.99, stock="SIP-100")
.gable(ridge_height=4810.71)
.build()) # default local frame — the Placer's yaw sets world orientation
result.add_to_doc("Body")
```
- `height` = the gable's own `eave_height` field (rake-end height, NOT the eave
WALLS' plain height — the gable wall is taller because it carries the triangle)
- `ridge_height` = Z of the gable apex — always read from the goal string, never
derived by hand
- `apex_x` defaults to `span/2`; set explicitly only for asymmetric gables
For walls under a **mono-pitch** roof (sloped top, not triangular) use `height_end=` instead:
```python
result = Wall(span=3716, height=2440, stock="SIP-100", height_end=2590).build()
result.add_to_doc("Body")
```
Cannot combine `.gable()` with `height_end`.
---
## Flat Roof — `flat_parapet` and `flat_eaves`
Two flat variants. Both keep the **mandatory 1:20 tapered fall** (below); they differ
ONLY at the edge:
- **`flat_parapet`** (default): parapet upstand at the wall line + **scuppers** through
the low-side parapet (primary + overflow). No overhang.
- **`flat_eaves`**: a **cantilevered eave** projecting past the wall with **fascia +
soffit + gutter** at the low side, and **no parapet / no scuppers**. Use when the
goal specifies a roof overhang. A single roof may mix the two per edge; the project
default is one or the other.
The `RoofPanel` builder is a flat box only — use it as the structural deck. Tapered
insulation, membrane, and the chosen edge detail are modelled on top.
**`RoofPanel` is for flat roofs only. For any pitched roof use `PitchedRoofSlope`.**
```python
from elixifree.domains.sip import RoofPanel
result = RoofPanel(span=4200, depth=3200, stock="SIP-200").build()
result.add_to_doc("Body")
```
### Drainage fall — MANDATORY, always 1:20
A truly flat surface ponds water and will fail. The SIP deck stays level; the fall is created by tapered insulation on top. Always use 1:20 (`FALL_HEIGHT = INNER_DEPTH / 20`). Never use 1:40 (code minimum only, no construction margin).
- `INSUL_MIN = 50` — minimum insulation thickness at the low point (code floor is 18mm; 50mm gives visible slope and construction tolerance)
- `INSUL_HIGH = INSUL_MIN + FALL_HEIGHT`
Tapered insulation and membrane must both be polygon-face wedge extrudes (not flat boxes) so the finished roof surface reads as sloped in the model.
### Scuppers — MANDATORY for parapeted flat roofs
Without scuppers the roof will flood. Cut openings through the parapet at the low side:
- Primary scupper: at membrane level, centred on the low-side parapet
- Overflow scupper: 50mm higher, offset to one side
### Flat roof full example
```python
import FreeCAD, Part, math
from FreeCAD import Vector
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("FlatRoof")
BUILDING_WIDTH = 3000
BUILDING_DEPTH = 2000
WALL_HEIGHT = 2970
PANEL_THICKNESS = 172 # match wall SIP stock
ROOF_CORE = 200
ROOF_FACE = 11
ROOF_T = ROOF_CORE + 2 * ROOF_FACE # 222mm
ROOF_Z = WALL_HEIGHT
INSUL_MIN = 50
FALL_RATIO = 20
MEMBRANE_T = 3
PARAPET_H = 300
PARAPET_T = 122 # SIP-100 parapet panels
COPING_T = 3
INNER_WIDTH = BUILDING_WIDTH - 2 * PANEL_THICKNESS
INNER_DEPTH = BUILDING_DEPTH - 2 * PANEL_THICKNESS
FALL_HEIGHT = INNER_DEPTH / FALL_RATIO
INSUL_HIGH = INSUL_MIN + FALL_HEIGHT
insul_z = ROOF_Z + ROOF_T
# SIP roof deck (horizontal)
from elixifree.domains.sip import RoofPanel
deck_result = RoofPanel(span=INNER_WIDTH, depth=INNER_DEPTH, stock="SIP-200").build()
deck = deck_result.shape
deck_obj = doc.addObject("Part::Feature", "RoofDeck")
deck_obj.Shape = deck
deck_obj.Placement.Base = Vector(PANEL_THICKNESS, PANEL_THICKNESS, ROOF_Z)
# Tapered insulation — wedge profile in YZ plane, extruded in X
# High side at north (Y = INNER_DEPTH), low side at south (Y = 0)
insul_pts = [
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z),
Vector(PANEL_THICKNESS, BUILDING_DEPTH - PANEL_THICKNESS, insul_z),
Vector(PANEL_THICKNESS, BUILDING_DEPTH - PANEL_THICKNESS, insul_z + INSUL_HIGH),
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z + INSUL_MIN),
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z),
]
insul_face = Part.Face(Part.makePolygon(insul_pts))
tapered_insul = insul_face.extrude(Vector(INNER_WIDTH, 0, 0))
insul_obj = doc.addObject("Part::Feature", "TaperedInsulation")
insul_obj.Shape = tapered_insul
# Membrane — same wedge profile, 3mm thick on top of insulation
mem_pts = [
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z + INSUL_MIN),
Vector(PANEL_THICKNESS, BUILDING_DEPTH - PANEL_THICKNESS, insul_z + INSUL_HIGH),
Vector(PANEL_THICKNESS, BUILDING_DEPTH - PANEL_THICKNESS, insul_z + INSUL_HIGH + MEMBRANE_T),
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z + INSUL_MIN + MEMBRANE_T),
Vector(PANEL_THICKNESS, PANEL_THICKNESS, insul_z + INSUL_MIN),
]
mem_face = Part.Face(Part.makePolygon(mem_pts))
membrane = mem_face.extrude(Vector(INNER_WIDTH, 0, 0))
mem_obj = doc.addObject("Part::Feature", "Membrane")
mem_obj.Shape = membrane
# Parapet walls
PARAPET_Z = ROOF_Z
par_s = Part.makeBox(BUILDING_WIDTH, PARAPET_T, PARAPET_H, Vector(0, 0, PARAPET_Z))
par_n = Part.makeBox(BUILDING_WIDTH, PARAPET_T, PARAPET_H, Vector(0, BUILDING_DEPTH - PARAPET_T, PARAPET_Z))
par_w = Part.makeBox(PARAPET_T, BUILDING_DEPTH - 2*PARAPET_T, PARAPET_H, Vector(0, PARAPET_T, PARAPET_Z))
par_e = Part.makeBox(PARAPET_T, BUILDING_DEPTH - 2*PARAPET_T, PARAPET_H, Vector(BUILDING_WIDTH - PARAPET_T, PARAPET_T, PARAPET_Z))
# Primary scupper through south parapet at low side
SCUPPER_W = 100
SCUPPER_H = 75
SCUPPER_Z = insul_z + INSUL_MIN - 10
par_s = par_s.cut(Part.makeBox(SCUPPER_W, PARAPET_T, SCUPPER_H,
Vector(BUILDING_WIDTH/2 - SCUPPER_W/2, 0, SCUPPER_Z)))
# Overflow scupper: 50mm higher, offset
par_s = par_s.cut(Part.makeBox(SCUPPER_W, PARAPET_T, SCUPPER_H,
Vector(BUILDING_WIDTH/4 - SCUPPER_W/2, 0, SCUPPER_Z + 50)))
PARAPET_TOP_Z = PARAPET_Z + PARAPET_H
cope_s = Part.makeBox(BUILDING_WIDTH, PARAPET_T+50, COPING_T, Vector(0, -25, PARAPET_TOP_Z))
cope_n = Part.makeBox(BUILDING_WIDTH, PARAPET_T+50, COPING_T, Vector(0, BUILDING_DEPTH-PARAPET_T-25, PARAPET_TOP_Z))
cope_w = Part.makeBox(PARAPET_T+50, BUILDING_DEPTH, COPING_T, Vector(-25, 0, PARAPET_TOP_Z))
cope_e = Part.makeBox(PARAPET_T+50, BUILDING_DEPTH, COPING_T, Vector(BUILDING_WIDTH-PARAPET_T-25, 0, PARAPET_TOP_Z))
for shape, name in [
(par_s, "ParapetSouth"), (par_n, "ParapetNorth"),
(par_w, "ParapetWest"), (par_e, "ParapetEast"),
(cope_s, "CopingSouth"), (cope_n, "CopingNorth"),
(cope_w, "CopingWest"), (cope_e, "CopingEast"),
]:
o = doc.addObject("Part::Feature", name)
o.Shape = shape
doc.recompute()
if FreeCAD.GuiUp:
FreeCAD.Gui.ActiveDocument.ActiveView.fitAll()
```
---
## Ridge — bevelled RidgeBoard + mitre + screws (duo_pitch)
Every `duo_pitch` roof has all three, together (see `docs/design/SIP_ROOF_STANDARDS.md` §3):
1. **RidgeBoard** — a centreline component (its own agent), LVL/glulam along X,
with a double-bevelled top (NOT a flat-topped plain box — see below).
2. **Mitred plumb panel ends** — produced automatically by the `PitchedRoofSlope`
ridge bevel (`bevel_y = TT·tan(pitch)`); the two slopes close tight on the centreline.
3. **Ridge joint screws** — emitted automatically by `RidgeBoard(...).construct()`
(~600mm centres, one row per slope) — see below. There is no separate
ridge-blocking part; the bevel itself is the bearing/nailing surface.
The ridge board is a separate building component, centred on `BUILDING_DEPTH/2`, with
its arris at `RIDGE_Z` so both slope undersides bear on it. **`RIDGE_Z` is owned by
`Elixihub.Concept.RoofGeometry.underside_ridge_z/4`** (Elixir side) — the Placer and
the design-spec normalizer both compute it from the SAME formula so the ridge board,
the gable wall's apex, and the roof slope undersides can never drift apart:
```
RIDGE_Z = underside_ridge_z(EAVE_HEIGHT, BUILDING_DEPTH, PITCH_DEG)
= EAVE_HEIGHT + LIFT + (BUILDING_DEPTH / 2) * tan(PITCH_DEG)
```
`LIFT = 90mm` is the Fig 1.4.7 eaves joint lift (top plate 45mm + eave plate low
edge 45mm) — the roof underside bears ABOVE the eave wall top by this amount, not
at the wall top directly. **Do not drop the `+ LIFT` term** — omitting it is the
exact bug that put the ridge board and gable rake 90mm below the roof underside
in a live project.
Worked reference example (H_e=2700, θ=30°, D=7000):
```
RIDGE_Z = 2700 + 90 + 3500 * tan(30°) ≈ 4810.71
```
### Ridge board MUST use `RidgeBoard` — never a plain box
The ridge board's top is a **double bevel** (box minus two wedges): each top face
is raked to the roof pitch so it bears the FULL underside of its slope, and the
**arris** (where the two bevels meet, along the top centreline) just touches the
panels' plumb-cut mitre line from below. The roof panels keep their plumb-cut
mitre meeting at the centreline — the board, not the panels, is shaped. A
flat-topped `Part.makeBox` can only line-contact the sloping undersides — it is
a construction error.
```python
from elixifree.domains.sip import RidgeBoard
# === PARAMETERS — read every value directly from the goal string ===
RIDGE_BOARD_LENGTH = BUILDING_WIDTH - 2 * PANEL_THICKNESS # bears between gable inner faces
RIDGE_BOARD_W = 90 # width across the ridge (Y)
RIDGE_BOARD_D = 350 # overall depth (Z); the arris is the local top edge
result = RidgeBoard(
RIDGE_BOARD_LENGTH,
RIDGE_BOARD_W,
RIDGE_BOARD_D,
pitch_degrees=PITCH_DEG,
).build()
result.add_to_doc("Body")
# DO NOT set feature.Placement — the assembly layer (Placer) snaps the arris to RIDGE_Z
```
**Placement (owned by the Placer — never computed in the script):** the board's
min corner lands at
```
(PANEL_THICKNESS, BUILDING_DEPTH/2 - RIDGE_BOARD_W/2, RIDGE_Z - RIDGE_BOARD_D)
```
so the arris sits exactly at `RIDGE_Z` on the building centreline and both bevel
faces lie flush on the two roof slope underside planes. Worked reference
(H_e=2700, θ=30°, D=7000, PT=142, board 90×350):
```
RIDGE_Z ≈ 4810.71 → min-corner = (142, 3455, 4460.71)
bevel drop each side = 45 * tan(30°) ≈ 25.98
```
**Ridge joint screws:** `RidgeBoard(...).construct()` emits a
`ridge_joint_screws` part automatically — two plumb rows (one per slope, at
`RIDGE_BOARD_W/2 ∓ RIDGE_BOARD_W/4` across the board), #14 structural screws at
600mm centres with a 150mm end offset, driven vertically down through the roof
SIP into the board bevel, head at the panel top face
(`screw_len = ROOF_TOTAL_THICKNESS / cos(pitch) + 60mm bite`). Do not model
ridge screws by hand.
> `HALF_SPAN = BUILDING_DEPTH/2`. Eave/ridge **blocking**, **fascia**, and **soffit**
> are emitted by the slope's `.construct()` pipeline — do not model them by hand.
---
## Panel Span Splitting — MANDATORY above 2440mm
Standard SIP stock is 2440mm × 1220mm. Any panel span exceeding 2440mm (measured along the panel's structural direction) must be split. For pitched roofs the span direction is along the slope (`slope_length = HALF_SPAN / cos(pitch_rad)`).
Use equal sections, not 2440 + remainder (equal sections avoid a sliver panel):
```python
import math
MAX_SPAN = 2440
def span_sections(total_span):
n = math.ceil(total_span / MAX_SPAN)
section = total_span / n
return [section] * n
```
Model an LVL bearer (45mm wide × panel core depth) at every splice point.
| Building depth | HALF_SPAN | slope_length @20° | n sections |
|---------------|-----------|-------------------|------------|
| 4000mm | 2122mm | 2259mm | 1 |
| 6000mm | 3122mm | 3323mm | 2 |
| 8000mm | 4122mm | 4387mm | 2 |
---
## Non-Negotiable Rules
1. **Pitched roofs MUST use `PitchedRoofSlope`.** Never write `make_slope_panel`, `cut_ridge_bevel_slope`, or manual bevel geometry. Doing so produces a monolithic panel that bypasses constructability segmentation.
2. **Do NOT set `feature.Placement` in a roof slope script.** The panel is built flat in local frame. The Placer applies pitch rotation from the design spec. Setting Placement in the script will fight the assembly layer.
3. **`half_span = BUILDING_DEPTH/2`** (to the centreline). Never add `PANEL_THICKNESS` — the eave is placed at the wall outer face, so `+ PT` clashes the two slopes by `2×PT` at the ridge.
4. **Flat roofs MUST have 1:20 drainage fall and scupper outlets.** Use `FALL_HEIGHT = INNER_DEPTH / 20`, `INSUL_MIN = 50`. Tapered insulation and membrane must be polygon-face wedges, not flat boxes. Scuppers must be cut through the parapet at the low side.
5. **Any panel span > 2440mm MUST be split into equal sections with LVL bearers.**
6. **Gable end walls MUST use `.gable(ridge_height)`.** A plain rectangular wall will not fit under the pitched roof.
7. **Duo-pitch ridge boards MUST use `RidgeBoard(length, width, depth, pitch_degrees)`.** A plain `Part.makeBox` ridge has a flat top that can only line-contact the sloping panel undersides; `RidgeBoard` cuts the double-bevelled top (arris on the centreline, bevels coplanar with the panel undersides) and its `.construct()` adds the mandatory ridge joint screws.
SIP Roof Construction
Native
Mono-pitch, duo-pitch, and flat roof construction code, ridge bevels, gable sections, and flat roof drainage. Injected to agents building roof components.
~5289 tokens
# Deterministic Pitched-L SIP Roofs
Use this skill for graph-owned pitched-L agents. The manifest metadata marks them with
`generation_intent = deterministic_pitched_l`, but persisted component records may not
retain the metadata map; stable ids and `assembly_placement.graph.kind` therefore remain
authoritative routing signals.
These agents are enabled for one to three identical axis-aligned L storeys with one
uniform 15–45 degree gable pitch. Any other pitched-L request must fail eligibility
before component generation; do not approximate unsupported geometry.
Their `metadata` and `assembly_placement` come from the approved building graph and are
authoritative. Do not infer a footprint, choose a ridge direction, calculate a valley,
change a pitch, or replace graph coordinates with values derived from a bounding box.
## Roof panel system
For `metadata.builder = SIPRoofPanelizer`, pass `lower_wing`, `upper_wing`,
`pitch_degrees`, and `stock` directly to `SIPRoofPanelizer`. Build every descriptor
returned by its plan. Panel splits are fabrication parts; `GableRoom` is reference
envelope metadata only and must never be emitted as a manufacturable component.
The Design assembly may present these panels as one selectable `Part.makeCompound`, but
the compound MUST be built from the same panelizer result. Never infer pitch, wing split,
panel thickness, or ridge height from its bounding box.
## Structure and connections
For `metadata.builder = SIPRoofStructure`, use the exact skeleton and panel descriptors
provided by the manifest. Ridge and valley `from`/`to` values are immutable graph-owned
lines. Emit the planned ridge members, single continuous valley rafter, sloped rafters
beneath panel seams, transverse purlins beneath stock-row joints, wall-mounted joist
hangers at rafter eaves, and the catalogued frame fastener schedules.
The graph skeleton describes the **exterior roof face**. Derive each structural bearing
line on the parallel SIP underside by subtracting
`roof_panel_thickness / cos(pitch)` from its Z coordinate. That is the vertical
intercept of the normal-offset underside plane. The underside line is the
top/contact datum, never the bottom of a timber member. Ridge, valley and eave bearing
members must extend downward from it into the building envelope. Never place a member
directly on the exterior skeleton line: that embeds it through the full panel depth.
This is distinct from positioning the complete panel envelope at the walls: place the
exterior eave at `wall_top_z + roof_panel_thickness * cos(pitch)` so the panel solid's
lowest eave edge meets the wall top. Do not use the underside-plane intercept as the
whole-roof lift; the difference appears as a visible gap above the walls.
Both wing ridges must use the same double-bevelled `RidgeBoard` geometry as an ordinary
duo-pitch SIP roof. Centre its arris on the graph ridge line, place its arris exactly at
the panel underside, and extend its depth downward. A flat-topped box or an unbevelled
beam is prohibited because it only line-contacts the two roof slopes.
Every sloped rafter that reaches an eave must terminate in a catalogued joist hanger
fixed to the wall structure. Rafters and transverse purlins remain inside the building
envelope and their top faces are trimmed tangent to the SIP underside. Gable closures
remain non-bearing infill. Roof panels bear and fasten onto this recessed frame.
An L made from overlapping gable wings also exposes two transition gables even though
their footprint edges are classified as eaves: `gable_transition_south` and
`gable_transition_west`. Generate both from their graph outlines. The west transition
owns their shared southwest corner. Trim both against the actual `SIPRoofPanelizer`
envelope with `SIPRoofPanelizer.gable_transition(...)`; a visible triangle is not
acceptable if it occupies roof-panel volume.
Preserve STEP-measured `bounding_box` and `bbox_origin` when graph placement is refreshed.
The panelizer extrudes roof thickness inward and therefore produces a negative local Z
origin. Dropping it places the complete roof approximately one panel vertical projection
too low and drives it through the walls and bearing plates.
## Prohibited fallback
- Do not use `RoofPanel`, `PitchedRoofSlope`, or hand-authored solids for these agents.
- Do not merge the two roof wings into a rectangular gable roof.
- Do not add a second ridge, valley, eave plate, perimeter closure, transition closure,
spline, or fastener agent.
- Do not present embedded panel splines as the roof-bearing structure. Use
`SIPRoofStructure` and retain `roof_rafter`, `roof_purlin`, and `joist_hanger` parts
for the design frame body.
- Fail with a missing-contract error if required graph metadata is absent.
# SIP Building Decomposition Skill
This skill is used during **manifest generation and design spec creation**. It covers how to decompose a SIP building brief into a set of component agents, naming conventions, ordering rules, and coordinate system. It does not contain FreeCAD construction geometry — that is in the domain-specific skills (sip_walls, sip_roofs, etc.).
---
## When to Apply
Apply this skill when the project involves SIP construction: garden rooms, sheds, studios, cabins, or any non-habitable structure where walls and roof are built from structural insulated panels. If the brief mentions "SIP", "structural insulated panel", "wall panel", or "roof panel", this skill applies.
---
## Complete SIP Building — Required Agents
A complete SIP building manifest MUST include all of the following agent types. A manifest missing any of these is incomplete.
| Agent type | Required count | Purpose |
|---|---|---|
| `foundation` | exactly 1 | Concrete slab or strip foundation |
| `*_wall` | 4 (all perimeter walls) | South, north, east, west walls |
| `*_roof` | 1+ (depends on roof type) | Roof assembly |
| `ridge_beam` | 1 for duo-pitch roofs | Structural beam at the apex (see Ridge beam component) |
| `*_fasteners` / `*_anchor_bolts` / `*_ties` | 1+ | Structural fixings — MANDATORY, see Fastener agents below |
**All four perimeter walls are mandatory.** A brief that mentions only one wall means the other three must still be included. A manifest with fewer than four wall agents is wrong.
---
## ⚠ NON-NEGOTIABLE: Agent Naming
These naming rules are enforced by validators. Violating them produces validation failures.
### Foundation agent
The foundation agent MUST be named exactly:
```
"agent_id": "foundation"
```
The following names are **FORBIDDEN** — they will fail validation:
- ❌ `concrete_slab`
- ❌ `slab`
- ❌ `strip_foundation`
- ❌ `foundation_slab`
- ❌ `base_plate` or `base_plate_*`
- ❌ `foundation_front`, `foundation_south`, `foundation_north`, etc.
**There is exactly one foundation agent and its id is `foundation`.** No exceptions.
**Wrong:**
```json
{ "agent_id": "concrete_slab", "role": "foundation" }
```
**Correct:**
```json
{ "agent_id": "foundation", "role": "foundation" }
```
### Wall agents
Wall agents use directional names: `south_wall`, `north_wall`, `east_wall`, `west_wall`.
For buildings with multiple wall panels on one face, the wall agent still represents the full assembled wall — panel splitting happens inside the component, not as separate agents.
### Roof agents
**Reuse the design spec's roof component ids VERBATIM.** The Placer pairs
agents to spec components by exact id — a renamed roof agent (spec
`south_roof` → agent `gable_roof_south`) silently keeps a stale placement and
the roof panel floats away from the walls. If (and only if) the spec has no
roof component to copy from, use descriptive names: `flat_roof`,
`mono_pitch_roof`, `gable_roof_south`, `gable_roof_north`.
---
## Agent Dependency Ordering
Dependencies define which agents must complete before others can start. Always declare:
```json
{
"agent_id": "south_wall",
"dependencies": ["foundation.dimensions"]
}
```
Standard ordering:
1. `foundation` — no dependencies
2. All four walls — depend on `foundation.dimensions`
3. Roof — depends on all four walls (e.g. `south_wall.top_plate`, `north_wall.top_plate`)
---
## Panel Size Limits — When to Split Agents vs. Split Panels
Standard SIP stock: **2440mm × 1220mm**. The 2440mm is the span limit; 1200mm is the standard panel width.
**Panels within a single wall/roof agent are always split** — a 6m wall uses five 1200mm panels joined by splines. This splitting happens inside the component code, not as separate agents.
**Agent splitting** is only needed if the design is inherently multi-section (e.g. two roof slopes for a duo-pitch). One wall = one agent regardless of length.
---
## MANDATORY: Design Spec `"roof"` Section
Every SIP design spec **MUST** include a top-level `"roof"` object. Without it the profile validator fires a warning on every generation and downstream component agents have no authoritative roof type.
| Roof type | `roof_type_key` | `roof_structure_key` |
|-----------|-----------------|----------------------|
| Flat / warm deck | `"flat"` | `"roof_structure/sip_flat_warm_roof"` |
| Mono-pitch | `"mono_pitch"` | `"roof_structure/sip_mono_pitch_panels"` |
| Duo-pitch gable | `"duo_pitch"` | `"roof_structure/sip_simple_gable_panels"` |
Example for a duo-pitch gable garden room:
```json
"roof": {
"roof_type_key": "duo_pitch",
"roof_structure_key": "roof_structure/sip_simple_gable_panels",
"pitch_degrees": 30,
"ridge_direction": "east_west"
}
```
**Non-negotiable:** A design spec with no `roof` section will always produce a validation warning. Include the `roof` object and always include `pitch_degrees` — read it from the brief; default to `pitch_degrees: 30` for duo-pitch if the brief does not specify, `pitch_degrees: 5` for mono-pitch, and omit `pitch_degrees` for flat. **`pitch_degrees` is critical** — it flows directly into the roof slope and gable wall component goals and is used verbatim by the builder. An incorrect default produces a visibly wrong roof angle.
---
## Coordinate System
All components share a common building coordinate system:
```
Origin (0, 0, 0) = SW corner of building footprint at Z = 0 (top of slab / floor level)
X-axis = East (along building width, BUILDING_WIDTH)
Y-axis = North (along building depth, BUILDING_DEPTH)
Z-axis = Up (vertical)
```
The foundation agent defines the global footprint. All wall and roof agents must use the same BUILDING_WIDTH, BUILDING_DEPTH, and PANEL_THICKNESS as the foundation.
**Z placement — foundation top at Z=0, walls sit ON Z=0:**
Z=0 is the **top of the slab** (floor level). The Foundation builder's local origin IS the
top face — geometry extends DOWN from local Z=0 to Z=−STRIP_DEPTH internally. So:
- ✅ foundation `position`: `{ "x": -PT_WALL, "y": -PT_WALL, "z": 0 }`
- ✅ every wall `position.z`: `0`
- ❌ foundation `position.z = -STRIP_DEPTH` — this DOUBLES the internal offset and buries
the slab top at world Z=−STRIP_DEPTH, making the walls float above the foundation.
Key shared variables declared in the manifest (all agents must agree on these):
| Variable | Where set | Consumed by |
|---|---|---|
| `BUILDING_WIDTH` | foundation | all walls, roof |
| `BUILDING_DEPTH` | foundation | all walls, roof |
| `PANEL_THICKNESS` | design spec | all walls, roof positioning |
| `WALL_HEIGHT` | wall agents | roof eave height |
| `EAVE_HEIGHT` | derived | roof slopes |
---
## NON-NEGOTIABLE: Mandatory Key Values in Agent Goals
**The component agent receives only the `goal` string and its bounding box.** It has no access to the design spec. Every scalar value the component agent needs to generate correct geometry MUST appear verbatim in the `goal` field. Vague goals produce wrong geometry.
### Foundation goal — MUST include strip depth and panel thickness
Strip foundation goal template (substitute real values from the spec):
```
Create strip perimeter foundation: building footprint BUILDING_WIDTHmm × BUILDING_DEPTHmm,
strip depth STRIP_Dmm, strip width STRIP_Wmm, wall panel thickness PANEL_THICKNESSmm.
Foundation extends PANEL_THICKNESSmm beyond footprint on all sides.
Place at Vector(-PANEL_THICKNESS, -PANEL_THICKNESS, -STRIP_D).
```
Example for 6000×4000mm building, SIP-100 (122mm), 300mm deep strip:
```
Create strip perimeter foundation: 6000×4000mm footprint, strip depth 300mm, strip width 450mm,
wall panel thickness 122mm. Total slab 6244×4244mm. Place at Vector(-122, -122, -300).
```
### South/north wall goals — MUST include span, height, stock, and panel thickness
**Eave (south/north) span = `BUILDING_WIDTH`** (the FULL outer-to-outer building width). The eave walls are the roof-bearing walls and ALWAYS run through the corners outer-to-outer — never shorten them to a fit-between span.
```
Create south wall SIP panel: span BUILDING_WIDTHmm, height WALL_HEIGHTmm, stock SIP-XXX
(panel thickness PANEL_THICKNESSmm). Inner face at Y=0, outer face at Y=-PANEL_THICKNESS.
```
### East/west gable end wall goals — MUST include eave height AND ridge height
For duo-pitch (gable) roofs the east and west walls have a triangular gable top. The goal MUST include both the eave height and the ridge height so the component agent can call `.gable()`.
**These two numbers are deterministic and owned by `Elixihub.Concept.RoofGeometry`** — `SipSpecNormalizer` OVERWRITES whatever you put in `dimensions.height`/`dimensions.eave_height` (and the `"gable"` sub-map, if present) with the formula result before the component agent ever sees the goal, so get them right here rather than relying on the correction:
```
gable_rake_end_h (eave_height field) = WALL_HEIGHT + LIFT + PANEL_THICKNESS * tan(PITCH_DEGREES * π/180)
underside_ridge_z (ridge_height/apex) = WALL_HEIGHT + LIFT + (BUILDING_DEPTH/2) * tan(PITCH_DEGREES * π/180)
```
`LIFT = 90mm` (Fig 1.4.7 eaves joint: top plate 45mm + eave plate low edge 45mm) — the roof underside bears ABOVE the eave wall top by this amount. **Do not omit `+ LIFT`** — that omission is what let the ridge board and gable rake end up 90mm below the roof underside in a live project.
```
Create east gable end wall SIP panel: span (BUILDING_DEPTH - 2*PANEL_THICKNESS)mm,
eave height GABLE_EAVE_HEIGHTmm, ridge height RIDGE_Zmm (duo-pitch gable roof, symmetric apex),
stock SIP-XXX.
```
Do NOT tell the component agent to call `.orient("Y")` — the goal only needs
span/height/stock/ridge values. Pipeline wall scripts are expanded through
`Wall.construct()`, which always builds span-along-local-X regardless of the
wall's compass direction; the Placer's yaw sets world orientation. `.orient()`
is a `.build()`-only, non-pipeline API — see `sip_walls_elixifree.md`.
**Gable span = `BUILDING_DEPTH - 2*PANEL_THICKNESS`** (NOT the full `BUILDING_DEPTH`, NOT `BUILDING_DEPTH - PT`). Corner rule: the eave (south/north) walls run through the corners outer-to-outer, and the gable end walls BUTT between the eave walls' inner faces — the clear opening between those inner faces is `DEPTH - 2*PT`. This holds for every duo-pitch building regardless of which wall pair is longer. A full-depth gable span overlaps the eave wall bodies at both corners by PT. Embed the numeric value.
Compute both formulas and embed the numeric results. Do not leave either as a bare formula.
Example for 7000mm depth, 142mm panel, 2700mm eave, 30° pitch:
`span = 7000 - 2*142 = 6716` (butts between the eave inner faces),
`gable_rake_end_h = 2700 + 90 + 142 * tan(30°) ≈ 2871.99mm`,
`underside_ridge_z = 2700 + 90 + 3500 * tan(30°) ≈ 4810.71mm`:
```
Create east gable end wall SIP panel: span 6716mm, eave_height 2871.99mm, ridge height 4810.71mm
(duo-pitch gable roof, symmetric apex), stock SIP-120.
```
### Roof slope goals — MUST include eave height, pitch, building depth, and panel thickness
```
Create DIRECTION pitched roof SIP slope: building width BUILDING_WIDTHmm, half-span HALF_SPANmm
(= BUILDING_DEPTH/2), pitch PITCH_DEGREESdeg, eave height WALL_HEIGHTmm
(Z of top of eave wall), wall panel thickness PANEL_THICKNESSmm, building depth BUILDING_DEPTHmm,
stock SIP-XXX. is_south=True/False. eave_overhang 400mm, rake_overhang 300mm.
```
`HALF_SPAN = BUILDING_DEPTH / 2` — to the building centreline. **Never add PANEL_THICKNESS**;
the eave is placed at the wall outer face by the assembly layer, so both slopes meet on the
centreline. Adding `+ PT` clashes the two slopes by `2×PT` at the ridge. Embed the computed
numeric value; do not leave it as a formula.
**`pitch_degrees`, `stock`, `eave_overhang`, and `rake_overhang` must ALL be explicit** in the goal and passed directly to `PitchedRoofSlope(...)`. The builder defaults are 50mm for overhangs (far too small) and has no pitch default. The goal text will supply all four — read them verbatim.
The component script calls `slope.build()` and `result.add_to_doc("Body")`. The assembly layer
(Placer) applies pitch rotation from the design spec — do not set `feature.Placement` in the script.
Example for 8000×6000mm building, SIP-120 (142mm wall), 30° pitch, 2600mm eave, SIP-200 roof:
```
Create south pitched roof SIP slope: building width 8000mm, half-span 3000mm
(= 6000/2), pitch 30deg, eave height 2600mm, wall panel thickness 142mm,
building depth 6000mm, stock SIP-200. is_south=True. eave_overhang 400mm, rake_overhang 300mm.
```
### Ridge board component — REQUIRED for duo-pitch roofs (bevelled `RidgeBoard`, NOT a plain box)
A duo-pitch (gable) roof MUST include a `ridge_beam` component (agent id/role kept
as `ridge_beam` for backward compatibility — the geometry it builds is the
bevelled `RidgeBoard`, never a flat-topped `Part.makeBox`). The two slopes bear
on its two bevel faces at the apex; the arris (where the bevels meet) just
touches the panels' plumb-cut mitre from below. Omitting it leaves the ridge
unsupported and the board unplaced.
- `agent_id`: `ridge_beam`, role `ridge_beam`.
- Runs along the ridge (X), BUTTS between the gable walls' inner faces (same
corner rule as every other component — it does NOT run outer-to-outer):
`length = BUILDING_WIDTH - 2*PANEL_THICKNESS`.
- Cross-section: `width` (Y, across the ridge) = 90mm, `depth` (Z, overall,
local top = arris) = `RIDGE_BOARD_D = max(200, ceil(HALF_SPAN/10))`.
- `dimensions`: `{ "length": BUILDING_WIDTH - 2*PANEL_THICKNESS, "width": 90, "height": RIDGE_BOARD_D }`.
- `position` (min-corner, starting hint only): `{ "x": PANEL_THICKNESS, "y": BUILDING_DEPTH/2 - 45, "z": RIDGE_Z - RIDGE_BOARD_D }`
where `RIDGE_Z = EAVE_HEIGHT + LIFT + (BUILDING_DEPTH/2) * tan(PITCH)` (`LIFT = 90mm`,
the Fig 1.4.7 eaves joint lift — do not omit it, see the gable end wall section above).
The Placer (`Elixihub.Assembly.Placer`) deterministically snaps the board's
final position so its ARRIS (not a flat top) lands exactly on
`Elixihub.Concept.RoofGeometry.underside_ridge_z/4` regardless of what is
declared here: `pos_z = underside_ridge_z - depth`.
- `connects_to`: the two roof slopes.
- The component script MUST use `RidgeBoard(length, width, depth, pitch_degrees)`
— see `sip_roofs.md` "Ridge board MUST use `RidgeBoard`". A plain `Part.makeBox`
ridge is a construction error (line contact only, not full-face bearing) and
is auto-rewritten by the constructability layer to `RidgeBoard(...).construct()`
when detected, but do not rely on that fallback.
Goal string:
```
Create ridge board: length (BUILDING_WIDTH - 2*PANEL_THICKNESS)mm along the ridge (X), 90mm wide (Y),
RIDGE_BOARD_Dmm deep (Z), double-bevelled top (arris on the centreline). Position centred on
building depth at the ridge line, arris at RIDGE_Zmm. LVL/glulam.
```
### Fastener agents — MANDATORY, every SIP building manifest needs at least one
Fasteners are structural, not decorative — a SIP building's walls and roof are
held together and to the foundation by anchor bolts, structural screws, and
hurricane ties, not by friction. `Elixihub.DesignProfiles.Validators.Sip.FastenersPresent`
rejects any manifest with zero fastener agents (`:error` severity — this is a
hard validation failure, not a warning to be silently proceeded past).
**A manifest missing fastener agents is incomplete, exactly like a manifest
missing a wall.** Do not treat fasteners as future work, an optional
enhancement, or something to add "if there's room" — every SIP building spec
in this profile requires at least a sole-plate anchor bolt agent, and duo-pitch
roofs additionally require a roof-to-wall tie agent. The full set of fastener
types, their spacing rules, and their `library_key`s are in the
`connection_details`/`fasteners` catalog context provided at manifest
generation — read it, don't invent fastener specs.
- Model fasteners as their own agents, one per fastener type/location — never
fold a fastener spec into another component's goal text or `library_key`.
A wall agent's `library_key` must resolve to the wall itself, not a bolt.
- Name fastener agents descriptively: `sole_plate_anchor_bolts_south`,
`sole_plate_anchor_bolts_north`, `roof_hurricane_ties`, etc. — one agent per
wall/roof edge that needs fixing is fine; a single combined agent covering
the whole perimeter is also acceptable as long as every eave/gable wall is
covered.
- Set `source_kind` to `"library"` and `library_key` to the matching catalog
entry's `library_key` (e.g. `hardware/anchors/m12_anchor_bolt_200mm` for
sole-plate anchors) whenever the fastener catalog has a matching part —
these are standard hardware, not bespoke geometry.
- At minimum, every manifest needs: one sole-plate anchor bolt agent (fixes
every perimeter wall's sole plate to the foundation). Duo-pitch and
pitched-L roofs additionally need a roof-to-wall hurricane tie agent.
**Wrong** (fasteners omitted entirely, or folded into another agent):
```json
{ "agent_id": "south_wall", "role": "South Eave Wall", "library_key": "hardware/anchors/m12_anchor_bolt_200mm" }
```
**Correct:**
```json
{
"agent_id": "sole_plate_anchor_bolts",
"role": "Sole Plate Anchor Bolts — Perimeter",
"source_kind": "library",
"library_key": "hardware/anchors/m12_anchor_bolt_200mm",
"goal": "M12 anchor bolts at 600mm centres, max 150mm from each plate end, fixing every perimeter wall's sole plate to the foundation.",
"dependencies": ["foundation.body", "south_wall.body", "north_wall.body", "east_wall.body", "west_wall.body"]
}
```
### Interior partition components — span MUST be derived from `connects_to`, never the raw envelope
Interior partitions (`partition_*` agents, timber stud, not SIP) sit inside the building and are
listed in `connects_to` against their real neighbours (other partitions and/or perimeter walls).
**Their declared `dimensions` span is the distance between those two neighbours' faces — NEVER
`BUILDING_WIDTH` or `BUILDING_DEPTH`.** A partition's box must fit entirely inside the inner
envelope `[PT, BUILDING_WIDTH-PT] x [PT, BUILDING_DEPTH-PT]`.
`Elixihub.Concept.SipSpecNormalizer` re-derives (or, failing that, clamps) every partition's span
and position deterministically before component generation — but a badly authored span still
indicates the spec's `connects_to` graph disagrees with its declared geometry, so get it right
here.
**Worked example** (9000x7000mm building, PT=142mm — inner envelope Y in `[142, 6858]`):
A north-south hallway partition (`partition_hall_west`) running from the `partition_living_back_west`
partition's back face (y=3942) up to the north wall inner face (y=6858) has:
```
span = 6858 - 3942 = 2916mm ✅ correct — derived from the real connects_to neighbours
position = { "x": 4142, "y": 3942, "z": 0 }
```
The observed field failure: the SAME partition declared with `span=7000` (the raw `BUILDING_DEPTH`,
not the neighbour distance) at `position.y=4042`:
```
far_end_y = 4042 + 7000 = 11042mm ❌ wrong — 4184mm outside the north wall inner face (6858)
```
This happens when the span is copied from the building's overall depth instead of computed from
`connects_to`. Always compute span from the two real neighbour faces; if a neighbour cannot be
resolved, clamp the span so the partition's box stays inside the inner envelope rather than
emitting a full-envelope span.
### User-drawn partitions — `interior_walls` must reproduce verbatim, 1:1 with `partition_*`
When the context includes a USER-AUTHORED FLOOR-PLAN SKETCH block whose graph has `interior_walls`,
those are BINDING geometry, same as the perimeter. The emitted `building_graph.storeys[].interior_walls`
MUST reproduce them verbatim (same ids, endpoints, thickness, openings) — do not invent, drop, or
reposition them. Every `partition_*` component MUST correspond 1:1 with one `interior_wall` id: same
count, and each partition's position/span still follows the `connects_to`-derived rule above, applied
to the sketch's endpoints rather than a text description.
Example: sketch `interior_walls` has one entry `{"id": "s0_p0", "a": [4000,142], "b": [4000,5858],
"thickness": 100}` → the spec emits exactly one `partition_*` component, `connects_to` resolving to
the two walls/partitions at those endpoints, span `5858 - 142 = 5716mm` along Y at `x=4000`. A sketch
with two `interior_walls` and only one emitted `partition_*` (or vice versa) is wrong.
SIP Decomposition
Native
Agent naming conventions, dependency ordering, coordinate system, and decomposition rules for SIP building manifests. Injected at manifest_generation and design_spec_generate.
~5111 tokens
# SIP Foundation Construction Skill
## Foundations
### 1. Concrete Slab (most common for SIP)
No ElixiFree builder for slab-on-grade — write raw `Part` geometry. The slab MUST extend beyond the wall footprint on all sides (`SLAB_OVERHANG = 200mm` minimum).
```
Section view:
┌──────────────────────────────────────────────┐
│ SIP wall panel (foam min 150mm above ground)│
├──────────────────────────────────────────────┤ ← PT bottom plate (45×90mm)
├──────────────────────────────────────────────┤ ← DPC membrane (3mm)
├──────────────────────────────────────────────┤ ← slab top (Z=0)
│ concrete slab 125mm │
├─────┬────────────────────────────────┬───────┤
│edge │ insulation (optional) │ edge │
│beam │ │ beam │
│300× │ │ 300× │
│600 │ │ 600 │
└─────┴────────────────────────────────┴───────┘
← SLAB_OVERHANG (200mm min) →
← slab extends past wall face on all sides
```
```python
import FreeCAD, Part
from FreeCAD import Vector
# === SLAB PARAMETERS ===
BUILDING_WIDTH = 3000 # outer wall face to outer wall face
BUILDING_DEPTH = 2000
SLAB_OVERHANG = 200 # slab extends beyond wall footprint — minimum 200mm
SLAB_W = BUILDING_WIDTH + 2 * SLAB_OVERHANG
SLAB_D = BUILDING_DEPTH + 2 * SLAB_OVERHANG
SLAB_T = 125 # slab field thickness
EDGE_BEAM_W = 300 # thickened perimeter beam width
EDGE_BEAM_D = 600 # total depth including slab thickness
DPC_T = 3 # damp proof course
CORE_THICKNESS = 150 # foam core of the SIP panel (SIP-150)
FACE_THICKNESS = 11 # OSB skin each side
SILL_PLATE_W = CORE_THICKNESS # plate slots into foam groove — NOT full SIP thickness
SILL_PLATE_H = 90
# Z=0 = slab top (all wall and floor references start here)
slab_x0 = -SLAB_OVERHANG
slab_y0 = -SLAB_OVERHANG
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Foundation")
# Slab field
slab = Part.makeBox(SLAB_W, SLAB_D, SLAB_T, Vector(slab_x0, slab_y0, -SLAB_T))
# Edge beams (perimeter thickening)
eb_n = Part.makeBox(SLAB_W, EDGE_BEAM_W, EDGE_BEAM_D,
Vector(slab_x0, slab_y0 + SLAB_D - EDGE_BEAM_W, -EDGE_BEAM_D))
eb_s = Part.makeBox(SLAB_W, EDGE_BEAM_W, EDGE_BEAM_D,
Vector(slab_x0, slab_y0, -EDGE_BEAM_D))
eb_e = Part.makeBox(EDGE_BEAM_W, SLAB_D, EDGE_BEAM_D,
Vector(slab_x0 + SLAB_W - EDGE_BEAM_W, slab_y0, -EDGE_BEAM_D))
eb_w = Part.makeBox(EDGE_BEAM_W, SLAB_D, EDGE_BEAM_D,
Vector(slab_x0, slab_y0, -EDGE_BEAM_D))
foundation = slab.fuse(eb_n).fuse(eb_s).fuse(eb_e).fuse(eb_w)
# Anchor bolts M12 at 600mm centres (perimeter, modelled as cylinders)
BOLT_D = 12
BOLT_L = 150
for x_pos in range(300, BUILDING_WIDTH - 300, 600):
bolt = Part.makeCylinder(BOLT_D / 2, BOLT_L,
Vector(x_pos, FACE_THICKNESS + SILL_PLATE_W / 2, -BOLT_L))
foundation = foundation.fuse(bolt)
# DPC membrane strip and sill plate sit at Z=0 on slab surface.
# Offset by FACE_THICKNESS so they align with the foam channel.
dpc_s = Part.makeBox(BUILDING_WIDTH, SILL_PLATE_W, DPC_T,
Vector(0, FACE_THICKNESS, 0))
sp_s = Part.makeBox(BUILDING_WIDTH, SILL_PLATE_W, SILL_PLATE_H,
Vector(0, FACE_THICKNESS, DPC_T))
# Repeat for N, E, W walls
feature = doc.addObject("Part::Feature", "Foundation")
feature.Shape = foundation
doc.recompute()
```
**Key rules for slab foundations:**
- Slab top surface = Z=0 (all wall/floor heights reference from this)
- `SLAB_OVERHANG` ≥ 200mm — if SLAB_W or SLAB_D equals BUILDING_WIDTH/DEPTH, the slab is too small
- Foam core bottom = DPC_T + SILL_PLATE_H = must be ≥ 150mm above finished ground
- Anchor bolts: M12, 150mm embedment, 600mm centres, 150mm from corners
---
### 2. Strip Foundation — use `Foundation` builder
**Use the `Foundation` builder with `type="strip_foundation"`.** Do not write raw `Part.makeBox` fuse code for the strip ring — the builder handles segment geometry internally and is tested.
For an L-shaped building, pass the exact ordered OUTER concrete footprint to
`footprint=`. Do not reduce it to a bounding rectangle and do not inset the polygon
again in the component script. The builder preserves the re-entrant notch, creates
the hollow perimeter strip, and keeps its top face at local Z=0.
```python
from elixifree.domains.sip import Foundation
OUTER_CONCRETE_FOOTPRINT = [
[0, 0], [18284, 0], [18284, 5284],
[12142, 5284], [12142, 7284], [0, 7284],
]
result = Foundation(
footprint=OUTER_CONCRETE_FOOTPRINT,
depth=400,
type="strip_foundation",
strip_w=450,
).build()
feature = result.add_to_doc("Body")
```
The footprint vertices are the component's local outer boundary. Its bounding-box
anchors therefore use the min/max of those vertices. Apply world placement only once
through the assembly placement contract.
```python
from elixifree.domains.sip import Foundation
from FreeCAD import Placement, Rotation, Vector
# === PARAMETERS — read every value from the goal string ===
BUILDING_WIDTH = 6000
BUILDING_DEPTH = 4000
PANEL_THICKNESS = 172 # wall SIP total thickness
STRIP_W = 450 # strip width in plan
STRIP_D = 300 # strip depth (always positive)
result = Foundation(
length=BUILDING_WIDTH + 2 * PANEL_THICKNESS,
width=BUILDING_DEPTH + 2 * PANEL_THICKNESS,
depth=STRIP_D,
type="strip_foundation",
strip_w=STRIP_W,
).build()
feature = result.add_to_doc("Body")
# MANDATORY: offset so strip sits under all wall outer faces
feature.Placement = Placement(Vector(-PANEL_THICKNESS, -PANEL_THICKNESS, -STRIP_D), Rotation())
```
**`length` and `width` are the OUTER concrete footprint** — they must encompass all wall outer faces. Formula: `BUILDING_WIDTH + 2 × PANEL_THICKNESS`. The hollow centre is `length − 2×strip_w` by `width − 2×strip_w`.
**Z reference:** Z=0 is the top face of the strip (sole plate bearing face). Strip extends downward to Z=−STRIP_D. The Placement offset puts Z=0 at the strip's top face after positioning.
**Key rules:**
- ONE agent (`foundation`), ONE model — never split into four side agents
- Strip top face at Z=0 is the bearing face for sole plates — no foundation wall above it
- `strip_w` defaults to 450mm if omitted
---
### 3. Screw Pile Foundation
No ElixiFree builder — write raw `Part` geometry. Steel screw piles driven to bearing depth, carrying LVL bearer beams.
```python
import FreeCAD, Part
from FreeCAD import Vector
# === SCREW PILE PARAMETERS ===
PILE_DIAMETER = 114
PILE_SPACING_X = 2400
PILE_SPACING_Y = 2400
PILE_EXPOSED_H = 600
BEARER_W = 90
BEARER_H = 190
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Foundation")
piles_x = (BUILDING_WIDTH // PILE_SPACING_X) + 1
piles_y = (BUILDING_DEPTH // PILE_SPACING_Y) + 1
parts = []
for ix in range(piles_x):
for iy in range(piles_y):
x = ix * PILE_SPACING_X
y = iy * PILE_SPACING_Y
pile = Part.makeCylinder(PILE_DIAMETER / 2, PILE_EXPOSED_H,
Vector(x, y, -PILE_EXPOSED_H))
parts.append(pile)
for iy in range(piles_y):
y = iy * PILE_SPACING_Y
b1 = Part.makeBox(BUILDING_WIDTH, BEARER_W, BEARER_H, Vector(0, y, 0))
b2 = Part.makeBox(BUILDING_WIDTH, BEARER_W, BEARER_H,
Vector(0, y + BEARER_W + 10, 0))
parts.extend([b1, b2])
result = parts[0]
for p in parts[1:]:
result = result.fuse(p)
feature = doc.addObject("Part::Feature", "Foundation")
feature.Shape = result
doc.recompute()
```
---
## Building Assembly — Coordinate System
```
Origin (0, 0, 0) = SW corner of the building footprint at Z = 0 (top of slab / floor level).
X-axis = East (along building width)
Y-axis = North (along building depth)
Z-axis = Up
```
The slab extends `SLAB_OVERHANG` beyond the building footprint on all four sides.
---
## MANDATORY: Foundation Component
**Every SIP building manifest MUST include a `foundation` component.** No exceptions. The foundation is always generated — even when the user hasn't mentioned it. It is the root of the dependency tree.
**NON-NEGOTIABLE — agent id MUST be exactly `foundation`:**
❌ FORBIDDEN agent ids:
- `concrete_slab`, `slab`, `strip_foundation`, `foundation_slab`
- `base_plate` / `base_plate_south` / `base_plate_north`
- `foundation_front` / `foundation_south` / `foundation_strip_north`
✅ CORRECT: one agent, id = `foundation`, priority = 1, no dependencies.
**Rules:**
- Agent id: `foundation` (exactly — see forbidden list above)
- Priority: 1 (no dependencies)
- Foundation type defaults to `slab_on_grade` unless the design spec states otherwise
- The foundation is modelled as a single piece — never split into four sides
**Goal string template (slab):**
```
Create slab-on-grade foundation, BUILDINGWIDTHxBUILDINGDEPTHmm slab overhang 200mm all sides, 125mm slab thickness, 600mm edge beam depth, 300mm edge beam width. Single fused solid. Z=0 is slab top. Place at Vector(-SLAB_OVERHANG, -SLAB_OVERHANG, -SLAB_THICKNESS) so the slab extends DOWN from z=0 (slab top at z=0, walls bear on it).
```
The Z offset MUST be `-SLAB_THICKNESS` (negative), not 0. With `Vector(..., 0)` the slab
sits ABOVE the floor (z=0..thickness) and the walls embed into it. Example for a 200mm
slab with 200mm overhang: `Place at Vector(-200, -200, -200)`.
**Goal string template (strip):**
```
Create strip perimeter foundation: BUILDINGWIDTHxBUILDINGDEPTHmm footprint, strip depth 300mm, strip width 450mm, wall panel thickness PT mm. Total outer footprint (BUILDING_WIDTH+2*PT)x(BUILDING_DEPTH+2*PT)mm. Place at Vector(-PT, -PT, -300).
```
---
## ⚠ Non-Negotiable Foundation Rule
**Foundation slab extends beyond the wall footprint on all sides.**
`SLAB_W = BUILDING_WIDTH + 2 × SLAB_OVERHANG` (minimum 200mm). A slab the same size as the wall footprint leaves no bearing margin.
SIP Foundation Construction
Native
Concrete slab and strip foundation geometry, DPC placement, slab overhang rule, and the NON-NEGOTIABLE foundation agent naming rule.
~2482 tokens
# SIP Openings Construction Skill
## Wall Openings — Windows and Doors
### Construction Principle — Full-Depth Timber Buck
In SIP construction, every window and door opening is framed with a **full-depth timber buck**: a structural timber frame whose depth equals the full wall thickness (`TOTAL_THICKNESS`). The SIP panels stop at the face of the king stud and butt flush against solid timber across the entire wall section. There is no EPS foam in the framing zone — it is entirely replaced by engineered timber.
```
Plan view (top-down cross-section through wall at opening):
←─── SIP panel (left) ───→ ←── FRAME_ZONE_W ──────────────→ ←── SIP panel (right) ───→
[OSB][ EPS core ][OSB] [KNG][TRM][ clear void ][TRM][KNG] [OSB][ EPS core ][OSB]
←── TOTAL_THICKNESS ──→ ←──── TOTAL_THICKNESS each member ────→ ←── TOTAL_THICKNESS ──→
```
This is why king studs must be **45 × TOTAL_THICKNESS** in cross-section — not 45 × 90mm. A 90mm-deep king stud leaves the SIP panel foam unsupported on either side, creates a thermal bridge gap, and provides no bearing surface for the panel OSB faces.
### Opening Coordinate System
`OPENING_X` is always **the left face of the left king stud** — the X position where SIP panels stop.
```
X: 0 ─────── [panels] ──── OPENING_X
├── king_left (KING_W = 45mm wide)
├── trimmer_left (TRIMMER_W = 45mm wide)
├── [clear void] (CLEAR_W wide)
├── trimmer_right
├── king_right
OPENING_X + FRAME_ZONE_W ──── [panels] ──── WALL_LENGTH
```
### Sizing Calculations
```python
KING_W = 45 # king stud face width (X)
KING_D = TOTAL_THICKNESS # king stud FULL WALL DEPTH (Y) — not 90mm
TRIMMER_W = 45 # trimmer stud face width (X)
TRIMMER_D = TOTAL_THICKNESS # trimmer stud full depth (Y)
SILL_H = 90 # sill plate height for windows (Z)
CLEAR_W = FRAME_W # clear glazing/door width inside frame
CLEAR_H = FRAME_H # clear glazing/door height inside frame
RO_W = CLEAR_W + 2 * TRIMMER_W # rough opening width (trimmer outer faces)
HEADER_SPAN = RO_W + 2 * KING_W # king-to-king span = total framing width
FRAME_ZONE_W = HEADER_SPAN # X width replaced by framing (no SIP panels here)
HEADER_DEPTH = max(150, RO_W // 10) # LVL header depth (Z); min 150mm
# OPENING_X must produce a clean left-panel width: ideally a multiple of 1200mm
# or leave a remainder panel ≥ 300mm. Align to nearest panel seam where possible.
OPENING_X = 1200 # example: one full 1200mm panel to the left, then the frame
# Vertical positions
if IS_DOOR:
CLEAR_BASE_Z = BOTTOM_PLATE_H # door clear void starts at bottom plate top
TRIMMER_H = CLEAR_H # trimmers height = clear door height
else:
CLEAR_BASE_Z = BOTTOM_PLATE_H + SILL_H # window void starts above sill
TRIMMER_H = SILL_H + CLEAR_H # trimmers from bottom plate to header
HEADER_BASE_Z = BOTTOM_PLATE_H + TRIMMER_H # Z of header underside
```
### Framing Member Summary
| Member | Width (X) | Depth (Y) | Height (Z) | Notes |
|---|---|---|---|---|
| King stud | 45mm | **TOTAL_THICKNESS** | PANEL_HEIGHT | Full height, full depth — primary load path |
| Trimmer stud | 45mm | **TOTAL_THICKNESS** | TRIMMER_H | Bears on bottom plate; carries header |
| LVL header | HEADER_SPAN | **TOTAL_THICKNESS** | HEADER_DEPTH | Spans king-to-king; min depth 150mm |
| Window sill | CLEAR_W | **TOTAL_THICKNESS** | SILL_H | Windows only; sits on trimmer tops at base of void |
| Cripple SIP | CLEAR_W | TOTAL_THICKNESS | cripple_h | Short SIP section above header to top plate |
### Panel Zone Helper and Opening Layout
Before building panels, divide the wall into solid SIP zones and framing zones. Panels must never be placed where the frame is — they stop at the king stud face.
```python
def make_panel_zone(x_start, zone_length):
"""Build SIP panels + block splines filling zone_length starting at x_start."""
zone_parts = []
if zone_length <= 0:
return zone_parts
x = x_start
full = int(zone_length) // 1200
rem = int(zone_length) % 1200
widths = [1200] * full + ([rem] if rem > 0 else [])
for i, pw in enumerate(widths):
f1 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, 0, BOTTOM_PLATE_H))
co = Part.makeBox(pw, CORE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS, BOTTOM_PLATE_H))
f2 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS+CORE_THICKNESS, BOTTOM_PLATE_H))
zone_parts.extend([f1, co, f2])
x += pw
if i < len(widths) - 1:
sp = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x - 22, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
zone_parts.append(sp)
return zone_parts
# Single opening — panel zones on each side
parts.extend(make_panel_zone(0, OPENING_X)) # left of frame
parts.extend(make_panel_zone(OPENING_X + FRAME_ZONE_W, # right of frame
WALL_LENGTH - OPENING_X - FRAME_ZONE_W))
```
### Complete Wall-with-Opening Code
```python
import FreeCAD, Part
from FreeCAD import Vector
# === PARAMETERS ===
WALL_LENGTH = 4800
PANEL_HEIGHT = 2700
CORE_THICKNESS = 150
FACE_THICKNESS = 11
TOTAL_THICKNESS = CORE_THICKNESS + 2 * FACE_THICKNESS # 172mm for SIP-150
BOTTOM_PLATE_H = 90
TOP_PLATE_H = 180
IS_DOOR = False
FRAME_W = 1000 # clear opening width (inside frame rebates)
FRAME_H = 1200 # clear opening height
KING_W = 45
KING_D = TOTAL_THICKNESS # FULL WALL DEPTH
TRIMMER_W = 45
TRIMMER_D = TOTAL_THICKNESS # FULL WALL DEPTH
SILL_H = 90
CLEAR_W = FRAME_W
CLEAR_H = FRAME_H
RO_W = CLEAR_W + 2 * TRIMMER_W
HEADER_SPAN = RO_W + 2 * KING_W
FRAME_ZONE_W = HEADER_SPAN
HEADER_DEPTH = max(150, RO_W // 10)
# OPENING_X = left face of left king stud (where left panels end)
# Align to a panel seam: 1200, 2400, 3600, etc.
OPENING_X = 1200
if IS_DOOR:
CLEAR_BASE_Z = BOTTOM_PLATE_H
TRIMMER_H = CLEAR_H
else:
CLEAR_BASE_Z = BOTTOM_PLATE_H + SILL_H
TRIMMER_H = SILL_H + CLEAR_H
HEADER_BASE_Z = BOTTOM_PLATE_H + TRIMMER_H
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("WallWithOpening")
parts = []
# === STEP 1: Bottom plate — full wall length (unbroken, even under opening) ===
# CORE_THICKNESS depth only — slots into the SIP foam channel, offset by FACE_THICKNESS.
bp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, BOTTOM_PLATE_H, Vector(0, FACE_THICKNESS, 0))
parts.append(bp)
# === STEP 2: SIP panel zones — stop at king stud faces ===
def make_panel_zone(x_start, zone_length):
zone_parts = []
if zone_length <= 0:
return zone_parts
x = x_start
full = int(zone_length) // 1200
rem = int(zone_length) % 1200
widths = [1200] * full + ([rem] if rem > 0 else [])
for i, pw in enumerate(widths):
f1 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, 0, BOTTOM_PLATE_H))
co = Part.makeBox(pw, CORE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS, BOTTOM_PLATE_H))
f2 = Part.makeBox(pw, FACE_THICKNESS, PANEL_HEIGHT, Vector(x, FACE_THICKNESS + CORE_THICKNESS, BOTTOM_PLATE_H))
zone_parts.extend([f1, co, f2])
x += pw
if i < len(widths) - 1:
sp = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x - 22, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
zone_parts.append(sp)
return zone_parts
parts.extend(make_panel_zone(0, OPENING_X))
parts.extend(make_panel_zone(OPENING_X + FRAME_ZONE_W,
WALL_LENGTH - OPENING_X - FRAME_ZONE_W))
# === STEP 3: Timber buck framing (full TOTAL_THICKNESS depth throughout) ===
# King studs — full wall depth, full panel height, one each side
king_left = Part.makeBox(KING_W, KING_D, PANEL_HEIGHT,
Vector(OPENING_X, 0, BOTTOM_PLATE_H))
king_right = Part.makeBox(KING_W, KING_D, PANEL_HEIGHT,
Vector(OPENING_X + FRAME_ZONE_W - KING_W, 0, BOTTOM_PLATE_H))
parts.extend([king_left, king_right])
# Trimmer studs — full wall depth, height from bottom plate to header underside
trimmer_left = Part.makeBox(TRIMMER_W, TRIMMER_D, TRIMMER_H,
Vector(OPENING_X + KING_W, 0, BOTTOM_PLATE_H))
trimmer_right = Part.makeBox(TRIMMER_W, TRIMMER_D, TRIMMER_H,
Vector(OPENING_X + FRAME_ZONE_W - KING_W - TRIMMER_W, 0, BOTTOM_PLATE_H))
parts.extend([trimmer_left, trimmer_right])
# LVL header — full wall depth, spans king-to-king
header = Part.makeBox(HEADER_SPAN, TOTAL_THICKNESS, HEADER_DEPTH,
Vector(OPENING_X, 0, HEADER_BASE_Z))
parts.append(header)
if not IS_DOOR:
# Window sill — full wall depth, sits on trimmers at base of clear void
sill = Part.makeBox(CLEAR_W, TOTAL_THICKNESS, SILL_H,
Vector(OPENING_X + KING_W + TRIMMER_W, 0, BOTTOM_PLATE_H))
parts.append(sill)
# Cripple SIP panels above header — short panels between header top and top plate
cripple_h = PANEL_HEIGHT - TRIMMER_H - HEADER_DEPTH
if cripple_h > 40:
cx = OPENING_X + KING_W + TRIMMER_W
cz = HEADER_BASE_Z + HEADER_DEPTH
cp_f1 = Part.makeBox(CLEAR_W, FACE_THICKNESS, cripple_h, Vector(cx, 0, cz))
cp_co = Part.makeBox(CLEAR_W, CORE_THICKNESS, cripple_h, Vector(cx, FACE_THICKNESS, cz))
cp_f2 = Part.makeBox(CLEAR_W, FACE_THICKNESS, cripple_h, Vector(cx, FACE_THICKNESS + CORE_THICKNESS, cz))
parts.extend([cp_f1, cp_co, cp_f2])
# === STEP 4: Double top plate — full wall length (unbroken) ===
# CORE_THICKNESS depth only — slots into the SIP foam channel, offset by FACE_THICKNESS.
tp = Part.makeBox(WALL_LENGTH, CORE_THICKNESS, TOP_PLATE_H,
Vector(0, FACE_THICKNESS, BOTTOM_PLATE_H + PANEL_HEIGHT))
parts.append(tp)
# === STEP 5: Fuse everything into one wall solid ===
wall = parts[0]
for p in parts[1:]:
wall = wall.fuse(p)
feature = doc.addObject("Part::Feature", "WallWithOpening")
feature.Shape = wall
# === STEP 6: Window or door unit — separate feature, not fused to wall ===
# The clear void between inner trimmer faces is the visible opening.
# Add the unit as a separate Part::Feature so it can be coloured/selected independently.
UNIT_FRAME_T = 65 # window or door frame depth (sits centred in wall depth)
if IS_DOOR:
DOOR_LEAF_T = 44
door_frame = Part.makeBox(CLEAR_W, UNIT_FRAME_T, CLEAR_H,
Vector(OPENING_X + KING_W + TRIMMER_W,
(TOTAL_THICKNESS - UNIT_FRAME_T) / 2,
CLEAR_BASE_Z))
door_leaf = Part.makeBox(CLEAR_W - 10, DOOR_LEAF_T, CLEAR_H - 15,
Vector(OPENING_X + KING_W + TRIMMER_W + 5,
(TOTAL_THICKNESS - DOOR_LEAF_T) / 2,
CLEAR_BASE_Z + 10))
unit = door_frame.fuse(door_leaf)
else:
GLASS_T = 28
win_frame = Part.makeBox(CLEAR_W, UNIT_FRAME_T, CLEAR_H,
Vector(OPENING_X + KING_W + TRIMMER_W,
(TOTAL_THICKNESS - UNIT_FRAME_T) / 2,
CLEAR_BASE_Z))
glazing = Part.makeBox(CLEAR_W - 40, GLASS_T, CLEAR_H - 40,
Vector(OPENING_X + KING_W + TRIMMER_W + 20,
(TOTAL_THICKNESS - GLASS_T) / 2,
CLEAR_BASE_Z + 20))
unit = win_frame.fuse(glazing)
unit_obj = doc.addObject("Part::Feature", "DoorUnit" if IS_DOOR else "WindowUnit")
unit_obj.Shape = unit
doc.recompute()
if FreeCAD.GuiUp:
FreeCAD.Gui.ActiveDocument.ActiveView.fitAll()
```
### Multiple Openings in One Wall
Plan all `OPENING_X` positions before building panels. Panels fill every gap between framing zones.
```python
# Define all openings as (opening_x, frame_zone_w, is_door, clear_w, clear_h)
# OPENING_X values must be sorted and non-overlapping
openings = [
{"x": 1200, "fzw": DOOR_FRAME_ZONE_W, "is_door": True, "cw": 900, "ch": 2100},
{"x": 3000, "fzw": WIN_FRAME_ZONE_W, "is_door": False, "cw": 1000, "ch": 1200},
]
# Build panel zones around all opening zones
solid_zones = []
prev_end = 0
for o in sorted(openings, key=lambda o: o["x"]):
if o["x"] > prev_end:
solid_zones.append((prev_end, o["x"] - prev_end))
prev_end = o["x"] + o["fzw"]
if prev_end < WALL_LENGTH:
solid_zones.append((prev_end, WALL_LENGTH - prev_end))
for x_start, zone_len in solid_zones:
parts.extend(make_panel_zone(x_start, zone_len))
# Add framing for each opening
for o in openings:
ox = o["x"]
fzw = o["fzw"]
# ... king studs, trimmers, header, sill per opening using ox as OPENING_X
```
### OPENING_X Alignment Guide
Choose `OPENING_X` so the panel zones on each side are buildable widths (≥ 300mm, ideally multiples of 1200mm):
| Left zone target | OPENING_X | Left zone actual | Notes |
|---|---|---|---|
| 1 full panel | 1200 | 1200mm | Clean seam |
| 2 full panels | 2400 | 2400mm | Clean seam |
| 1.5 panels | 1800 | 600mm + 1200mm | 600mm remainder — acceptable |
| Centred in 4800mm wall, 1090mm frame | 1855 | 1855mm = 1200+655 | 655mm remainder — acceptable |
---
## ⚠ Non-Negotiable Opening Rule
3. **Openings described in the brief MUST be modelled with a full-depth timber buck AND a unit.**
If the brief mentions a window or door on a wall, that wall component MUST: (a) use `make_panel_zone` to stop SIP panels at the king stud face — never cut a void from a continuous wall, (b) add full-depth king studs (`KING_D = TOTAL_THICKNESS`) + full-depth trimmer studs + LVL header + window sill, and (c) add a separate feature for the glazing or door unit. King studs that are only 90mm deep are wrong — they leave the SIP foam unsupported and create a structural gap.
SIP Openings Construction
Native
Full-depth timber buck construction for windows and doors, framing member sizing, panel zone helper, and multiple-opening layout patterns.
~3596 tokens
# SIP Interface Rules Skill
This skill describes the **static connection rules** between SIP component types. Every agent that interfaces with another component receives this skill. Use it to know which side of a connection you are responsible for, and what dimensions to use.
Dimensional values are declared in the `connection_details` catalog injected into this stage. Reference catalog entries by key rather than hardcoding values.
> **Note on dynamic dimensions:** This skill covers interface *rules* (who cuts what, where). The *actual dimensions* of adjacent components (e.g. the exact thickness of the roof panel chosen for this project) are resolved at manifest time and passed via the agent's `domain_knowledge` and `interfaces_provided` fields. Use those values when available; fall back to catalog defaults when not.
---
## Roof-to-Wall Interface
**Rule:** The wall agent is responsible for the connection geometry at its top edge. The roof agent bears on the wall top plate and does not modify the wall.
**Wall agent responsibilities:**
- The double top plate (2× 45×90mm timber) provides the bearing surface for the roof panel.
- For pitched roofs: the top plate must be cut at the pitch angle so the roof panel bears flat. Model this as an angled-cut top plate or use a separate angled bearer.
- For flat roofs: the top plate is horizontal; the roof panel bears flat on it with no cuts needed.
- The wall top plate depth is `CORE_THICKNESS` (not `TOTAL_THICKNESS`) — it slots into the foam groove.
**Roof agent responsibilities:**
- The roof panel eave end bears on the wall top plate.
- For pitched roofs: cut the eave bevel (`eave_bevel_y = TOTAL_THICKNESS / tan(PITCH_RAD)`) so the panel bears cleanly.
- For flat roofs: the panel bottom face sits directly on the top plate — no cuts needed.
**Shared dimension:**
- `PANEL_THICKNESS` (wall SIP total thickness) defines the inset for roof panel positioning:
- Mono-pitch: `Placement(Vector(0, PANEL_THICKNESS, EAVE_HEIGHT), rot)`
- Duo-pitch south: `Placement(Vector(0, PANEL_THICKNESS, EAVE_HEIGHT), rot)`
- Duo-pitch north: `Placement(Vector(0, BUILDING_DEPTH - PANEL_THICKNESS, EAVE_HEIGHT), rot)`
---
## Wall-to-Foundation Interface
**Rule:** The wall agent is responsible for the sole plate (bottom plate) at Z=0. The foundation agent is responsible for the slab surface and DPC layer.
**Wall agent responsibilities:**
- Create a 45×90mm PT timber bottom plate at Z=0 as the first solid.
- Plate depth = `CORE_THICKNESS` (not `TOTAL_THICKNESS`) — it slots into the foam groove.
- Y-offset = `FACE_THICKNESS` so OSB skins overhang on both faces.
- SIP panels start at Z=90 (top of bottom plate).
**Foundation agent responsibilities:**
- The slab top surface is at Z=0.
- A DPC (damp proof course) layer sits between slab and bottom plate — model as a 3mm high-density polyethylene layer if required, or omit for clarity.
- The slab extends `SLAB_OVERHANG` (minimum 200mm) beyond the wall footprint on all sides.
**Connection detail:** `sole_plate_to_slab` in the `connection_details` catalog.
---
## Wall-to-Wall Corner Interface
**Rule:** For L-corners, one wall runs full length to the outer face; the perpendicular wall butts against it. A double block spline fills the internal corner pocket.
**Responsibility split:**
- The "full-length" wall includes the corner panel at its end — no special treatment needed.
- The "butting" wall's end panel butts against the exterior OSB face of the full-length wall. Its bottom plate runs to the face of the full-length wall. A double block spline (2× 45×90mm) fills the corner pocket on the butting wall's end.
**Corner spline geometry:**
```python
# Double block spline at L-corner (butting wall end)
# Both splines sit in the foam channel, side by side
spline_a = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x_corner, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
spline_b = Part.makeBox(45, 90, PANEL_HEIGHT,
Vector(x_corner + 45, (TOTAL_THICKNESS - 90) / 2, BOTTOM_PLATE_H))
```
---
## Opening-to-Wall Interface
**Rule:** Openings (windows, doors) are framed within the wall component. The wall agent is responsible for stopping SIP panels at the king stud face and building the full-depth timber buck. If a window or door is described in the wall brief, the wall component includes all framing — there is no separate framing agent.
**King stud depth:** `KING_D = TOTAL_THICKNESS` (full wall thickness, not 90mm).
**Opening agent (if separate):** A separate opening agent adds only the glazed unit or door leaf — it does NOT build framing. Its X position, width, and height come from the wall agent's `interfaces_provided`.
---
## Quick Interface Checklist
Before generating any component that touches another, confirm:
| Check | Wall | Roof | Foundation | Opening |
|---|---|---|---|---|
| Sole plate at Z=0 with CORE_THICKNESS depth | ✅ my responsibility | — | — | — |
| Top plate angled for pitched roof | ✅ my responsibility | — | — | — |
| Eave bevel cut | — | ✅ my responsibility | — | — |
| Slab extends SLAB_OVERHANG beyond walls | — | — | ✅ my responsibility | — |
| King studs are TOTAL_THICKNESS deep | ✅ (if I have openings) | — | — | ✅ |
| Corner spline on butting wall end | ✅ (butting wall only) | — | — | — |
SIP Interface Rules
Native
Static connection rules between SIP components: roof-to-wall, wall-to-foundation, wall-to-wall corners, and opening-to-wall. Injected alongside domain skills.
~1331 tokens
# FreeCAD Direct Modeling Skill
This skill defines how to generate reliable FreeCAD code using **direct modeling** with the Part workbench.
Direct modeling is simpler, more reliable, and better suited for AI-generated CAD code.
## Why Direct Modeling?
| Aspect | Direct (Part) | Parametric (PartDesign) |
|--------|---------------|-------------------------|
| **Reliability** | High - no face references | Low - face refs are unstable |
| **Code complexity** | Simple | Complex (sketches, constraints) |
| **Error rate** | Low | High |
| **Editable in FreeCAD** | No (final shape only) | Yes (feature tree) |
| **Best for** | AI generation | Manual CAD work |
---
## Standard Code Structure
```python
import FreeCAD
import Part
import math
from FreeCAD import Vector, Placement, Rotation
# === PARAMETERS ===
LENGTH = 100
WIDTH = 60
HEIGHT = 40
HOLE_DIAMETER = 10
# === DOCUMENT ===
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Model")
# === BUILD GEOMETRY ===
# Start with a base shape
base = Part.makeBox(LENGTH, WIDTH, HEIGHT)
# Add features using boolean operations
hole = Part.makeCylinder(HOLE_DIAMETER / 2, HEIGHT, Vector(LENGTH/2, WIDTH/2, 0))
result = base.cut(hole)
# === ADD TO DOCUMENT ===
feature = doc.addObject("Part::Feature", "Model")
feature.Shape = result
doc.recompute()
if FreeCAD.GuiUp:
FreeCAD.Gui.ActiveDocument.ActiveView.fitAll()
```
---
## Primitive Shapes
### Box
```python
# Part.makeBox(length, width, height, position, direction)
box = Part.makeBox(100, 60, 40)
# Centered at origin
box = Part.makeBox(100, 60, 40, Vector(-50, -30, 0))
# With custom placement
box = Part.makeBox(100, 60, 40)
box.translate(Vector(10, 20, 30))
```
### Cylinder
```python
# Part.makeCylinder(radius, height, position, direction)
cyl = Part.makeCylinder(25, 50) # At origin, along Z
# Horizontal cylinder (along X)
cyl = Part.makeCylinder(25, 100, Vector(0, 0, 0), Vector(1, 0, 0))
# Positioned
cyl = Part.makeCylinder(10, 30, Vector(50, 30, 0), Vector(0, 0, 1))
```
### Sphere
```python
# Part.makeSphere(radius, center)
sphere = Part.makeSphere(25)
sphere = Part.makeSphere(25, Vector(50, 50, 50))
```
### Cone
```python
# Part.makeCone(radius1, radius2, height, position, direction)
cone = Part.makeCone(30, 10, 50) # Tapers from r=30 to r=10
cone = Part.makeCone(20, 0, 40) # Pointed cone
```
### Torus
```python
# Part.makeTorus(majorRadius, minorRadius)
torus = Part.makeTorus(50, 10) # Donut shape
```
---
## Boolean Operations
### Fusion (Union/Add)
```python
# Combine two shapes
box = Part.makeBox(100, 100, 50)
cyl = Part.makeCylinder(30, 80, Vector(50, 50, 0))
result = box.fuse(cyl)
# Multiple shapes
shape1.fuse([shape2, shape3, shape4])
```
### Cut (Subtract)
```python
# Remove material
base = Part.makeBox(100, 100, 50)
hole = Part.makeCylinder(15, 50, Vector(50, 50, 0))
result = base.cut(hole)
# Multiple cuts
base.cut([hole1, hole2, hole3])
```
### Common (Intersection)
```python
# Keep only overlapping volume
box = Part.makeBox(100, 100, 100)
sphere = Part.makeSphere(60, Vector(50, 50, 50))
result = box.common(sphere) # Rounded box corner
```
---
## Transformations
### Translate (Move)
```python
shape = Part.makeBox(50, 50, 50)
shape.translate(Vector(100, 0, 0)) # Move 100mm along X
```
### Rotate
```python
import math
shape = Part.makeBox(50, 50, 50)
# Rotate around Z axis at origin
shape.rotate(Vector(0, 0, 0), Vector(0, 0, 1), 45) # 45 degrees
# Rotate around center
center = Vector(25, 25, 25)
shape.rotate(center, Vector(0, 0, 1), 90)
```
### Mirror
```python
# Mirror across XY plane at Z=0
shape = Part.makeBox(50, 50, 50, Vector(10, 10, 10))
mirrored = shape.mirror(Vector(0, 0, 0), Vector(0, 0, 1))
```
### Scale
```python
# Scale uniformly
import FreeCAD
matrix = FreeCAD.Matrix()
matrix.scale(2, 2, 2) # Double size
scaled = shape.transformGeometry(matrix)
# Non-uniform scale
matrix.scale(2, 1, 0.5) # Stretch X, compress Z
```
---
## Complex Shapes
### Extrusion
```python
# Create a wire (closed profile)
wire = Part.makePolygon([
Vector(0, 0, 0),
Vector(100, 0, 0),
Vector(100, 50, 0),
Vector(50, 50, 0),
Vector(50, 30, 0),
Vector(0, 30, 0),
Vector(0, 0, 0) # Close the profile
])
face = Part.Face(wire)
solid = face.extrude(Vector(0, 0, 40)) # Extrude 40mm in Z
```
### Revolution
```python
# Profile to revolve (in XZ plane)
profile = Part.makePolygon([
Vector(10, 0, 0),
Vector(30, 0, 0),
Vector(30, 0, 50),
Vector(20, 0, 60),
Vector(10, 0, 50),
Vector(10, 0, 0)
])
face = Part.Face(Part.Wire(profile))
# Revolve around Z axis
solid = face.revolve(Vector(0, 0, 0), Vector(0, 0, 1), 360)
```
### Loft
```python
# Multiple profiles at different heights
def make_circle_wire(center, radius):
circle = Part.Circle(center, Vector(0, 0, 1), radius)
return Part.Wire(circle.toShape())
profiles = [
make_circle_wire(Vector(0, 0, 0), 30),
make_circle_wire(Vector(0, 0, 50), 40),
make_circle_wire(Vector(0, 0, 100), 20),
]
solid = Part.makeLoft(profiles, True) # True = solid
```
### Sweep
```python
# Sweep a profile along a path
profile = Part.makeCircle(10)
profile = Part.Wire(profile)
path = Part.makeLine(Vector(0, 0, 0), Vector(100, 50, 30))
path = Part.Wire(path)
solid = Part.makePipe(path, profile)
```
---
## Fillets and Chamfers
### Fillet (Round edges)
```python
box = Part.makeBox(100, 60, 40)
# Fillet specific edges by index
filleted = box.makeFillet(5, [box.Edges[0], box.Edges[4], box.Edges[8]])
# Fillet all edges
filleted = box.makeFillet(3, box.Edges)
```
### Chamfer
```python
box = Part.makeBox(100, 60, 40)
chamfered = box.makeChamfer(3, box.Edges)
```
---
## Shell (Hollow out)
```python
box = Part.makeBox(100, 60, 40)
# Shell with 2mm wall thickness, removing top face
# Faces are indexed; find top face by checking normals
top_face = None
for face in box.Faces:
if face.normalAt(0, 0).z > 0.9:
top_face = face
break
shelled = box.makeShell([top_face], 2) # 2mm walls
```
---
## Pattern (Arrays)
### Linear Array
```python
base = Part.makeCylinder(5, 10)
shapes = [base]
for i in range(1, 5):
copy = base.copy()
copy.translate(Vector(i * 20, 0, 0))
shapes.append(copy)
result = shapes[0].fuse(shapes[1:])
```
### Polar Array
```python
import math
base = Part.makeCylinder(5, 10, Vector(30, 0, 0))
shapes = [base]
for i in range(1, 8):
angle = i * 360 / 8
copy = base.copy()
copy.rotate(Vector(0, 0, 0), Vector(0, 0, 1), angle)
shapes.append(copy)
result = shapes[0].fuse(shapes[1:])
```
---
## Complete Examples
### Bracket with Holes
```python
import FreeCAD
import Part
from FreeCAD import Vector
# Parameters
BRACKET_L = 80
BRACKET_W = 40
BRACKET_H = 5
HOLE_DIA = 8
HOLE_SPACING = 30
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Bracket")
# Base plate
plate = Part.makeBox(BRACKET_L, BRACKET_W, BRACKET_H)
# Mounting holes
holes = []
for x in [BRACKET_L/2 - HOLE_SPACING/2, BRACKET_L/2 + HOLE_SPACING/2]:
hole = Part.makeCylinder(HOLE_DIA/2, BRACKET_H, Vector(x, BRACKET_W/2, 0))
holes.append(hole)
# Cut holes
result = plate.cut(holes)
# Add to document
feature = doc.addObject("Part::Feature", "Bracket")
feature.Shape = result
doc.recompute()
```
### Cup/Container
```python
import FreeCAD
import Part
from FreeCAD import Vector
# Parameters
OUTER_DIA = 80
INNER_DIA = 74
HEIGHT = 100
WALL_THICK = 3
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Cup")
# Outer cylinder
outer = Part.makeCylinder(OUTER_DIA/2, HEIGHT)
# Inner cavity (leave bottom thickness)
inner = Part.makeCylinder(INNER_DIA/2, HEIGHT - WALL_THICK, Vector(0, 0, WALL_THICK))
# Cut to create hollow
result = outer.cut(inner)
feature = doc.addObject("Part::Feature", "Cup")
feature.Shape = result
doc.recompute()
```
### Gear (Simplified)
```python
import FreeCAD
import Part
import math
from FreeCAD import Vector
# Parameters
NUM_TEETH = 20
MODULE = 2
THICKNESS = 10
BORE_DIA = 10
pitch_dia = NUM_TEETH * MODULE
outer_dia = pitch_dia + 2 * MODULE
root_dia = pitch_dia - 2.5 * MODULE
doc = FreeCAD.ActiveDocument or FreeCAD.newDocument("Gear")
# Base cylinder
gear = Part.makeCylinder(outer_dia/2, THICKNESS)
# Cut tooth gaps
tooth_angle = 360 / NUM_TEETH
gap_angle = tooth_angle * 0.4
for i in range(NUM_TEETH):
angle = i * tooth_angle
# Create wedge-shaped gap
gap = Part.makeBox(outer_dia, outer_dia/4, THICKNESS)
gap.translate(Vector(root_dia/2, -outer_dia/8, 0))
gap.rotate(Vector(0, 0, 0), Vector(0, 0, 1), angle)
gear = gear.cut(gap)
# Center bore
bore = Part.makeCylinder(BORE_DIA/2, THICKNESS)
gear = gear.cut(bore)
feature = doc.addObject("Part::Feature", "Gear")
feature.Shape = gear
doc.recompute()
```
---
## Rules (Non-Negotiable)
### ALWAYS Do:
1. **Import math** for trigonometry: `import math`
2. **Use Vector() with positional args**: `Vector(10, 20, 30)`
3. **Call doc.recompute()** at the end
4. **Define parameters at the top** for easy modification
### NEVER Do:
1. ❌ `Vector(x=10, y=20)` - No keyword arguments
2. ❌ Zero-dimension shapes - `Part.makeBox(0, 50, 50)` fails
3. ❌ Face references - Not needed in direct modeling
4. ❌ `PartDesign::Body` - Use `Part::Feature` for direct modeling
5. ❌ `Part.makeWedge` - Its 10-argument signature is
`(xmin, ymin, zmin, z2min, x2min, xmax, ymax, zmax, z2max, x2max)` — NOT two
box corners — and getting it wrong fails with "delta x/y/z of wedge too
small". For any wedge, raked, or tapered solid, build a closed polygon
profile of the cross-section and extrude it instead:
```python
# Raked/wedge solid: profile in one plane, extruded along the length.
# Example: plate LENGTH long (X), WIDTH deep (Y), low edge H_LOW at y=WIDTH,
# high edge H_HIGH at y=0, sitting at BASE_Z.
profile = Part.makePolygon([
Vector(0, 0, BASE_Z),
Vector(0, WIDTH, BASE_Z),
Vector(0, WIDTH, BASE_Z + H_LOW),
Vector(0, 0, BASE_Z + H_HIGH),
Vector(0, 0, BASE_Z), # close the profile
])
solid = Part.Face(profile).extrude(Vector(LENGTH, 0, 0))
```
### Output Format
When generating code:
1. Keep text response to 1-2 sentences
2. Put complete code in a ```python block at the END
3. Do NOT repeat or explain the code in text
FreeCAD Direct Modeling
Native
Part workbench primitives, booleans, transformations, and extrusions. Default skill injected at every stage that produces FreeCAD Python code.
~2566 tokens
# Wood Decomposition Skill
## Agent Naming
Use `{position}_{part}`: `left_front_leg`, `top_shelf`, `back_rail`, `right_side_panel`.
Position tokens: `left`, `right`, `front`, `back`, `top`, `bottom`, `inner`, `outer`, `upper`, `lower`.
## Coordinate System
- X = width (left → right)
- Y = depth (front → back)
- Z = height (bottom → top)
## Dependency Ordering
Declare agents in assembly order so construction is logical:
1. Legs
2. Aprons and rails
3. Stretchers
4. Top or seat panel
5. Back panels, shelves, slats
## Duplicate Components: `same_as`
When multiple parts are geometrically identical (chair legs, bed slats, bookshelf shelves):
- Declare ONE canonical agent with full Board or Dowel script (use Dowel for cylindrical parts)
- Declare each duplicate with `same_as: "<canonical_agent_id>"` and its own `assembly_placement`
- FreeCAD executes once; STL is copied to each duplicate — do NOT generate separate scripts for duplicates
Example manifest fragment:
```json
{
"agent_id": "left_front_leg",
"role": "Leg",
"script": "..."
},
{
"agent_id": "right_front_leg",
"same_as": "left_front_leg",
"assembly_placement": { "position": {"x": 420, "y": 0, "z": 0} }
}
```
## Dimension Proposal Workflow
1. At `design_spec_generate`: propose ALL dimensions and materials. Include a clear table.
2. WAIT for user confirmation or override before generating the manifest.
3. At `manifest_generation`: use the user-confirmed dimensions only.
## Grain Direction Declaration
Every board agent MUST declare `grain_direction` in its `interfaces_provided` metadata:
- `"long"` — grain runs along the board's length (legs, rails, stiles)
- `"face"` — grain across the face panel (tops, shelves, sheet goods)
- `"cross"` — end grain (avoid in load-bearing joints; triggers a gap warning)
Woodworking Decomposition
Native
Agent naming conventions, dependency ordering, and decomposition rules for woodworking project manifests.
~450 tokens
# Wood Boards Skill
Pick the builder from `elixifree.domains.woodworking` by the part's shape and orientation:
| Part | Builder |
|------|---------|
| Horizontal rectangular parts — tops, aprons, rails, stretchers, shelves, panels | `Board` |
| **Vertical rectangular parts** — legs, posts, columns (declared bounding box has Z as its largest dimension) | `Leg` |
| Cylindrical parts — round legs, turned spindles, dowel pins, discs | `Dowel` |
Do NOT use `Part.makeBox`, `Part.makeCylinder`, or any raw FreeCAD API. Do NOT write `# ElixiFree gap:` unless a modifier listed below genuinely cannot express the shape.
**Orientation rule (non-negotiable):** `Board` builds its length along the **X axis** and cannot stand a part upright. If the component's declared bounding box is Z-dominant (height is the largest dimension), you MUST use `Leg` (rectangular) or `Dowel` (cylindrical) — both build along Z. A leg built with `Board` comes out lying on its side and fails anchor verification.
## Script template — use this for every board component
```python
from elixifree.domains.woodworking import Board
# === PARAMETERS ===
LENGTH = 1200 # mm — along grain direction
WIDTH = 600 # mm
THICKNESS = 25 # mm
result = Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="oak", grain="face").build()
result.add_to_doc("Body")
```
That is the complete script. Do not add `import FreeCAD`, `doc = ...`, `doc.recompute()`, or any other lines.
## Parameters
| Param | Values | When to use |
|-------|--------|-------------|
| `material` | `"oak"`, `"walnut"`, `"pine"`, `"birch_ply"`, `"mdf"` | Match project material |
| `grain` | `"long"` | Legs, rails, aprons, stretchers |
| `grain` | `"face"` | Tops, shelves, sheet goods |
## Optional modifiers (chain before `.build()`)
**Tapered horizontal part** (taper along the X length axis — for tapered legs use `Leg`, see Vertical Parts below):
```python
result = Board(length=700, width=40, thickness=40, material="oak", grain="long").taper(40, 25, 40, 25).build()
result.add_to_doc("Body")
```
**Chamfered edges:**
```python
result = Board(length=800, width=100, thickness=38, material="oak", grain="long").chamfer("all_long", 3).build()
result.add_to_doc("Body")
```
**Decorative arc cut on apron:**
```python
result = Board(length=500, width=80, thickness=22, material="oak", grain="long").arc_cut("bottom", 50, "concave").build()
result.add_to_doc("Body")
```
## Joinery modifiers — always use these, never raw Part booleans
Aprons, rails, and stretchers commonly need mortises or tenons. Add them as modifiers — do NOT fall back to `# ElixiFree gap:` for joinery. (Mortised **legs** use `Leg` — see Vertical Parts below.)
**Apron with tenon at one end:**
```python
result = (
Board(length=400, width=80, thickness=22, material="oak", grain="long")
.tenon("end", 14, 0, 12, 40, 32) # x, z, width, height, length
.build()
)
result.add_to_doc("Body")
```
**Apron with tenon at each end** (joins two legs — the standard apron pattern):
```python
result = (
Board(length=400, width=80, thickness=22, material="oak", grain="long")
.tenon("end", 14, 0, 12, 40, 32) # tenon protruding past X=length
.tenon("start", 14, 0, 12, 40, 32) # tenon protruding past X=0
.build()
)
result.add_to_doc("Body")
```
Valid face names for `.tenon()`:
| face | protrudes past | use when |
|------|---------------|----------|
| `"end"` or `"right"` | X=length face | right end of apron/rail |
| `"start"` or `"left"` | X=0 face | left end of apron/rail |
| `"front"` | Y=0 face | tenon on front face |
| `"back"` | Y=width face | tenon on back face |
Modifier signature:
- `.mortise(face, x, z, width, height, depth)` — cuts a pocket into the board
- `.tenon(face, x, z, width, height, length)` — adds a protruding peg
Valid face names for `.mortise()`:
| face | opens onto | use when |
|------|-----------|----------|
| `"end"` or `"front"` | Y=0 face | apron joins from front |
| `"back"` | Y=width face | apron joins from back |
| `"side"` or `"right"` | X=length face | rail joins from right end |
| `"left"` | X=0 face | rail joins from left end |
| `"top"` | Z=thickness face | through-mortise from top |
| `"bottom"` | Z=0 face | pocket receives a tenon from **below** |
**Show-face rule (non-negotiable):** pockets that receive legs or tenons from
below — e.g. a table top receiving leg tenons — MUST use `"bottom"`. Never cut
them with `"top"`: the part is placed unrotated, so a `"top"` pocket ends up
visible on the finished surface. Use `"top"` only for genuine through-mortises
or pockets meant to be seen from above.
**Table top with four leg-tenon pockets** (pockets open downward, show face stays clean):
```python
result = (
Board(length=1100, width=550, thickness=25, material="walnut", grain="face")
.mortise("bottom", 53, 53, 14, 14, 14) # front-left (x, y, width, height, depth)
.mortise("bottom", 1033, 53, 14, 14, 14) # front-right
.mortise("bottom", 53, 483, 14, 14, 14) # back-left
.mortise("bottom", 1033, 483, 14, 14, 14) # back-right
.build()
)
result.add_to_doc("Body")
```
For `"top"`/`"bottom"` the second and third arguments are the pocket's X and Y
positions on the face.
Catalog proportions for hardwood medium stock (25–50mm thickness): width=12, height=40, depth/length=32.
**NEVER write `# ElixiFree gap:` for joinery.** Wanting "explicit control", "precision", or "boolean operations" is not a valid gap reason — the modifiers above are the explicit, precise boolean operations. Only gap if the shape is physically impossible with the Board/Leg modifiers (curved board, etc.). Cylindrical cross-sections are NOT a gap — use `Dowel` instead (see below). Vertical rectangular parts are NOT a gap — use `Leg` (see below).
## Vertical Parts — use Leg, never Board
Any **rectangular leg, post, or column** — a part whose declared bounding box has Z (height) as its largest dimension — MUST use `Leg`. It builds standing along Z: the bounding box is `X=top_side, Y=top_side, Z=height` (+ tenon length if present), foot centered under the top.
All Leg coordinates are world-frame and natural: mortise faces are the four vertical faces, and z positions are measured **up from the floor**.
**Straight post:**
```python
from elixifree.domains.woodworking import Leg
result = Leg(height=700, top_side=40, material="oak").build()
result.add_to_doc("Body")
```
**Tapered leg with top tenon and two mortises** (40×40mm top tapering to 25×25mm foot, 375mm tall, 13mm cube tenon on top, mortises on the two inner faces 50mm up from the floor):
```python
from elixifree.domains.woodworking import Leg
HEIGHT = 375 # mm — leg height (Z)
TOP_SIDE = 40 # mm — cross-section at top
FOOT_SIDE = 25 # mm — cross-section at foot
result = (
Leg(height=HEIGHT, top_side=TOP_SIDE, foot_side=FOOT_SIDE, material="walnut")
.tenon_top(13, 13, 13) # width (X), depth (Y), length (protrusion above top)
.mortise("right", 50, 13, 40, 14) # face, z_from_floor, width, height, depth
.mortise("back", 50, 13, 40, 14)
.build()
)
result.add_to_doc("Body")
```
Leg modifier signatures:
- `.tenon_top(width, depth, length)` — tenon centered on the top face, protruding above Z=height
- `.mortise(face, z_bottom, width, height, depth, offset=0.0)` — pocket on a vertical face; `z_bottom` is measured up from the floor; centered horizontally unless `offset` shifts it
Valid face names for `Leg.mortise()`:
| face | opens onto |
|------|-----------|
| `"front"` | Y=0 face |
| `"back"` | Y=top_side face |
| `"left"` | X=0 face |
| `"right"` | X=top_side face |
## Cylindrical Parts — use Dowel, never Board
Any part described as cylindrical, round, or turned MUST use `Dowel`, not `Board`. This includes:
- Round/cylindrical **legs** and spindles
- Round **seat discs** and circular tops
- Rungs, dowel pins, and any other circular cross-section part
`Dowel(diameter, length, material)` — the bounding box is `X=diameter, Y=diameter, Z=length`.
**Cylindrical leg** (40mm diameter × 475mm tall):
```python
from elixifree.domains.woodworking import Dowel
DIAMETER = 40 # mm
LENGTH = 475 # mm
result = Dowel(diameter=DIAMETER, length=LENGTH, material="oak").build()
result.add_to_doc("Body")
```
**Round seat disc** (400mm diameter × 35mm thick) — `length` = the thickness:
```python
from elixifree.domains.woodworking import Dowel
DIAMETER = 400 # mm
THICKNESS = 35 # mm — this is the "length" parameter (Z height)
result = Dowel(diameter=DIAMETER, length=THICKNESS, material="oak").build()
result.add_to_doc("Body")
```
**Bounding box rule for Dowel:** the bounding box is always `X=diameter, Y=diameter, Z=length`. For a seat disc where the goal says "400mm diameter × 35mm thick", use `diameter=400, length=35`.
**Through-mortises and holes** on round parts (e.g. leg holes in a seat disc) are cut at the constructability stage — do NOT attempt to cut them in the Dowel script. Generate the pure disc shape only.
Wood Boards
Native
Board stock dimensions, grain direction, sheet goods, and standard lumber sizing for woodworking projects.
~2257 tokens
# Wood Joints Skill
A joint component is a **Board with a modifier**. Build the board first, then chain the joint cut. Each component agent generates one body — never two.
## Mortise (the receiving board — has a rectangular pocket)
```python
from elixifree.domains.woodworking import Board
LENGTH = 700 # mm — board length
WIDTH = 40 # mm
THICKNESS = 40 # mm
# Mortise centered on end face
MORTISE_W = 12
MORTISE_H = 40
MORTISE_D = 32
result = (
Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="oak", grain="long")
.mortise("end", WIDTH/2 - MORTISE_W/2, THICKNESS/2 - MORTISE_H/2, MORTISE_W, MORTISE_H, MORTISE_D)
.build()
)
result.add_to_doc("Body")
```
## Tenon (the protruding board — has a peg that fits the mortise)
```python
from elixifree.domains.woodworking import Board
LENGTH = 400 # mm
WIDTH = 40 # mm
THICKNESS = 40 # mm
TENON_W = 12
TENON_H = 40
TENON_L = 32
result = (
Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="oak", grain="long")
.tenon("end", WIDTH/2 - TENON_W/2, THICKNESS/2 - TENON_H/2, TENON_W, TENON_H, TENON_L)
.build()
)
result.add_to_doc("Body")
```
## Apron with tenon at each end (joins two legs — standard apron pattern)
```python
from elixifree.domains.woodworking import Board
LENGTH = 500 # mm
WIDTH = 80 # mm
THICKNESS = 22 # mm
TENON_W = 12
TENON_H = 40
TENON_L = 32
result = (
Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="oak", grain="long")
.tenon("end", WIDTH/2 - TENON_W/2, THICKNESS/2 - TENON_H/2, TENON_W, TENON_H, TENON_L)
.tenon("start", WIDTH/2 - TENON_W/2, THICKNESS/2 - TENON_H/2, TENON_W, TENON_H, TENON_L)
.build()
)
result.add_to_doc("Body")
```
## Dado (shelf receiver — cross-grain groove)
```python
from elixifree.domains.woodworking import Board
LENGTH = 800
WIDTH = 250
THICKNESS = 18
result = (
Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="birch_ply", grain="face")
.dado("top", LENGTH/2 - 9, 18, 6) # x, width, depth
.build()
)
result.add_to_doc("Body")
```
## Rabbet (edge step — backs, drawer bottoms)
```python
from elixifree.domains.woodworking import Board
LENGTH = 600
WIDTH = 200
THICKNESS = 18
result = (
Board(length=LENGTH, width=WIDTH, thickness=THICKNESS, material="birch_ply", grain="face")
.rabbet("back", 12, 9) # width, depth
.build()
)
result.add_to_doc("Body")
```
## Mortise + Tenon modifier signature reference
```
.mortise(face, x, z, width, height, depth)
face — "end" (standard) or "top"/"side" for through-mortise
x, z — corner position of the pocket within that face
width — mortise width (across grain)
height — mortise height (along grain)
depth — how far the pocket goes into the board
.tenon(face, x, z, width, height, length)
face — "end" (standard)
x, z — corner position of the tenon base
width, height — tenon cross-section (match mortise width × height)
length — tenon protrusion (match mortise depth)
```
## Catalog proportions (use these unless the design explicitly overrides)
| Material band | Thickness | Mortise W | Mortise H | Depth |
|---------------|-----------|-----------|-----------|-------|
| hardwood thin (<25mm) | any | 8 | 25 | 20 |
| hardwood medium (25–50mm) | any | 12 | 40 | 32 |
| hardwood thick (>50mm) | any | 16 | 50 | 40 |
| softwood medium | any | 12 | 38 | 28 |
## Joint type selection rules
| Load case | Joint |
|-----------|-------|
| Racking (chair legs, table aprons) | `mortise_tenon` — non-negotiable for lateral loads |
| Shelf support (bookcase dadoes, bed slats) | `dado` preferred; `pocket_screw` for softwood/sheet |
| Face frames, backs | `pocket_screw` or `biscuit` |
| Alignment only (edge glue-up) | `dowel` or `biscuit` |
| Knock-down / quick assembly | `butt_screw` with counterbore |
## When you have two boards and need both cut at once (assembly scripts only)
`Joint` is a convenience that queues the same modifiers on both boards in one call. Only use it when generating an assembly-level script that builds multiple bodies.
```python
from elixifree.domains.woodworking import Board, Joint
leg = Board(700, 40, 40, "oak", grain="long")
apron = Board(400, 40, 40, "oak", grain="long")
leg, apron = Joint(leg, apron, "mortise_tenon").apply()
result_leg = leg.build()
result_apron = apron.build()
# add each to doc separately
```
Do NOT use `Joint` in a component agent script — the agent generates one body at a time.
# Wood Fasteners Skill
## Fastener Selection
Select fasteners from `catalogs/fasteners.json` by `material_class` and `min_board_thickness_mm`.
Match on the thinner of the two boards being joined.
Material classes: `"hardwood"` (oak, walnut), `"softwood"` (pine), `"sheet"` (birch_ply, mdf).
## Pilot Hole Rules
- Hardwood: ALWAYS drill pilot holes to prevent splitting.
- Softwood ≤ 32mm thick: pilot holes are optional but recommended near edges.
- Sheet goods: pilot holes required for butt_screw; pocket_screw jigs pre-drill the pocket.
## Counterbore Depth
- Flush finish (paint/stain): counterbore depth = 5–6mm, fill with matching plug.
- Exposed fastener acceptable: counterbore depth = 3mm.
## Screw Length Rule
2/3 of the total screw length must penetrate the receiving piece.
Example: joining 18mm panel to 25mm board → minimum screw length = 18 + (25 × 0.67) = 35mm → use 38mm or 40mm screw.
Wood Fasteners
Native
Screw, bolt, dowel, and cam lock placement rules and clearance hole sizing for woodworking assemblies.
~227 tokens
# Wood Interfaces Skill
## Leg-to-Apron
- Apron face is FLUSH with the inner face of the leg (not the outer face).
- Mortise is centred on the apron's thickness.
- Apron sits below the top surface; top-of-apron is typically 10–15mm below top of leg.
## Apron-to-Top
- Top OVERHANGS the apron by 20–40mm on each side.
- Top is NOT glued rigidly to aprons for solid timber wider than 150mm — use figure-8 clips or slotted blocks to allow seasonal movement.
- For sheet goods (plywood), gluing is acceptable — sheet goods have negligible seasonal movement.
## Shelf-to-Side (Bookshelf)
- Dado depth = 1/3 of side panel thickness.
- Shelf sits FLUSH with the front edge of the side panel.
- For painted/finished work: rabbet the back edge of the sides to accept a back panel.
## Rail-to-Leg
- Same flush-face rule as apron-to-leg.
- Upper and lower rails must be PARALLEL — set the same inset on both.
- Rail width ≥ 50mm for structural rails; ≥ 30mm for decorative rails.
## Slat-to-Rail (Bed, Chair Back)
- Slats sit in dadoes on the rails OR rest on top of the rail with a pocket screw below.
- Slat spacing: equal gaps; compute as `(span - n_slats × slat_width) / (n_slats + 1)`.
- Use `same_as` for canonical slat — all slats in a series are identical geometry.
Wood Interfaces
Native
Static connection rules between woodworking components: panel-to-panel, shelf-to-side, back panel, and face frame interfaces.
~317 tokens
The Blender Rendering (Core)
skill is always injected for every render. Add complementary skills below to guide style on top of it.
# Blender Rendering Skill
**Default target: Blender 4.2+ with Cycles CPU renderer (fly.io server has no GPU).
High-quality / demo renders on M1 MacBook: use Metal GPU + HDRI — see "High Quality Demo" section.**
## Quick Rules
| ALWAYS | NEVER |
|--------|-------|
| **Generate code for the requested render_type ONLY** | ❌ Combining still + animation in one script (hangs for minutes) |
| `bpy.context.scene.render.engine = 'CYCLES'` | ❌ `'BLENDER_EEVEE_NEXT'` or `'BLENDER_EEVEE'` (no GPU on this server — software fallback is 10x slower than Cycles CPU) |
| **Server (default):** `scene.cycles.device = 'CPU'`, samples 64/32 | ❌ `scene.cycles.device = 'GPU'` on the fly.io server (no GPU) |
| **M1 / high-quality demo:** call `enable_gpu(scene)` then `scene.cycles.samples = 256` + adaptive sampling + OIDN denoiser | ❌ High sample counts (256+) without the OIDN denoiser — wastes render time on CPU |
| **M1 / high-quality demo:** use `setup_hdri_world(hdri_path)` for environment | ❌ Nishita sky for demo renders — procedural sky looks flat compared to HDRI |
| **When `_HDRI_PATH` is set:** ground MUST be `ground.is_shadow_catcher = True`, size `size * 8` — HDRI provides the landscape; shadow catcher is invisible but receives shadows so model looks grounded | ❌ Textured/coloured ground plane with HDRI — creates a visible raised platform that breaks immersion; the HDRI background and the ground plane horizon never align |
| **`scene.render.film_transparent = False`** always — if True, shadow catcher renders as solid black rectangle instead of showing HDRI through it | ❌ `film_transparent = True` with shadow catcher — produces black ground band in final video |
| **When `_HDRI_PATH` is set:** add a large `AREA` fill light (DISK, `size * 3`, `energy = size * size * 0.8`, `use_shadow = False`) directly above the model — prevents fully-black walls when HDRI sun is behind the building during 360° orbit | ❌ HDRI-only lighting for orbit animations — half the orbit the building faces are in full shadow and render black |
| **When `_HDRI_PATH` is set:** camera height MUST be `min_z + size * 0.35`, look_at target `min_z + size * 0.4` — eye-level view keeps shadow-catcher ground visible in frame so model looks grounded, not floating | ❌ `centre.z + size * 0.8` camera with HDRI — camera looks steeply down, ground plane off-frame, model hovers in mid-air |
| `bpy.ops.wm.stl_import(filepath=path)` | ❌ `bpy.ops.import_mesh.stl()` (old API) |
| `bpy.data.*` for object/material access | ❌ `scene.eevee.*` settings (wrong engine) |
| `obj.keyframe_insert(data_path='...', frame=N)` — pass frame number directly | ❌ Face/vertex index assumptions on imported STL |
| Set position THEN call `keyframe_insert(frame=N)` — never call `scene.frame_set()` inside keyframe loops | ❌ `bpy.ops.render.opengl()` (needs viewport) |
| After every `render(animation=True)`, run `ffmpeg -movflags +faststart -c copy` to move moov atom to front | ❌ Skipping faststart — browsers cannot play the video without downloading the full file |
| **Animations:** `1280×720`, 32 samples max, no OIDN denoiser — GPU renders 120 frames to RAM before flushing; 1080p + OIDN exhausts memory and crashes mid-render | ❌ `1920×1080` + `scene.cycles.samples = 64` + OIDN for animations — causes OOM crash, empty output file |
| Clear the default scene before importing | ❌ Assume object names after STL import |
| Use `_OUTPUT_DIR` for all render output paths | ❌ Hardcode `/output/dir/` paths |
| Use `_COMPONENT_FILES` dict to get STL paths | ❌ Hardcode STL file paths |
| **`dist = size * 3.5`** — model fills ~60% of frame | ❌ `size * 1.6` (too close) or `size * 5` (model tiny, flat) |
| **`cam.data.clip_end = size * 20`** after every camera add | ❌ Leaving clip_end at default (100 units clips mm-scale models entirely) |
| **`cam.data.lens = 85`** — architectural telephoto, less distortion | ❌ Default 50mm lens (too wide, distorts buildings) |
| **No HDRI only:** Add **Sun lamp** (`type='SUN'`, energy 6.0) + Nishita sky for directional shadows | ❌ Sky-only lighting with no Sun lamp (flat, no shadows, looks fake) |
| Always add a **ground plane** at `min_z` | ❌ No ground (model floats in void) |
| Use **procedural wood/rubber** (`make_wood_material`, `make_rubber_material`) when prompt names surface materials | ❌ Flat `make_material()` for named textures (wood grain, rubber bumps invisible) |
| Map texture → colour via **`ShaderNodeValToRGB`** (ColorRamp): connect wave `Fac` → ramp `Fac` → BSDF `Base Color` | ❌ Setting `Color1`/`Color2` defaults then connecting a texture to the same socket — the connection silently overrides the colour and renders gray |
| Replace one still with a **material close-up** when prompt specifies surface materials | ❌ All 4 shots at same wide distance (can't see material texture) |
| Camera Z: `size * 0.5` facade/side · **`size * 2.5` isometric** (shows flat roof at ~30°) · `size * 0.3` hero | ❌ `size * 0.8` for iso — only 10° down, flat roof invisible |
| Sky `Strength = 0.25`, Sun `energy = 6.0` — high contrast defines edges and roof/wall boundaries | ❌ Sky `Strength = 1.0` (washes out all shadows, model looks flat) |
---
## Injected Variables (always available)
The executor injects these at the top of every script — use them, never hardcode paths:
```python
# Injected by executor — do NOT redefine these
# _OUTPUT_DIR : str — absolute path to write rendered files
# _COMPONENT_FILES : dict — {"component_name": "/absolute/path/to/file.stl"}
# _HDRI_PATH : str | None — absolute path to the selected HDRI .exr, or None
```
**Always branch on `_HDRI_PATH`:**
```python
if _HDRI_PATH:
# Set up HDRI environment lighting
world = bpy.context.scene.world
world.use_nodes = True
wt = world.node_tree
for n in list(wt.nodes):
wt.nodes.remove(n)
coord = wt.nodes.new("ShaderNodeTexCoord")
mapping = wt.nodes.new("ShaderNodeMapping")
env_tex = wt.nodes.new("ShaderNodeTexEnvironment")
bg = wt.nodes.new("ShaderNodeBackground")
out_w = wt.nodes.new("ShaderNodeOutputWorld")
env_tex.image = bpy.data.images.load(_HDRI_PATH)
# Z rotation (yaw): point the bright part of the HDRI toward the camera side
mapping.inputs["Rotation"].default_value[2] = math.radians(135.0)
# X rotation (pitch): tilt HDRI horizon DOWN slightly so it aligns with the
# shadow-catcher ground plane when camera is at eye level (~15° elevation).
# Without this the HDRI horizon sits too high and the model still looks airborne.
mapping.inputs["Rotation"].default_value[0] = math.radians(-8.0)
bg.inputs["Strength"].default_value = 1.0
wt.links.new(coord.outputs["Generated"], mapping.inputs["Vector"])
wt.links.new(mapping.outputs["Vector"], env_tex.inputs["Vector"])
wt.links.new(env_tex.outputs["Color"], bg.inputs["Color"])
wt.links.new(bg.outputs["Background"], out_w.inputs["Surface"])
# CRITICAL: film_transparent MUST be False so the shadow catcher shows the
# HDRI background through it instead of rendering as a solid black rectangle.
bpy.context.scene.render.film_transparent = False
# Shadow catcher ground — INVISIBLE, just receives shadows.
# The HDRI landscape IS the background; a visible ground breaks the illusion.
# Use size * 8 so the plane extends to the visible horizon at eye-level camera angles.
bpy.ops.mesh.primitive_plane_add(size=size * 8, location=(centre.x, centre.y, min_z))
ground = bpy.context.active_object
ground.name = "Ground"
ground.is_shadow_catcher = True
# Fill light: large soft dome above the model to prevent fully-black shadowed faces.
# Essential for 360° orbit animations — the HDRI sun will be behind the building on
# at least half the orbit, leaving walls nearly black without a fill.
bpy.ops.object.light_add(type='AREA', location=(centre.x, centre.y, min_z + size * 2.5))
fill = bpy.context.active_object
fill.name = "FillDome"
fill.data.shape = 'DISK'
fill.data.size = size * 3 # large disk = very soft, low-contrast fill
fill.data.energy = size * size * 0.8 # scale with scene — mm-scale needs large values
fill.data.use_shadow = False # fill light adds no shadows of its own
else:
# Nishita sky + Sun lamp (see World/Environment Lighting section)
...
```
To import a component STL:
```python
import os
stl_path = _COMPONENT_FILES["component_name"] # already absolute — use directly
bpy.ops.wm.stl_import(filepath=stl_path)
```
---
## Scene Setup (always start here)
```python
import bpy, math, os
# Clear default scene
for obj in bpy.data.objects:
bpy.data.objects.remove(obj, do_unlink=True)
for block in bpy.data.meshes:
bpy.data.meshes.remove(block)
```
---
## STL Import (Blender 4.x API)
```python
# Import a single STL — store the reference immediately
def import_stl(filepath, name):
for obj in bpy.data.objects:
obj.select_set(False)
bpy.ops.wm.stl_import(filepath=filepath)
obj = bpy.context.selected_objects[0]
obj.name = name
return obj
```
---
## PBR Textures (`_TEXTURE_LIBRARY`)
`_TEXTURE_LIBRARY` is injected into every script. It maps material id → `{color, normal, roughness}` file paths for all downloaded Poly Haven PBR materials.
**Always prefer PBR textures over procedural shaders when a matching key is available.**
### `make_pbr_material` — standard function (copy into every script that uses textures)
```python
def make_pbr_material(name, maps, uv_scale=1.0):
"""
Build a Principled BSDF material from a PBR maps dict.
maps: {"color": path, "normal": path, "roughness": path} (all optional)
uv_scale: tile the texture — for mm-scale FreeCAD models use ~0.001 to 0.005
so a 2K texture covers a realistic physical area rather than one giant tile.
"""
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
coord = nt.nodes.new("ShaderNodeTexCoord")
mapping = nt.nodes.new("ShaderNodeMapping")
mapping.inputs["Scale"].default_value = (uv_scale, uv_scale, uv_scale)
nt.links.new(coord.outputs["Object"], mapping.inputs["Vector"])
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
if "color" in maps:
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.load(maps["color"])
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if "normal" in maps:
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.load(maps["normal"])
tex.image.colorspace_settings.name = "Non-Color"
nmap = nt.nodes.new("ShaderNodeNormalMap")
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
nt.links.new(tex.outputs["Color"], nmap.inputs["Color"])
nt.links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
if "roughness" in maps:
tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = bpy.data.images.load(maps["roughness"])
tex.image.colorspace_settings.name = "Non-Color"
nt.links.new(mapping.outputs["Vector"], tex.inputs["Vector"])
nt.links.new(tex.outputs["Color"], bsdf.inputs["Roughness"])
return mat
```
### UV scale guide for mm-scale FreeCAD models
FreeCAD exports STL in millimetres. Blender treats those as metres, so textures tile at 1000× real scale without correction. Use `uv_scale` to restore physical plank/tile sizes:
| Surface | Recommended `uv_scale` | Effect |
|---------|----------------------|--------|
| Timber cladding (150mm boards) | `0.003` | ~8 boards visible per metre |
| Concrete wall/floor | `0.002` | Realistic aggregate grain size |
| Grass ground plane | `0.001` | Dense blades, not giant blobs |
| Corrugated metal (100mm pitch) | `0.004` | ~10 corrugations per metre |
### Usage example
```python
# Assign grey timber cladding to the building walls
if "wood_planks_grey" in _TEXTURE_LIBRARY:
wall_mat = make_pbr_material("Cladding", _TEXTURE_LIBRARY["wood_planks_grey"], uv_scale=0.003)
else:
wall_mat = make_wood_material("Cladding") # fallback to procedural
model.data.materials.clear()
model.data.materials.append(wall_mat)
# Ground — shadow catcher when HDRI is active, PBR material otherwise
# ⚠️ NEVER assign a ground material when _HDRI_PATH is set — the HDRI IS the ground
if _HDRI_PATH:
ground.is_shadow_catcher = True # invisible; HDRI landscape shows through
else:
if "grass_path_2" in _TEXTURE_LIBRARY:
ground_mat = make_pbr_material("Ground", _TEXTURE_LIBRARY["grass_path_2"], uv_scale=0.001)
else:
ground_mat = bpy.data.materials.new("GroundMat")
ground_mat.use_nodes = True
ground_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.3, 0.28, 0.22, 1.0)
ground_mat.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.9
ground.data.materials.append(ground_mat)
```
### Available materials (as of current download)
| Key | Visual | Best used for |
|-----|--------|---------------|
| `wood_planks_grey` | Grey weathered boards | SIP exterior wall cladding |
| `weathered_brown_planks` | Warm brown planks | Decking, unfinished cladding |
| `concrete_floor_02` | Smooth grey concrete | Foundations, slab, paths |
| `grass_path_2` | Short lawn grass | Ground plane around buildings |
| `corrugated_iron` | Galvanised metal sheets | Metal roofs, industrial cladding |
| `metal_plate_02` | Brushed steel | Fascias, gutters, frames |
### Fallback rule
Always check before use — a texture may not be downloaded on the current machine:
```python
if "wood_planks_grey" in _TEXTURE_LIBRARY:
mat = make_pbr_material("Cladding", _TEXTURE_LIBRARY["wood_planks_grey"], uv_scale=0.003)
else:
mat = make_wood_material("Cladding")
```
---
## Materials
### Simple flat material (when no texture variation is needed)
```python
def make_material(name, base_color, metallic=0.0, roughness=0.5):
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*base_color, 1.0)
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
return mat
```
### Procedural Wood (use for timber, cladding, decking)
**⚠️ Critical node graph rule:** When you connect a texture output to a socket, it **overrides** any `default_value` you set on that socket. Never set a color default and then connect a texture to the same socket — the color will be silently ignored.
Use a **ColorRamp** (`ShaderNodeValToRGB`) to map the grayscale wave output to actual wood colors. This is the only reliable pattern.
```python
def make_wood_material(name, dark_color=(0.15, 0.08, 0.02), light_color=(0.55, 0.32, 0.10)):
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
nt = mat.node_tree
for n in nt.nodes:
nt.nodes.remove(n)
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
ramp = nt.nodes.new("ShaderNodeValToRGB") # maps 0-1 float → colour
wave = nt.nodes.new("ShaderNodeTexWave")
noise = nt.nodes.new("ShaderNodeTexNoise") # distorts the wave for realism
coord = nt.nodes.new("ShaderNodeTexCoord")
# BANDS along Z = horizontal cladding boards visible from the side
wave.wave_type = 'BANDS'
wave.bands_direction = 'Z'
wave.inputs["Scale"].default_value = 30.0 # ~30 visible boards across model height
wave.inputs["Distortion"].default_value = 2.0
wave.inputs["Detail"].default_value = 4.0
wave.inputs["Detail Scale"].default_value = 2.0
noise.inputs["Scale"].default_value = 8.0 # large-scale grain variation
noise.inputs["Detail"].default_value = 4.0
noise.inputs["Roughness"].default_value = 0.6
# ColorRamp: wave Fac (0-1) → dark to light wood colour
ramp.color_ramp.interpolation = 'LINEAR'
ramp.color_ramp.elements[0].position = 0.0
ramp.color_ramp.elements[0].color = (*dark_color, 1.0)
ramp.color_ramp.elements[1].position = 1.0
ramp.color_ramp.elements[1].color = (*light_color, 1.0)
bsdf.inputs["Roughness"].default_value = 0.75
nt.links.new(coord.outputs["Object"], noise.inputs["Vector"])
nt.links.new(noise.outputs["Fac"], wave.inputs["Vector"]) # noise distorts wave coords
nt.links.new(wave.outputs["Fac"], ramp.inputs["Fac"]) # wave float → colour ramp
nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
```
### Procedural Rubber / EPDM (use for flat roofs, membranes)
```python
def make_rubber_material(name, base_color=(0.03, 0.03, 0.03)):
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
nt = mat.node_tree
for n in nt.nodes:
nt.nodes.remove(n)
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
noise = nt.nodes.new("ShaderNodeTexNoise")
bump = nt.nodes.new("ShaderNodeBump")
coord = nt.nodes.new("ShaderNodeTexCoord")
bsdf.inputs["Base Color"].default_value = (*base_color, 1.0)
bsdf.inputs["Roughness"].default_value = 0.95
bsdf.inputs["Specular IOR Level"].default_value = 0.02
noise.inputs["Scale"].default_value = 50.0 # object-space scale, visible surface texture
noise.inputs["Detail"].default_value = 6.0
noise.inputs["Roughness"].default_value = 0.7
bump.inputs["Strength"].default_value = 0.2
nt.links.new(coord.outputs["Object"], noise.inputs["Vector"])
nt.links.new(noise.outputs["Fac"], bump.inputs["Height"])
nt.links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
```
### Common flat materials
```python
aluminium = make_material("Aluminium", (0.8, 0.8, 0.82), metallic=0.9, roughness=0.2)
painted_steel= make_material("PaintedSteel",(1.0, 1.0, 1.0), metallic=0.0, roughness=0.6)
concrete = make_material("Concrete", (0.55, 0.53, 0.50), metallic=0.0, roughness=0.95)
```
Exact Principled BSDF input names (4.2+): `"Base Color"`, `"Metallic"`, `"Roughness"`,
`"Specular IOR Level"`, `"Emission Color"`, `"Alpha"`, `"IOR"`, `"Subsurface Weight"`.
## Material Detail Shot
When the prompt specifies surface materials (wood, rubber, brick, etc.), replace one of the 4 still angles with a **material close-up** that shows the texture clearly. Aim the camera at a representative wall section from ~`size * 1.0` distance at eye height.
```python
# Material detail shot — close-up of the main wall surface at eye height
eye_z = min_z + size * 0.4 # ~40% up the model height
render_at((cx, cy - size * 1.0, eye_z), "detail.jpg") # replaces hero.jpg
```
This gives the viewer one image that clearly shows what the materials look like up close, alongside the wider architectural shots.
---
## World / Environment Lighting
Always use Nishita sky + an explicit Sun lamp. Sky alone produces flat, shadowless renders.
```python
# Nishita sky (clear blue sky, midday sun)
world = bpy.context.scene.world
world.use_nodes = True
wt = world.node_tree
for n in wt.nodes:
wt.nodes.remove(n)
bg = wt.nodes.new("ShaderNodeBackground")
sky = wt.nodes.new("ShaderNodeTexSky")
out = wt.nodes.new("ShaderNodeOutputWorld")
sky.sky_type = 'NISHITA'
sky.sun_elevation = math.radians(55)
sky.sun_rotation = math.radians(45)
sky.altitude = 0.1
bg.inputs["Strength"].default_value = 0.25 # LOW ambient — let the sun create contrast
wt.links.new(sky.outputs["Color"], bg.inputs["Color"])
wt.links.new(bg.outputs["Background"], out.inputs["Surface"])
# Sun lamp — high energy so shadows define edges and roof/wall contrast is clear
bpy.ops.object.light_add(type='SUN', location=(0, 0, 0))
sun = bpy.context.active_object
sun.data.energy = 6.0 # strong directional light
sun.data.angle = math.radians(0.5)
sun.rotation_euler = (math.radians(55), 0, math.radians(-45))
```
## Ground Plane
Always add a ground plane. The treatment depends entirely on whether HDRI is active:
- **HDRI active (`_HDRI_PATH` is set):** shadow catcher only — the HDRI provides the landscape, a visible ground breaks it
- **No HDRI:** visible plane with a material
```python
min_z = min((model.matrix_world @ mathutils.Vector(v)).z for v in model.bound_box)
bpy.ops.mesh.primitive_plane_add(size=size * 4, location=(centre.x, centre.y, min_z))
ground = bpy.context.active_object
ground.name = "Ground"
if _HDRI_PATH:
# Shadow catcher: receives shadows from the model, HDRI landscape shows through
# Do NOT assign any material — shadow catcher needs no material
ground.is_shadow_catcher = True
else:
gmat = bpy.data.materials.new("GroundMat")
gmat.use_nodes = True
gbsdf = gmat.node_tree.nodes["Principled BSDF"]
gbsdf.inputs["Base Color"].default_value = (0.35, 0.38, 0.30, 1.0)
gbsdf.inputs["Roughness"].default_value = 0.9
ground.data.materials.append(gmat)
```
---
## Camera Setup
**Always use `look_at` to point the camera — never compute rotation angles manually.**
**⚠️ FreeCAD STL exports are in millimetres.** Blender's default camera `clip_end` is 100 units. For mm-scale models (3000 mm garden room, etc.) the model sits thousands of units away — completely clipped. **Always set `clip_end` to at least `size * 20`** after creating the camera.
```python
from mathutils import Vector
def look_at(cam, target):
"""Point cam's -Z axis at target (world coords). Works reliably in headless mode."""
direction = Vector(target) - cam.location
rot_quat = direction.to_track_quat('-Z', 'Y')
cam.rotation_euler = rot_quat.to_euler()
# Add camera, position it, then aim it at the model centre
bpy.ops.object.camera_add()
cam = bpy.context.active_object
bpy.context.scene.camera = cam
# CRITICAL: expand clip range for mm-scale models (FreeCAD exports in mm)
cam.data.clip_end = size * 20 # size is max(obj.dimensions) in Blender units
# Example: place camera and point it at model centre
cam.location = (centre.x + dist, centre.y - dist, centre.z + dist * 0.5)
look_at(cam, centre) # centre = world-space bounding box centre
```
**Compute the model bounding box centre and set up camera:**
```python
import mathutils
obj = bpy.context.active_object # your imported mesh
local_bbox = [mathutils.Vector(v) for v in obj.bound_box]
cx = sum(v.x for v in local_bbox) / 8
cy = sum(v.y for v in local_bbox) / 8
cz = sum(v.z for v in local_bbox) / 8
centre = obj.matrix_world @ mathutils.Vector((cx, cy, cz))
size = max(obj.dimensions)
dist = size * 3.5 # building fills ~60% of frame; not so far it looks tiny
bpy.ops.object.camera_add()
cam = bpy.context.active_object
bpy.context.scene.camera = cam
cam.data.clip_end = size * 20 # ← critical for mm-scale FreeCAD models
cam.data.lens = 85 # 85mm telephoto: less distortion, more architectural
cam.location = (centre.x + dist, centre.y - dist, min_z + size * 0.35)
look_at(cam, Vector(centre.x, centre.y, min_z + size * 0.4))
```
**Camera height guidance for architectural shots (HDRI grounding rule):**
- **Eye-level (default for HDRI renders):** `min_z + size * 0.35` pointing at `min_z + size * 0.4` — camera ~35% up the building height, nearly level with the structure. The shadow-catcher ground plane fills the lower 20% of the frame, anchoring the model to the HDRI landscape. This is the correct height for all HDRI renders.
- Front/side views: `min_z + size * 0.35` — shows facade and visible ground
- Isometric: `min_z + size * 0.8` — elevated enough to see roof and both sides (only for non-HDRI stills)
- Hero: `min_z + size * 0.2` — low, dramatic, above ground but below roofline
- **NEVER** use `centre.z + size * 0.8` for HDRI renders — camera is too high, ground plane disappears off bottom of frame, model hovers in mid-air
---
## Cycles CPU Render Settings (Blender 4.2+)
```python
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 64 # 64 for stills; 32 for animation frames
# Resolution
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.resolution_percentage = 100
# Output — always use _OUTPUT_DIR
scene.render.image_settings.file_format = 'JPEG'
scene.render.image_settings.quality = 95
scene.render.filepath = os.path.join(_OUTPUT_DIR, "image.jpg")
# Render single frame
bpy.ops.render.render(write_still=True)
```
---
## ⚠️ Render Type — Output EXACTLY What Was Requested
The render type is specified in the prompt header. **Generate code for that type only.**
| Render type | Files to produce | Do NOT produce |
|-------------|-----------------|----------------|
| `isometric` | `isometric.jpg` | ❌ Any MP4 / animation or extra stills |
| `still` | `front.jpg`, `side.jpg`, `isometric.jpg`, `detail.jpg` | ❌ Any MP4 / animation |
| `turntable` | `turntable.mp4` | ❌ Stills or other animations |
| `camera_orbit` | `orbit.mp4` | ❌ Stills or other animations |
| `assembly_flyin` | `flyin.mp4` | ❌ Stills or other animations |
Never combine types. A `still` or `isometric` request must never call `bpy.ops.render.render(animation=True)`.
---
## Single Isometric Render (render_type = isometric)
One shot from a high isometric angle — fastest render, ideal for proof-of-concept.
```python
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 64
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.image_settings.file_format = 'JPEG'
scene.render.image_settings.quality = 95
bpy.ops.object.camera_add()
cam = bpy.context.active_object
scene.camera = cam
cam.data.clip_end = size * 20
cam.data.lens = 85
cx, cy, cz = centre.x, centre.y, centre.z
cam.location = (cx + dist, cy - dist, cz + size * 2.5) # HIGH: ~30° down so flat roof is visible
look_at(cam, centre)
scene.render.filepath = os.path.join(_OUTPUT_DIR, "isometric.jpg")
bpy.ops.render.render(write_still=True)
```
---
## Still Renders (render_type = still)
```python
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 64
scene.render.resolution_x = 1920
scene.render.resolution_y = 1080
scene.render.image_settings.file_format = 'JPEG'
scene.render.image_settings.quality = 95
bpy.ops.object.camera_add()
cam = bpy.context.active_object
scene.camera = cam
cam.data.clip_end = size * 20
cam.data.lens = 85
def render_at(location, filename):
cam.location = location
look_at(cam, centre)
scene.render.filepath = os.path.join(_OUTPUT_DIR, filename)
bpy.ops.render.render(write_still=True) # write_still=True, NOT animation=True
cx, cy, cz = centre.x, centre.y, centre.z
min_z = min((model.matrix_world @ mathutils.Vector(v)).z for v in model.bound_box)
eye_z = min_z + size * 0.4
render_at((cx, cy - dist, cz + size * 0.5), "front.jpg") # facade at mid-height
render_at((cx + dist, cy, cz + size * 0.5), "side.jpg") # side elevation
render_at((cx + dist, cy - dist, cz + size * 2.5), "isometric.jpg") # HIGH: ~30° down so flat roof is clearly visible
render_at((cx, cy - size, eye_z), "detail.jpg") # material close-up
```
---
## Turntable Animation (render_type = turntable only)
**⚠️ Never rotate the model.** FreeCAD models have their origin at a corner, not the centre. Rotating the model pivots around that corner — the building swings out of frame and the video is mostly blank sky.
**Always orbit the camera** around the bounding-box centre instead.
```python
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
enable_gpu(scene)
scene.cycles.samples = 32 # keep low — GPU renders to RAM before flush
scene.cycles.use_denoising = False # OIDN adds significant RAM pressure on animations
scene.render.resolution_x = 1280 # 720p for animations — 1080p causes OOM on 120 frames
scene.render.resolution_y = 720
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.constant_rate_factor = 'MEDIUM'
scene.render.fps = 24
scene.frame_start = 1
# Duration: derive from prompt if the user specifies (e.g. "15 second turntable").
# Default is 15 s = 360 frames. Adjust DURATION_S to match prompt.
DURATION_S = 15 # ← change this if prompt specifies a different duration
TOTAL_FRAMES = DURATION_S * 24
scene.frame_end = TOTAL_FRAMES
bpy.ops.object.camera_add()
cam = bpy.context.active_object
scene.camera = cam
cam.data.clip_end = size * 20
cam.data.lens = 85
# Orbit camera 360° around the bounding-box centre at a fixed elevation
radius = dist # same dist used for stills
# ⚠️ Do NOT call scene.frame_set() inside this loop — it re-evaluates animation curves
# and overwrites the location we just set, causing all frames to bake the same position.
# keyframe_insert(frame=N) stores the current value at frame N without needing frame_set.
# Eye-level height: ~35% up the building so the shadow-catcher ground plane
# is visible in the bottom of the frame and the model looks grounded.
# look_at target is ~40% up (slightly above camera height) for a natural tilt.
eye_height = min_z + size * 0.35
look_target = Vector(centre.x, centre.y, min_z + size * 0.4)
deg_per_frame = 360.0 / TOTAL_FRAMES # one full rotation over the whole clip
for frame in range(1, TOTAL_FRAMES + 2):
angle = math.radians((frame - 1) * deg_per_frame)
cam.location = (
centre.x + radius * math.sin(angle),
centre.y - radius * math.cos(angle),
eye_height,
)
look_at(cam, look_target)
cam.keyframe_insert(data_path='location', frame=frame)
cam.keyframe_insert(data_path='rotation_euler', frame=frame)
scene.render.filepath = os.path.join(_OUTPUT_DIR, "turntable.mp4")
bpy.ops.render.render(animation=True)
# Move moov atom to front so browsers can play without downloading the full file
import subprocess
_mp4 = os.path.join(_OUTPUT_DIR, "turntable.mp4")
_tmp = _mp4 + ".tmp.mp4"
subprocess.run(["ffmpeg", "-i", _mp4, "-movflags", "+faststart", "-c", "copy", _tmp, "-y"],
capture_output=True)
os.replace(_tmp, _mp4)
```
---
## Camera Orbit Animation (render_type = camera_orbit only)
```python
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
enable_gpu(scene)
scene.cycles.samples = 32 # keep low — GPU renders to RAM before flush
scene.cycles.use_denoising = False # OIDN adds significant RAM pressure on animations
scene.render.resolution_x = 1280 # 720p for animations — 1080p causes OOM on 120 frames
scene.render.resolution_y = 720
scene.frame_start = 1
# Duration: derive from prompt if the user specifies (e.g. "20 second orbit").
# Default is 15 s = 360 frames. Adjust DURATION_S to match prompt.
DURATION_S = 15 # ← change this if prompt specifies a different duration
TOTAL_FRAMES = DURATION_S * 24
scene.frame_end = TOTAL_FRAMES
bpy.ops.object.camera_add()
cam = bpy.context.active_object
scene.camera = cam
cam.data.clip_end = size * 20
radius = dist
deg_per_frame = 360.0 / TOTAL_FRAMES # one full rotation over the whole clip
# Eye-level height so shadow-catcher ground fills the bottom of the frame.
eye_height = min_z + size * 0.35
look_target = Vector(centre.x, centre.y, min_z + size * 0.4)
for frame in range(1, TOTAL_FRAMES + 1):
angle = math.radians((frame - 1) * deg_per_frame)
x = centre.x + radius * math.sin(angle)
y = centre.y - radius * math.cos(angle)
cam.location = (x, y, eye_height)
look_at(cam, look_target)
cam.keyframe_insert(data_path='location', frame=frame)
cam.keyframe_insert(data_path='rotation_euler', frame=frame)
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.filepath = os.path.join(_OUTPUT_DIR, "orbit.mp4")
bpy.ops.render.render(animation=True)
_mp4 = os.path.join(_OUTPUT_DIR, "orbit.mp4")
_tmp = _mp4 + ".tmp.mp4"
subprocess.run(["ffmpeg", "-i", _mp4, "-movflags", "+faststart", "-c", "copy", _tmp, "-y"],
capture_output=True)
os.replace(_tmp, _mp4)
```
---
## Assembly Fly-in Animation
```python
# placement_manifest: {"component_name": {"location": [x,y,z], "rotation": [rx,ry,rz]}}
for name, placement in placement_manifest.items():
obj = bpy.data.objects.get(name)
if not obj:
continue
final_loc = placement["location"]
start_loc = [final_loc[0], final_loc[1] - 5.0, final_loc[2] + 2.0]
scene.frame_set(1)
obj.location = start_loc
obj.keyframe_insert(data_path='location', frame=1)
obj.location = final_loc
obj.keyframe_insert(data_path='location', frame=60)
for fc in obj.animation_data.action.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = 'BEZIER'
```
---
---
## High Quality Demo (M1 MacBook / Local GPU)
Use this section when the user asks for a high-quality, impressive, or demo render — especially turntable animations. Requires:
- Blender running locally on an M1/M2/M3 Mac (or any machine with a GPU)
- A free HDRI `.exr` file downloaded from [polyhaven.com/hdris](https://polyhaven.com/hdris)
- Best choices for a garden room: **"Meadow 2"**, **"Rosendal Plains 2"**, **"Kloofendal 48d Partly Cloudy"**
- Download the 4K `.exr` version; place it anywhere (e.g. `~/Downloads/meadow_2_4k.exr`)
- The user includes the path in their render prompt; Claude embeds it in the script
**Quality stack (combine all four for best results):**
1. `enable_gpu(scene)` — Metal on M1, 5-10× faster than CPU
2. `setup_hdri_world(hdri_path)` — real outdoor lighting + background
3. Adaptive sampling (256 max, threshold 0.02) — stops early when converged
4. OIDN denoiser — 128 adaptive samples looks like 512+ without it
---
### GPU Acceleration (M1 Metal / CUDA / OptiX)
Call this before setting `scene.cycles.samples`. It detects the best available GPU and falls back to CPU gracefully.
```python
def enable_gpu(scene):
"""Enable Metal (M1/M2), CUDA, OptiX, or HIP — whichever is available. Falls back to CPU."""
prefs = bpy.context.preferences
cprefs = prefs.addons["cycles"].preferences
cprefs.refresh_devices()
for device_type in ('METAL', 'CUDA', 'OPTIX', 'HIP'):
try:
cprefs.compute_device_type = device_type
devices = cprefs.get_devices_for_type(device_type)
if devices:
for d in devices:
d.use = True
scene.cycles.device = 'GPU'
print(f"[CadClaude] GPU: {device_type}")
return
except Exception:
pass
scene.cycles.device = 'CPU'
print("[CadClaude] GPU: none found, using CPU")
```
---
### HDRI Environment Lighting
Replaces the Nishita sky + Sun lamp setup. The HDRI provides both realistic lighting and a real outdoor background — single biggest quality jump available.
```python
def setup_hdri_world(hdri_path, rotation_z_deg=135.0, strength=1.0):
"""
Load an HDRI .exr for environment lighting and background.
rotation_z_deg rotates the HDRI so the brightest light hits the model from a good angle.
Adjust until shadows fall attractively (135° is a good starting point).
"""
world = bpy.context.scene.world
world.use_nodes = True
wt = world.node_tree
for n in list(wt.nodes):
wt.nodes.remove(n)
coord = wt.nodes.new("ShaderNodeTexCoord")
mapping = wt.nodes.new("ShaderNodeMapping")
env_tex = wt.nodes.new("ShaderNodeTexEnvironment")
bg = wt.nodes.new("ShaderNodeBackground")
out = wt.nodes.new("ShaderNodeOutputWorld")
env_tex.image = bpy.data.images.load(hdri_path)
mapping.inputs["Rotation"].default_value[2] = math.radians(rotation_z_deg)
# Tilt horizon down so it aligns with shadow-catcher at eye-level camera
mapping.inputs["Rotation"].default_value[0] = math.radians(-8.0)
bg.inputs["Strength"].default_value = strength
wt.links.new(coord.outputs["Generated"], mapping.inputs["Vector"])
wt.links.new(mapping.outputs["Vector"], env_tex.inputs["Vector"])
wt.links.new(env_tex.outputs["Color"], bg.inputs["Color"])
wt.links.new(bg.outputs["Background"], out.inputs["Surface"])
```
**No Sun lamp needed** when using HDRI — the HDRI already contains directional light from the sky/sun.
---
### Shadow Catcher Ground
Use with HDRI. The ground plane is invisible but catches shadows from the model, grounding it visually without the HDRI background being masked by a flat polygon.
```python
# MUST be False — True renders shadow catcher as a solid black rectangle
bpy.context.scene.render.film_transparent = False
# Shadow catcher — invisible, only catches shadows from the building.
# size * 8 so the plane extends to the visual horizon at eye-level camera angles.
bpy.ops.mesh.primitive_plane_add(size=size * 8, location=(centre.x, centre.y, min_z))
ground = bpy.context.active_object
ground.name = "Ground"
ground.is_shadow_catcher = True
# Fill light — large soft dome above the model.
# Prevents fully-black walls when HDRI sun is behind the building (critical for orbit animations).
bpy.ops.object.light_add(type='AREA', location=(centre.x, centre.y, min_z + size * 2.5))
fill = bpy.context.active_object
fill.name = "FillDome"
fill.data.shape = 'DISK'
fill.data.size = size * 3
fill.data.energy = size * size * 0.8
fill.data.use_shadow = False
```
⚠️ Do NOT use `render.film_transparent = True` with shadow catcher — it makes the shadows also transparent, producing a black ground band in the video.
---
### Adaptive Sampling + OIDN Denoiser
Use for all high-quality renders. Stops sampling each pixel as soon as it converges rather than running all `samples` regardless.
```python
# High quality — 256 max samples, stops early when converged, denoised
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.02 # stop when per-pixel variance < 2%
scene.cycles.adaptive_min_samples = 32 # always do at least 32 passes
scene.cycles.samples = 256 # upper bound; most pixels finish at 64-128
scene.cycles.use_denoising = True
scene.cycles.denoiser = 'OPENIMAGEDENOISE' # OIDN ships with Blender, no extra install
```
---
### High Quality Demo Turntable (M1)
Complete template combining all the above. Use this when the user asks for an impressive or demo-quality turntable.
The user must provide `hdri_path` in their prompt (e.g. "use HDRI at ~/Downloads/meadow_2_4k.exr").
```python
import bpy, math, os, mathutils
# Clear scene
for obj in bpy.data.objects:
bpy.data.objects.remove(obj, do_unlink=True)
for block in bpy.data.meshes:
bpy.data.meshes.remove(block)
# ── Import model ──────────────────────────────────────────────────────────────
stl_path = list(_COMPONENT_FILES.values())[0]
for obj in bpy.data.objects:
obj.select_set(False)
bpy.ops.wm.stl_import(filepath=stl_path)
model = bpy.context.selected_objects[0]
model.name = "GardenRoom"
# ── Bounding box ──────────────────────────────────────────────────────────────
local_bbox = [mathutils.Vector(v) for v in model.bound_box]
cx = sum(v.x for v in local_bbox) / 8
cy = sum(v.y for v in local_bbox) / 8
cz = sum(v.z for v in local_bbox) / 8
centre = model.matrix_world @ mathutils.Vector((cx, cy, cz))
size = max(model.dimensions)
dist = size * 3.2 # slightly closer than 3.5 for more presence
min_z = min((model.matrix_world @ mathutils.Vector(v)).z for v in model.bound_box)
# ── Materials ─────────────────────────────────────────────────────────────────
# (apply make_wood_material / make_rubber_material here per prompt)
# ── Render engine + GPU ───────────────────────────────────────────────────────
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
enable_gpu(scene) # Metal on M1, falls back to CPU
# Adaptive sampling + OIDN
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.03 # slightly looser than still renders — fine for motion
scene.cycles.adaptive_min_samples = 16
scene.cycles.samples = 64 # 64 max + OIDN denoiser = excellent quality at 720p
scene.cycles.use_denoising = True
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
# ── Resolution + output ───────────────────────────────────────────────────────
scene.render.resolution_x = 1280
scene.render.resolution_y = 720
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'FFMPEG'
scene.render.ffmpeg.format = 'MPEG4'
scene.render.ffmpeg.codec = 'H264'
scene.render.ffmpeg.constant_rate_factor = 'HIGH'
# Do NOT set ffmpeg_preset = 'BEST' — maps to x264 veryslow and breaks moov atom finalisation in headless mode
scene.render.fps = 24
scene.frame_start = 1
scene.frame_end = 120 # 5 seconds — smooth full orbit
# ── HDRI world ────────────────────────────────────────────────────────────────
# _HDRI_PATH is injected by the executor — use it directly, never hardcode a path
setup_hdri_world(_HDRI_PATH, rotation_z_deg=135.0, strength=1.0)
# ── Shadow catcher ground ─────────────────────────────────────────────────────
bpy.ops.mesh.primitive_plane_add(size=size * 4, location=(centre.x, centre.y, min_z))
ground = bpy.context.active_object
ground.name = "Ground"
ground.is_shadow_catcher = True
# ── Camera ────────────────────────────────────────────────────────────────────
def look_at(cam, target):
from mathutils import Vector
direction = Vector(target) - cam.location
rot_quat = direction.to_track_quat('-Z', 'Y')
cam.rotation_euler = rot_quat.to_euler()
bpy.ops.object.camera_add()
cam = bpy.context.active_object
scene.camera = cam
cam.data.clip_end = size * 20
cam.data.lens = 70 # 70mm: good architectural feel, shows context without distortion
radius = dist
# Camera height: starts at size*0.45, rises gently to size*0.65 over the orbit
# This gives a slight dolly-up feeling that makes the video feel more dynamic
for frame in range(1, scene.frame_end + 2):
t = (frame - 1) / scene.frame_end # 0.0 → 1.0
angle = math.radians(t * 360) # full 360° orbit
height_frac = 0.45 + t * 0.20 # gentle rise: 0.45 → 0.65
cam.location = (
centre.x + radius * math.sin(angle),
centre.y - radius * math.cos(angle),
centre.z + size * height_frac,
)
look_at(cam, centre)
# Do NOT call scene.frame_set() here — it re-evaluates animation curves and
# overwrites the location we just set, causing all frames to bake to frame 1's position.
cam.keyframe_insert(data_path='location', frame=frame)
cam.keyframe_insert(data_path='rotation_euler', frame=frame)
# ── Render ────────────────────────────────────────────────────────────────────
scene.render.filepath = os.path.join(_OUTPUT_DIR, "turntable.mp4")
bpy.ops.render.render(animation=True)
_mp4 = os.path.join(_OUTPUT_DIR, "turntable.mp4")
_tmp = _mp4 + ".tmp.mp4"
subprocess.run(["ffmpeg", "-i", _mp4, "-movflags", "+faststart", "-c", "copy", _tmp, "-y"],
capture_output=True)
os.replace(_tmp, _mp4)
```
**Expected render time on M1 MacBook Pro:** ~30-45 minutes for 120 frames at 1280×720 with 64 adaptive samples + OIDN. The denoiser keeps each frame clean; 64 samples at 720p with OIDN is indistinguishable from 256 samples without it.
---
## Common Mistakes
```python
# ❌ WRONG — manual atan2 rotation points camera the WRONG direction
cam.rotation_euler = (math.pi/2 - pitch, 0, math.atan2(x, -y))
# ✅ RIGHT — use look_at with mathutils
from mathutils import Vector
def look_at(cam, target):
direction = Vector(target) - cam.location
rot_quat = direction.to_track_quat('-Z', 'Y')
cam.rotation_euler = rot_quat.to_euler()
look_at(cam, (cx, cy, cz))
```
```python
# ❌ WRONG — EEVEE requires a GPU; falls back to extremely slow software rendering on this server
scene.render.engine = 'BLENDER_EEVEE_NEXT'
scene.render.engine = 'BLENDER_EEVEE'
# ✅ RIGHT — Cycles is a proper CPU renderer, no GPU needed
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 64
# ❌ WRONG — hardcoded output path
scene.render.filepath = "/output/dir/image.jpg"
# ✅ RIGHT — use injected _OUTPUT_DIR
scene.render.filepath = os.path.join(_OUTPUT_DIR, "image.jpg")
# ❌ WRONG — old STL import
bpy.ops.import_mesh.stl(filepath=path)
# ✅ RIGHT — Blender 4.x
bpy.ops.wm.stl_import(filepath=path)
# ❌ WRONG — bpy.ops.object.select_all for deselect (can fail in headless)
bpy.ops.object.select_all(action='DESELECT')
# ✅ RIGHT
for obj in bpy.data.objects:
obj.select_set(False)
# ❌ WRONG — default clip_end (100 units) clips mm-scale FreeCAD models entirely
bpy.ops.object.camera_add()
cam = bpy.context.active_object
# (clip_end never set → blank/gradient renders for any model > 100 Blender units away)
# ✅ RIGHT — always set clip_end relative to model size
cam.data.clip_end = size * 20 # e.g. size=3000mm → clip_end=60000
# ❌ WRONG — camera placed at world-origin offset when model is not at origin
cam.location = (dist, -dist, dist * 0.5)
# ✅ RIGHT — always offset from the computed bounding box centre
cam.location = (centre.x + dist, centre.y - dist, centre.z + dist * 0.5)
look_at(cam, centre)
# ❌ WRONG — Sun lamp + HDRI doubles the lighting, blows out the render
setup_hdri_world(hdri_path)
bpy.ops.object.light_add(type='SUN', ...) # don't add a sun when using HDRI
# ✅ RIGHT — HDRI is the only light source when using setup_hdri_world()
# ❌ WRONG — film_transparent = True with shadow catcher makes shadows vanish
scene.render.film_transparent = True
ground.is_shadow_catcher = True
# ✅ RIGHT — leave film_transparent at its default (False)
ground.is_shadow_catcher = True
# ❌ WRONG — HDRI strength > 1.5 washes out the model
bg.inputs["Strength"].default_value = 3.0
# ✅ RIGHT — 1.0 for outdoor/meadow HDRIs
bg.inputs["Strength"].default_value = 1.0
```
Blender Rendering (Core)
Native
Always loaded
Always injected for every render. Defines camera rules, lighting, ColorRamp material patterns, and render type dispatch. Read-only.
~11568 tokens
Sign In
Enter the access password to unlock full editing.
Read-only preview — browse projects and explore 3D models.
to create and edit projects.