Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 200 additions & 3 deletions data/jurisdictions-adoption.json

Large diffs are not rendered by default.

324 changes: 321 additions & 3 deletions data/jurisdictions-climate.json

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions data/wall-assemblies.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,13 @@
"OH": "vinyl", "OK": "brickVeneer", "OR": "fiberCement", "PA": "vinyl", "RI": "vinyl",
"SC": "fiberCement", "SD": "vinyl", "TN": "brickVeneer", "TX": "brickVeneer", "UT": "stucco",
"VA": "fiberCement", "VT": "vinyl", "WA": "fiberCement", "WI": "vinyl", "WV": "vinyl",
"WY": "vinyl"
"WY": "vinyl",
"CA-ON-S": "brickVeneer", "CA-ON-E": "brickVeneer", "CA-ON-N": "vinyl", "CA-BC": "fiberCement",
"CA-AB": "vinyl", "CA-SK": "vinyl", "CA-MB": "vinyl", "CA-QC": "brickVeneer", "CA-NB": "vinyl",
"CA-NS": "vinyl", "CA-PE": "vinyl", "CA-NL": "vinyl", "CA-YT": "vinyl", "CA-NT": "vinyl",
"CA-NU": "vinyl", "CA-GEN": "vinyl"
},
"defaultCladdingCitation": "Regional defaults keyed to 2021 IRC R703.6/.8/.10/.11 + data/jurisdictions-climate.json (termiteRisk, weatheringPotential, flags) and jurisdictions-adoption.json; FL default is stucco-on-CMU variant",
"defaultCladdingCitation": "Regional defaults keyed to 2021 IRC R703.6/.8/.10/.11 + data/jurisdictions-climate.json (termiteRisk, weatheringPotential, flags) and jurisdictions-adoption.json; FL default is stucco-on-CMU variant. Canadian entries (CA-*) reflect dominant provincial single-family practice — brick veneer is common on Ontario and Quebec street elevations, vinyl elsewhere.",
"insulationByClimateZone": {
"1": { "value": "R13", "compliance2021": ["R-13 cavity", "R-0 + R-10ci"], "compliance2018": ["R-13 cavity"], "studDepthIn": 3.5, "battThicknessIn": 3.5, "citation": "2021 IECC Table R402.1.3 / IRC N1102.1.3 (2018: Table R402.1.2)" },
"2": { "value": "R13", "compliance2021": ["R-13 cavity", "R-0 + R-10ci"], "compliance2018": ["R-13 cavity"], "studDepthIn": 3.5, "battThicknessIn": 3.5, "citation": "2021 IECC Table R402.1.3 / IRC N1102.1.3" },
Expand Down Expand Up @@ -262,9 +266,13 @@
"ND": "6A-7", "OH": "5A (4A south)", "OK": "3A (4B panhandle)", "OR": "4C west (5B east)",
"PA": "5A (4A SE)", "RI": "5A", "SC": "3A (2A coast)", "SD": "5A-6A", "TN": "4A (3A west)",
"TX": "2A-3A (3B/4B west)", "UT": "5B (3B St. George)", "VT": "6A", "VA": "4A",
"WA": "4C west (5B east)", "WV": "4A-5A", "WI": "6A (5A south)", "WY": "6B (7 mtn)"
"WA": "4C west (5B east)", "WV": "4A-5A", "WI": "6A (5A south)", "WY": "6B (7 mtn)",
"CA-ON-S": "5A", "CA-ON-E": "6A", "CA-ON-N": "7 (6A Sudbury)", "CA-BC": "4C (5B-6B interior)",
"CA-AB": "7 (6B south)", "CA-SK": "7", "CA-MB": "7 (6A south)", "CA-QC": "6A (7 north)",
"CA-NB": "6A", "CA-NS": "6A", "CA-PE": "6A", "CA-NL": "6A (7 Labrador)",
"CA-YT": "7 (8 north)", "CA-NT": "8", "CA-NU": "8", "CA-GEN": "6A"
},
"stateClimateZoneCitation": "2021 IECC Figure R301.1 / county tables — dominant residential zone per state; split states (parenthesized) need a 'zone varies by county — confirm with AHJ' warning. jurisdictions-climate.json lacks an ieccZone field; overlay generator writes ieccZone + ieccZoneRange from this map.",
"stateClimateZoneCitation": "2021 IECC Figure R301.1 / county tables — dominant residential zone per state; split states (parenthesized) need a 'zone varies by county — confirm with AHJ' warning. jurisdictions-climate.json lacks an ieccZone field; overlay generator writes ieccZone + ieccZoneRange from this map. Canadian entries (CA-*) map each province to its closest ASHRAE/IECC-equivalent zone: Canada uses NBC/NECB heating-degree-day data rather than IECC zones, so these are approximate and exist so the shared insulation/vapour-retarder tables resolve. Note NBC 9.25 requires a vapour barrier on the warm side throughout Canada, which is stricter than the IECC vapor-retarder class implied by these zones.",
"overlays": {
"hvhz": {
"trigger": "flags.hvhz === true (jurisdictions-climate.json)",
Expand Down
2 changes: 1 addition & 1 deletion src/framing/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function computeLevel(
return result
}

function computeLevelUncached(
export function computeLevelUncached(
nodes: Record<string, Record<string, unknown>>,
config: FramingNode,
): ComputeResult {
Expand Down
43 changes: 6 additions & 37 deletions src/framing/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,43 +336,12 @@ export const FramingRenderer = ({ node }: { node: FramingNode }) => {
}
}

// Auto-switch the host to its most revealing wall mode while the X-ray is
// on (round-13 user feedback). 'down' — host walls fully hidden — not
// 'cutaway': the host's cutaway needs per-face exterior tags the scene
// data doesn't carry, so it painted every wall with its dot-stipple film
// (quality rounds 1-2). With the host shells gone, Bones' own assembly
// layers ARE the walls, and the per-face camera culling below gives the
// true dollhouse: near faces open, far drywall is the backdrop.
// Restores the previous mode on unmount UNLESS the user changed it since.
useEffect(() => {
if (node.seeThrough === false) return
// Dynamic import: the viewer package drags browser-only deps that must
// never evaluate under bun test (this effect only runs in the host).
let previous: string | undefined
let restore: (() => void) | undefined
let cancelled = false
import('@pascal-app/viewer').then(({ useViewer }) => {
if (cancelled) return
const viewer = useViewer.getState() as unknown as {
wallMode?: string
setWallMode?: (mode: string) => void
}
if (!viewer.setWallMode || viewer.wallMode === 'down') return
previous = viewer.wallMode
viewer.setWallMode('down')
restore = () => {
const now = useViewer.getState() as unknown as {
wallMode?: string
setWallMode?: (m: string) => void
}
if (now.wallMode === 'down' && previous && now.setWallMode) now.setWallMode(previous)
}
})
return () => {
cancelled = true
restore?.()
}
}, [node.seeThrough])
// NOTE: the wall-mode takeover ('down' while X-raying) used to live here,
// keyed to this renderer's lifetime — which is the NODE's lifetime, so
// leaving the Bones panel (or merely loading a scene that contained an
// X-ray node) left the host's walls hidden, persisted across reloads.
// It moved to the panel (see ../view-takeover.ts), whose mount/unmount is
// the lifetime the intent actually has. This renderer only draws.

// Dollhouse cut (round 13): assembly-layer buckets carry their face
// normal — hide the stacks whose face points TOWARD the camera so you
Expand Down
25 changes: 25 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,28 @@ export const bonesHostPanel: PluginHostPanel = {
export { lumberDefinition } from './definition'
export { LumberNode } from './schema'
export { LUMBER_CROSS_SECTIONS, LUMBER_SIZES, lumberBoxDims } from './lumber'

// Headless engine surface. The derivation pipeline is pure (no React, no
// three, no stores) — a host estimator can compute the same members and
// quantities the panel shows, per level, without mounting anything.
// `computeLevelUncached` exists so a multi-level rollup loop doesn't thrash
// the 1-deep render memo that the panel and 3D renderer share.
export {
computeLevel,
computeLevelUncached,
type ComputeResult,
wallConstruction,
} from './framing/compute'
export { FramingNode } from './framing/schema'
export {
computeTakeoff,
cutList,
cutListCsv,
takeoffCsv,
type CutRow,
type TakeoffAreas,
type TakeoffRow,
} from './engines/takeoff'
export { extractRoofs } from './engines/roof-framing'
export { extractLevels, type LevelSlice } from './core/wall-model'
export type { BonesSystem, Fixture, FixtureKind, Member, MemberRole } from './core/types'
112 changes: 112 additions & 0 deletions src/jurisdiction/canada.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'bun:test'
import { DEFAULT_SPEC } from '../core/spec'
import { INCH } from '../core/units'
import { applyJurisdiction, jurisdictionOptions, profileFor } from './profiles'

/**
* Canada rides the same data path as the US states: two JSON rows merged by
* `profileFor`, then mapped onto the FramingSpec by `applyJurisdiction`. These
* tests pin the parts that would silently regress — a missing data row
* degrades to the INTL fallback rather than throwing, so "it still renders"
* is not evidence that a province actually landed.
*/

const CANADA = [
'CA-ON-S',
'CA-ON-E',
'CA-ON-N',
'CA-BC',
'CA-AB',
'CA-SK',
'CA-MB',
'CA-QC',
'CA-NB',
'CA-NS',
'CA-PE',
'CA-NL',
'CA-YT',
'CA-NT',
'CA-NU',
'CA-GEN',
] as const

const footingInches = (code: string): number =>
applyJurisdiction(DEFAULT_SPEC, profileFor(code)).footingDepth / INCH

describe('Canadian jurisdictions', () => {
it('every province resolves to real data, not the INTL fallback', () => {
for (const code of CANADA) {
const p = profileFor(code)
// The fallback returns `{...INTL_PROFILE, code, name: code}` — so a name
// equal to the bare code means the data row is missing.
expect(p.name).not.toBe(code)
expect(p.name.startsWith('Canada')).toBe(true)
expect(p.residentialCode).not.toBe('IRC (edition unverified)')
}
})

it('cites the National Building Code, not the IRC', () => {
expect(profileFor('CA-SK').residentialCode).toContain('National Building Code')
expect(profileFor('CA-ON-S').residentialCode).toContain('Ontario Building Code')
expect(profileFor('CA-QC').residentialCode).toContain('2015')
})

it('no Canadian code collides with a US state code', () => {
const codes = jurisdictionOptions().map((o) => o.code)
expect(new Set(codes).size).toBe(codes.length)
// The obvious trap: bare 'CA' is California and must stay California.
expect(profileFor('CA').name).toBe('California')
})

it('southern Ontario frames like the GTA: shallow-ish frost, no ties, 2x6 rafters', () => {
const spec = applyJurisdiction(DEFAULT_SPEC, profileFor('CA-ON-S'))
expect(footingInches('CA-ON-S')).toBeCloseTo(47, 5)
expect(spec.hurricaneTies).toBe(false)
expect(spec.seismicHoldDowns).toBe(false)
expect(spec.rafterSize).toBe(DEFAULT_SPEC.rafterSize)
})

it('the Ottawa Valley digs deeper and bumps the rafter for snow', () => {
const spec = applyJurisdiction(DEFAULT_SPEC, profileFor('CA-ON-E'))
expect(footingInches('CA-ON-E')).toBeCloseTo(71, 5)
expect(spec.rafterSize).toBe('2x8')
})

it('prairie frost depths exceed every US state', () => {
// The reason Canada needed real rows instead of a US proxy: the deepest
// US frost line in the data is 60in, and Saskatchewan runs deeper.
expect(footingInches('CA-SK')).toBeCloseTo(84, 5)
expect(footingInches('CA-MB')).toBeCloseTo(78, 5)
expect(footingInches('CA-SK')).toBeGreaterThan(footingInches('ND'))
})

it('coastal BC turns on seismic hold-downs and tightens anchor bolts', () => {
const spec = applyJurisdiction(DEFAULT_SPEC, profileFor('CA-BC'))
expect(spec.seismicHoldDowns).toBe(true)
expect(spec.anchorBoltSpacing).toBeCloseTo(4 * 12 * INCH, 5)
})

it('Atlantic Canada gets uplift ties, the interior does not', () => {
expect(applyJurisdiction(DEFAULT_SPEC, profileFor('CA-NS')).hurricaneTies).toBe(true)
expect(applyJurisdiction(DEFAULT_SPEC, profileFor('CA-NL')).hurricaneTies).toBe(true)
expect(applyJurisdiction(DEFAULT_SPEC, profileFor('CA-SK')).hurricaneTies).toBe(false)
})

it('exterior walls stay framed everywhere in Canada', () => {
for (const code of CANADA) {
expect(profileFor(code).exteriorWallDefault).toBe('framed')
}
})

it('permafrost territories carry an explicit warning in their notes', () => {
for (const code of ['CA-YT', 'CA-NT', 'CA-NU']) {
expect(profileFor(code).notes.join(' ')).toContain('PERMAFROST')
}
})

it('the dropdown still leads with INTL and now carries Canada', () => {
const options = jurisdictionOptions()
expect(options[0]?.code).toBe('INTL')
expect(options.filter((o) => o.code.startsWith('CA-')).length).toBe(CANADA.length)
})
})
39 changes: 37 additions & 2 deletions src/jurisdiction/guess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
*
* 1. `Intl.DateTimeFormat().resolvedOptions().timeZone` — IANA zone ids name
* actual cities (`America/Denver`, `America/Indiana/Indianapolis`), which
* maps to a US state for every US zone.
* 2. `navigator.language` region subtag — a non-US locale (fr-FR, de-DE…)
* maps to a US state (or Canadian province) for every US/CA zone.
* 2. `navigator.language` region subtag — a non-US/CA locale (fr-FR, de-DE…)
* falls back to the INTL profile.
*
* This is a SUGGESTION rendered in the panel ("guessed from your browser") —
Expand Down Expand Up @@ -44,6 +44,38 @@ const TZ_STATE: Record<string, string> = {
'America/Nome': 'AK',
'America/Adak': 'AK',
'Pacific/Honolulu': 'HI',

// Canada. Ontario is split three ways (the frost line runs 1200mm in the
// GTA to 2100mm at Thunder Bay), so the zone picks the region it names.
// Caveat: modern tzdata folds Montreal into America/Toronto, so Quebec
// users land on southern Ontario and have to pick their province by hand.
'America/Toronto': 'CA-ON-S',
'America/Nipigon': 'CA-ON-N',
'America/Thunder_Bay': 'CA-ON-N',
'America/Atikokan': 'CA-ON-N',
'America/Rainy_River': 'CA-ON-N',
'America/Blanc-Sablon': 'CA-QC',
'America/Winnipeg': 'CA-MB',
'America/Regina': 'CA-SK',
'America/Swift_Current': 'CA-SK',
'America/Edmonton': 'CA-AB',
'America/Vancouver': 'CA-BC',
'America/Dawson_Creek': 'CA-BC',
'America/Fort_Nelson': 'CA-BC',
'America/Creston': 'CA-BC',
'America/Halifax': 'CA-NS',
'America/Glace_Bay': 'CA-NS',
'America/Moncton': 'CA-NB',
'America/St_Johns': 'CA-NL',
'America/Goose_Bay': 'CA-NL',
'America/Whitehorse': 'CA-YT',
'America/Dawson': 'CA-YT',
'America/Yellowknife': 'CA-NT',
'America/Inuvik': 'CA-NT',
'America/Iqaluit': 'CA-NU',
'America/Rankin_Inlet': 'CA-NU',
'America/Cambridge_Bay': 'CA-NU',
'America/Resolute': 'CA-NU',
}

export type JurisdictionGuess = {
Expand All @@ -61,6 +93,9 @@ export function guessJurisdiction(): JurisdictionGuess {
typeof navigator !== 'undefined' ? (navigator.language ?? navigator.languages?.[0]) : ''
const region = lang?.split('-')[1]?.toUpperCase()
if (region === 'US') return { code: 'TX', reason: `US locale (${lang}), unknown state` }
if (region === 'CA') {
return { code: 'CA-GEN', reason: `Canadian locale (${lang}), unknown province` }
}
if (region) return { code: 'INTL', reason: `non-US locale (${lang})` }
} catch {
// SSR or restricted environment — fall through.
Expand Down
20 changes: 17 additions & 3 deletions src/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { guessJurisdiction } from './jurisdiction/guess'
import { jurisdictionOptions, profileFor } from './jurisdiction/profiles'
import { LUMBER_CROSS_SECTIONS, LUMBER_SIZES, type LumberSize } from './lumber'
import { useBonesStore } from './store'
import { createWallModeTakeover, type WallModeViewer } from './view-takeover'

const LUMBER_KIND: string = 'bones:lumber'
const FRAMING_KIND: string = 'bones:framing'
Expand Down Expand Up @@ -40,6 +41,19 @@ export default function BonesPanel() {
(n) => (n.type as string) === FRAMING_KIND && n.parentId === activeLevelId,
) as (FramingNode & { id: string }) | undefined
})
// While this panel is OPEN with a live X-ray, hide the host's wall shells
// so the skeleton reads as the building; restore on leave. Panel-owned on
// purpose — see view-takeover.ts for why the renderer must not do this.
const takeoverActive = Boolean(framingNode) && framingNode?.seeThrough !== false
useEffect(() => {
if (!takeoverActive) return
const takeover = createWallModeTakeover(
() => useViewer.getState() as unknown as WallModeViewer,
)
takeover.engage()
return () => takeover.release()
}, [takeoverActive])

// ONE derivation per scene edit, shared by the X-Ray status line and the
// takeoff — the renderer runs its own (also once). Reviewer advisory r1.
const nodes = useScene((s) => s.nodes)
Expand All @@ -52,9 +66,9 @@ export default function BonesPanel() {
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
<header className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-base">Bones</h2>
<h2 className="font-semibold text-base">Structural Framing</h2>
<span className="rounded-full border border-sidebar-border/60 bg-sidebar-accent px-1.5 py-px font-semibold text-[9px] text-sidebar-foreground/70 uppercase tracking-widest">
Alpha
Beta
</span>
</div>
<p className="text-sidebar-foreground/50 text-xs leading-relaxed">
Expand Down Expand Up @@ -551,7 +565,7 @@ function ExportPlansButton({
className="flex w-full flex-col items-center gap-0.5 rounded-lg bg-primary px-3 py-2.5 font-semibold text-primary-foreground text-sm shadow-sm transition-transform hover:scale-[1.02] active:scale-[0.99]"
onClick={() => {
const sheets = buildPlanSet(result.members, result.fixtures, {
projectName: document.title.split('—')[0]?.trim() || 'Pascal project',
projectName: document.title.split('—')[0]?.trim() || 'Project',
levelName,
// resolved state code — raw 'AUTO' printed on sheets (quality C1)
jurisdiction: result.jurisdiction,
Expand Down
6 changes: 3 additions & 3 deletions src/plans/plan-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function chrome(
<g font-family="Helvetica, Arial, sans-serif">
<rect x="${W - 380}" y="${H - TITLE_H - 8}" width="${372}" height="${TITLE_H}" fill="#fff" stroke="#222"/>
<text x="${W - 368}" y="${H - TITLE_H + 12}" font-size="14" font-weight="bold" fill="#111">${esc(clip(title, 44))}</text>
<text x="${W - 368}" y="${H - TITLE_H + 27}" font-size="10" fill="#333">${esc(clip(`${opts.projectName ?? 'Pascal project'} — ${opts.levelName ?? 'Level'}`, 66))}</text>
<text x="${W - 368}" y="${H - TITLE_H + 27}" font-size="10" fill="#333">${esc(clip(`${opts.projectName ?? 'Project'} — ${opts.levelName ?? 'Level'}`, 66))}</text>
<text x="${W - 368}" y="${H - TITLE_H + 38}" font-size="8.5" fill="#555">${esc(line1)}</text>
${line1b ? `<text x="${W - 368}" y="${H - TITLE_H + 48}" font-size="8.5" fill="#555">${esc(line1b)}</text>` : ''}
<text x="${W - 368}" y="${H - TITLE_H + 58}" font-size="8.5" fill="#555">${esc(line2)}</text>
Expand Down Expand Up @@ -803,7 +803,7 @@ function coverSheet(members: Member[], opts: PlanSetOptions, index: string[]): P
)
const f = fitSegs(segs)
if (!f) return null
const title = opts.projectName ?? 'Pascal project'
const title = opts.projectName ?? 'Project'
const lines = [
`${opts.levelName ?? 'Level'} — full construction set`,
[opts.jurisdiction, opts.codeName].filter(Boolean).join(' · '),
Expand Down Expand Up @@ -942,7 +942,7 @@ export function planSetHtml(sheets: PlanSheet[], opts: PlanSetOptions = {}): str
.map((s) => `<section class="sheet">${s.svg}</section>`)
.join('\n')
return `<!doctype html>
<html><head><meta charset="utf-8"><title>${esc(opts.projectName ?? 'Pascal')} — Full plans (LOD 400)</title>
<html><head><meta charset="utf-8"><title>${esc(opts.projectName ?? 'Project')} — Full plans (LOD 400)</title>
<style>
@page { size: letter landscape; margin: 0; }
html, body { margin: 0; padding: 0; background: #6b7078; }
Expand Down
Loading