Files
Claude f5b20e07f6 perf(floor): key the floor-geometry caches by the floor's content (#744)
The physical bodies, the wall union pool, the inner room contours and the
clean floor carried the global config epoch in their keys. Every edit of
any floor bumps it, so after one edit every other floor was cold again:
in large-house the first visit to an untouched floor rebuilt its wall
union and paid ~0.7 s flat / ~0.65 s 2.5D instead of ~40-55 ms.

A floor's geometry reads only its own config record (spaceModels) and
constants, so the key is now a content fingerprint of that record
(src/floor-geometry-key.ts), remembered per epoch and per record object.
The geometry also reads the current floor's config next to the model it
is given; when those records differ the key covers both. The live resize
preview is its own record, so preview frames get their own key; the
editor runtime seeds the pool and re-keys the bodies through the same
reader. The stairs editor no longer clears the clean floors of every
floor: the stairs are part of the floor's record.

The #735 switch-cycle guard now also sees the union pool and the inner
contours (optional members of the large-house card contract, so an older
comparison bundle reads 0). smoke_floor_geometry_cache proves the warm other floor and the
invalidation against an independent card (multi-floor push with shared
walls, a stair, a resize preview and its cancel); two mutants guard it.

Issue: #744
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00

200 lines
7.0 KiB
JavaScript

/**
* Private houseplan-card surface consumed by the performance runners.
*
* The candidate runner profiles both the candidate bundle and a bundle built
* from the comparison SHA. Keep these lists explicit so a private rename in
* either tree fails before measurements instead of silently reporting zeroes.
*/
const CACHE_FIELDS = Object.freeze([
'_cleanFloorCache',
'_glowClipCache',
'_wallUnionCache',
'_openingTunnelCache',
'_openingWallIndexCache',
]);
export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
label: 'large-house-v1',
methods: Object.freeze([
'_baseVb',
'_bindingStatus',
'_buildModel',
'_cancelDecorGesture',
'_checkSpacePhysicalGeometry',
'_decorBoxOf',
'_dtMeasure',
'_dtMove',
'_dtStart',
'_openSettingsDialog',
'_pickSpace',
'_pos',
'_renderBody',
'_rszCancelDrag',
'_rszEdgeDown',
'_rszMove',
'_rszRooms',
'_setMode',
'_viewOr',
]),
fields: Object.freeze([
'_booting',
'_bootSoft',
'_cameraTransition',
'_cfgEpoch',
...CACHE_FIELDS,
'_continuity',
'_cursorPt',
'_decorList',
'_decorSel',
'_decorTool',
'_devices',
'_gridPitch',
'_hassSequence',
'_loadOk',
'_model',
'_modeTransitionBusy',
'_path',
'_serverCfg',
'_space',
'_settingsDialog',
'_tool',
]),
// A comparison SHA before #89 is intentionally flat; the isometric runner
// checks these members only when the target source tree supports Stage 1.
optionalFields: Object.freeze([
'_activeWallChainId', '_activeWallChainPartitionIds', '_wallChainSegmentCms',
'_effectiveProjection', '_ensureIsoSceneRuntime',
'_isoGeometryCache', '_isoStructuralBuildCount', '_offerWallFaces',
'_liveEditorPaintCount', '_onLabsSnapshot',
'_planSnapGeometryCache', '_roomDialog', '_syncVolumetricSetting', '_wallFaceBatch',
'_wallFaceGraphCache',
// #744: the #735 switch-cycle guard counts the union pool and the inner
// contours; a comparison bundle without them reads 0.
'_wallUnionPool', '_innerContourCache',
]),
// #649: members that only comparison bundles own. The benchmark feature-probes
// them (`typeof card._setProjection === 'function'`); the current card no
// longer has them, so they are declared but never required.
legacyOnlyFields: Object.freeze(['_setProjection']),
// #380: v1.68.1 owns the same resize session directly on the card; newer
// bundles moved it into ResizeController. A comparison target must expose
// one of the two explicit shapes; the current member retains its object
// type check and is also verified against current production source.
// #500 replaced the private structural adoption with a gated public entry
// point. The boot diagnostics (#520) count adoptions through whichever of
// the two the measured bundle owns; an undeclared rename would silently
// report zero adoptions instead of failing.
fieldAlternatives: Object.freeze([
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
]),
fieldTypes: Object.freeze({
_adoptAuthoritative: 'function',
_booting: 'boolean',
_bootSoft: 'boolean',
_cameraTransition: 'object',
_cfgEpoch: 'number',
_cleanFloorCache: 'map',
_devices: 'array',
_continuity: 'object',
_decorList: 'array',
_decorTool: 'string',
_activeWallChainPartitionIds: 'array',
_wallChainSegmentCms: 'array',
_effectiveProjection: 'function',
_ensureIsoSceneRuntime: 'function',
_glowClipCache: 'map',
_gridPitch: 'number',
_hassSequence: 'number',
_loadOk: 'boolean',
_liveEditorPaintCount: 'number',
_model: 'array',
_onLabsSnapshot: 'function',
_offerWallFaces: 'function',
_path: 'array',
_isoGeometryCache: 'map',
_isoStructuralBuildCount: 'number',
_innerContourCache: 'map',
_planSnapGeometryCache: 'object',
_roomDialog: 'boolean',
_resize: 'object',
_syncVolumetricSetting: 'function',
_serverCfg: 'object',
_space: 'string',
_tool: 'string',
_wallFaceGraphCache: 'array',
_wallUnionPool: 'map',
}),
});
export const GLOW_CARD_CONTRACT = Object.freeze({
label: 'Glow performance profiles',
methods: Object.freeze([]),
fields: Object.freeze([
...CACHE_FIELDS,
'_devices',
'_loadOk',
]),
// Additive blending was introduced after the first supported performance
// bases. Its absence is safe: the runner keeps the historical normal blend.
optionalFields: Object.freeze(['_glowScreenBlend']),
fieldTypes: Object.freeze({
_cleanFloorCache: 'map',
_devices: 'array',
_glowClipCache: 'map',
_glowScreenBlend: 'boolean',
_loadOk: 'boolean',
}),
});
export const SPACE_GLOW_CARD_CONTRACT = Object.freeze({
label: 'Static-card Glow performance profiles',
methods: Object.freeze([]),
fields: Object.freeze(['_devices', '_loading', '_snap']),
optionalFields: Object.freeze(['_glowRuntimeState', '_glowScreenBlend']),
fieldTypes: Object.freeze({
_devices: 'array',
_loading: 'boolean',
_glowRuntimeState: 'object',
_glowScreenBlend: 'boolean',
}),
});
/** Single fail-fast implementation injected into both browser runners. Keep
* this function self-contained: runners serialize it with `toString()`. */
export function assertCardContract(card, contract) {
const matches = (value, expected) => {
if (expected === 'array') return Array.isArray(value);
if (expected === 'map') return value instanceof Map;
return typeof value === expected;
};
const missingMethods = contract.methods
.filter((name) => typeof card[name] !== 'function')
.map((name) => `${name}()`);
const missingFields = contract.fields
.filter((name) => !(name in card) || card[name] === undefined);
const missingAlternatives = (contract.fieldAlternatives || [])
.filter((choice) => !Object.values(choice)
.some((name) => name in card && card[name] !== undefined))
.map((choice) => Object.values(choice).join('|'));
const alternativeFields = (contract.fieldAlternatives || [])
.flatMap((choice) => Object.values(choice));
const invalidFields = [
...contract.fields, ...(contract.optionalFields || []), ...alternativeFields,
]
.filter((name) => name in card && contract.fieldTypes?.[name]
&& !matches(card[name], contract.fieldTypes[name]))
.map((name) => `${name}:${contract.fieldTypes[name]}`);
const missing = [...missingMethods, ...missingFields, ...missingAlternatives];
if (missing.length || invalidFields.length) {
const details = [
missing.length ? `missing private API: ${missing.join(', ')}` : '',
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
].filter(Boolean).join('; ');
throw new Error(
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
+ 'Update the explicit candidate/base compatibility contract before profiling.',
);
}
}