Supplements the root AGENTS.md; the shared contract (canonical load path, exports, tours, IFC EXPRESS naming) still applies here. Viewer-specific footguns:
- Selection has two channels. Only the global-id set
selectedEntityIds(and scalarselectedEntityId) drives renderer highlight andframeSelection;selectedEntity/selectedEntitiesSetholdEntityRef(expressId) for property lookup only. The multi-model actions (setSelectedEntity/addEntityToSelection/toggleEntitySelection) deliberately do not writeselectedEntityId, so also callsetSelectedEntityId(globalId)or the element won't highlight (src/store/slices/selectionSlice.ts:171). main.tsximport order is load-bearing../disable-react-dev-perf-trackmust be the first import and./harden-dom-mutationssecond, both before react-dom. The first nullsperformance.measureso React 19.2's dev render tracker can't recurse big geometry/dataStore props into a 4GB OOM; the second no-opsremoveChild/insertBeforeso a translate/password browser extension mutating the DOM can't crash the reconciler. Do not reorder or drop them (#1229/#1230/#1232).- PostHog is scrub-gated. Every event passes
scrubEvent(before_send) inlib/analytics-scrub.ts, which deletes any property keyed name/filename/model/title/label/path/url/email/comment/query/psetname and redacts path-ish string values. Never put a file name, model name, BCF title, pset/property name, or free text in acapture()call; it is silently stripped by design (the confidential-model privacy contract), not a bug. - Storey isolation is one channel. Solo and "isolate this storey" both ride
selectedStoreys, andapplyLevelDisplayMode()(store/levelDisplay.ts) is the single transition every entry point (storey tab, command palette, hierarchy click, viewport chip) must call. Stacked/Exploded clearsselectedStoreys. Do not add a secondisolatedEntitieschannel (it left models stuck isolated before). - The IfcAnnotation/IfcGrid 3D overlay is a global toggle, never section-filtered. It renders on the
typeVisibility.ifcAnnotations/typeVisibility.ifcGridbooleans regardless of any active section cut; do not tie it tosectionPlane. Grids get their own 1.5mgridSectionClipband, but annotation curves are never section-filtered (components/viewer/Viewport.tsx:943). - CSV/list exports neutralize formula injection in two places.
lib/lists/export/csv.tsesc()(also strips a leading BOM first) andlib/search/result-export.tsescapeCsvCell()both prefix a leading= + - @ TAB CRwith an apostrophe (CWE-1236). Any new CSV/list export of attacker-controllable IFC values must reuse one of these, not add a third. - Save files through
lib/export/download.ts(downloadBlob/downloadFile/downloadDataUrl); never hand-roll an<a download>blob dance. Pass the stem throughsanitizeFilename(preserves case and dots soDRAWINGSand000.000survive, #1299), then append the extension yourself. Every download firesemitFileDownloaded(filename), which task-gated tours observe; keep that seam. - New top-right viewport overlays anchor at
top-32 right-4or lower. The ViewCube sits attop-6 right-6(components/viewer/ViewportOverlays.tsx); the existing top-right panels (SunSky, level-display selector) clear it deliberately. Never cover the ViewCube corner. - Materials built from IFC geometry need double-sided rendering. Anything meshing an actual IFC file must set
side: THREE.DoubleSide/doubleSided: trueon every material: the MCP playground's per-entity materials (playground-scene-registry.ts) and the Cesium GLB export. IFC winding is not reliably outward (MeshData.indicessays so in as many words), so back-face culling shows see-through walls. The/mcphero is a deliberate exception, not an oversight:hero-scene-building.tsmeshes authoredBoxGeometry/PlaneGeometryand a hand-wound hip roof, all outward-facing by construction, so it renders front-side (its oneDoubleSidematerial is the section-plane rectangle, which is genuinely viewed from both sides). Don't "fix" it to match the rule — the rule is about IFC winding, and there is no IFC in the hero. This is all separate from the main WebGPU renderer (which lives inpackages/*, not here). - Never construct a GPU context straight from a mount effect.
new THREE.WebGLRendererandnew maplibregl.Mapboth throw when the device refuses a context, and a throw insideuseEffectunwinds to the nearest error boundary — which on/mcpis the route-levelChunkErrorBoundary, so one dead canvas replaced a whole page of GPU-free content with an unrecoverable "Reload" card (#1914, #2401). Mount three.js scenes throughcomponents/mcp/useThreeScene.tsand maps throughLocationMap.tsx'sdegradeMap; both ride the one session-latched gate inlib/webgl-capability.ts(probe once, latch the verdict, report ONE handled exception per session). Don't add a third probe or a second latch — a device that refuses a context refuses it for the whole session, and re-probing burns one of the ~16 context slots the page gets. A guard must still rethrow anything that is not the library's own context-refusal message, or a real bug in scene code gets relabelled "your device cannot do 3D" and disappears from error tracking.