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.

Core idea: The browser collects roof measurements and product choices. The server (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).

KeyValueRole
bundlesPerSquare3Shingle bundles per square
starterCoverage100 LFStarter per bundle
ridgeCapCoverage20 LFRidge/hip cap per bundle
underlaymentEffectiveCoverage360 sq ftAfter overlaps
dripEdgeLength10 LFStick length
nailsPerSquare320Nail 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.js appear on a final page (PDF/Word) and an “Important notes” area in Excel.
  • Job address: When job.address is 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

  1. Validate user input client-side for UX; rely on server validation for correctness (hybrid: pitched_area_sqft/flat_area_sqft/area_sqft; flat_system when any flat area; area_input_type for plan vs slope-adjusted areas). Optional job.address, waste_*, pricing_margin_percent, and pricing_overrides follow the schemas in utils/validation.js.
  2. Store normalized input plus full materials JSON; order lines live under materials.recommendations. Read materials.meta.wasteFactor (applied) vs suggestedWasteFactor (model) for audits.
  3. Maintain the manufacturer catalog via dashboard or admin APIs so each roof type has active products.
  4. Sync Settings prices (or CRM supplier feeds) without expecting quantity changes from price edits alone.
  5. Map Excel / Word section and product columns to your internal SKU table.
  6. Extend takeoff rules in logic/calculator.js and catalog shape via DB + Joi as needed; exporters consume the same materials object.

9. Files map

FileResponsibility
server.jsHTTP entry, wiring, public estimate + export routes
db/database.jsSQLite schema, admin seed, settings, history, manufacturer_products seed
services/estimateService.jsValidate → normalize → calculate → price (margin + overrides from body or defaults)
services/manufacturerCatalogService.jsCatalog CRUD, list, grouped list
services/settingsService.jsRead/write margin + material unit prices
services/historyService.jsPersist and query saved calculations
routes/authRoutes.jsLogin / logout / me
routes/adminRoutes.jsDashboard API (stats, history, profile, settings, manufacturers)
middleware/auth.jsJWT cookie, optional + required auth
middleware/validate.jsJoi body/query validation wrapper
utils/joiSchemas.jsJoi schemas for auth/admin (includes manufacturer product body)
utils/validation.jsEstimator request schema + normalization
constants/estimation.jsQuantity constants (CONSTANTS)
constants/pricing.jsBaseline unit prices (pitched + flat membrane/base/sealant) + default margin constant
constants/reportNotes.jsVerbatim Important notes / disclaimer text for exports
constants/materialCatalog.jsSeed data for initial catalog population / reset scripts
logic/calculator.jsAll quantity logic
logic/pricing.jsLine-item dollars + margin math
utils/reportHelpers.jsShared rows/labels and material table grouping for exporters
utils/reportPricing.jsPrice key per catalog row, merged price map (baseline + DB + request)
utils/pdf.jsPDF layout
utils/word.jsWord (.docx) layout
utils/excel.jsRoofing material order workbook
public/index.htmlEstimator UI
public/login.htmlAdmin sign-in
public/dashboard.htmlAdmin dashboard (tabs)
public/guide.htmlThis documentation