diff --git a/web/src/lib/codes/__tests__/edition-availability.test.ts b/web/src/lib/codes/__tests__/edition-availability.test.ts index e19af1d00..665c84f55 100644 --- a/web/src/lib/codes/__tests__/edition-availability.test.ts +++ b/web/src/lib/codes/__tests__/edition-availability.test.ts @@ -168,21 +168,26 @@ describe('every implemented rule cites the edition it came from', () => { it('the registry never marks an edition available whose text is not supplied', () => { // The invariant that makes the whole model trustworthy: availability is downstream of // whether the app actually has the text to implement. + // + // This holds for EVERY role, not just the design-governing one. An earlier revision + // asserted `o.role === 'concrete'` here, which for a basis/loads/wind option reduces to + // `expect(false).toBe(false)` — the check passed vacuously for exactly the roles it was + // not covering, and three offered options whose text is not supplied went unnoticed. + const offending: string[] = []; for (const role of REGULATION_ROLES) { for (const o of optionsForRole(role)) { if (!o.regulation) continue; const info = findRegulation(o.regulation, o.edition as never); if (!info) continue; if (!info.textAvailable) { - // An available option whose text is absent is exactly the state this test forbids - // for the design-governing concrete role. - expect( - o.role === 'concrete', - `${o.adapterId} is AVAILABLE for role ${o.role} but its text is not supplied`, - ).toBe(false); + offending.push(`${o.adapterId} (role ${o.role}, edition ${o.edition})`); } } } + expect( + offending, + `offered for selection but their official text is not supplied: ${offending.join(', ')}`, + ).toEqual([]); }); }); diff --git a/web/src/lib/codes/__tests__/roles-revisions.test.ts b/web/src/lib/codes/__tests__/roles-revisions.test.ts index 24862c7e9..30ca658aa 100644 --- a/web/src/lib/codes/__tests__/roles-revisions.test.ts +++ b/web/src/lib/codes/__tests__/roles-revisions.test.ts @@ -224,7 +224,7 @@ describe('stack compatibility', () => { describe('pending changes', () => { it('detects a pending load-affecting change', () => { const r = defaultRegulations(); - r.wind = bindRole('wind', 'cirsoc102-2005'); + r.wind = bindRole('wind', 'cirsoc102-2025'); expect(pendingRoles(r)).toEqual(['wind']); expect(pendingRequiresLoadRegeneration(r)).toBe(true); }); @@ -266,21 +266,21 @@ describe('migration from the CIRSOC-specific v1 shape', () => { jurisdiction: { name: 'CABA', basis: 'adopted' }, concrete: { maxAggregateSizeMm: 19, shotcrete: false }, }); - // A v1 project naming concreteEdition '2005' is bound to the edition IN FORCE and told, - // because CIRSOC 201-2005 is no longer available for design. No migration workflow is - // offered: results stored under 2005 came from rules the app no longer applies, so - // re-running the design is the only honest outcome. The load and wind roles are - // untouched — 101-2005 and 102-2005 remain available. + // A v1 project naming a 2005 edition is bound to the edition IN FORCE and told, because + // none of the 2005 texts are supplied and their rules are therefore not implemented. No + // migration workflow is offered: results stored under 2005 came from rules the app no + // longer applies, so re-running is the only honest outcome. This holds for the load and + // wind roles exactly as it does for concrete — 101-2005 and 102-2005 are reserved, and + // binding one would mean a project labelled 2005 running the 2025 rules. expect(m.stored.roles.concrete.adapterId).toBe('cirsoc'); expect(m.stored.roles.concrete.edition).toBe('2025'); - expect(m.notices.map((n) => n.key)) - .toContain('regulations.migration.editionWithdrawn'); - const withdrawn = m.notices.find( + const withdrawn = m.notices.filter( (n) => n.key === 'regulations.migration.editionWithdrawn'); - expect(withdrawn?.params?.role).toBe('concrete'); - expect(withdrawn?.params?.edition).toBe('2005'); + expect(withdrawn.map((n) => n.params?.role)).toEqual(['concrete', 'wind']); + for (const n of withdrawn) expect(n.params?.edition).toBe('2005'); expect(m.stored.roles.basis.adapterId).toBe('cirsoc101-2025-basis'); - expect(m.stored.roles.wind.adapterId).toBe('cirsoc102-2005'); + expect(m.stored.roles.wind.adapterId).toBe('cirsoc102-2025'); + expect(m.stored.roles.wind.edition).toBe('2025'); expect(m.stored.roles.concrete.jurisdiction).toBe('CABA'); expect(m.stored.roles.concrete.adoption).toBe('adopted'); }); diff --git a/web/src/lib/codes/cirsoc102/wind.ts b/web/src/lib/codes/cirsoc102/wind.ts index ae3e0d9aa..02254ac6b 100644 --- a/web/src/lib/codes/cirsoc102/wind.ts +++ b/web/src/lib/codes/cirsoc102/wind.ts @@ -377,6 +377,27 @@ export function velocityPressure(z: number, p: WindProject): number { return 0.613 * kz * p.kzt * kd * ke * p.basicSpeed * p.basicSpeed; } +/** + * Net along-wind pressure on a strip at height `z`, N/m² — windward push plus leeward pull. + * + * The windward term uses `q_z` AT THAT HEIGHT (§1.13: q = q_z on the windward wall, and + * K_z grows with z), which is the whole reason a caller distributing wind over levels must + * ask per level rather than reuse one sample. The leeward term uses `q_h` over the full + * height (§2.4.1) and therefore does not vary. + * + * The internal pressure GC_pi cancels: it acts on both faces with the same sign, so it + * drops out of the difference and is deliberately absent here. + * + * Lives beside the pressure rules rather than in the load generator so the two cannot + * drift — the generator asks for a pressure, it does not re-derive one. + */ +export function netAlongWindPressureAt(z: number, p: WindProject): number { + const lOverB = p.B > 0 ? p.L / p.B : 1; + const qz = velocityPressure(z, p); + const qh = velocityPressure(p.meanRoofHeight, p); + return qz * G_RIGID * CP_WINDWARD_WALL - qh * G_RIGID * cpLeewardWall(lOverB); +} + /** * Full MWFRS wind pressures for one wind direction. * diff --git a/web/src/lib/codes/roles.ts b/web/src/lib/codes/roles.ts index 447e51aa2..0130d6ea2 100644 --- a/web/src/lib/codes/roles.ts +++ b/web/src/lib/codes/roles.ts @@ -128,10 +128,15 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([ maturity: 'VALIDATED', requiresConfig: false, }, { + // RESERVED, not selectable — same reason as CIRSOC 201-2005 below. The official + // 101-2005 text is not supplied (`REGULATIONS` records `textAvailable: false`), and + // `cirsoc101/combinations.ts` implements the 2025 §2.3.2 combinations only, citing + // 2025 clauses. Offering this option meant a project stamped 2005 whose combinations + // came from the 2025 text — a result citing a rule it did not apply. adapterId: 'cirsoc101-2005-basis', role: 'basis', regulation: 'cirsoc-101', edition: '2005', nameKey: 'regulations.name.cirsoc101', family: 'cirsoc', - maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: false, - noteKey: 'regulations.note.legacyEdition', + maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: false, + noteKey: 'regulations.note.editionTextNotSupplied', }, { adapterId: 'en1990', role: 'basis', edition: 'EN 1990:2002', @@ -146,10 +151,13 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([ maturity: 'VALIDATED', requiresConfig: true, }, { + // RESERVED, not selectable. `cirsoc101/live-loads.ts` implements the 2025 Table 4.1 + // occupancies and the §4.7.2 reduction only; there is no 2005 branch and no 2005 text + // to write one from. adapterId: 'cirsoc101-2005-loads', role: 'loads', regulation: 'cirsoc-101', edition: '2005', nameKey: 'regulations.name.cirsoc101', family: 'cirsoc', - maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: true, - noteKey: 'regulations.note.legacyEdition', + maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: true, + noteKey: 'regulations.note.editionTextNotSupplied', }, { adapterId: 'en1991-1-1', role: 'loads', edition: 'EN 1991-1-1', @@ -164,10 +172,14 @@ export const ROLE_CATALOG: readonly RoleOption[] = Object.freeze([ maturity: 'VALIDATED', requiresConfig: true, }, { + // RESERVED, not selectable. `cirsoc102/wind.ts` is a full rebuild against the 2025 + // text — K_e, the pressure coefficients, the exposure and enclosure rules — and takes + // no edition parameter. Under this label the wind loads and every clause ref were the + // 2025 edition's, which is the mislabelling the availability model exists to stop. adapterId: 'cirsoc102-2005', role: 'wind', regulation: 'cirsoc-102', edition: '2005', nameKey: 'regulations.name.cirsoc102', family: 'cirsoc', - maturity: 'IMPLEMENTED_PROVISIONAL', requiresConfig: true, - noteKey: 'regulations.note.legacyEdition', + maturity: 'UNSUPPORTED', availability: 'UNAVAILABLE_SOURCE', requiresConfig: true, + noteKey: 'regulations.note.editionTextNotSupplied', }, { adapterId: 'en1991-1-4', role: 'wind', edition: 'EN 1991-1-4', @@ -664,26 +676,43 @@ export function migrateRegulations(raw: unknown): RegulationsMigration { // than bound to something inapplicable or silently left unset. No migration workflow is // offered: stored 2005 results were produced by rules the app no longer applies, so // re-running the design is the only honest outcome. + // The same applies to the 2005 load and wind editions: their texts are not supplied + // either, so they are bound to the edition in force and the user is TOLD, rather than + // bound to a label whose rules would come from the 2025 text. if (concreteEd === '2005') { notices.push({ key: 'regulations.migration.editionWithdrawn', params: { role: 'concrete', edition: '2005' }, }); } + if (loadEd === '2005') { + for (const role of ['basis', 'loads'] as const) { + notices.push({ + key: 'regulations.migration.editionWithdrawn', + params: { role, edition: '2005' }, + }); + } + } + if (windEd === '2005') { + notices.push({ + key: 'regulations.migration.editionWithdrawn', + params: { role: 'wind', edition: '2005' }, + }); + } roles.concrete = { ...bindRole('concrete', 'cirsoc', common), state: 'applied', appliedAtRevision: 0, }; roles.basis = { - ...bindRole('basis', loadEd === '2005' ? 'cirsoc101-2005-basis' : 'cirsoc101-2025-basis', common), + ...bindRole('basis', 'cirsoc101-2025-basis', common), state: 'applied', appliedAtRevision: 0, }; roles.loads = { - ...bindRole('loads', loadEd === '2005' ? 'cirsoc101-2005-loads' : 'cirsoc101-2025-loads', common), + ...bindRole('loads', 'cirsoc101-2025-loads', common), state: 'applied', appliedAtRevision: 0, }; roles.wind = { - ...bindRole('wind', windEd === '2005' ? 'cirsoc102-2005' : 'cirsoc102-2025', common), + ...bindRole('wind', 'cirsoc102-2025', common), state: 'applied', appliedAtRevision: 0, }; diff --git a/web/src/lib/engine/loads/__tests__/load-plan.test.ts b/web/src/lib/engine/loads/__tests__/load-plan.test.ts index 12e69dabd..8de31c10b 100644 --- a/web/src/lib/engine/loads/__tests__/load-plan.test.ts +++ b/web/src/lib/engine/loads/__tests__/load-plan.test.ts @@ -282,6 +282,38 @@ describe('wind uses the CIRSOC 102-2025 engine', () => { const p = windOn({ directions: { x: true, y: true } }); expect(p.cases.filter((c) => c.type === 'W')).toHaveLength(2); }); + + it('distributes windward pressure by height instead of applying the base value everywhere', () => { + // q_z rises with height (§1.13, K_z), and `wind.ts` evaluates the windward wall at both + // the base and the mean roof height specifically so this caller can distribute. Taking + // the first sample — z = min(5, h), the weakest point — and reusing it for every level + // under-predicts wind on everything above 5 m, which is unconservative. + const model = frame(4, 6, 5); // levels at 5, 10, 15, 20 m + const p = buildLoadPlan(input({ + model, + wind: { + enabled: true, basicSpeed: 45, exposure: 'C', enclosure: 'enclosed', + siteAltitudeM: 0, kzt: 1, kztSurveyed: true, roofSlopeDeg: 20, rigid: true, + directions: { x: true, y: false }, + }, + })); + + // The §1.10 minimum must not be what decides this, or the comparison proves nothing. + expect(p.unsupportedKeys.map((u) => u.key)).not.toContain('loadPlan.note.windMinimumGoverns'); + + const byLevel = new Map(); + for (const n of p.nodal) { + if (n.caseType !== 'W') continue; + const z = model.nodes.get(n.nodeId)!.z!; + byLevel.set(z, (byLevel.get(z) ?? 0) + Math.abs(n.fx)); + } + // Levels 5, 10 and 15 all carry a 5 m tributary height (only the roof level is halved), + // so their totals are directly comparable without normalising. + const totals = [5, 10, 15].map((z) => byLevel.get(z) ?? 0); + expect(totals.every((t) => t > 0)).toBe(true); + expect(totals[1]).toBeGreaterThan(totals[0]); + expect(totals[2]).toBeGreaterThan(totals[1]); + }); }); // ─── Seismic ───────────────────────────────────────────────────── diff --git a/web/src/lib/engine/loads/load-plan.ts b/web/src/lib/engine/loads/load-plan.ts index ab16fd8ac..f116a64ed 100644 --- a/web/src/lib/engine/loads/load-plan.ts +++ b/web/src/lib/engine/loads/load-plan.ts @@ -40,7 +40,7 @@ import { type ElementKind, type OccupancyEntry, } from '../../codes/cirsoc101/live-loads'; import { - applyMinimumWindLoad, computeWindPressures, + applyMinimumWindLoad, computeWindPressures, netAlongWindPressureAt, type Enclosure, type Exposure, type WindProject, } from '../../codes/cirsoc102/wind'; import { @@ -422,16 +422,18 @@ export function buildLoadPlan(input: LoadPlanInput): LoadPlan { windQh = fromProject(res.qhNm2, 'N/m²'); // Windward + leeward on each level, distributed over that level's nodes. - const ww = res.pressures.find((p) => p.surface === 'windwardWall' && p.gcpiSign === 1); - const lw = res.pressures.find((p) => p.surface === 'leewardWall' && p.gcpiSign === 1); - const net = (Math.abs(ww?.pNm2 ?? 0) + Math.abs(lw?.pNm2 ?? 0)) / 1000; // kPa - + // + // Sampled PER LEVEL: the windward velocity pressure grows with height, so taking one + // sample and reusing it for every storey under-predicts wind everywhere above it. The + // net pressure is asked of the code layer rather than assembled from the surface list + // here, so the height rule has exactly one implementation. const elevated = levels.filter((l) => l.elevation > 0); for (let i = 0; i < elevated.length; i++) { const lv = elevated[i]; const below = i === 0 ? 0 : elevated[i - 1].elevation; const above = i === elevated.length - 1 ? lv.elevation : elevated[i + 1].elevation; const tribH = (lv.elevation - below) / 2 + (above - lv.elevation) / 2; + const net = netAlongWindPressureAt(lv.elevation, project) / 1000; // kPa const force = net * across * tribH; const min = applyMinimumWindLoad(force * 1000, across * tribH, 0); const applied = min.totalN / 1000; @@ -454,9 +456,13 @@ export function buildLoadPlan(input: LoadPlanInput): LoadPlan { type: 'W', nameKey: 'autoLoad.windCaseDir', nameParams: { dir: dir.toUpperCase(), v: input.wind.basicSpeed }, }); + // The reported net is the one at the mean roof height — the largest of the profile, + // and the one that pairs with the q_h printed beside it. Levels below it received + // less, which is the point of sampling per level. derivation.push(msg('loadPlan.derivation.wind', { dir: dir.toUpperCase(), qh: round(res.qhNm2, 0), - net: round(net, 3), front: round(across, 1), + net: round(netAlongWindPressureAt(project.meanRoofHeight, project) / 1000, 3), + front: round(across, 1), })); } } diff --git a/web/src/lib/store/__tests__/regulations-persistence.test.ts b/web/src/lib/store/__tests__/regulations-persistence.test.ts new file mode 100644 index 000000000..abc4b6e70 --- /dev/null +++ b/web/src/lib/store/__tests__/regulations-persistence.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { modelStore } from '../model.svelte'; +import { REGULATIONS_SCHEMA_VERSION, bindRole, defaultRegulations } from '../../codes/roles'; +import { bump, emptyRevisions } from '../../codes/revisions'; + +/** + * The regulation stack and the revision vector live on the model for the same reason + * `codeSettings` does: so they travel through every persistence path for free — .ded + * save/open, tab capture/restore, URL share and autosave all go through + * snapshot()/restore(), and so does undo/redo. + * + * These pin that they actually do. Without them the stack was declared on the model and + * on `ModelSnapshot`, but `snapshot()` never emitted it and `restore()` never read it, so + * every save/open and every undo silently reset the project to `defaultRegulations()` and + * dropped the staleness stamps that gate stored results. + * + * Each round-trip test blanks the live fields between snapshot and restore. Reading them + * back without that step proves nothing: the value would still be sitting on the model + * from before the snapshot, and the assertion passes whether or not `restore()` works. + */ +describe('project regulations persistence', () => { + beforeEach(() => { + modelStore.clear(); + }); + + function boundToConcrete2025() { + const roles = defaultRegulations(); + roles.concrete = { + ...bindRole('concrete', 'cirsoc', { jurisdiction: 'CABA', adoption: 'adopted' }), + state: 'applied', appliedAtRevision: 0, + }; + return { version: REGULATIONS_SCHEMA_VERSION, roles }; + } + + /** What a fresh tab or a newly opened file looks like before restore() runs. */ + function blankLiveStack() { + modelStore.model.regulations = undefined; + modelStore.model.revisions = undefined; + } + + it('round-trips the role bindings through snapshot and restore', () => { + modelStore.model.regulations = boundToConcrete2025(); + const snap = modelStore.snapshot(); + blankLiveStack(); + + modelStore.restore(snap); + + expect(modelStore.model.regulations?.roles.concrete.adapterId).toBe('cirsoc'); + expect(modelStore.model.regulations?.roles.concrete.jurisdiction).toBe('CABA'); + expect(modelStore.model.regulations?.roles.concrete.state).toBe('applied'); + }); + + it('round-trips the revision vector, which is what gates stale results', () => { + modelStore.model.revisions = bump(emptyRevisions(), 'analysis'); + const before = modelStore.model.revisions.analysis; + expect(before).toBeGreaterThan(0); + const snap = modelStore.snapshot(); + blankLiveStack(); + + modelStore.restore(snap); + + expect(modelStore.model.revisions?.analysis).toBe(before); + }); + + it('survives a JSON round-trip, which is what .ded and URL sharing actually do', () => { + modelStore.model.regulations = boundToConcrete2025(); + modelStore.model.revisions = bump(emptyRevisions(), 'analysis'); + + const wire = JSON.parse(JSON.stringify(modelStore.snapshot())); + blankLiveStack(); + modelStore.restore(wire); + + expect(modelStore.model.regulations?.roles.concrete.adapterId).toBe('cirsoc'); + expect(modelStore.model.revisions?.analysis).toBe(1); + }); + + it('unsets a stored edition that has since been withdrawn rather than carrying it', () => { + // A project saved naming an edition whose text is not supplied must not come back + // bound to it — the rules cannot be applied, so the binding would be a lie. + const roles = defaultRegulations(); + roles.concrete = { + ...roles.concrete, adapterId: 'cirsoc-2005', edition: '2005', state: 'applied', + }; + const snap = modelStore.snapshot(); + blankLiveStack(); + + modelStore.restore({ + ...snap, + regulations: { version: REGULATIONS_SCHEMA_VERSION, roles }, + }); + + expect(modelStore.model.regulations?.roles.concrete.adapterId).toBeNull(); + }); + + it('gives a new project the default stack instead of inheriting the previous one', () => { + modelStore.model.regulations = boundToConcrete2025(); + modelStore.model.regulations.roles.concrete.jurisdiction = 'Santa Fe'; + + modelStore.clear(); + + expect(modelStore.model.regulations?.roles.concrete.jurisdiction ?? '').toBe(''); + }); +}); diff --git a/web/src/lib/store/model.svelte.ts b/web/src/lib/store/model.svelte.ts index 3bc7d3caf..1dde7d818 100644 --- a/web/src/lib/store/model.svelte.ts +++ b/web/src/lib/store/model.svelte.ts @@ -1,6 +1,11 @@ import { defaultCodeSettings, migrateCodeSettings, type ProjectCodeSettings, } from '../codes/project-code-settings'; +import { + REGULATIONS_SCHEMA_VERSION, defaultRegulations, migrateRegulations, + type StoredRegulations, +} from '../codes/roles'; +import { emptyRevisions, type RevisionVector } from '../codes/revisions'; // Model store - manages the structural model import type { KinematicResult } from '../engine/kinematic-2d'; import type { SolverInput, FullEnvelope, AnalysisResults } from '../engine/types'; @@ -985,6 +990,13 @@ function createModelStore() { codeSettings: snap.codeSettings ? (JSON.parse(JSON.stringify(snap.codeSettings)) as ProjectCodeSettings) : defaultCodeSettings(), + // The regulation stack and the revision vector are on the model precisely so they + // ride every persistence path. Emitting them here is what makes that true for + // .ded save, autosave, URL share, tab capture and undo/redo alike. + regulations: snap.regulations + ? (JSON.parse(JSON.stringify(snap.regulations)) as StoredRegulations) + : undefined, + revisions: snap.revisions ? { ...snap.revisions } : undefined, }; if (snap.provenance) { result.provenance = { @@ -1095,6 +1107,12 @@ function createModelStore() { // Migration is deliberate, not a fallback: a project with no settings is stamped // CIRSOC 201-2005, the edition its stored results were actually checked against. model.codeSettings = migrateCodeSettings(s.codeSettings).settings; + // The stack goes through the migrator rather than being assigned raw, so a stored + // project naming an edition that has since been withdrawn comes back UNSET rather + // than bound to rules the app cannot apply. A project saved before the stack existed + // migrates from the v1 `codeSettings` shape instead. + model.regulations = migrateRegulations(s.regulations ?? s.codeSettings).stored; + model.revisions = s.revisions ? { ...s.revisions } : emptyRevisions(); }, /** Explicit user action: clear the CAD-draft "unreviewed" tag. */ @@ -1642,6 +1660,8 @@ function createModelStore() { // A new model is a new project: it adopts the edition in force, not whatever the // previously open project happened to be designed to. model.codeSettings = defaultCodeSettings(); + model.regulations = { version: REGULATIONS_SCHEMA_VERSION, roles: defaultRegulations() }; + model.revisions = emptyRevisions(); // Reset materials/sections to defaults model.materials = new Map([[1, { ...defaultMaterial }]]); model.sections = new Map([[1, { ...defaultSection }]]);