← Skills

SIP Roof Construction

Native Read-only

Mono-pitch, duo-pitch, and flat roof construction code, ridge bevels, gable sections, and flat roof drainage. Injected to agents building roof components.

/skills/sip_roofs.md

Estimated tokens
6698
Characters
26790
Source
Native

Markdown

# 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
```

### Skylights / roof windows — NON-NEGOTIABLE

A slope carrying a skylight (velux, roof window, rooflight) is still built by
`PitchedRoofSlope`. Cut each opening with `.opening()`:

```python
slope = PitchedRoofSlope(
    ridge_length=RIDGE_LENGTH,
    half_span=HALF_SPAN,
    pitch_degrees=PITCH_DEG,
    stock=STOCK,
    is_south=False,
    eave_overhang=EAVE_OVERHANG,
    rake_overhang=RAKE_OVERHANG,
)
# One call per skylight. u = along the ridge (X, 0..ridge_length);
# run = up-slope from the EAVE BEARING LINE (Y=0, the wall outer face)
# toward the ridge. Both are the opening's LOWER-LEFT corner, not its centre.
# width = across-slope (X) extent, depth = up-slope (Y) extent.
slope.opening(u=1810.0, run=1310.0, width=780.0, depth=1180.0)
slope.opening(u=4860.0, run=1310.0, width=1340.0, depth=1180.0)
result = slope.build()
result.add_to_doc("Body")
```

`width`/`depth` are TRUE in-plane dimensions — the values in the spec are already
the real frame size, so do NOT divide or multiply by `cos(pitch)`. The builder
works in the slope's own plane; there is no foreshortening to undo.

**NEVER do any of these for a slope with skylights:**

- ❌ Build the panel with raw `Part.makeBox` / `.fuse()` / `.cut()` instead of
  `PitchedRoofSlope`. A hand-built box has **no ridge plumb-cut**, so the two
  slopes of a gable will not meet at the apex, and it is sized and seated
  differently from what the Placer expects — the panel lands misaligned.
- ❌ `removeSplitter()` — it causes SIGSEGV crashes in the persistent FreeCAD
  workers this runs on.
- ❌ Converting a slope position into a "flat panel Y" with
  `y / math.cos(pitch)` and cutting there. `.opening()` already takes
  slope-plane coordinates. Doing the division puts the hole past the end of
  the panel, where it silently cuts nothing.
- ❌ Forcing every skylight to the panel's X centre. Use each opening's real
  `u` from the spec, or two skylights will be drawn stacked on one another.

If a skylight genuinely cannot be expressed with `.opening()`, emit the slope
WITHOUT it and report the gap — a missing skylight is recoverable, a hand-built
panel that breaks the ridge is not.

**Overhangs:** `eave_overhang` and `rake_overhang` extend the structural deck past the
walls (default 50mm = a tight closed eave). For a deep soffit eave pass
`eave_overhang=400` (or whatever the goal states); for a minimal clipped eave
leave the defaults.

Do not model **fascia**, **soffit** or **eave blocking** by hand in a component
script. They are eave-trim parts derived per eave edge by the roof-finishes
layer from the selected eaves detail (below) — not `.construct()` output, and not
yours to author. Your job at the eave is the structural deck and its projection.

**`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 — canonical eave overhang

The sketch/building graph is authoritative for flat-roof edge geometry. Read
`roof.deck_outline`, `roof.deck_regions`, and `roof.deck_edges`; never infer an
overhang from wall bounds or enlarge a generated panel independently.

- `roof.overhang.ns` controls the north and south projections.
- `roof.overhang.ew` controls the east and west projections.
- Zero is a flush eave; a positive value is a projecting eave.
- Every edge has fascia backing. A projecting edge also has soffit backing. Only
  an edge marked `drainage: "low"` has gutter backing.
- On an L footprint, `notch_coverage: "follow"` preserves the notch and `"cover"`
  uses the expanded bounding rectangle. Use the supplied non-overlapping
  `deck_regions` for structural panels and finish quantities.

Parapets, scuppers, and mixed parapet/eave edges are not part of the current
flat-roof profile. Do not invent them from a prompt.

The `RoofPanel` builder is a flat box only — use it as the structural deck. It is
laid on the raked wall tops (see Drainage fall below), which is what gives it its
pitch; membrane, insulation, 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. Always use 1:20
(`FALL_HEIGHT = INNER_DEPTH / 20`). Never use 1:40 (code minimum only, no
construction margin).

**The fall is created by SLOPED WALL TOPS, not by the deck or the insulation.**
The two walls that run ALONG the fall direction (east/west for a south-to-north
fall) are built as TAPERS — one end at the low eave height, the other at
`low + FALL_HEIGHT`. The SIP deck is a flat panel that rests on those raked tops
and therefore tilts with them. The fall-run end walls (south/north) stay level:
one at the high value, one at the low.

Declare a tapered wall with both ends — `Wall(span, height, height_end=…)`. A wall
given only `height` builds as a rectangular box; if that happens to an along-fall
wall, the deck has nothing sloped to bear on and floats above the wall tops.

Tapered insulation on top is a FINISH layer for the membrane fall, not the
structural fall — do not rely on it to create the slope, and never model the deck
as level and expect insulation to supply the pitch.

- `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 — legacy/future parapet reference only

The current profile does not generate parapets. If parapets are introduced by a
future explicit edge contract, they require drainage outlets:

- 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()
```

### Eaves detail — pick exactly one (DL96)

The overhang says how far the deck projects. The **eaves detail** says how that
projecting edge is closed: where the panel is cut, which plane the fascia sits
in, and whether there is a soffit at all. Pick one from
`catalogs/eave_details.json` and put its id in `roof.eave_detail_key`.

| Detail | id | Panel end cut | Fascia plane | Soffit | Beveled block |
|---|---|---|---|---|---|
| **A** | `eave_detail/sloping_soffit` | square (90°) | plumb | sloping, follows panel underside | yes |
| **B** | `eave_detail/raked_flush` | raked (⊥ to slope) | perpendicular to slope | none — underside exposed | no |
| **C** | `eave_detail/plumb_flush` | plumb (vertical) | plumb | none | yes |
| **D** | `eave_detail/horizontal_soffit` | square (90°) | plumb | horizontal, on double framing | yes |

Default is **A** when the goal says nothing about the eave.

**Two hard rules:**

1. **C is the only detail valid at a zero overhang.** A, B and D all need the
   panel to oversail the wall; C has no projection and must not be used where
   the deck projects. Either mismatch is `sip.eave_detail.overhang_conflict`.
2. **The soffit follows the detail, not the overhang.** Only A (sloping) and D
   (horizontal) carry a soffit. A soffit on a B or C edge is
   `sip.eave_detail.soffit_unexpected`; a missing soffit on A or D is
   `sip.eave_detail.soffit_missing`.

**Shared by all four** — do not re-invent these values:
Ø8 nails @150 each side of the beveled block; roof-to-wall screws @300 with
≥25mm penetration; mastic both faces of the block; a drip edge at the fascia
head; waterproofing carried over the panel end per the manufacturer.

**Panel-end interaction — read this before setting the cut.** The structural
panel keeps its **square 90° eave edge** (Fig 1.4.7); details A and D rely on
that square end plus a beveled block to bring the fascia plumb. Only detail B
rakes the panel end, and only detail C cuts it plumb. Never cut an eave bevel
into the panel to "achieve" a plumb fascia — that is what the block is for.

---


## 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 follow the canonical deck outline and declared drainage fall.** Do not infer deck extents from the wall bounding box. The current profile uses eaves, not parapets; guttering belongs only on canonical low edges.

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.
v0.0.985