Roof material engine — formulas, API & integration
Runtime: Node.js 22+. Persistence uses the built-in node:sqlite module (currently marked experimental by Node).
This document is for your development team and stakeholders who need to understand how measurements flow through the system, how quantities are derived, and how to integrate the API into a larger CRM, database, or quoting product.
logic/calculator.js) turns that into a structured materials object and
optional pricing. Reports (PDF, Word, Excel) are views of that same data—no
duplicate business logic in the exporters.
Takeoff (primary): Geometry, a dynamic waste factor, and per-line quantities from the SQLite
manufacturer_products catalog (brand, product, unit, coverage). Results include
materials.recommendations.pitched or materials.recommendations.flat—pitched and low-slope
lines are never mixed. Aggregate fields (shingles, starter, etc.) remain for compatibility and pricing hooks.
Pricing (secondary): Unit costs merge
constants/pricing.js with admin Settings
(material_prices_json) and optional per-request pricing_overrides.
Margin: pricing_margin_percent on the request (0–100) when provided; otherwise the admin
default (default_margin_percent). Changing prices does not change computed quantities.
Product spec snapshots: Iteration notes for stakeholders live under
.cursor/context/client-requirements-v8.md (report summary, area-linked suggested waste, disclaimer page) and
.cursor/context/client-requirements-v9.md (export parity, job.address, single waste column, flat line
totals, editable margin). This guide reflects the running code, not a verbatim copy of those files.
1. System architecture
Browser (index.html)
POST /api/calculate → JSON { materials, pricing, input }
POST /api/proposal → PDF stream
POST /api/proposal/docx → Word (.docx) file
POST /api/proposal/xlsx → Excel workbook (material order + pricing sheet)
server.js
→ validation (utils/validation.js) + Joi on admin/auth routes
→ estimate pipeline (services/estimateService.js)
→ defaults + unit prices from SQLite (services/settingsService.js)
→ calculateRoofEstimate (logic/calculator.js)
→ calculatePricing (logic/pricing.js) with merged unit prices (constants + SQLite + optional request overrides)
→ persist each successful /api/calculate (services/historyService.js)
→ PDF / Word / Excel generators (utils/pdf.js, word.js, excel.js)
Admin UI
→ /login.html → POST /api/auth/login (httpOnly cookie JWT)
→ /dashboard.html → /api/admin/* (Stats, Profile, History, Settings, Manufacturers, Products)
Brand/product order lines are driven by active rows in manufacturer_products
(see services/manufacturerCatalogService.js), seeded at first run and editable in the dashboard.
Exports and utils/reportHelpers.js include a Section column:
Pitched Roof Materials vs Flat Roof Materials. Each recommendation row uses
ceil for purchasable units. If no active rows exist for the selected roof type (and flat system),
the API returns an error—keep the catalog maintained.
2. Request body (what you send)
Estimator POST bodies share one shape; utils/validation.js validates and normalizes before runEstimate.
{
"roof": {
"area_sqft": 2500,
"pitched_area_sqft": null,
"flat_area_sqft": null,
"area_input_type": "plan" | "slope_adjusted",
"pitch": 6,
"pitches": [6, 8],
"complexity": "low" | "medium" | "high",
"type": "pitched" | "flat" | "hybrid",
"flat_system": "tpo" | "epdm" | "modified_bitumen"
},
"lengths": {
"ridge_lf": 120,
"hip_lf": 80,
"valley_lf": 60,
"eave_lf": 140,
"rake_lf": 100,
"flashing_lf": 0
},
"product": {
"shingle_style": "3tab" | "architectural" | "designer",
"manufacturer": "GAF" | ...,
"manufacturers": ["GAF", "CertainTeed"],
"manufacturer_custom": "string when Other is selected"
},
"waste_suggested_percent": null,
"waste_custom_percent": null,
"pricing_margin_percent": 20,
"pricing_overrides": { "shingles_bundle": 35.5 },
"job": { "address": "Optional site address (≤500 chars, echoed on exports)" }
}
product is optional; the server normalizes defaults (architectural, manufacturer GAF).
Hybrid roofs (type: "hybrid") require pitched_area_sqft, flat_area_sqft, and
area_sqft === pitched + flat. For flat or any job with a flat section, flat_system is required.
Margin: If pricing_margin_percent is provided (0–100), it is used for calculatePricing.
If omitted or empty, the admin default from Settings applies (getDefaultMargin()).
Unit prices: Baseline keys in constants/pricing.js are merged with SQLite
material_prices_json and optional request pricing_overrides (non-negative numbers). The public
estimator can send overrides from the UI after “Calculate prices.”
Area & pitch: When area_input_type === "slope_adjusted", stored areas are already pitch-expanded;
the calculator does not multiply by pitchMultiplier again. When plan, pitched sections use
planArea × pitchMultiplier(pitch) for surface area. Flat sections never use the pitch multiplier.
Job address: Optional job.address is normalized and echoed on PDF, Word, and Excel headers.
Shingle style and manufacturers drive brand filtering; recommendation quantities come from the
catalog (roof.type, and for flat rows system_type matching flat_system).
3. API reference
| Method & path | Response | Purpose |
|---|---|---|
GET /api/health |
JSON | Load balancer / uptime check. |
POST /api/calculate |
JSON | Primary integration: materials + pricing + echoed normalized input. |
POST /api/proposal |
PDF binary | Tabular estimate PDF. |
POST /api/proposal/docx |
Word binary | Same data as PDF in editable Word format. |
POST /api/proposal/xlsx |
Excel binary | “Roofing material order” sheet + “Pricing” sheet (aligned to contractor order workflows). |
POST /api/auth/login |
JSON + cookie | Admin sign-in; sets httpOnly JWT cookie. |
POST /api/auth/logout |
JSON | Clears session cookie. |
GET /api/auth/me |
JSON | Current user or null if not signed in. |
GET/PUT /api/admin/settings |
JSON | Read/update default margin % and per-line material unit prices (requires auth). |
GET /api/admin/manufacturers |
JSON | List catalog rows. Query: roofType, systemType, activeOnly. |
GET /api/admin/manufacturers/grouped |
JSON | Grouped by roof/system/category/brand for the Manufacturers dashboard table. |
GET /api/admin/manufacturers/:id |
JSON | Single row (edit modal). |
POST /api/admin/manufacturers |
JSON | Create row (Joi-validated body). |
PUT /api/admin/manufacturers/:id |
JSON | Update row. |
DELETE /api/admin/manufacturers/:id |
JSON | Delete row. |
GET /api/admin/stats |
JSON | History aggregates for dashboard. |
GET /api/admin/history |
JSON | Paginated list; rows include user_name (display name or email, joined from users). Query limit, offset, optional dateFrom / dateTo (YYYY-MM-DD). |
GET /api/admin/history/:id |
JSON | Full stored payload for one run; item includes userName. |
GET/PUT /api/admin/profile |
JSON | Display name. |
PUT /api/admin/password |
JSON | Change password. |
Error format (robust handling)
Failed requests return JSON (never a partial file) with a stable shape:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR" | "INVALID_JSON" | "NOT_FOUND" | "INTERNAL_ERROR",
"message": "Human-readable summary",
"details": ["Optional bullet list", "..."]
}
}
Binary export endpoints use the same error JSON on 4xx/5xx so clients can branch on Content-Type: application/json.
4. Core formulas (logic/calculator.js)
4.1 Pitch-adjusted surface area
For each pitched section, planAreaSqft is the section’s plan footprint
(pitched_area_sqft on hybrid, or area_sqft on pitched-only).
Surface area for quantity math is:
pitchMultiplier = sqrt((pitch² + 12²) / 12²) // 12" run
If roof.area_input_type === "slope_adjusted":
surfaceArea = planAreaSqft // already slope-expanded input
Else:
surfaceArea = planAreaSqft × pitchMultiplier
Flat sections use flat_area_sqft as surface area with no pitch multiplier.
Hybrid (roof.type === "hybrid") runs two independent sections (pitched + flat);
materials.recommendations lists both; top-level meta.roofType is mixed.
Example: 2,500 sq ft plan at 6/12, area_input_type: "plan" → multiplier ≈ 1.118 → ~2,795 sq ft surface.
4.2 Waste factor (suggested vs applied)
Per section, the engine computes a calibrated model waste fraction in dynamicWasteFactor
(using that section’s roofType: pitched or flat). It depends on
complexity, pitched pitch, linear feet (valley, hip, ridge), and an area-linked term
areaWasteAdjustment(surfaceAreaSqft, roofType) (smaller footprints bump waste slightly; larger bump down—bounded).
The suggested value is stored as meta.suggestedWasteFactor (per section on hybrid).
base = (sectionRoofType === "flat") ? 0.038 : 0.068
complexityBump = f(complexity, sectionRoofType) // high/medium/low; flat vs pitched
pitchBump = (flat) ? 0 : clamp((pitch - 4) × 0.0027, 0, 0.036)
shapeBump = (flat) ? 0 : (valley/100)×0.036 + (hip/100)×0.022 + (ridge/100)×0.009
areaBump = areaWasteAdjustment(surfaceArea, sectionRoofType) // ref 3200 sq ft, capped ± bump
computedWaste = min(base + complexityBump + pitchBump + shapeBump + areaBump, cap)
// cap: flat 0.18, pitched 0.30
// Applied waste (priority):
// 1) waste_custom_percent (≥0) as a percent → /100
// 2) else waste_suggested_percent if a finite number ≥ 0 → /100
// 3) else computedWaste
applied → meta.wasteFactor; drives (1 + waste) for shingle-area rows and pitched aggregates
meta.wasteSource records which branch ran (client_custom_waste_percent,
client_suggested_waste_override, or calibrated_dynamic_model).
A separate legacy curve remains as meta.legacyWasteFactor for reference only (not applied to quantities).
4.3 Shingles (squares & bundles)
Applies to pitched sections (aggregate compatibility and underlayment pairing). Flat membrane quantities come from catalog rows only.
adjustedArea = surfaceArea × (1 + waste)
rawSquares = adjustedArea / 100
squares = ceil(rawSquares) // contractor-style full squares
bundles = squares × 3 // CONSTANTS.bundlesPerSquare
4.4 Starter
lf = eave_lf + rake_lf
bundles = ceil(lf / starterCoverage) // 100 LF per bundle (constant)
4.5 Ridge / hip caps
lf = ridge_lf + hip_lf
bundles = ceil(lf / ridgeCapCoverage) // 20 LF per bundle (constant)
4.6 Underlayment
Pitched aggregate only; flat underlayment is a catalog line if present.
adjusted = surfaceArea × (1 + waste)
rolls = ceil(adjusted / underlaymentEffectiveCoverage) // 360 sq ft/roll effective
4.7 Drip edge
lf = eave_lf + rake_lf
pieces = ceil(lf / dripEdgeLength) // 10 LF sticks
4.8 Valley membrane
sqft = valley_lf × 3 // 3' width assumption
rolls = ceil(sqft / 200)
4.9 Nails
count = squares × nailsPerSquare // 320 per square (constant)
4.10 Vents
units = ceil(section_plan_area_sqft / 300)
Pitched section plan area (not pitch-expanded) drives the default ventilation row when the catalog has no Ventilation line.
4.11 Flashing (order estimate)
lf = (lengths.flashing_lf > 0) ? flashing_lf : (eave_lf + rake_lf)
rolls = ceil(lf / 75)
Optional lengths.flashing_lf overrides the eave+rake default for recommendation rows and aggregate flashing pricing.
Flat sections omit aggregate flashing.
4.12 Brand/product recommendation lines
Active rows from manufacturer_products are read with listManufacturerProducts
(roof_type and, for flat, system_type matching flat_system).
Each row has category, brand, product, unit, coverage_value,
optional component_type (e.g. membrane for flat pricing heuristics). The calculator maps
category (and sometimes product name, e.g. rake vs eave drip edge) to a quantity formula; every displayed quantity is
rounded up. Pitched-only rows populate materials.recommendations.pitched; flat-only rows populate
materials.recommendations.flat—no cross-listing.
5. Constants (single source of truth)
Defined in export const CONSTANTS inside constants/estimation.js. These feed the
aggregate material lines (bundles per square, generic starter/ridge coverage, underlayment roll effectiveness, etc.).
Per-brand SKU coverage lives on each manufacturer_products row (coverage_value / coverage_basis).
| Key | Value | Role |
|---|---|---|
bundlesPerSquare | 3 | Shingle bundles per square |
starterCoverage | 100 LF | Starter per bundle |
ridgeCapCoverage | 20 LF | Ridge/hip cap per bundle |
underlaymentEffectiveCoverage | 360 sq ft | After overlaps |
dripEdgeLength | 10 LF | Stick length |
nailsPerSquare | 320 | Nail estimate per square |
6. Pricing module (logic/pricing.js & utils/reportPricing.js)
calculatePricing(materials, marginPercent, priceOverrides) builds subtotals from aggregate quantities
(pitched) or from flat recommendation rows (see below). Baseline unit keys live in constants/pricing.js;
admin Settings (material_prices_json) and optional request pricing_overrides merge into the
active price map (utils/reportPricing.js’s getPriceMap for exports). Margin comes from the
request when pricing_margin_percent is set; otherwise getDefaultMargin() from Settings.
Pitched: Breakdown lines use legacy aggregates: shingles_bundle, starter_bundle,
ridge_bundle, underlayment_roll, drip_edge_piece, valley_roll,
nail_unit, vent_unit, flashing_roll.
Flat: No aggregate shingle bundle math. Each flat catalog row’s qty × unitPrice is rolled into
breakdown.flatMembrane (rows with componentType === "membrane", or uncategorized flat rows priced as membrane)
or breakdown.flatBase (componentType === "base" or flat underlayment priced at
underlayment_roll). Keys include flat_membrane_roll, flat_base_roll,
sealant_tube, etc., per constants/pricing.js.
Mixed (hybrid): Pitched and flat priced separately; subtotals summed and margin applied once on the total.
Exports: PDF/Word/Excel use unitPriceForRow / category mapping so each printed line gets a unit
price and line total consistent with the catalog row (including sealant and flat splits)—not only the coarse
pricing.breakdown buckets.
7. Reports & exports (PDF, Word, Excel)
- Single waste column: Tables show one quantity column; waste is applied inside quantities. PDF/Word can show the effective waste % in one column (no duplicate “with waste” column).
- Summary: Coversheet-style summary does not repeat total plan area as a headline stat; it emphasizes areas by section, suggested vs applied waste where relevant, and pricing totals.
- Category rows: Manufacturer category header rows use highlight styling (not alternating zebra stripes).
- Disclaimer / Important notes: Verbatim legal/safety notes from
constants/reportNotes.jsappear on a final page (PDF/Word) and an “Important notes” area in Excel. - Job address: When
job.addressis present, it appears in report headers and the Excel material sheet. - Excel workbook: Material order sheet includes section bands and line-level unit price / extended price where applicable; a Pricing sheet mirrors contractor workflow labels aligned with flat membrane vs base splits.
8. Integration checklist for your devs
- Validate user input client-side for UX; rely on server validation for correctness (hybrid:
pitched_area_sqft/flat_area_sqft/area_sqft;flat_systemwhen any flat area;area_input_typefor plan vs slope-adjusted areas). Optionaljob.address,waste_*,pricing_margin_percent, andpricing_overridesfollow the schemas inutils/validation.js. - Store normalized
inputplus fullmaterialsJSON; order lines live undermaterials.recommendations. Readmaterials.meta.wasteFactor(applied) vssuggestedWasteFactor(model) for audits. - Maintain the manufacturer catalog via dashboard or admin APIs so each roof type has active products.
- Sync Settings prices (or CRM supplier feeds) without expecting quantity changes from price edits alone.
- Map Excel / Word section and product columns to your internal SKU table.
- Extend takeoff rules in
logic/calculator.jsand catalog shape via DB + Joi as needed; exporters consume the samematerialsobject.
9. Files map
| File | Responsibility |
|---|---|
server.js | HTTP entry, wiring, public estimate + export routes |
db/database.js | SQLite schema, admin seed, settings, history, manufacturer_products seed |
services/estimateService.js | Validate → normalize → calculate → price (margin + overrides from body or defaults) |
services/manufacturerCatalogService.js | Catalog CRUD, list, grouped list |
services/settingsService.js | Read/write margin + material unit prices |
services/historyService.js | Persist and query saved calculations |
routes/authRoutes.js | Login / logout / me |
routes/adminRoutes.js | Dashboard API (stats, history, profile, settings, manufacturers) |
middleware/auth.js | JWT cookie, optional + required auth |
middleware/validate.js | Joi body/query validation wrapper |
utils/joiSchemas.js | Joi schemas for auth/admin (includes manufacturer product body) |
utils/validation.js | Estimator request schema + normalization |
constants/estimation.js | Quantity constants (CONSTANTS) |
constants/pricing.js | Baseline unit prices (pitched + flat membrane/base/sealant) + default margin constant |
constants/reportNotes.js | Verbatim Important notes / disclaimer text for exports |
constants/materialCatalog.js | Seed data for initial catalog population / reset scripts |
logic/calculator.js | All quantity logic |
logic/pricing.js | Line-item dollars + margin math |
utils/reportHelpers.js | Shared rows/labels and material table grouping for exporters |
utils/reportPricing.js | Price key per catalog row, merged price map (baseline + DB + request) |
utils/pdf.js | PDF layout |
utils/word.js | Word (.docx) layout |
utils/excel.js | Roofing material order workbook |
public/index.html | Estimator UI |
public/login.html | Admin sign-in |
public/dashboard.html | Admin dashboard (tabs) |
public/guide.html | This documentation |